diff --git a/README.md b/README.md index eb15245..2dfe3b1 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,10 @@ inventory/ │ ├── index.html │ ├── box.html │ ├── edit.html -│ ├── scan.html │ └── stats.html └── static/ ├── css/ │ └── style.css - └── js/ - └── scanner.js ``` ## 2. 本地运行 @@ -68,9 +65,9 @@ python app.py ### 3.1 首页 `/` -- 首页已改为入口跳转到 `分类总览` 页面。 +- 首页已改为入口跳转到 `仓库概览` 页面。 -### 3.1.1 分类总览 `/types` +### 3.1.1 仓库概览 `/types` - 展示三类独立界面入口:`28格小盒大盒`、`14格中盒大盒`、`袋装清单`。 - 每类入口显示当前容器数量,点击进入单独分类页面。 @@ -87,24 +84,19 @@ python app.py - `袋装清单` 仅使用编号前缀(如 `BAG`),不设置编号范围。 - `28格/14格` 支持快速入库:多行粘贴后自动分配空位。 - 支持按当前盒子导出打标 CSV(仅导出启用记录),可用于热敏打标机导入。 -- 打标 CSV 列名为中英双语格式(如 `料号(part_no)`、`打标文本(label_text)`),便于直接识别。 +- 打标 CSV 列名为中英双语格式(如 `料号(part_no)`、`备注(note)`),便于直接识别。 ### 3.3 编辑页 `/edit//` -- 编辑料号、名称、规格、数量、位置备注、备注。 -- 支持勾选启用,或通过按钮启用/停用。 +- 编辑料号、名称、规格、数量、备注。 +- 通过按钮启用/停用。 - 可删除当前格子记录。 -### 3.4 扫码/搜索 `/scan` - -- 可按料号或名称搜索。 -- 支持扫码枪输入后回车触发搜索。 - -### 3.5 统计页 `/stats` +### 3.4 统计页 `/stats` - 独立统计页,仅展示核心指标:`库存总量 / 分类占比 / 变动趋势`。 - 支持 `7天` 与 `30天` 视图切换:`/stats?days=7`、`/stats?days=30`。 -- 支持分类筛选:`/stats?days=30&box_type=small_28`(可选值:`small_28`、`medium_14`、`bag`、`all`)。 +- 支持分类筛选:`/stats?days=30&box_type=small_28`(可选值:`small_28`、`medium_14`、`custom`、`bag`、`all`)。 - 趋势图基于库存变动日志实时计算,来源包括:新增、快速入库、启用/停用、删除。 - 说明:升级前的历史操作不会自动回溯写入日志,趋势从启用该版本后开始逐步真实化。 - 新增最近操作时间线(最新 20 条),便于追踪库存变化来源。 @@ -112,12 +104,18 @@ python app.py - 支持趋势数据导出 CSV:`/stats/export?days=7&box_type=all`(包含 `daily_delta` 日增减列)。 - 支持清除统计日志(当前筛选或全部),仅影响统计与趋势,不影响库存数据本体。 +### 3.5 快速搜索与出库 `/search` + +- 支持按 `料号` 或 `名称` 搜索已启用元件。 +- 搜索结果可一键跳转到对应盒位编辑页。 +- 支持快速出库:只填写数量即可扣减库存,并写入统计日志。 + ## 4. 袋装批量新增格式 在袋装清单页面的批量输入框里,每行一条,可用英文逗号或 Tab 分隔: ```text -料号, 名称, 数量, 规格, 位置备注, 备注 +料号, 名称, 数量, 规格, 备注 ``` 示例: diff --git a/app.py b/app.py index a5a5f5b..bf7adba 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,8 @@ import os import re import csv +import json +from copy import deepcopy from io import StringIO from datetime import datetime, timedelta @@ -19,9 +21,10 @@ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db = SQLAlchemy(app) LOW_STOCK_THRESHOLD = 5 +BOX_TYPES_OVERRIDE_PATH = os.path.join(DB_DIR, "box_types.json") -BOX_TYPES = { +DEFAULT_BOX_TYPES = { "small_28": { "label": "28格小盒大盒", "default_capacity": 28, @@ -34,6 +37,12 @@ BOX_TYPES = { "default_desc": "14格中盒,内部摆放方向与28格不同", "default_prefix": "B", }, + "custom": { + "label": "自定义容器", + "default_capacity": 20, + "default_desc": "可按实际盒型设置格数与编号前缀", + "default_prefix": "C", + }, "bag": { "label": "袋装清单", "default_capacity": 1, @@ -42,6 +51,52 @@ BOX_TYPES = { }, } +BOX_TYPES = deepcopy(DEFAULT_BOX_TYPES) + + +def _apply_box_type_overrides() -> None: + if not os.path.exists(BOX_TYPES_OVERRIDE_PATH): + return + + try: + with open(BOX_TYPES_OVERRIDE_PATH, "r", encoding="utf-8") as f: + overrides = json.load(f) + except (OSError, json.JSONDecodeError): + return + + if not isinstance(overrides, dict): + return + + for key, value in overrides.items(): + if key not in BOX_TYPES or not isinstance(value, dict): + continue + + for field in ("label", "default_desc", "default_prefix"): + if field not in value: + continue + BOX_TYPES[key][field] = value[field] + + # Keep bag list capacity fixed by domain rule. + BOX_TYPES["bag"]["default_capacity"] = 1 + + +def _save_box_type_overrides() -> None: + payload = {} + for key, defaults in DEFAULT_BOX_TYPES.items(): + current = BOX_TYPES.get(key, defaults) + changed = {} + for field in ("label", "default_desc", "default_prefix"): + if current.get(field) != defaults.get(field): + changed[field] = current.get(field) + if changed: + payload[key] = changed + + with open(BOX_TYPES_OVERRIDE_PATH, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + +_apply_box_type_overrides() + class Box(db.Model): __tablename__ = "boxes" @@ -512,6 +567,7 @@ def event_type_label(event_type: str) -> str: labels = { "quick_inbound_add": "快速入库新增", "quick_inbound_merge": "快速入库合并", + "component_outbound": "快速出库", "component_save": "编辑保存", "component_enable": "启用元件", "component_disable": "停用元件", @@ -581,11 +637,14 @@ def build_dashboard_context(): low_stock_items = [] for c in sorted(low_stock_components, key=lambda item: (item.quantity, item.name or ""))[:12]: box = box_by_id.get(c.box_id) + box_type_key = box.box_type if box and box.box_type in BOX_TYPES else "small_28" low_stock_items.append( { "name": c.name, "part_no": c.part_no, "quantity": c.quantity, + "box_type": box_type_key, + "box_type_label": BOX_TYPES[box_type_key]["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), "edit_url": url_for("edit_component", box_id=c.box_id, slot=c.slot_index), @@ -636,19 +695,82 @@ def index(): @app.route("/types") def types_page(): dashboard = build_dashboard_context() + notice = request.args.get("notice", "").strip() + error = request.args.get("error", "").strip() + category_stats_map = {item["key"]: item for item in dashboard["category_stats"]} + low_stock_groups = [] + + for key, meta in BOX_TYPES.items(): + grouped_items = [ + item + for item in dashboard["low_stock_items"] + if item.get("box_type") == key + ] + low_stock_groups.append( + { + "key": key, + "label": meta["label"], + "items": grouped_items, + } + ) + type_cards = [] for key, meta in BOX_TYPES.items(): + category_item = category_stats_map.get(key, {}) type_cards.append( { "key": key, "label": meta["label"], "desc": meta["default_desc"], "count": len(dashboard["groups"].get(key, [])), + "item_count": int(category_item.get("item_count", 0)), + "quantity": int(category_item.get("quantity", 0)), "url": url_for("type_page", box_type=key), } ) - return render_template("types.html", type_cards=type_cards) + return render_template( + "types.html", + type_cards=type_cards, + stats=dashboard["stats"], + low_stock_groups=low_stock_groups, + notice=notice, + error=error, + ) + + +@app.route("/container-type//edit", methods=["GET", "POST"]) +def edit_container_type(box_type: str): + if box_type not in BOX_TYPES: + return bad_request("无效盒子类型", "") + + meta = BOX_TYPES[box_type] + error = "" + + if request.method == "POST": + label = request.form.get("label", "").strip() + default_desc = request.form.get("default_desc", "").strip() + default_prefix = request.form.get("default_prefix", "").strip().upper() + + if not label: + error = "容器名称不能为空" + elif not default_prefix: + error = "默认前缀不能为空" + + if not error: + meta["label"] = label + meta["default_desc"] = default_desc or DEFAULT_BOX_TYPES[box_type]["default_desc"] + meta["default_prefix"] = default_prefix + + _save_box_type_overrides() + return redirect(url_for("types_page", notice="容器属性已更新")) + + return render_template( + "type_edit.html", + box_type=box_type, + meta=meta, + error=error, + ) @app.route("/type/") @@ -691,12 +813,24 @@ def create_box(): return bad_request("起始序号必须是大于等于 0 的整数", box_type) meta = BOX_TYPES[box_type] + slot_capacity = meta["default_capacity"] + if box_type == "custom": + try: + slot_capacity = _parse_non_negative_int( + request.form.get("slot_capacity", str(meta["default_capacity"])), + meta["default_capacity"], + ) + except ValueError: + return bad_request("格数必须是大于等于 1 的整数", box_type) + if slot_capacity < 1: + return bad_request("格数必须是大于等于 1 的整数", box_type) + effective_prefix = slot_prefix or meta["default_prefix"] conflict, other_box = has_range_conflict( box_type=box_type, prefix=effective_prefix, start_number=start_number, - slot_capacity=meta["default_capacity"], + slot_capacity=slot_capacity, ) if conflict: return bad_request( @@ -710,7 +844,7 @@ def create_box(): base_name=base_name, prefix=effective_prefix, start_number=start_number, - slot_capacity=meta["default_capacity"], + slot_capacity=slot_capacity, ) final_name = make_unique_box_name(generated_name) @@ -718,7 +852,7 @@ def create_box(): name=final_name, description=description or meta["default_desc"], box_type=box_type, - slot_capacity=meta["default_capacity"], + slot_capacity=slot_capacity, slot_prefix=effective_prefix, start_number=start_number, ) @@ -745,12 +879,36 @@ def update_box(box_id: int): except ValueError: return bad_request("起始序号必须是大于等于 0 的整数", box.box_type) + slot_capacity = box.slot_capacity + if box.box_type == "custom": + try: + slot_capacity = _parse_non_negative_int( + request.form.get("slot_capacity", str(box.slot_capacity)), + box.slot_capacity, + ) + except ValueError: + return bad_request("格数必须是大于等于 1 的整数", box.box_type) + if slot_capacity < 1: + return bad_request("格数必须是大于等于 1 的整数", box.box_type) + + max_used_slot = ( + db.session.query(db.func.max(Component.slot_index)) + .filter_by(box_id=box.id) + .scalar() + or 0 + ) + if max_used_slot > slot_capacity: + return bad_request( + f"格数不能小于已使用位置 {max_used_slot}", + box.box_type, + ) + effective_prefix = slot_prefix or BOX_TYPES[box.box_type]["default_prefix"] conflict, other_box = has_range_conflict( box_type=box.box_type, prefix=effective_prefix, start_number=start_number, - slot_capacity=box.slot_capacity, + slot_capacity=slot_capacity, exclude_box_id=box.id, ) if conflict: @@ -765,13 +923,14 @@ def update_box(box_id: int): base_name=base_name, prefix=effective_prefix, start_number=start_number, - slot_capacity=box.slot_capacity, + slot_capacity=slot_capacity, ) box.name = make_unique_box_name(generated_name, exclude_box_id=box.id) box.description = description or BOX_TYPES[box.box_type]["default_desc"] box.slot_prefix = effective_prefix box.start_number = start_number + box.slot_capacity = slot_capacity db.session.commit() return_to_type = parse_box_type_filter(request.form.get("return_to_type", "")) target_type = return_to_type if return_to_type != "all" else box.box_type @@ -813,6 +972,17 @@ def suggest_start(): exclude_box_id = None slot_capacity = BOX_TYPES[box_type]["default_capacity"] + if box_type == "custom" and not box_id_raw: + try: + slot_capacity = _parse_non_negative_int( + request.args.get("slot_capacity", str(slot_capacity)), + slot_capacity, + ) + except ValueError: + return {"ok": False, "message": "格数必须是大于等于 1 的整数"}, 400 + if slot_capacity < 1: + return {"ok": False, "message": "格数必须是大于等于 1 的整数"}, 400 + if box_id_raw: try: box_id = int(box_id_raw) @@ -1195,10 +1365,13 @@ def edit_component(box_id: int, slot: int): if slot < 1 or slot > box.slot_capacity: return "无效的格子编号", 400 + search_query = request.args.get("q", "").strip() component = Component.query.filter_by(box_id=box.id, slot_index=slot).first() if request.method == "POST": action = request.form.get("action", "save") + search_query_post = request.form.get("q", "").strip() + search_query_effective = search_query_post or search_query if action == "delete": if component: @@ -1211,6 +1384,8 @@ def edit_component(box_id: int, slot: int): ) db.session.delete(component) db.session.commit() + if search_query_effective: + return redirect(url_for("search_page", q=search_query_effective)) return redirect(url_for("view_box", box_id=box.id)) if action == "toggle_enable": @@ -1225,7 +1400,7 @@ def edit_component(box_id: int, slot: int): component=component, ) db.session.commit() - return redirect(url_for("edit_component", box_id=box.id, slot=slot)) + return redirect(url_for("edit_component", box_id=box.id, slot=slot, q=search_query_post)) if action == "toggle_disable": if component: @@ -1239,7 +1414,7 @@ def edit_component(box_id: int, slot: int): component=component, ) db.session.commit() - return redirect(url_for("edit_component", box_id=box.id, slot=slot)) + return redirect(url_for("edit_component", box_id=box.id, slot=slot, q=search_query_post)) part_no = request.form.get("part_no", "").strip() name = request.form.get("name", "").strip() @@ -1256,6 +1431,7 @@ def edit_component(box_id: int, slot: int): slot_code=slot_code_for_box(box, slot), component=component, error=error, + search_query=search_query_post, ) try: @@ -1269,6 +1445,7 @@ def edit_component(box_id: int, slot: int): slot_code=slot_code_for_box(box, slot), component=component, error=error, + search_query=search_query_post, ) old_enabled_qty = 0 @@ -1299,6 +1476,8 @@ def edit_component(box_id: int, slot: int): ) db.session.commit() + if search_query_effective: + return redirect(url_for("search_page", q=search_query_effective)) return redirect(url_for("view_box", box_id=box.id)) return render_template( @@ -1307,24 +1486,28 @@ def edit_component(box_id: int, slot: int): slot=slot, slot_code=slot_code_for_box(box, slot), component=component, + search_query=search_query, ) -@app.route("/scan") -def scan_page(): +@app.route("/search") +def search_page(): keyword = request.args.get("q", "").strip() + notice = request.args.get("notice", "").strip() + error = request.args.get("error", "").strip() results = [] if keyword: raw_results = ( Component.query.join(Box, Box.id == Component.box_id) .filter( + Component.is_enabled.is_(True), db.or_( Component.part_no.ilike(f"%{keyword}%"), Component.name.ilike(f"%{keyword}%"), - ) + ), ) - .order_by(Component.part_no.asc()) + .order_by(Component.part_no.asc(), Component.name.asc()) .all() ) @@ -1335,10 +1518,52 @@ def scan_page(): "component": c, "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), + "edit_url": url_for("edit_component", box_id=c.box_id, slot=c.slot_index, q=keyword), } ) - return render_template("scan.html", keyword=keyword, results=results) + return render_template( + "search.html", + keyword=keyword, + results=results, + notice=notice, + error=error, + ) + + +@app.route("/component//outbound", methods=["POST"]) +def quick_outbound(component_id: int): + component = Component.query.get_or_404(component_id) + keyword = request.form.get("q", "").strip() + + try: + amount = _parse_non_negative_int(request.form.get("amount", "0"), 0) + except ValueError: + return redirect(url_for("search_page", q=keyword, error="出库数量必须是大于等于 0 的整数")) + + if amount <= 0: + return redirect(url_for("search_page", q=keyword, error="出库数量必须大于 0")) + + if not component.is_enabled: + return redirect(url_for("search_page", q=keyword, error="该元件已停用,不能出库")) + + if amount > int(component.quantity or 0): + return redirect(url_for("search_page", q=keyword, error="出库数量超过当前库存")) + + component.quantity = int(component.quantity or 0) - amount + box = Box.query.get(component.box_id) + log_inventory_event( + event_type="component_outbound", + delta=-amount, + box=box, + component=component, + part_no=component.part_no, + ) + db.session.commit() + + slot_code = slot_code_for_box(box, component.slot_index) if box else str(component.slot_index) + notice = f"出库成功: {component.part_no} -{amount}({slot_code})" + return redirect(url_for("search_page", q=keyword, notice=notice)) @app.route("/stats") @@ -1580,33 +1805,6 @@ def clear_stats_logs(): return redirect(url_for("stats_page", days=days, box_type=box_type_filter, notice=notice)) -@app.route("/api/scan") -def scan_api(): - code = request.args.get("code", "").strip() - if not code: - return {"ok": False, "message": "code 不能为空"}, 400 - - item = Component.query.filter_by(part_no=code).first() - if not item: - return {"ok": False, "message": "未找到元件"}, 404 - - box = Box.query.get(item.box_id) - return { - "ok": True, - "data": { - "id": item.id, - "part_no": item.part_no, - "name": item.name, - "quantity": item.quantity, - "box_id": item.box_id, - "box_name": box.name if box else None, - "slot_index": item.slot_index, - "slot_code": slot_code_for_box(box, item.slot_index) if box else str(item.slot_index), - "is_enabled": bool(item.is_enabled), - }, - } - - def bootstrap() -> None: with app.app_context(): db.create_all() diff --git a/static/css/style.css b/static/css/style.css index c26dd10..1b4c7f8 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -421,6 +421,34 @@ body { gap: var(--space-1); } +.type-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-1); +} + +.type-card-more { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--card-alt); + color: var(--muted); + text-decoration: none; + font-weight: 700; + line-height: 1; +} + +.type-card-more:hover { + color: var(--accent-press); + border-color: color-mix(in srgb, var(--accent) 60%, var(--line)); + background: color-mix(in srgb, var(--card-alt) 76%, var(--accent) 24%); +} + .catalog-content { min-width: 0; } @@ -772,6 +800,16 @@ input[type="checkbox"] { flex: 1; } +.search-outbound-form { + display: flex; + gap: 8px; + align-items: center; +} + +.search-outbound-form input[type="number"] { + width: 88px; +} + .table-wrap { overflow-x: auto; } diff --git a/static/js/scanner.js b/static/js/scanner.js deleted file mode 100644 index 60f9f4f..0000000 --- a/static/js/scanner.js +++ /dev/null @@ -1,45 +0,0 @@ -(function () { - const input = document.getElementById("scan-input"); - const form = document.getElementById("scan-search-form"); - - function showToast(message) { - let stack = document.querySelector(".toast-stack"); - if (!stack) { - stack = document.createElement("div"); - stack.className = "toast-stack"; - document.body.appendChild(stack); - } - - const toast = document.createElement("div"); - toast.className = "toast"; - toast.textContent = message; - stack.appendChild(toast); - - setTimeout(function () { - toast.remove(); - }, 1600); - } - - if (!input || !form) { - return; - } - - // Keep focus for barcode scanners that type and send Enter immediately. - window.addEventListener("load", function () { - input.focus(); - }); - - document.addEventListener("keydown", function (event) { - if (event.key === "Escape") { - input.value = ""; - input.focus(); - showToast("已清空搜索词"); - } - }); - - form.addEventListener("submit", function () { - if (input.value.trim()) { - showToast("正在搜索..."); - } - }); -})(); diff --git a/templates/box.html b/templates/box.html index 0de4106..99513b0 100644 --- a/templates/box.html +++ b/templates/box.html @@ -14,9 +14,10 @@ diff --git a/templates/edit.html b/templates/edit.html index 2265553..78d3057 100644 --- a/templates/edit.html +++ b/templates/edit.html @@ -13,6 +13,7 @@

