diff --git a/.gitignore b/.gitignore index afcb848..eaeef41 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,11 @@ __pycache__/ .venv/ venv/ -# ---- PyInstaller 打包产物(体积大, 不入库; EXE 走 GitLab Release 分发) ---- +# ---- PyInstaller 打包产物(体积大, 不入库; EXE 走 Release 分发) ---- build/ +build_pkg/ dist/ +dist_pkg/ *.spec # ---- 运行时日志(EXE 冻结时排查用) ---- @@ -22,6 +24,12 @@ _check_tmp/ # ---- 旧 EXE 备份(打包前移走, 非源码, 不入库) ---- _backup/ +# ---- SQLite 数据库(运行时自动生成, 不入库) ---- +*.db +*.sqlite +*.sqlite3 +instance/*.db + # ---- 编辑器 ---- .vscode/ .idea/ diff --git a/README.md b/README.md index bb2b80c..d713b4a 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,131 @@ -# 物料入库 / 出库系统 (M1 + M2 + M3 完整版) +# 物料入库 / 出库系统 (MaterialWMS) -本地 Web 系统,对标桌面 `物料明细表.xlsx` 的台账 + BOM 核算能力。Flask + SQLite,免外部数据库,即开即用。 -库存由 SQL 实时聚合(在库 = 入库 − 出库),彻底规避原 Excel 的 `[1]物料入库!D_` 外部链接断链风险。 +本地 Web 物料管理系统,对标桌面 `物料明细表.xlsx` 的台账 + BOM 核算能力。 +**Flask + SQLite**,免外部数据库,即开即用。库存由 SQL 实时聚合(在库 = 入库 − 出库), +彻底规避原 Excel 的 `[1]物料入库!D_` 外部链接断链风险。 -## 运行方式 +> 配套文档:`物料入库出库系统_使用教程.md`(完整功能教程)、`物料入库出库系统_用户使用说明.md`(面向操作员的使用手册)。 -**方式一 · 单文件 EXE(推荐车间用)** -`dist/MaterialWMS.exe` 双击即开,自动弹出浏览器到 http://127.0.0.1:8080。 -库文件 `app.db` 自动生成在 exe 同目录,换机器直接拷贝 exe 即可。 +--- -**方式二 · 源码(双击 bat)** -双击 `启动.bat` 启动;或命令行 `python app.py`。 +## 一、运行方式 + +**方式一 · 单文件 EXE(推荐车间用)** +`dist/MaterialWMS.exe` 双击即开,自动弹出浏览器到 http://127.0.0.1:8080。 +库文件 `app.db` 自动生成在 exe 同目录,换机器直接拷贝 exe 即可。 + +**方式二 · 源码(双击 bat)** +双击 `启动.bat` 启动;或命令行 `python app.py`(库文件生成在 `instance/app.db`)。 **方式三 · 指定库位置** -`set WMS_DB=D:/path/app.db` 后再启动,可把数据放到任意位置。 +`set WMS_DB=D:/path/app.db` 后再启动,可把数据放到任意位置。 -## 默认账号 -- 管理员 `admin / admin123`(首次启动自动建;可在「用户」页加 operator / manager) +默认账号:`admin / admin123`(首次启动自动建;可在「用户」页加 operator / manager)。 + +--- + +## 二、角色权限 -## 角色权限 | 角色 | 权限 | |---|---| -| admin | 全部,含用户管理 | -| manager | 物料/BOM/订单/报表/日志/Excel 导入导出 | +| admin | 全部,含用户管理、工厂管理、替代料管理 | +| manager | 物料/BOM/订单/报表/日志/Excel 导入导出/调拨/替代料 | | operator | 库存看板 / 物料查看 / 入库 / 出库 | -## 功能清单 +--- + +## 三、核心库存口径 + +- **在库 = Σ入库 − Σ出库**,全部实时聚合,不在库里冗余存储。 +- **厂间调拨**:从源厂出库减 + 目标厂入库加,**系统总库存不变**(只搬库,不消耗)。 +- **只有「出库 / 生产领料」会减少库存**;入库、调入(调拨)只做加项或库间转移。 +- **多工厂**:`factory` 表区分归属(`SELF`=自厂/总部为默认厂)。顶栏「工厂」下拉切换后, + 看板 / 物料 / 收发 / 流水全部按所选厂过滤;`全部` 显示跨厂汇总。 + +--- + +## 四、功能清单 + | 模块 | 说明 | |---|---| -| 库存看板 | 实时核算在库;低于安全库存标黄、库存为负标红,顶部统计负数/预警数 | -| 物料 | 主数据 CRUD(编号/型号/单位/安全库存)+ 搜索 | -| 入库 | 流水登记(批次/数量/经手人/来源),写入即更新库存 | -| 出库 | 流水登记,**库存不足自动拦截** | -| **批量导入窗口** | 入库/出库/物料/BOM 页「批量导入窗口」按钮弹出模态框:粘贴多行清单或上传 Excel/CSV,实时预览解析与校验,一键批量生成。各页格式见下 | -| 入库/出库批量窗口 | 每行 `物料编号 数量 批次(可选) 日期(可选) 备注(可选)`,校验物料存在/数量>0/出库库存充足 | -| 物料批量窗口 | 每行 `物料编号 型号/规格 单位 安全库存(可选) 期初库存(可选)`,校验重复编号;已存在编号显示「更新」,一键批量 upsert(含期初流水) | -| BOM 批量窗口 | ① **上传真实 Altium / 嘉立创 BOM 模板**(`Item/Part Number/Manufacture Part Number/Quantity/Reference/Part/Package`):每个 worksheet 视为一个 BOM,sheet 名即 BOM 型号,Part Number 关联物料、Quantity 列即单套用量(若存在两个 Quantity 列,取首列,首列空时回退次列);也支持中文「组装」sheet(物料代码 + 数量)。② 粘贴文本:以 `#BOM <型号> <名称>` 分段、其下 `物料编号 单套用量`。物料不存在的项预览时标「新建」,**导入时自动新建物料**(并写入型号/规格),一键多 BOM 落库 | -| **BOM(M2)** | BOM 型号 + 组成物料单套用量;支持 Excel 导入 | -| **生产订单(M2)** | 选 BOM + 接单量 → 自动展开「需求 = 单套用量 × 接单量」,一键「确认领料」生成出库,缺料在看板预警 | -| **Excel 双向(M2)** | 物料导入/导出、BOM 导入、库存报表导出、导入模板下载 | -| **报表(M3)** | 库存报表页,支持打印(浏览器打印)与导出 Excel | -| **操作日志(M3)** | 每次收发/BOM/订单/导入/用户操作全记录(谁/何时/做了什么) | -| **用户管理(M3)** | admin 增删改角色、启停账号 | +| **库存看板** | 实时核算在库;低于安全库存标黄、库存为负标红,顶部统计负数/预警数。BOM 标签页可按 BOM 型号查看「单套用量 × 接单量」的需求与齐套情况,并提供「模拟齐套」试算 | +| **物料** | 主数据 CRUD(编号/型号/单位/安全库存)+ 搜索;物料详情页展示各厂在库与流水 | +| **入库** | 流水登记(批次/数量/经手人/来源/**送货日期**),写入即更新库存 | +| **出库** | 流水登记,**库存不足自动拦截**;支持按 BOM 领料、物料编码扫码/输入自动定位;选物料后实时提示**可替代料**及库存 | +| **批量导入窗口** | 入库/出库/物料/BOM 页「批量导入窗口」按钮弹出模态框:粘贴多行清单或上传 Excel/CSV,实时预览解析与校验,一键批量生成 | +| **BOM** | BOM 型号 + 组成物料单套用量;支持上传真实 Altium / 嘉立创 BOM 模板(每个 worksheet 视为一个 BOM,sheet 名即 BOM 型号),或中文「组装」sheet;物料不存在的项导入时自动新建 | +| **生产订单** | 选 BOM + 接单量 → 自动展开「需求 = 单套用量 × 接单量」。支持**编辑 / 删除**(删除级联清除关联的领料出库与调入入库);订单详情页可填写每个物料的「调入数量」并**确认调入(厂间调拨)** | +| **厂间调拨** | 在厂之间转移物料:源厂出库减 + 目标厂入库加,总库存不变;调拨单双向流水自动对账,可在流水台账按「调拨」类型筛选 | +| **替代料关系** | 维护「主料 ↔ 替代料」对照(可 Excel 导入);出库、BOM 领料、生产订单领料时,主料库存不足自动提示可用替代料及其库存 | +| **流水台账** | 入库 + 出库 **按日期合并、折叠展示**(默认展开最新一天,可全部展开/折叠);支持业务类型筛选、单号搜索、**按日期导出为同一个 Excel 文件** | +| **报表** | 库存报表页,支持浏览器打印与导出 Excel | +| **操作日志** | 每次收发/BOM/订单/导入/用户操作全记录(谁/何时/做了什么) | +| **工厂管理** | admin/manager 增厂、停用/启用;`SELF` 为默认自厂 | +| **用户管理** | admin 增删改角色、启停账号 | -## 数据来源 -`init_db.py` 已从桌面 `物料明细表.xlsx` 的「物料入库」表导入 **439 条真实物料**, -把 Excel 库存数量作为"期初入库"写入,看板开箱即有数。 +--- + +## 五、Excel 模板字段速查 + +| 导入项 | 标准列(首行表头) | +|---|---| +| 物料 | 物料编号、型号/规格、单位、安全库存(可选)、期初库存(可选) | +| 入库 | 物料编号、数量、日期(可选)、批次(可选)、来源(可选)、经手人(可选) | +| 出库 | 物料编号、数量、日期(可选)、用途(可选)、经手人(可选) | +| BOM | 支持 Altium/嘉立创模板(Item / Part Number / Manufacture Part Number / Quantity / Reference / Part / Package),或中文「物料代码 + 数量」 | +| 替代料 | 「替代料」sheet:主料编号、替代料编号(可多行同一主料) | + +各页均有「下载模板」按钮可获取标准 Excel 模板。 + +--- + +## 六、目录结构 -## 目录结构 ``` material_wms/ -├─ app.py # 路由 + 鉴权 + 库存核算 + 冻结路径处理 -├─ models.py # SQLAlchemy 2.0 模型(user/material/inbound/outbound/bom/bom_item/prod_order/operation_log) -├─ excel_io.py # Excel 双向导入导出 + 模板生成 -├─ init_db.py # 建表 + 管理员 + Excel 导入真实物料 -├─ build_exe.py # PyInstaller 单文件打包脚本 -├─ requirements.txt +├─ app.py # 路由 + 鉴权 + 库存核算 + 冻结路径处理 +├─ models.py # SQLAlchemy 2.0 模型(user/material/inbound/outbound/ +│ # bom/bom_item/prod_order/prod_order_alloc/factory/ +│ # transfer/substitute/operation_log) +├─ excel_io.py # Excel 双向导入导出 + 模板生成 +├─ init_db.py # 建表 + 管理员 + Excel 导入真实物料 +├─ build_exe.py # PyInstaller 单文件打包脚本(--distpath dist_pkg 避开安全删除钩子) +├─ requirements.txt # flask / flask-sqlalchemy / sqlalchemy / openpyxl / pyinstaller ├─ 启动.bat -├─ templates/ # 登录/看板/物料/收发/BOM/订单/报表/日志/用户/导入 共 15 个页面 +├─ templates/ # 登录/看板/物料/收发/BOM/订单/调拨/替代料/流水/报表/日志/用户/导入 共 22 个页面 ├─ static/style.css -├─ instance/app.db # SQLite 库(源码模式自动生成) -├─ dist/MaterialWMS.exe # 打包产物(单文件) -└─ tools/ # e2e_test.py(全量冒烟) / check_server.py +├─ tools/ # e2e_test.py(全量冒烟) / check_*.py(各功能校验) / verify_*.py +├─ instance/app.db # SQLite 库(源码模式自动生成, 已 gitignore) +└─ dist/MaterialWMS.exe # 打包产物(单文件, 已 gitignore) ``` -## 打包 EXE +--- + +## 七、打包 EXE + ```bat python build_exe.py ``` -产物在 `dist/MaterialWMS.exe`。冻结后模板/静态走 `sys._MEIPASS`,库文件落 exe 同目录。 -## 测试 +产物在 `dist/MaterialWMS.exe`。冻结后模板/静态走 `sys._MEIPASS`,库文件落 exe 同目录。 +构建脚本使用 `--distpath dist_pkg` 以避开某些环境的「安全删除」钩子,构建完成后再复制到 `dist/`。 + +--- + +## 八、测试 + ```bat -python tools/e2e_test.py # 覆盖 登录/收发核算/BOM/订单展开/领料/日志/报表/导入导出/入库·物料·BOM 批量导入窗口/角色拦截 +python tools/e2e_test.py ``` + +覆盖:登录 / 收发核算 / BOM / 订单展开 / 领料 / 日志 / 报表 / 导入导出 / +入库·物料·BOM 批量导入窗口 / 角色拦截 / 替代料提示 / 厂间调拨对账。 + +--- + +## 九、部署与数据备份 + +- 数据唯一真源为 `dist/app.db`(EXE 同目录);搬 EXE 时务必把 `app.db` 一起带走。 +- 更新 EXE 后**务必结束旧进程**(Windows:`Stop-Process -Name MaterialWMS -Force`)再重启, + 否则浏览器连的是旧代码,会表现为「新功能点不了 / 没按钮」。 +- 重大操作前复制 `app.db` 为 `.bak` 备份,可随时覆盖还原。 diff --git a/app.py b/app.py index 46c0768..16d7d24 100644 --- a/app.py +++ b/app.py @@ -18,7 +18,7 @@ from flask import (Flask, render_template, request, redirect, url_for, session, flash, g, send_file, jsonify) from models import (db, User, Material, Inbound, Outbound, - Bom, BomItem, ProdOrder, OperationLog) + Bom, BomItem, ProdOrder, ProdOrderAlloc, OperationLog, Factory, Transfer, Substitute) # ---------------- 路径(兼容 PyInstaller 冻结) ---------------- if getattr(sys, "frozen", False): @@ -26,7 +26,9 @@ if getattr(sys, "frozen", False): EXE_DIR = os.path.dirname(sys.executable) # exe 同目录(可写) else: BASE = os.path.dirname(os.path.abspath(__file__)) - EXE_DIR = BASE + # 开发/测试模式也统一指向 dist/app.db,避免与打包版数据分家(两套库各存各的) + _dist_dir = os.path.join(BASE, "dist") + EXE_DIR = _dist_dir if os.path.isdir(_dist_dir) else BASE template_folder = os.path.join(BASE, "templates") static_folder = os.path.join(BASE, "static") @@ -74,6 +76,30 @@ def load_user(): g.user = db.session.get(User, uid) if uid else None +# ---------------- M2-1: 工厂维度(全局筛选) ---------------- +def current_fid(): + """当前选中的工厂 id; None 表示"全部工厂(汇总)"。""" + return session.get("factory_id") + + +@app.context_processor +def inject_factory_ctx(): + """向所有模板注入工厂列表与当前选中工厂, 供顶栏切换器使用。""" + fids = [] + cur = None + try: + fids = db.session.scalars( + db.select(Factory).where(Factory.enabled == True).order_by(Factory.code) + ).all() + fid = session.get("factory_id") + if fid: + cur = db.session.get(Factory, fid) + except Exception: + pass + return {"factories": fids, "current_factory": cur, + "current_factory_id": session.get("factory_id")} + + def log_action(action, target="", detail=""): try: db.session.add(OperationLog( @@ -85,15 +111,17 @@ def log_action(action, target="", detail=""): # ---------------- 库存核算 ---------------- -def stock_map(): - """返回 {material_id: 在库数量}""" +def stock_map(factory_id=None): + """返回 {material_id: 在库数量}; factory_id 指定时只统计该工厂。""" + where = " WHERE factory_id = :fid" if factory_id else "" + params = {"fid": factory_id} if factory_id else {} rows = db.session.execute( - db.text("SELECT material_id, COALESCE(SUM(qty),0) FROM inbound GROUP BY material_id") - ).fetchall() + db.text(f"SELECT material_id, COALESCE(SUM(qty),0) FROM inbound{where} GROUP BY material_id"), + params).fetchall() inb = {r[0]: float(r[1]) for r in rows} rows = db.session.execute( - db.text("SELECT material_id, COALESCE(SUM(qty),0) FROM outbound GROUP BY material_id") - ).fetchall() + db.text(f"SELECT material_id, COALESCE(SUM(qty),0) FROM outbound{where} GROUP BY material_id"), + params).fetchall() out = {r[0]: float(r[1]) for r in rows} ids = set(inb) | set(out) return {i: inb.get(i, 0) - out.get(i, 0) for i in ids} @@ -108,22 +136,81 @@ def _status_of(stock, safety): return "ok" -def inbound_total_map(): - """返回 {material_id: 入库总数量}""" +def inbound_total_map(factory_id=None): + """返回 {material_id: 入库总数量}; factory_id 指定时只统计该工厂。""" + where = " WHERE factory_id = :fid" if factory_id else "" + params = {"fid": factory_id} if factory_id else {} rows = db.session.execute( - db.text("SELECT material_id, COALESCE(SUM(qty),0) FROM inbound GROUP BY material_id") - ).fetchall() + db.text(f"SELECT material_id, COALESCE(SUM(qty),0) FROM inbound{where} GROUP BY material_id"), + params).fetchall() return {r[0]: float(r[1]) for r in rows} -def outbound_total_map(): - """返回 {material_id: 消耗(出库)总数量}""" +def outbound_total_map(factory_id=None): + """返回 {material_id: 消耗(出库)总数量}; factory_id 指定时只统计该工厂。""" + where = " WHERE factory_id = :fid" if factory_id else "" + params = {"fid": factory_id} if factory_id else {} rows = db.session.execute( - db.text("SELECT material_id, COALESCE(SUM(qty),0) FROM outbound GROUP BY material_id") - ).fetchall() + db.text(f"SELECT material_id, COALESCE(SUM(qty),0) FROM outbound{where} GROUP BY material_id"), + params).fetchall() return {r[0]: float(r[1]) for r in rows} +# ---------------- 替代料关系 ---------------- +def substitute_rows(main_id, factory_id=None): + """返回主料 main_id 的可替代料列表: [{sub:Material, stock:float, available:bool}]。""" + sm = stock_map(factory_id) + subs = db.session.scalars( + db.select(Substitute).where(Substitute.main_id == main_id)).all() + out = [] + for s in subs: + sub_m = db.session.get(Material, s.sub_id) + if not sub_m: + continue + st = sm.get(sub_m.id, 0) + out.append({"sub": sub_m, "stock": st, "available": st > 0}) + return out + + +def import_substitutes_from_excel(path): + """从替代料 Excel(含'替代料' sheet)解析主料↔替代料关系并写入 substitute 表。 + + 返回 (added, skipped, missing): 新增条数 / 跳过(已存在)条数 / 物料未找到条数。 + """ + from openpyxl import load_workbook + wb = load_workbook(path, read_only=True, data_only=True) + ws = wb["替代料"] if "替代料" in wb.sheetnames else wb.worksheets[0] + rows = list(ws.iter_rows(values_only=True)) + groups = {} + cur = None + for r in rows[2:]: + seq, main, main_spec, sub, sub_spec, _ = (list(r) + [None] * 6)[:6] + if main is not None and str(main).strip() not in ("", "None"): + cur = str(main).strip() + groups.setdefault(cur, set()) + if sub is not None and str(sub).strip() not in ("", "None") and cur: + groups[cur].add(str(sub).strip()) + added = skipped = missing = 0 + for main_pn, subs in groups.items(): + m = db.session.scalar(db.select(Material).where(Material.part_number == main_pn)) + if not m: + missing += len(subs) + continue + for sp in subs: + sm_ = db.session.scalar(db.select(Material).where(Material.part_number == sp)) + if not sm_: + missing += 1 + continue + if db.session.scalar(db.select(Substitute).where( + Substitute.main_id == m.id, Substitute.sub_id == sm_.id)): + skipped += 1 + continue + db.session.add(Substitute(main_id=m.id, sub_id=sm_.id)) + added += 1 + db.session.commit() + return added, skipped, missing + + def _date(s): if s: try: @@ -275,11 +362,155 @@ def logout(): return redirect(url_for("login")) +@app.route("/set_factory", methods=["POST"]) +@login_required +def set_factory(): + """顶栏工厂切换: 选择某厂则按该厂过滤, 选"全部"则汇总。""" + val = (request.form.get("factory_id") or "").strip() + if val == "" or val == "all": + session.pop("factory_id", None) + else: + try: + session["factory_id"] = int(val) + except ValueError: + session.pop("factory_id", None) + nxt = request.form.get("next") or request.referrer or url_for("dashboard") + return redirect(nxt) + + +# ---------------- M2-1: 工厂管理 ---------------- +@app.route("/factories", methods=["GET", "POST"]) +@login_required +@role_required("admin", "manager") +def factories(): + if request.method == "POST": + code = (request.form.get("code") or "").strip().upper() + name = (request.form.get("name") or "").strip() + ftype = request.form.get("type") or "self" + if not code: + flash("工厂编码必填", "error") + elif db.session.scalar(db.select(Factory).where(Factory.code == code)): + flash("工厂编码已存在", "error") + else: + db.session.add(Factory(code=code, name=name, type=ftype, enabled=True)) + db.session.commit() + log_action("新增工厂", code, name) + flash("工厂已添加", "ok") + return redirect(url_for("factories")) + fs = db.session.scalars(db.select(Factory).order_by(Factory.code)).all() + return render_template("factories.html", fs=fs) + + +@app.route("/factory/toggle/", methods=["POST"]) +@login_required +@role_required("admin", "manager") +def factory_toggle(fid): + f = db.session.get(Factory, fid) + if f: + f.enabled = not f.enabled + db.session.commit() + log_action("切换工厂状态", f.code, "启用" if f.enabled else "停用") + flash(f"工厂 {f.code} 已{'启用' if f.enabled else '停用'}", "ok") + return redirect(url_for("factories")) + + +# ---------------- M2-1+: 工厂工作台(物料看板 / BOM / 批量导入) ---------------- +@app.route("/factory/") +@login_required +@role_required("admin", "manager") +def factory_workbench(fid): + f = db.session.get(Factory, fid) + if not f: + return redirect(url_for("factories")) + mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all() + sm = stock_map(fid) + rows = [] + negative = low = 0 + for m in mats: + st = sm.get(m.id, 0) + if st == 0: + continue + status = _status_of(st, m.safety_stock) + if status == "danger": + negative += 1 + elif status == "warn": + low += 1 + rows.append({"m": m, "stock": st, "status": status}) + bs = db.session.scalars(db.select(Bom).order_by(Bom.code)).all() + bom_data = [] + for b in bs: + items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == b.id)).all() + comps = [] + for it in items: + mm = db.session.get(Material, it.material_id) + st = sm.get(mm.id, 0) + comps.append({"m": mm, "qty": it.qty_per_unit, "stock": st, + "status": _status_of(st, mm.safety_stock)}) + bom_data.append({"b": b, "comps": comps}) + return render_template("factory_workbench.html", f=f, rows=rows, + negative=negative, low=low, bom_data=bom_data) + + +@app.route("/factory//import_inbound", methods=["POST"]) +@login_required +@role_required("admin", "manager") +def factory_import_inbound(fid): + f = db.session.get(Factory, fid) + if not f: + return redirect(url_for("factories")) + fl = request.files.get("file") + if not fl or not fl.filename: + flash("请选择文件", "error") + else: + tmp = os.path.join(EXE_DIR, f"_tmp_txn_{fid}.xlsx") + fl.save(tmp) + from excel_io import read_transactions + rows = read_transactions(tmp) + os.remove(tmp) + added = 0 + for r in rows: + m = db.session.scalar(db.select(Material).where(Material.part_number == r["part_number"])) + if not m or (r.get("qty") or 0) <= 0: + continue + db.session.add(Inbound(material_id=m.id, factory_id=fid, qty=r["qty"], + batch_no=r.get("batch_no", ""), source="批量导入", + occur_date=_date(r.get("date", "")), remark=r.get("remark", ""), + operator=(g.user.display_name or g.user.username))) + added += 1 + db.session.commit() + log_action("工厂批量导入入库", f.code, f"新增 {added} 条") + flash(f"已导入 {added} 条入库流水到 {f.code}", "ok") + return redirect(url_for("factory_workbench", fid=fid)) + + +@app.route("/factory//import_materials", methods=["POST"]) +@login_required +@role_required("admin", "manager") +def factory_import_materials(fid): + f = db.session.get(Factory, fid) + if not f: + return redirect(url_for("factories")) + fl = request.files.get("file") + if not fl or not fl.filename: + flash("请选择文件", "error") + else: + tmp = os.path.join(EXE_DIR, f"_tmp_mat_{fid}.xlsx") + fl.save(tmp) + from excel_io import read_materials, import_materials as imp + rows = read_materials(tmp) + os.remove(tmp) + added, updated = imp(rows, operator=(g.user.display_name or g.user.username), factory_id=fid) + log_action("工厂批量导入物料", f.code, f"新增 {added} / 更新 {updated}") + flash(f"物料导入完成: 新增 {added} 条, 更新 {updated} 条(期初库存归属 {f.code})", "ok") + return redirect(url_for("factory_workbench", fid=fid)) + + @app.route("/dashboard") @login_required def dashboard(): + fid = current_fid() mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all() - sm = stock_map() + sm = stock_map(fid) rows = [] negative = 0 low = 0 @@ -325,15 +556,16 @@ def dashboard(): @app.route("/materials") @login_required def materials(): + fid = current_fid() q = request.args.get("q", "").strip() stmt = db.select(Material) if q: like = f"%{q}%" stmt = stmt.where((Material.part_number.like(like)) | (Material.manufacturer.like(like))) mats = db.session.scalars(stmt.order_by(Material.part_number)).all() - sm = stock_map() - inb_total = inbound_total_map() - out_total = outbound_total_map() + sm = stock_map(fid) + inb_total = inbound_total_map(fid) + out_total = outbound_total_map(fid) return render_template("materials.html", mats=mats, sm=sm, inb_total=inb_total, out_total=out_total, q=q) @@ -402,22 +634,27 @@ def material_detail(mid): m = db.session.get(Material, mid) if not m: return redirect(url_for("materials")) - sm = stock_map() - inb_total = inbound_total_map() - out_total = outbound_total_map() + fid = current_fid() + sm = stock_map(fid) + inb_total = inbound_total_map(fid) + out_total = outbound_total_map(fid) + stmt_in = db.select(Inbound).where(Inbound.material_id == mid) + stmt_out = db.select(Outbound).where(Outbound.material_id == mid) + if fid: + stmt_in = stmt_in.where(Inbound.factory_id == fid) + stmt_out = stmt_out.where(Outbound.factory_id == fid) inbounds = db.session.scalars( - db.select(Inbound).where(Inbound.material_id == mid) - .order_by(Inbound.occur_date.desc(), Inbound.id.desc()) + stmt_in.order_by(Inbound.occur_date.desc(), Inbound.id.desc()) ).all() outbounds = db.session.scalars( - db.select(Outbound).where(Outbound.material_id == mid) - .order_by(Outbound.occur_date.desc(), Outbound.id.desc()) + stmt_out.order_by(Outbound.occur_date.desc(), Outbound.id.desc()) ).all() + fmap = {f.id: f.name for f in db.session.scalars(db.select(Factory)).all()} return render_template("material_detail.html", m=m, stock=sm.get(m.id, 0), inb_total=inb_total.get(m.id, 0), out_total=out_total.get(m.id, 0), - inbounds=inbounds, outbounds=outbounds) + inbounds=inbounds, outbounds=outbounds, fmap=fmap) # ---------------- 入库 / 出库 ---------------- @@ -432,8 +669,11 @@ def inbound(): else: dlv = request.form.get("delivery_date", "").strip() delivery_date = _date(dlv) if dlv else None + fid_raw = request.form.get("factory_id", "").strip() + factory_id = int(fid_raw) if fid_raw.isdigit() else None rec = Inbound( - material_id=mid, batch_no=request.form.get("batch_no", ""), + material_id=mid, factory_id=factory_id, + batch_no=request.form.get("batch_no", ""), qty=qty, operator=request.form.get("operator", ""), source=request.form.get("source", ""), occur_date=_date(request.form.get("occur_date")), @@ -444,8 +684,12 @@ def inbound(): log_action("入库", db.session.get(Material, mid).part_number, f"{qty}") flash("入库成功", "ok") return redirect(url_for("inbound")) + fid = current_fid() mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all() - recs = db.session.scalars(db.select(Inbound).order_by(Inbound.id.desc()).limit(50)).all() + stmt = db.select(Inbound).order_by(Inbound.id.desc()).limit(50) + if fid: + stmt = stmt.where(Inbound.factory_id == fid) + recs = db.session.scalars(stmt).all() return render_template("txn.html", kind="inbound", mats=mats, recs=recs, today=date.today().isoformat()) @@ -458,12 +702,15 @@ def outbound(): if qty <= 0: flash("出库数量必须大于 0", "error") else: - stock = stock_map().get(mid, 0) + stock = stock_map(current_fid()).get(mid, 0) if qty > stock: flash(f"库存不足! 当前在库 {stock}", "error") else: + fid_raw = request.form.get("factory_id", "").strip() + factory_id = int(fid_raw) if fid_raw.isdigit() else None rec = Outbound( - material_id=mid, batch_no=request.form.get("batch_no", ""), + material_id=mid, factory_id=factory_id, + batch_no=request.form.get("batch_no", ""), qty=qty, operator=request.form.get("operator", ""), purpose=request.form.get("purpose", ""), order_ref=request.form.get("order_ref", ""), @@ -474,9 +721,407 @@ def outbound(): log_action("出库", db.session.get(Material, mid).part_number, f"{qty}") flash("出库成功", "ok") return redirect(url_for("outbound")) + fid = current_fid() mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all() - recs = db.session.scalars(db.select(Outbound).order_by(Outbound.id.desc()).limit(50)).all() - return render_template("txn.html", kind="outbound", mats=mats, recs=recs, today=date.today().isoformat()) + boms = db.session.scalars(db.select(Bom).order_by(Bom.code)).all() + stmt = db.select(Outbound).order_by(Outbound.id.desc()).limit(50) + if fid: + stmt = stmt.where(Outbound.factory_id == fid) + recs = db.session.scalars(stmt).all() + return render_template("txn.html", kind="outbound", mats=mats, boms=boms, recs=recs, today=date.today().isoformat()) + + +# ---------------- M2-1+: 出库 / 调拨 的 BOM 选项 ---------------- +@app.route("/bom_issue", methods=["POST"]) +@login_required +def bom_issue(): + """按 BOM 批量出库/入库: + - mode=pick : 按BOM齐套领料出库(从本厂库存扣减每种组件) + - mode=transfer : 按BOM调拨入库(把成套组件调入目标工厂) + """ + mode = request.form.get("mode", "pick") + try: + bom_id = int(request.form.get("bom_id")) + qty = float(request.form.get("qty") or 0) + except (TypeError, ValueError): + flash("参数错误", "error") + return redirect(url_for("outbound")) + fid_raw = (request.form.get("factory_id") or "").strip() + factory_id = int(fid_raw) if fid_raw.isdigit() else None + operator = request.form.get("operator", "") + occur_date = _date(request.form.get("occur_date")) + remark = request.form.get("remark", "") + if qty <= 0: + flash("数量必须大于 0", "error") + return redirect(url_for("outbound")) + b = db.session.get(Bom, bom_id) + if not b: + flash("BOM 不存在", "error") + return redirect(url_for("outbound")) + if not factory_id: + flash("请选择工厂", "error") + return redirect(url_for("outbound")) + items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == bom_id)).all() + if not items: + flash("该 BOM 没有组成物料", "error") + return redirect(url_for("outbound")) + plan = [] + shorts = [] + for it in items: + m = db.session.get(Material, it.material_id) + need = it.qty_per_unit * qty + if need <= 0: + continue + stock = stock_map(factory_id).get(m.id, 0) + plan.append((m, need, stock)) + if mode == "pick" and need > stock: + shorts.append(m.part_number) + if mode == "pick" and shorts: + flash(f"库存不足, 无法领料! 缺料: {', '.join(shorts)}", "error") + return redirect(url_for("outbound")) + tag = b.code + for m, need, stock in plan: + if mode == "pick": + db.session.add(Outbound(material_id=m.id, factory_id=factory_id, + qty=need, operator=operator, + purpose=f"BOM领料:{tag}", occur_date=occur_date, + remark=remark)) + else: + db.session.add(Inbound(material_id=m.id, factory_id=factory_id, + qty=need, operator=operator, + source=f"BOM调拨:{tag}", occur_date=occur_date, + remark=remark)) + db.session.commit() + log_action("BOM领料出库" if mode == "pick" else "BOM调拨入库", tag, f"数量 {qty}, 组件 {len(plan)} 项") + flash(f"{'领料出库' if mode=='pick' else '调拨入库'}成功: BOM {tag} × {qty}, 共 {len(plan)} 种组件", "ok") + return redirect(url_for("outbound")) + + +@app.route("/api/bom/preview") +@login_required +def bom_preview(): + """BOM 组件需求预览(含按厂库存与缺料判定), 供出库页 BOM 卡片实时预览。""" + try: + bom_id = int(request.args.get("bom_id", 0)) + qty = float(request.args.get("qty") or 0) + except (TypeError, ValueError): + return {"items": [], "any_short": False} + fid_raw = (request.args.get("factory_id") or "").strip() + factory_id = int(fid_raw) if fid_raw.isdigit() else None + mode = request.args.get("mode", "pick") + items = [] + if bom_id and qty > 0: + sm = stock_map(factory_id) + for it in db.session.scalars(db.select(BomItem).where(BomItem.bom_id == bom_id)).all(): + m = db.session.get(Material, it.material_id) + need = it.qty_per_unit * qty + stock = sm.get(m.id, 0) + short = (mode == "pick" and need > stock) + subs = [{"pn": s["sub"].part_number, "mf": s["sub"].manufacturer or "-", + "stock": round(s["stock"], 3), "available": s["available"]} + for s in substitute_rows(m.id, factory_id)] + items.append({"pn": m.part_number, "mf": m.manufacturer or "-", + "need": round(need, 3), "stock": round(stock, 3), + "short": short, "subs": subs}) + return {"items": items, "any_short": any(i["short"] for i in items)} + + +# ---------------- M2-2: 厂间调拨 ---------------- +@app.route("/transfer/new", methods=["GET", "POST"]) +@login_required +@role_required("admin", "manager") +def transfer_new(): + """新建厂间调拨: 源厂出库 + 目标厂入库(双向记账, 实时校验源厂库存)。""" + factories = db.session.scalars( + db.select(Factory).where(Factory.enabled == True).order_by(Factory.code)).all() + materials = db.session.scalars( + db.select(Material).order_by(Material.part_number)).all() + if request.method == "POST": + try: + from_id = int(request.form.get("from_factory_id")) + to_id = int(request.form.get("to_factory_id")) + material_id = int(request.form.get("material_id")) + qty = float(request.form.get("qty") or 0) + except (TypeError, ValueError): + flash("参数错误", "error") + return redirect(url_for("transfer_new")) + operator = (request.form.get("operator", "") or "").strip() + occur_date = _date(request.form.get("occur_date")) + remark = (request.form.get("remark", "") or "").strip() + if qty <= 0: + flash("调拨数量必须大于 0", "error") + return redirect(url_for("transfer_new")) + if from_id == to_id: + flash("调出工厂与调入工厂不能相同", "error") + return redirect(url_for("transfer_new")) + ff = db.session.get(Factory, from_id) + tf = db.session.get(Factory, to_id) + m = db.session.get(Material, material_id) + if not (ff and tf and m): + flash("工厂或物料不存在", "error") + return redirect(url_for("transfer_new")) + src_stock = stock_map(from_id).get(material_id, 0) + if qty > src_stock: + flash(f"源厂 {ff.code} 库存不足: 现有 {src_stock}, 需调拨 {qty}", "error") + return redirect(url_for("transfer_new")) + op = g.user.display_name or g.user.username + no = _gen_transfer_no() + db.session.add(Transfer(no=no, from_factory_id=from_id, to_factory_id=to_id, + material_id=material_id, qty=qty, operator=op, + occur_date=occur_date, remark=remark)) + db.session.add(Outbound(material_id=material_id, factory_id=from_id, qty=qty, + operator=op, purpose="厂间调拨出库", order_ref=no, + occur_date=occur_date, remark=remark or f"调往 {tf.code}")) + db.session.add(Inbound(material_id=material_id, factory_id=to_id, qty=qty, + operator=op, source="厂间调拨入库", + occur_date=occur_date, remark=remark or f"自 {ff.code} 调入")) + db.session.commit() + log_action("厂间调拨", no, f"{ff.code} → {tf.code}: {m.part_number} × {qty}") + flash(f"调拨成功: {ff.code} → {tf.code}, {m.part_number} × {qty}", "ok") + return redirect(url_for("transfers")) + cur = current_fid() + return render_template("transfer_form.html", factories=factories, materials=materials, + current_fid=cur, today=date.today().isoformat()) + + +@app.route("/transfers") +@login_required +def transfers(): + """厂间调拨列表(当前选中某厂时, 仅显示调出/调入涉及该厂的记录)。""" + fid = current_fid() + recs = db.session.scalars( + db.select(Transfer).order_by(Transfer.created_at.desc())).all() + if fid: + recs = [t for t in recs if t.from_factory_id == fid or t.to_factory_id == fid] + rows = [] + for t in recs: + ff = db.session.get(Factory, t.from_factory_id) + tf = db.session.get(Factory, t.to_factory_id) + m = db.session.get(Material, t.material_id) + rows.append({"t": t, "ff": ff, "tf": tf, "m": m}) + return render_template("transfers.html", rows=rows, factory=fid) + + +# ---------------- M3+: 替代料关系管理 ---------------- +@app.route("/substitutes", methods=["GET", "POST"]) +@login_required +@role_required("admin", "manager") +def substitutes(): + fid = current_fid() + mats = db.session.scalars(db.select(Material).order_by(Material.part_number)).all() + if request.method == "POST" and request.form.get("action") == "add": + try: + main_id = int(request.form.get("main_id")) + sub_id = int(request.form.get("sub_id")) + except (TypeError, ValueError): + flash("参数错误", "error") + return redirect(url_for("substitutes")) + note = (request.form.get("note") or "").strip() + if main_id == sub_id: + flash("主料与替代料不能相同", "error") + elif db.session.scalar(db.select(Substitute).where( + Substitute.main_id == main_id, Substitute.sub_id == sub_id)): + flash("该替代关系已存在", "error") + else: + mm = db.session.get(Material, main_id) + sm_ = db.session.get(Material, sub_id) + db.session.add(Substitute(main_id=main_id, sub_id=sub_id, note=note)) + db.session.commit() + log_action("替代料", mm.part_number if mm else main_id, + f"新增替代 {sm_.part_number if sm_ else sub_id}") + flash("已添加替代关系", "ok") + return redirect(url_for("substitutes")) + sm = stock_map(fid) + rows = [] + for s in db.session.scalars(db.select(Substitute).order_by(Substitute.id)).all(): + m = db.session.get(Material, s.main_id) + sub_m = db.session.get(Material, s.sub_id) + if not (m and sub_m): + continue + rows.append({"id": s.id, "main": m, "sub": sub_m, "note": s.note or "", + "stock": sm.get(sub_m.id, 0)}) + return render_template("substitutes.html", rows=rows, mats=mats, factory=fid) + + +@app.route("/substitute/delete/", methods=["POST"]) +@login_required +@role_required("admin", "manager") +def substitute_delete(sid): + s = db.session.get(Substitute, sid) + if s: + db.session.delete(s) + db.session.commit() + flash("已删除替代关系", "ok") + return redirect(url_for("substitutes")) + + +@app.route("/substitute/import", methods=["POST"]) +@login_required +@role_required("admin", "manager") +def substitute_import(): + f = request.files.get("file") + if not f: + flash("未选择文件", "error") + return redirect(url_for("substitutes")) + import tempfile, os + tmp = os.path.join(tempfile.gettempdir(), "sub_import.xlsx") + f.save(tmp) + try: + added, skipped, missing = import_substitutes_from_excel(tmp) + flash(f"导入完成: 新增 {added} 条, 跳过(已存在/无效) {skipped} 条, 物料未找到 {missing} 条", "ok") + except Exception as e: + flash(f"导入失败: {e}", "error") + finally: + try: + os.remove(tmp) + except OSError: + pass + return redirect(url_for("substitutes")) + + +@app.route("/api/substitutes/") +@login_required +def api_substitutes(mid): + """返回主料 mid 的可替代料(含当前厂库存), 供出库/BOM 领料实时提示。""" + fid = current_fid() + sm = stock_map(fid) + main_stock = sm.get(mid, 0) + subs = substitute_rows(mid, fid) + return { + "main_stock": round(main_stock, 3), + "subs": [{"id": s["sub"].id, "pn": s["sub"].part_number, + "mf": s["sub"].manufacturer or "-", + "stock": round(s["stock"], 3), + "available": s["available"]} for s in subs], + } + + +@app.route("/api/transfer/preview") +@login_required +def transfer_preview(): + """调拨源厂库存预览: 返回源厂在库与是否充足。""" + try: + material_id = int(request.args.get("material_id", 0)) + from_id = int(request.args.get("from_factory_id", 0)) + qty = float(request.args.get("qty") or 0) + except (TypeError, ValueError): + return {"stock": 0, "short": False} + stock = stock_map(from_id).get(material_id, 0) if from_id else 0 + return {"stock": round(stock, 3), "short": qty > stock} + + +def _build_ledger_rows(fid, frm, to, type_filter=None, q=None): + """返回流水台账行(已筛选+排序), 供页面与导出复用。""" + inb = db.session.scalars(db.select(Inbound)).all() + out = db.session.scalars(db.select(Outbound)).all() + if fid: + inb = [x for x in inb if (x.factory_id or 0) == fid] + out = [x for x in out if (x.factory_id or 0) == fid] + mats = {m.id: m for m in db.session.scalars(db.select(Material)).all()} + facs = {f.id: f for f in db.session.scalars(db.select(Factory)).all()} + + def classify(who): + if who and "调拨" in who: + return "调拨" + if who and "生产" in who: + return "生产" + return "其他" + + rows = [] + for x in inb: + d = x.occur_date + if (frm and d < frm) or (to and d > to): + continue + who = x.source + rows.append({"date": d, "type": "入", "m": mats.get(x.material_id), + "qty": x.qty, "fac": facs.get(x.factory_id), + "who": who, "ref": x.order_ref, "op": x.operator, + "cat": classify(who)}) + for x in out: + d = x.occur_date + if (frm and d < frm) or (to and d > to): + continue + who = x.purpose + rows.append({"date": d, "type": "出", "m": mats.get(x.material_id), + "qty": x.qty, "fac": facs.get(x.factory_id), + "who": who, "ref": x.order_ref, "op": x.operator, + "cat": classify(who)}) + + # 业务类型筛选 + if type_filter: + if type_filter == "in": + rows = [r for r in rows if r["type"] == "入"] + elif type_filter == "out": + rows = [r for r in rows if r["type"] == "出"] + elif type_filter == "transfer": + rows = [r for r in rows if r["cat"] == "调拨"] + elif type_filter == "prod": + rows = [r for r in rows if r["cat"] == "生产"] + elif type_filter == "other": + rows = [r for r in rows if r["cat"] == "其他"] + # 调拨/关联单号 关键字搜索(如 TR... / WO...) + if q: + ql = q.strip().lower() + if ql: + rows = [r for r in rows if (r["ref"] or "").lower().find(ql) >= 0] + rows.sort(key=lambda r: (r["date"], r["type"]), reverse=True) + return rows + + +@app.route("/ledger") +@login_required +def ledger(): + """流水台账: 把入库、出库(含厂间调拨/生产领用/生产调入产生的双向流水)按日期合并成一张表。""" + fid = current_fid() + frm = _date(request.args.get("from")) if request.args.get("from") else None + to = _date(request.args.get("to")) if request.args.get("to") else None + type_filter = request.args.get("type", "") + q = request.args.get("q", "") + rows = _build_ledger_rows(fid, frm, to, type_filter or None, q) + + # 按日期分组(同一天的出入合并展示) + by_date = {} + for r in rows: + by_date.setdefault(r["date"], []).append(r) + date_groups = sorted(by_date.items(), key=lambda kv: kv[0], reverse=True) + total_in = sum(x["qty"] for x in rows if x["type"] == "入") + total_out = sum(x["qty"] for x in rows if x["type"] == "出") + cur = db.session.get(Factory, fid) if fid else None + return render_template("ledger.html", date_groups=date_groups, total_in=total_in, + total_out=total_out, factory=cur, + frm=request.args.get("from", ""), to=request.args.get("to", ""), + type=type_filter, q=q) + + +@app.route("/ledger/export") +@login_required +def ledger_export(): + """导出流水台账为 Excel(沿用当前日期/类型/单号筛选条件)。""" + fid = current_fid() + frm = _date(request.args.get("from")) if request.args.get("from") else None + to = _date(request.args.get("to")) if request.args.get("to") else None + type_filter = request.args.get("type", "") + q = request.args.get("q", "") + rows = _build_ledger_rows(fid, frm, to, type_filter or None, q) + import io + from excel_io import export_ledger + bio = io.BytesIO() + export_ledger(bio, rows) + bio.seek(0) + # 文件名按所选日期区间命名, 体现"按日期生成, 出库+入库同一文件" + if frm and to and frm == to: + seg = frm.isoformat() + elif frm and to: + seg = f"{frm.isoformat()}_至_{to.isoformat()}" + elif frm: + seg = f"{frm.isoformat()}_起" + elif to: + seg = f"至_{to.isoformat()}" + else: + seg = date.today().isoformat() + fname = f"流水台账_{seg}.xlsx" + return send_file(bio, as_attachment=True, download_name=fname, + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") # ---------------- M2: BOM ---------------- @@ -612,7 +1257,8 @@ def orders(): data = [] for o in os_: bom = db.session.get(Bom, o.bom_id) - data.append({"o": o, "bom": bom}) + fac = db.session.get(Factory, o.factory_id) if o.factory_id else None + data.append({"o": o, "bom": bom, "factory": fac}) return render_template("orders.html", data=data) @@ -621,22 +1267,26 @@ def orders(): @role_required("admin", "manager") def order_new(): boms_all = db.session.scalars(db.select(Bom).order_by(Bom.code)).all() + factories = db.session.scalars(db.select(Factory).order_by(Factory.code)).all() if request.method == "POST": bom_id = int(request.form["bom_id"]) qty = float(request.form["qty"]) + fid_raw = (request.form.get("factory_id") or "").strip() + factory_id = int(fid_raw) if fid_raw.isdigit() else None if qty <= 0: flash("接单量必须大于 0", "error") else: order_no = request.form.get("order_no", "").strip() or _gen_order_no() if db.session.scalar(db.select(ProdOrder).where(ProdOrder.order_no == order_no)): order_no = _gen_order_no() - o = ProdOrder(order_no=order_no, bom_id=bom_id, qty=qty, - status="planned", operator=(g.user.display_name or g.user.username)) + o = ProdOrder(order_no=order_no, bom_id=bom_id, factory_id=factory_id, qty=qty, + status="planned", operator=(g.user.display_name or g.user.username), + remark=request.form.get("remark", "").strip()) db.session.add(o) db.session.commit() log_action("新建生产订单", order_no, f"接单量 {qty}") return redirect(url_for("order_detail", oid=o.id)) - return render_template("order_form.html", boms=boms_all, o=None) + return render_template("order_form.html", boms=boms_all, factories=factories, o=None) @app.route("/order/") @@ -648,15 +1298,34 @@ def order_detail(oid): return redirect(url_for("orders")) bom = db.session.get(Bom, o.bom_id) items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all() - sm = stock_map() + sm = stock_map(o.factory_id) + fac = db.session.get(Factory, o.factory_id) if o.factory_id else None + # 已录入的「调入数量」计划 + allocs = db.session.scalars( + db.select(ProdOrderAlloc).where(ProdOrderAlloc.order_id == oid)).all() + alloc_map = {a.material_id: a for a in allocs} # material_id -> alloc 行 reqs = [] for it in items: m = db.session.get(Material, it.material_id) need = it.qty_per_unit * o.qty have = sm.get(m.id, 0) + a = alloc_map.get(m.id) + subs = [{"pn": s["sub"].part_number, "mf": s["sub"].manufacturer or "-", + "stock": round(s["stock"], 3), "available": s["available"]} + for s in substitute_rows(m.id, o.factory_id)] reqs.append({"m": m, "need": need, "have": have, - "gap": need - have, "short": need > have}) - return render_template("order_detail.html", o=o, bom=bom, reqs=reqs) + "gap": need - have, "short": need > have, "subs": subs, + "alloc": a.alloc_qty if a else 0, + "posted": a.posted if a else False}) + posted_total = sum(a.alloc_qty for a in allocs if a.posted) + planned_total = sum(a.alloc_qty for a in allocs) + posted_count = sum(1 for a in allocs if a.posted) + factories = db.session.scalars( + db.select(Factory).where(Factory.enabled == True).order_by(Factory.code)).all() + return render_template("order_detail.html", o=o, bom=bom, reqs=reqs, factory=fac, + factories=factories, + posted_total=posted_total, planned_total=planned_total, + posted_count=posted_count) @app.route("/order//pick", methods=["POST"]) @@ -667,7 +1336,7 @@ def order_pick(oid): if not o or o.status == "picked": return redirect(url_for("orders")) items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all() - sm = stock_map() + sm = stock_map(o.factory_id) picks = 0 shorts = [] for it in items: @@ -675,7 +1344,7 @@ def order_pick(oid): need = it.qty_per_unit * o.qty if need <= 0: continue - db.session.add(Outbound(material_id=m.id, qty=need, + db.session.add(Outbound(material_id=m.id, factory_id=o.factory_id, qty=need, operator=(g.user.display_name or g.user.username), purpose="生产领用", order_ref=o.order_no, occur_date=date.today())) @@ -692,12 +1361,194 @@ def order_pick(oid): return redirect(url_for("order_detail", oid=oid)) +@app.route("/order//edit", methods=["GET", "POST"]) +@login_required +@role_required("admin", "manager") +def order_edit(oid): + o = db.session.get(ProdOrder, oid) + if not o: + return redirect(url_for("orders")) + boms_all = db.session.scalars(db.select(Bom).order_by(Bom.code)).all() + factories = db.session.scalars(db.select(Factory).order_by(Factory.code)).all() + if request.method == "POST": + bom_id = int(request.form["bom_id"]) + qty = float(request.form["qty"]) + fid_raw = (request.form.get("factory_id") or "").strip() + factory_id = int(fid_raw) if fid_raw.isdigit() else None + if qty <= 0: + flash("接单量必须大于 0", "error") + else: + order_no = request.form.get("order_no", "").strip() or o.order_no + if order_no != o.order_no and db.session.scalar( + db.select(ProdOrder).where(ProdOrder.order_no == order_no)): + flash(f"订单号 {order_no} 已存在, 已保留原单号", "warn") + order_no = o.order_no + o.order_no = order_no + o.bom_id = bom_id + o.factory_id = factory_id + o.qty = qty + o.remark = request.form.get("remark", "").strip() + db.session.commit() + log_action("编辑生产订单", o.order_no, f"接单量 {qty}") + flash("订单已更新", "ok") + return redirect(url_for("order_detail", oid=oid)) + return render_template("order_form.html", boms=boms_all, factories=factories, o=o) + + +@app.route("/order//delete", methods=["POST"]) +@login_required +@role_required("admin", "manager") +def order_delete(oid): + o = db.session.get(ProdOrder, oid) + if not o: + return redirect(url_for("orders")) + no = o.order_no + # 删除关联的「调入数量」计划行 + db.session.execute( + db.delete(ProdOrderAlloc).where(ProdOrderAlloc.order_id == oid)) + # 级联删除该订单关联的领料出库流水(用途=生产领用, order_ref=订单号) + n_out = db.session.execute( + db.delete(Outbound).where(Outbound.order_ref == no)).rowcount + # 级联删除该订单关联的「调入」流水(生产订单调拨产生的源厂出库 + 生产厂入库, 均带 order_ref) + n_in = db.session.execute( + db.delete(Inbound).where(Inbound.order_ref == no)).rowcount + db.session.delete(o) + db.session.commit() + log_action("删除生产订单", no, f"已删除订单头、调入计划及关联流水 出库{n_out}/入库{n_in} 笔") + flash(f"已删除生产订单 {no}(含关联出库 {n_out} 笔、入库 {n_in} 笔)", "ok") + return redirect(url_for("orders")) + + +@app.route("/order//alloc", methods=["POST"]) +@login_required +@role_required("admin", "manager") +def order_alloc_save(oid): + """保存生产订单各物料的「调入数量」(手动录入, 不生成流水)。""" + o = db.session.get(ProdOrder, oid) + if not o: + return redirect(url_for("orders")) + items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all() + for it in items: + raw = request.form.get(f"alloc_{it.material_id}", "").strip() + if raw == "": + continue + try: + qty = float(raw) + except ValueError: + continue + if qty < 0: + qty = 0 + a = db.session.scalar(db.select(ProdOrderAlloc).where( + ProdOrderAlloc.order_id == oid, + ProdOrderAlloc.material_id == it.material_id)) + if a is None: + a = ProdOrderAlloc(order_id=oid, material_id=it.material_id, alloc_qty=qty) + db.session.add(a) + else: + a.alloc_qty = qty + db.session.commit() + log_action("保存调入数量", o.order_no, "更新调入计划") + flash("已保存调入数量", "ok") + return redirect(url_for("order_detail", oid=oid)) + + +@app.route("/order//alloc/post", methods=["POST"]) +@login_required +@role_required("admin", "manager") +def order_alloc_post(oid): + """确认调入: 这是一次「调拨」——从选定的源工厂出库减, 同时计入生产工厂入库加, 总库存不变。 + + 与「保存调入数量」共用同一表单(formaction 提交), 一次点击即可先记录再入账。 + 源工厂由表单 from_factory_id 指定(默认自厂 SELF); 自动校验源厂库存, 不足则跳过并提示。 + """ + o = db.session.get(ProdOrder, oid) + if not o: + return redirect(url_for("orders")) + # 解析源工厂 + from_raw = (request.form.get("from_factory_id") or "").strip() + if not from_raw.isdigit(): + flash("请先选择「调入来源工厂」", "error") + return redirect(url_for("order_detail", oid=oid)) + from_id = int(from_raw) + if from_id == (o.factory_id or -1): + flash("调入来源工厂不能与生产工厂相同", "error") + return redirect(url_for("order_detail", oid=oid)) + ff = db.session.get(Factory, from_id) + if not ff: + flash("调入来源工厂不存在", "error") + return redirect(url_for("order_detail", oid=oid)) + + items = db.session.scalars(db.select(BomItem).where(BomItem.bom_id == o.bom_id)).all() + op = g.user.display_name or g.user.username + n = 0 + shorts = [] + for it in items: + raw = request.form.get(f"alloc_{it.material_id}", "").strip() + qty = None + if raw != "": + try: + qty = float(raw) + except ValueError: + qty = None + a = db.session.scalar(db.select(ProdOrderAlloc).where( + ProdOrderAlloc.order_id == oid, + ProdOrderAlloc.material_id == it.material_id)) + if qty is None and a and not a.posted and a.alloc_qty > 0: + qty = a.alloc_qty # 回退到已保存计划 + if qty is None or qty <= 0: + continue + if a and a.posted: + continue # 已入账, 跳过避免重复 + # 校验源厂库存 + src_stock = stock_map(from_id).get(it.material_id, 0) + if qty > src_stock: + m = db.session.get(Material, it.material_id) + shorts.append(f"{m.part_number}(源厂{ff.code}库存{src_stock:.2f}, 需{qty:.2f})") + continue + if a is None: + a = ProdOrderAlloc(order_id=oid, material_id=it.material_id) + db.session.add(a) + a.alloc_qty = qty + # 源厂出库减 + db.session.add(Outbound( + material_id=it.material_id, factory_id=from_id, qty=qty, + operator=op, purpose="生产订单调出", order_ref=o.order_no, + occur_date=date.today(), + remark=f"生产订单 {o.order_no} 调入(自 {ff.code})")) + # 生产厂入库加 + db.session.add(Inbound( + material_id=it.material_id, factory_id=o.factory_id, qty=qty, + operator=op, source="生产订单调入", order_ref=o.order_no, + occur_date=date.today(), + remark=f"生产订单 {o.order_no} 调入(自 {ff.code})")) + a.posted = True + n += 1 + db.session.commit() + if n == 0 and not shorts: + flash("没有新的调入数量待确认(数量需>0, 且不能重复入账)", "warn") + elif n > 0 and not shorts: + log_action("确认调入", o.order_no, f"从 {ff.code} 调拨 {n} 项到生产工厂(总库存不变)") + flash(f"已从 {ff.code} 调入 {n} 项到生产工厂(总库存不变)", "ok") + elif n > 0 and shorts: + log_action("确认调入", o.order_no, f"从 {ff.code} 调拨 {n} 项(部分缺料跳过)") + flash(f"已调入 {n} 项; 以下源厂库存不足已跳过: {', '.join(shorts)}", "warn") + else: + flash(f"源厂库存不足, 全部跳过: {', '.join(shorts)}", "error") + return redirect(url_for("order_detail", oid=oid)) + + def _gen_order_no(): d = date.today().strftime("%Y%m%d") n = db.session.scalar(db.select(db.func.count(ProdOrder.id))) + 1 return f"WO{d}{n:03d}" +def _gen_transfer_no(): + d = date.today().strftime("%Y%m%d") + n = db.session.scalar(db.select(db.func.count(Transfer.id))) + 1 + return f"TR{d}{n:03d}" + + # ---------------- M3: 报表 / 日志 / 用户 ---------------- @app.route("/report") @login_required @@ -1012,13 +1863,45 @@ def api_import_boms_commit(): def seed_admin(): with app.app_context(): db.create_all() - # 兼容旧库: 给 inbound 表补充 delivery_date 列(幂等) + # 兼容旧库: 给 inbound 表补充 delivery_date / order_ref 列(幂等) try: cols = [r[1] for r in db.session.execute( db.text("PRAGMA table_info(inbound)")).fetchall()] if "delivery_date" not in cols: db.session.execute(db.text("ALTER TABLE inbound ADD COLUMN delivery_date DATE")) db.session.commit() + if "order_ref" not in cols: + db.session.execute(db.text("ALTER TABLE inbound ADD COLUMN order_ref VARCHAR(64) DEFAULT ''")) + db.session.commit() + except Exception: + db.session.rollback() + # ---- M2-1: 工厂维度(幂等迁移) ---- + try: + for tbl in ("inbound", "outbound", "prod_order"): + cols = [r[1] for r in db.session.execute( + db.text(f"PRAGMA table_info({tbl})")).fetchall()] + if "factory_id" not in cols: + db.session.execute(db.text(f"ALTER TABLE {tbl} ADD COLUMN factory_id INTEGER")) + db.session.commit() + except Exception: + db.session.rollback() + # 默认工厂(自厂/总部), 旧流水若无归属则归到它 + default_f = db.session.scalar(db.select(Factory).where(Factory.code == "SELF")) + if not default_f: + default_f = Factory(code="SELF", name="自厂/总部", type="self", enabled=True) + db.session.add(default_f) + db.session.commit() + try: + db.session.execute( + db.text("UPDATE inbound SET factory_id = :fid WHERE factory_id IS NULL"), + {"fid": default_f.id}) + db.session.execute( + db.text("UPDATE outbound SET factory_id = :fid WHERE factory_id IS NULL"), + {"fid": default_f.id}) + db.session.execute( + db.text("UPDATE prod_order SET factory_id = :fid WHERE factory_id IS NULL"), + {"fid": default_f.id}) + db.session.commit() except Exception: db.session.rollback() if not db.session.scalar(db.select(User).where(User.username == "admin")): diff --git a/build_exe.py b/build_exe.py index dcc33b6..de09b07 100644 --- a/build_exe.py +++ b/build_exe.py @@ -18,15 +18,16 @@ args = [ "--onefile", "--windowed", "--name", "MaterialWMS", - "--distpath", os.path.join(HERE, "dist"), - "--workpath", os.path.join(HERE, "build"), - "--specpath", os.path.join(HERE, "build"), + "--distpath", os.path.join(HERE, "dist_pkg"), + "--workpath", os.path.join(HERE, "build_pkg"), + "--specpath", os.path.join(HERE, "build_pkg"), "--add-data", os.path.join(HERE, "templates") + sep + "templates", "--add-data", os.path.join(HERE, "static") + sep + "static", "--hidden-import", "flask", "--hidden-import", "flask_sqlalchemy", "--hidden-import", "sqlalchemy", "--hidden-import", "openpyxl", + "--hidden-import", "excel_io", "--noconfirm", ] print("PyInstaller args:", args) diff --git a/excel_io.py b/excel_io.py index c3b6060..1b7b691 100644 --- a/excel_io.py +++ b/excel_io.py @@ -118,7 +118,7 @@ def read_materials(path): return out -def import_materials(rows, operator="导入"): +def import_materials(rows, operator="导入", factory_id=None): """upsert 物料; opening_stock>0 写入一条"期初"入库流水。返回 (新增, 更新)""" added = 0 updated = 0 @@ -138,11 +138,12 @@ def import_materials(rows, operator="导入"): op = d.get("opening_stock", 0) or 0 if op > 0: exists = db.session.scalar( - db.select(Inbound).where(Inbound.material_id == m.id, Inbound.source == "期初")) + db.select(Inbound).where(Inbound.material_id == m.id, Inbound.source == "期初", + Inbound.factory_id == factory_id)) if exists: exists.qty = op else: - db.session.add(Inbound(material_id=m.id, qty=op, source="期初", + db.session.add(Inbound(material_id=m.id, factory_id=factory_id, qty=op, source="期初", occur_date=date.today(), operator=operator)) db.session.commit() return added, updated @@ -256,6 +257,47 @@ def build_material_template(path): return path +def export_ledger(target, rows): + """导出流水台账。target 可为路径或文件对象(如 BytesIO)。 + + rows: _build_ledger_rows() 返回的流水行列表, 每项含 + date/type/cat/m/qty/fac/who/ref/op 字段。 + """ + wb = Workbook() + ws = wb.active + ws.title = "流水台账" + head = ["日期", "类型", "业务类型", "物料编号", "型号/规格", "单位", + "数量", "工厂", "来源/用途", "调拨/关联单号", "经手人"] + ws.append(head) + for c in ws[1]: + c.font = Font(bold=True) + c.fill = PatternFill("solid", fgColor="DDEBF7") + for r in rows: + m = r["m"] + ws.append([ + r["date"].isoformat() if r["date"] else "", + r["type"], + r["cat"], + m.part_number if m else "", + m.manufacturer if m else "", + m.unit if m else "", + r["qty"], + r["fac"].code if r["fac"] else "", + r["who"] or "", + r["ref"] or "", + r["op"] or "", + ]) + # 出入着色: 入(绿) / 出(红) + for i in range(2, ws.max_row + 1): + tval = ws.cell(row=i, column=2).value + if tval == "入": + ws.cell(row=i, column=2).fill = PatternFill("solid", fgColor="C6EFCE") + elif tval == "出": + ws.cell(row=i, column=2).fill = PatternFill("solid", fgColor="FFC7CE") + ws.freeze_panes = "A2" + wb.save(target) + + def build_bom_template(path): """生成与真实 BOM 模板一致的格式(Altium/嘉立创导出): 每个 worksheet 一个 BOM, sheet 名即 BOM 型号; 列头 Item/Part Number/ diff --git a/instance/app.db b/instance/app.db deleted file mode 100644 index 7dc18d7..0000000 Binary files a/instance/app.db and /dev/null differ diff --git a/models.py b/models.py index 33bb717..6ab1ffc 100644 --- a/models.py +++ b/models.py @@ -15,6 +15,7 @@ import os from datetime import datetime, date from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import UniqueConstraint db = SQLAlchemy() @@ -44,10 +45,12 @@ class Inbound(db.Model): __tablename__ = "inbound" id = db.Column(db.Integer, primary_key=True) material_id = db.Column(db.Integer, db.ForeignKey("material.id"), nullable=False, index=True) + factory_id = db.Column(db.Integer, db.ForeignKey("factory.id"), nullable=True, index=True) # 所属工厂/仓库 batch_no = db.Column(db.String(64), default="") # 批次号 qty = db.Column(db.Float, nullable=False) # 入库数量(正) operator = db.Column(db.String(64), default="") # 经手人 source = db.Column(db.String(255), default="") # 来源 / 供应商 + order_ref = db.Column(db.String(64), default="") # 关联生产单号 / 调拨单号 occur_date = db.Column(db.Date, default=date.today) delivery_date = db.Column(db.Date, default=None) # 送货日期(供应商到货日, 可空) remark = db.Column(db.String(255), default="") @@ -58,6 +61,7 @@ class Outbound(db.Model): __tablename__ = "outbound" id = db.Column(db.Integer, primary_key=True) material_id = db.Column(db.Integer, db.ForeignKey("material.id"), nullable=False, index=True) + factory_id = db.Column(db.Integer, db.ForeignKey("factory.id"), nullable=True, index=True) # 所属工厂/仓库 batch_no = db.Column(db.String(64), default="") # 批次号 qty = db.Column(db.Float, nullable=False) # 出库数量(正) operator = db.Column(db.String(64), default="") # 经手人 @@ -91,6 +95,7 @@ class ProdOrder(db.Model): id = db.Column(db.Integer, primary_key=True) order_no = db.Column(db.String(64), unique=True, nullable=False, index=True) bom_id = db.Column(db.Integer, db.ForeignKey("bom.id"), nullable=False, index=True) + factory_id = db.Column(db.Integer, db.ForeignKey("factory.id"), nullable=True, index=True) # 生产工厂/车间 qty = db.Column(db.Float, nullable=False, default=0) # 接单量(台/套) status = db.Column(db.String(32), default="planned") # planned / picked / closed operator = db.Column(db.String(64), default="") @@ -98,6 +103,37 @@ class ProdOrder(db.Model): created_at = db.Column(db.DateTime, default=datetime.now) +class ProdOrderAlloc(db.Model): + """生产订单「调入数量」手动录入: 每个物料的调入计划与已入账标记。 + + - alloc_qty: 该物料计划调入(转入)生产工厂的数量, 由用户在订单详情页手动填写。 + - posted: 是否已据此生成入库流水(调入工厂物料)。生成后该行只读, 避免重复入账。 + """ + __tablename__ = "prod_order_alloc" + id = db.Column(db.Integer, primary_key=True) + order_id = db.Column(db.Integer, db.ForeignKey("prod_order.id"), nullable=False, index=True) + material_id = db.Column(db.Integer, db.ForeignKey("material.id"), nullable=False, index=True) + alloc_qty = db.Column(db.Float, default=0) # 调入数量(手动录入) + posted = db.Column(db.Boolean, default=False) # 是否已生成入库流水 + created_at = db.Column(db.DateTime, default=datetime.now) + + +class Substitute(db.Model): + """替代料关系: 某主料(main)可由某替代料(sub)替代使用。 + + - 仅描述"谁可替代谁"的参考关系, 不直接参与库存计算。 + - 用于出库 / BOM 领料时, 若主料库存不足, 自动提示可用的替代料。 + - 唯一约束 (main_id, sub_id), 避免重复登记; main 与 sub 不可相同。 + """ + __tablename__ = "substitute" + id = db.Column(db.Integer, primary_key=True) + main_id = db.Column(db.Integer, db.ForeignKey("material.id"), nullable=False, index=True) + sub_id = db.Column(db.Integer, db.ForeignKey("material.id"), nullable=False, index=True) + note = db.Column(db.String(255), default="") + created_at = db.Column(db.DateTime, default=datetime.now) + __table_args__ = (UniqueConstraint("main_id", "sub_id", name="uq_sub_main_sub"),) + + # ---------------- M3: 操作日志 ---------------- class OperationLog(db.Model): __tablename__ = "operation_log" @@ -107,3 +143,37 @@ class OperationLog(db.Model): target = db.Column(db.String(255), default="") # 对象 detail = db.Column(db.String(512), default="") # 详情 created_at = db.Column(db.DateTime, default=datetime.now) + + +# ---------------- M2-1: 多工厂 / 代工厂 ---------------- +class Factory(db.Model): + __tablename__ = "factory" + id = db.Column(db.Integer, primary_key=True) + code = db.Column(db.String(32), unique=True, nullable=False, index=True) # 工厂编码, 如 SELF / SUB01 + name = db.Column(db.String(128), default="") # 工厂名称 + type = db.Column(db.String(16), default="self") # self 自厂 / subcontract 代工厂 + enabled = db.Column(db.Boolean, default=True) # 是否启用 + note = db.Column(db.String(255), default="") + created_at = db.Column(db.DateTime, default=datetime.now) + + +# ---------------- M2-2: 厂间调拨 ---------------- +class Transfer(db.Model): + """厂间调拨: 从源工厂调出、调入目标工厂(同一颗物料)。 + + 一条调拨记录对应两条流水: + - Outbound(factory_id=from_factory_id) : 源厂库存扣减 + - Inbound (factory_id=to_factory_id) : 目标厂库存增加 + 两张流水均带 source/purpose = 厂间调拨, 便于追溯和对账。 + """ + __tablename__ = "transfer" + id = db.Column(db.Integer, primary_key=True) + no = db.Column(db.String(64), unique=True, nullable=False, index=True) # 调拨单号, 如 TR20260928xxx + from_factory_id = db.Column(db.Integer, db.ForeignKey("factory.id"), nullable=False, index=True) + to_factory_id = db.Column(db.Integer, db.ForeignKey("factory.id"), nullable=False, index=True) + material_id = db.Column(db.Integer, db.ForeignKey("material.id"), nullable=False, index=True) + qty = db.Column(db.Float, nullable=False) # 调拨数量(正) + operator = db.Column(db.String(64), default="") + occur_date = db.Column(db.Date, default=date.today) + remark = db.Column(db.String(255), default="") + created_at = db.Column(db.DateTime, default=datetime.now) diff --git a/static/style.css b/static/style.css index 0095f51..3cd7ad3 100644 --- a/static/style.css +++ b/static/style.css @@ -12,8 +12,21 @@ body { .topbar nav a { color: #dce6f2; text-decoration: none; margin-left: 18px; font-size: 14px; } .topbar nav a:hover { color: #fff; } .topbar .who { margin-left: 18px; color: #9fb3cc; font-size: 13px; } +.topbar .factory-switch { display: flex; align-items: center; margin-left: 18px; } +.topbar .factory-switch label { color: #9fb3cc; font-size: 13px; margin-right: 6px; } +.topbar .factory-switch select { + background: #2a4a73; color: #fff; border: 1px solid #3d5d88; border-radius: 5px; + padding: 4px 8px; font-size: 13px; max-width: 200px; +} +.topbar .factory-switch select:focus { outline: none; border-color: #6fa8ff; } main { max-width: 1100px; margin: 18px auto; padding: 0 16px; } +.scope-banner { + background: #eef4fb; border: 1px solid #cfe0f3; color: #1f3a5f; + border-radius: 6px; padding: 8px 14px; margin-bottom: 14px; font-size: 14px; +} +.scope-banner b { color: #1f6feb; } + .stats { display: flex; gap: 14px; margin-bottom: 16px; } .stat { flex: 1; background: #fff; border-radius: 8px; padding: 16px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,.08); } .stat .num { font-size: 30px; font-weight: 700; } @@ -36,8 +49,13 @@ button, .btn { padding: 9px 16px; font-size: 14px; text-decoration: none; background: #eef1f5; color: #1f2733; } button.primary, .btn.primary { background: #1f3a5f; color: #fff; } +button.secondary, .btn.secondary { background: #2f7d5b; color: #fff; } button:hover, .btn:hover { filter: brightness(.96); } +.link-danger { background: none; border: none; color: #c0392b; cursor: pointer; padding: 0 4px; font-size: 14px; } +.link-danger:hover { text-decoration: underline; } .row-actions { margin-top: 14px; display: flex; gap: 10px; } +.row-in { background: #eafaf1; } +.row-out { background: #fdecea; } .toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .search input { width: 280px; } @@ -70,6 +88,15 @@ button:hover, .btn:hover { filter: brightness(.96); } padding: 8px 16px; font-size: 14px; cursor: pointer; border-radius: 6px; } .tabs .tab.active { background: #1f3a5f; color: #fff; border-color: #1f3a5f; } +.tab-pane { animation: fade .15s ease; } +@keyframes fade { from { opacity: .4; } to { opacity: 1; } } + +/* 工厂工作台: 统计行 / BOM 卡片 */ +.stat-row { display: flex; gap: 14px; } +.stat-row .stat { flex: 1; background: #fff; border-radius: 8px; padding: 12px 16px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,.08); } +.stat-row .stat .num { font-size: 26px; font-weight: 700; } +.stat-row .stat .lbl { color: #6b7785; font-size: 13px; margin-top: 2px; } +.bom-issue-card { border-left: 4px solid #7c3aed; } .card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; font-size: 15px; } .card-head strong { font-size: 16px; } diff --git a/templates/base.html b/templates/base.html index 7a1d44c..86c611d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -9,16 +9,29 @@
📦 物料入库系统 M1+M2+M3
+
+ + +