From 5060d027e529078f4f84d60536d6c2b8fbb38df6 Mon Sep 17 00:00:00 2001 From: star <395723461@qq.com> Date: Tue, 29 Sep 2026 11:17:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=9A=E5=B7=A5=E5=8E=82/=E5=8E=82?= =?UTF-8?q?=E9=97=B4=E8=B0=83=E6=8B=A8/=E6=9B=BF=E4=BB=A3=E6=96=99/?= =?UTF-8?q?=E6=B5=81=E6=B0=B4=E5=8F=B0=E8=B4=A6=E6=8A=98=E5=8F=A0=20+=20?= =?UTF-8?q?=E9=87=8D=E5=86=99=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 多工厂(factory 表) + 顶栏工厂下拉过滤, 库存按厂聚合 - 厂间调拨(transfer): 源厂减+目标厂加, 总库存不变, 双向流水对账 - 替代料关系(substitute): 出库/BOM领料/生产领料缺料时提示可替代料 - 流水台账(ledger): 入出按日期合并折叠, 按日期导出同一 Excel - 生产订单增强: 编辑/删除(级联清关联流水)/调入数量/确认调入=厂间调拨 - 入库新增送货日期字段; 出库物料编码扫码定位 - 重写 README 补全全部模块; .gitignore 排除 dist_pkg/build_pkg/*.db - 停止跟踪 instance/app.db(运行时自动生成) --- .gitignore | 10 +- README.md | 151 +++-- app.py | 971 +++++++++++++++++++++++++++++-- build_exe.py | 7 +- excel_io.py | 48 +- instance/app.db | Bin 131072 -> 0 bytes models.py | 70 +++ static/style.css | 27 + templates/base.html | 13 + templates/dashboard.html | 4 + templates/factories.html | 61 ++ templates/factory_workbench.html | 104 ++++ templates/ledger.html | 85 +++ templates/material_detail.html | 10 +- templates/order_detail.html | 85 ++- templates/order_form.html | 31 +- templates/orders.html | 16 +- templates/substitutes.html | 65 +++ templates/transfer_form.html | 77 +++ templates/transfers.html | 49 ++ templates/txn.html | 110 ++++ 物料入库出库系统_使用教程.md | 116 +++- 22 files changed, 1961 insertions(+), 149 deletions(-) delete mode 100644 instance/app.db create mode 100644 templates/factories.html create mode 100644 templates/factory_workbench.html create mode 100644 templates/ledger.html create mode 100644 templates/substitutes.html create mode 100644 templates/transfer_form.html create mode 100644 templates/transfers.html 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 7dc18d767731e6caa301ba3750a828a6faf438a2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 131072 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
📦 物料入库系统 M1+M2+M3
+
+ + +