步骤: 填写核心字段 -> 检查数量 -> 保存

@@ -26,6 +27,7 @@
+
@@ -109,6 +66,9 @@ {% if separate_mode %}{% endif %} + {% if key == 'custom' %} + + {% endif %} @@ -127,6 +87,9 @@ {% if item.box.box_type == 'bag' %}

编号前缀: {{ item.box.slot_prefix }} | 袋装清单不使用范围

已记录: {{ item.used_count }} 项

+ {% elif item.box.box_type == 'custom' %} +

格数: {{ item.box.slot_capacity }} | 编号前缀: {{ item.box.slot_prefix }} | 范围: {{ item.slot_range }}

+

已启用: {{ item.used_count }}/{{ item.box.slot_capacity }}

{% else %}

编号前缀: {{ item.box.slot_prefix }} | 范围: {{ item.slot_range }}

已启用: {{ item.used_count }}/{{ item.box.slot_capacity }}

@@ -162,6 +125,9 @@ {% if separate_mode %}{% endif %} + {% if item.box.box_type == 'custom' %} + + {% endif %} @@ -182,53 +148,6 @@ diff --git a/templates/scan.html b/templates/scan.html deleted file mode 100644 index 5ab159e..0000000 --- a/templates/scan.html +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - 扫码/搜索 - - - -
-
-

扫码 / 搜索

-

一步输入,直达元件位置与库存状态

-
- -
- -
-
- - - - - -

扫码枪通常会自动输入后回车,可直接触发搜索。

-
- -
-

搜索结果

- {% if keyword and results %} -
- - - - - - - - - - - - - {% for row in results %} - {% set c = row.component %} - - - - - - - - - {% endfor %} - -
料号名称库存位置状态操作
{{ c.part_no }}{{ c.name }}{{ c.quantity }}{{ row.box_name }} / {{ row.slot_code }}{% if c.is_enabled %}启用{% else %}停用{% endif %}编辑
-
- {% elif keyword %} -

未找到关键词 "{{ keyword }}" 的元件。

- {% else %} -

请输入关键词开始搜索。

- {% endif %} -
-
- - - - diff --git a/templates/search.html b/templates/search.html new file mode 100644 index 0000000..747089a --- /dev/null +++ b/templates/search.html @@ -0,0 +1,109 @@ + + + + + + 快速搜索 + + + +
+
+

快速搜索

+

按料号或名称搜索,点击可跳转到对应位置并直接出库

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

{{ error }}

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

{{ notice }}

+ {% endif %} + +
+
+ + +
+

出库只需要输入数量,系统会自动扣减库存并记录统计。

+
+ +
+

搜索结果

+
+ + + + + + + + + + + + + + {% for row in results %} + {% set c = row.component %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
料号名称规格库存位置跳转出库
{{ c.part_no }}{{ c.name }}{{ c.specification or '-' }}{{ c.quantity }}{{ row.box_name }} / {{ row.slot_code }}进入位置 +
+ + + +
+
{% if keyword %}未找到匹配元件{% else %}先输入关键字进行搜索{% endif %}
+
+
+
+ + + + diff --git a/templates/stats.html b/templates/stats.html index b030d0a..563ff9a 100644 --- a/templates/stats.html +++ b/templates/stats.html @@ -14,7 +14,7 @@
diff --git a/templates/type_edit.html b/templates/type_edit.html new file mode 100644 index 0000000..f914f37 --- /dev/null +++ b/templates/type_edit.html @@ -0,0 +1,47 @@ + + + + + + 编辑容器属性 + + + +
+
+

编辑容器属性

+

修改容器名称、默认描述和默认前缀

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

{{ error }}

+ {% endif %} + +
+
+ + + +
+ + 取消 +
+
+
+
+ + diff --git a/templates/types.html b/templates/types.html index 6fff779..d03d2ff 100644 --- a/templates/types.html +++ b/templates/types.html @@ -3,32 +3,83 @@ - 分类总览 + 仓库概览
-

分类总览

-

将容器拆分为独立界面,避免长页面翻找

+

仓库概览

+

先看关键指标与待补货,再进入对应分类处理

+ {% if error %} +

{{ error }}

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

{{ notice }}

+ {% endif %} + +
+
+

容器总数

+

{{ stats.box_count }}

+
+
+

启用元件

+

{{ stats.active_items }}

+
+
+

待补货元件

+

{{ stats.low_stock_count }}

+
+
+

近7天净变动

+

{% if stats.period_net_change_7d > 0 %}+{% endif %}{{ stats.period_net_change_7d }}

+
+
+
{% for item in type_cards %}
-

{{ item.label }}

+
+

{{ item.label }}

+ ... +

{{ item.count }}

{{ item.desc }}

+

容器 {{ item.count }} 个 | 启用元件 {{ item.item_count }} 种 | 总库存 {{ item.quantity }}

进入分类
{% endfor %}
+ +
+

低库存元器件

+ {% for group in low_stock_groups %} +

{{ group['label'] }}({{ group['items']|length }})

+
    + {% for item in group['items'] %} +
  • +
    + {{ item.name }} +

    {{ item.part_no }} | {{ item.box_name }} / {{ item.slot_code }} | 数量 {{ item.quantity }}

    +
    + 编辑 +
  • + {% else %} +
  • 当前分类没有低库存元器件。
  • + {% endfor %} +
+ {% endfor %} +