Compare commits
2
Commits
439877c6ba
...
5060d027e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5060d027e5 | ||
|
|
33efc7c846 |
+9
-1
@@ -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/
|
||||
|
||||
@@ -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` 备份,可随时覆盖还原。
|
||||
|
||||
+4
-3
@@ -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)
|
||||
|
||||
+45
-3
@@ -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/
|
||||
|
||||
Binary file not shown.
@@ -15,6 +15,7 @@
|
||||
import os
|
||||
from datetime import datetime, date
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy import UniqueConstraint
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
@@ -44,11 +45,14 @@ 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="")
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
|
||||
@@ -57,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="") # 经手人
|
||||
@@ -90,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="")
|
||||
@@ -97,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"
|
||||
@@ -106,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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -9,16 +9,29 @@
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">📦 物料入库系统 <span class="ver">M1+M2+M3</span></div>
|
||||
<form class="factory-switch" method="post" action="{{ url_for('set_factory') }}">
|
||||
<label for="facSel">工厂</label>
|
||||
<select id="facSel" name="factory_id" onchange="this.form.submit()">
|
||||
<option value="all">全部工厂(汇总)</option>
|
||||
{% for f in factories %}
|
||||
<option value="{{ f.id }}" {% if current_factory_id == f.id %}selected{% endif %}>{{ f.code }} · {{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
<nav>
|
||||
<a href="{{ url_for('dashboard') }}">库存看板</a>
|
||||
<a href="{{ url_for('materials') }}">物料</a>
|
||||
<a href="{{ url_for('inbound') }}">入库</a>
|
||||
<a href="{{ url_for('outbound') }}">出库</a>
|
||||
<a href="{{ url_for('ledger') }}">流水</a>
|
||||
{% if g.user and g.user.role in ('admin','manager') %}
|
||||
<a href="{{ url_for('boms') }}">BOM</a>
|
||||
<a href="{{ url_for('orders') }}">生产订单</a>
|
||||
<a href="{{ url_for('report') }}">报表</a>
|
||||
<a href="{{ url_for('logs') }}">日志</a>
|
||||
<a href="{{ url_for('factories') }}">工厂</a>
|
||||
<a href="{{ url_for('transfers') }}">调拨</a>
|
||||
<a href="{{ url_for('substitutes') }}">替代料</a>
|
||||
{% endif %}
|
||||
{% if g.user and g.user.role == 'admin' %}
|
||||
<a href="{{ url_for('users') }}">用户</a>
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<div class="stat danger"><div class="num">{{ negative }}</div><div class="lbl">库存为负(缺料)</div></div>
|
||||
</div>
|
||||
|
||||
<div class="scope-banner">
|
||||
{% if current_factory %}当前范围:<b>{{ current_factory.code }} · {{ current_factory.name }}</b> · 仅统计该工厂库存{% else %}当前范围:<b>全部工厂(汇总)</b> · 顶栏可切换具体工厂{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" type="button" onclick="showTab('mat', this)">📦 库存预警</button>
|
||||
{% if can_bom %}<button class="tab" type="button" onclick="showTab('bom', this)">🔧 BOM 物料齐套</button>{% endif %}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}工厂 / 代工厂管理{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="toolbar">
|
||||
<h3 style="margin:0">工厂 / 代工厂管理</h3>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<form method="post" class="form-card" style="display:flex;flex-wrap:wrap;gap:12px;align-items:flex-end">
|
||||
<div>
|
||||
<label>工厂编码 *</label>
|
||||
<input name="code" placeholder="如 SUB01" required style="text-transform:uppercase">
|
||||
</div>
|
||||
<div>
|
||||
<label>工厂名称</label>
|
||||
<input name="name" placeholder="如 东莞代工厂A">
|
||||
</div>
|
||||
<div>
|
||||
<label>类型</label>
|
||||
<select name="type">
|
||||
<option value="self">自厂</option>
|
||||
<option value="subcontract">代工厂</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="primary" type="submit">+ 添加工厂</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr><th>编码</th><th>名称</th><th>类型</th><th>状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in fs %}
|
||||
<tr>
|
||||
<td>{{ f.code }}</td>
|
||||
<td>{{ f.name or '-' }}</td>
|
||||
<td>{% if f.type == 'subcontract' %}代工厂{% else %}自厂{% endif %}</td>
|
||||
<td>
|
||||
{% if f.enabled %}<span class="badge ok">启用</span>
|
||||
{% else %}<span class="badge warn">停用</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn" href="{{ url_for('factory_workbench', fid=f.id) }}">进入</a>
|
||||
<form method="post" action="{{ url_for('factory_toggle', fid=f.id) }}" style="display:inline">
|
||||
<button class="btn" type="submit">{% if f.enabled %}停用{% else %}启用{% endif %}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="muted">暂无工厂, 请先添加</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="small muted" style="margin-top:14px">
|
||||
说明:每笔入库 / 出库都会归属到一个工厂。顶栏"工厂"下拉可切换查看范围(选"全部工厂"为各厂汇总)。
|
||||
停用后的工厂不会出现在下拉选项中,但其历史流水仍保留。
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,104 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}工厂工作台 · {{ f.code }}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="toolbar">
|
||||
<h3 style="margin:0">工厂工作台:{{ f.code }} · {{ f.name or '' }}
|
||||
<span class="badge {{ 'ok' if f.enabled else 'warn' }}">{{ '启用' if f.enabled else '停用' }}</span>
|
||||
</h3>
|
||||
<a class="btn" href="{{ url_for('factories') }}">← 返回工厂列表</a>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('kanban')">📊 物料看板</button>
|
||||
<button class="tab" onclick="switchTab('bom')">🧩 BOM 齐套</button>
|
||||
<button class="tab" onclick="switchTab('import')">⊞ 批量导入</button>
|
||||
</div>
|
||||
|
||||
<!-- 物料看板 -->
|
||||
<div id="tab-kanban" class="tab-pane">
|
||||
<div class="stat-row" style="margin:12px 0">
|
||||
<div class="stat"><div class="num">{{ rows|length }}</div><div class="lbl">有库存物料</div></div>
|
||||
<div class="stat warn"><div class="num">{{ low }}</div><div class="lbl">低于安全库存</div></div>
|
||||
<div class="stat danger"><div class="num">{{ negative }}</div><div class="lbl">库存为负</div></div>
|
||||
</div>
|
||||
<table class="tbl">
|
||||
<thead><tr><th>物料编号</th><th>型号/规格</th><th>单位</th><th>安全库存</th><th>在厂库存</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr class="{{ 'row-danger' if r.status=='danger' else ('row-warn' if r.status=='warn' else '') }}">
|
||||
<td>{{ r.m.part_number }}</td>
|
||||
<td>{{ r.m.manufacturer or '-' }}</td>
|
||||
<td>{{ r.m.unit or '-' }}</td>
|
||||
<td class="num">{{ r.m.safety_stock or 0 }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.stock) }}</td>
|
||||
<td><span class="badge {{ 'danger' if r.status=='danger' else ('warn' if r.status=='warn' else 'ok') }}">{{ '负库存' if r.status=='danger' else ('低于安全库存' if r.status=='warn' else '正常') }}</span></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="muted">该厂暂无库存数据</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- BOM 齐套 -->
|
||||
<div id="tab-bom" class="tab-pane" style="display:none">
|
||||
{% for bd in bom_data %}
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<h3 style="margin-top:0">{{ bd.b.code }} ({{ bd.b.name or '-' }})
|
||||
<span class="small muted">· 单套用量 × 该厂库存</span></h3>
|
||||
{% if bd.comps %}
|
||||
<table class="tbl">
|
||||
<thead><tr><th>物料编号</th><th>型号/规格</th><th>单套用量</th><th>该厂库存</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in bd.comps %}
|
||||
<tr class="{{ 'row-danger' if c.status=='danger' else ('row-warn' if c.status=='warn' else '') }}">
|
||||
<td>{{ c.m.part_number }}</td>
|
||||
<td>{{ c.m.manufacturer or '-' }}</td>
|
||||
<td class="num">{{ '%.3f'|format(c.qty) }}</td>
|
||||
<td class="num">{{ '%.2f'|format(c.stock) }}</td>
|
||||
<td><span class="badge {{ 'danger' if c.status=='danger' else ('warn' if c.status=='warn' else 'ok') }}">{{ '负库存' if c.status=='danger' else ('低于安全库存' if c.status=='warn' else '正常') }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="small muted">该 BOM 没有组成物料</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">暂无 BOM</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- 批量导入 -->
|
||||
<div id="tab-import" class="tab-pane" style="display:none">
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<h3 style="margin-top:0">导入入库流水到 {{ f.code }}</h3>
|
||||
<p class="small muted">上传 Excel/CSV(列:物料编号 / 数量 / 批次 / 日期 / 备注),将生成归属 {{ f.code }} 的入库流水。</p>
|
||||
<form method="post" action="{{ url_for('factory_import_inbound', fid=f.id) }}" enctype="multipart/form-data" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||
<input type="file" name="file" accept=".xlsx,.csv" required>
|
||||
<button class="primary" type="submit">上传并导入入库</button>
|
||||
<a class="btn" href="{{ url_for('download_template', kind='transactions') }}">⬇ 下载模板</a>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0">导入物料主数据(期初库存归属 {{ f.code }})</h3>
|
||||
<p class="small muted">上传物料 Excel(列:物料编号 / 型号 / 单位 / 安全库存 / 期初库存),期初库存将写入 {{ f.code }}。</p>
|
||||
<form method="post" action="{{ url_for('factory_import_materials', fid=f.id) }}" enctype="multipart/form-data" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||
<input type="file" name="file" accept=".xlsx,.csv" required>
|
||||
<button class="primary" type="submit">上传并导入物料</button>
|
||||
<a class="btn" href="{{ url_for('download_template', kind='materials') }}">⬇ 下载模板</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTab(name){
|
||||
document.querySelectorAll('.tab').forEach(b=>b.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-pane').forEach(p=>p.style.display='none');
|
||||
event.target.classList.add('active');
|
||||
document.getElementById('tab-'+name).style.display='block';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}流水台账{% endblock %}
|
||||
{% block content %}
|
||||
<div class="toolbar">
|
||||
<h3 style="margin:0">流水台账(出库 + 入库 按日期合并)</h3>
|
||||
<span class="badge ok">总入库 {{ '%.2f'|format(total_in) }}</span>
|
||||
<span class="badge danger">总出库 {{ '%.2f'|format(total_out) }}</span>
|
||||
</div>
|
||||
<p class="muted" style="margin:6px 0 0">选择日期区间后点「导出 Excel」,系统会把该区间内的<b>出库与入库合并到同一个文件</b>(文件名自动带上日期)。同一天的出库与入库已按日期折叠在一起,点日期可展开当天的明细。</p>
|
||||
|
||||
<style>
|
||||
details.date-group{margin:14px 0;border:1px solid #e6e9ef;border-radius:8px;overflow:hidden;background:#fff}
|
||||
details.date-group>summary{list-style:none;cursor:pointer;padding:11px 14px;background:#f5f8fc;font-weight:600;display:flex;align-items:center;gap:8px;user-select:none}
|
||||
details.date-group>summary::-webkit-details-marker{display:none}
|
||||
details.date-group>summary::before{content:"\25B8";display:inline-block;transition:transform .15s;color:#8a94a6;font-size:12px}
|
||||
details.date-group[open]>summary::before{content:"\25BE"}
|
||||
details.date-group>table{margin:0;border:none}
|
||||
details.date-group>table tr:first-child td,details.date-group>table tr:first-child th{border-top:none}
|
||||
.fold-btns{margin:10px 0 2px;display:flex;gap:8px}
|
||||
</style>
|
||||
|
||||
<div class="fold-btns">
|
||||
<button type="button" class="btn" onclick="document.querySelectorAll('details.date-group').forEach(d=>d.open=true)">全部展开</button>
|
||||
<button type="button" class="btn" onclick="document.querySelectorAll('details.date-group').forEach(d=>d.open=false)">全部折叠</button>
|
||||
</div>
|
||||
|
||||
{% if factory %}
|
||||
<div class="scope-banner">当前仅显示工厂:<b>{{ factory.code }}</b>(切换顶栏工厂可改变范围)</div>
|
||||
{% else %}
|
||||
<div class="scope-banner">当前显示:<b>全部工厂(汇总)</b></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="get" class="card" style="display:flex; gap:12px; align-items:flex-end; flex-wrap:wrap; margin-bottom:14px">
|
||||
<label style="font-size:13px">起始日期<input type="date" name="from" value="{{ frm }}"></label>
|
||||
<label style="font-size:13px">结束日期<input type="date" name="to" value="{{ to }}"></label>
|
||||
<label style="font-size:13px">类型
|
||||
<select name="type" style="margin-left:4px">
|
||||
<option value="" {% if type=='' %}selected{% endif %}>全部</option>
|
||||
<option value="in" {% if type=='in' %}selected{% endif %}>入库</option>
|
||||
<option value="out" {% if type=='out' %}selected{% endif %}>出库</option>
|
||||
<option value="transfer" {% if type=='transfer' %}selected{% endif %}>厂间调拨</option>
|
||||
<option value="prod" {% if type=='prod' %}selected{% endif %}>生产相关</option>
|
||||
<option value="other" {% if type=='other' %}selected{% endif %}>其他</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="font-size:13px">调拨/关联单号<input type="text" name="q" value="{{ q }}" placeholder="如 TR... / WO..." style="width:140px"></label>
|
||||
<button class="primary" type="submit">筛选</button>
|
||||
<a class="btn" href="{{ url_for('ledger') }}">清除</a>
|
||||
<a class="btn primary" href="{{ url_for('ledger_export') }}?from={{ frm|urlencode }}&to={{ to|urlencode }}&type={{ type|urlencode }}&q={{ q|urlencode }}" title="按所选日期生成 Excel,出库与入库合并在同一文件">按日期生成 Excel</a>
|
||||
</form>
|
||||
|
||||
{% if not date_groups %}
|
||||
<p class="muted">暂无流水记录。</p>
|
||||
{% endif %}
|
||||
|
||||
{% for d, items in date_groups %}
|
||||
<details class="date-group" {{ 'open' if loop.first else '' }}>
|
||||
<summary>
|
||||
<span class="dg-date">{{ d }}</span>
|
||||
<span class="small muted">({{ items|length }} 笔)</span>
|
||||
</summary>
|
||||
<table class="tbl">
|
||||
<thead><tr>
|
||||
<th style="width:48px">类型</th><th>业务类型</th><th>物料编号</th><th>型号</th>
|
||||
<th class="num">数量</th><th>工厂</th><th>来源 / 用途</th><th>调拨/关联单号</th><th>经手人</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for r in items %}
|
||||
<tr class="{{ 'row-in' if r.type=='入' else 'row-out' }}">
|
||||
<td><span class="badge {{ 'ok' if r.type=='入' else 'danger' }}">{{ r.type }}</span></td>
|
||||
<td>{{ r.cat }}</td>
|
||||
<td>{% if r.m %}<a href="{{ url_for('material_detail', mid=r.m.id) }}">{{ r.m.part_number }}</a>{% else %}-{% endif %}</td>
|
||||
<td>{{ r.m.manufacturer if r.m else '-' }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.qty) }}</td>
|
||||
<td>{{ r.fac.code if r.fac else '—' }}</td>
|
||||
<td>{{ r.who or '-' }}</td>
|
||||
<td>{{ r.ref or '-' }}</td>
|
||||
<td>{{ r.op or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -29,11 +29,13 @@
|
||||
|
||||
<h3>入库流水(每次入库时间 / 数量)</h3>
|
||||
<table class="tbl">
|
||||
<thead><tr><th>入库日期</th><th>录入时间</th><th>数量</th><th>批次号</th><th>来源 / 供应商</th><th>经手人</th><th>备注</th></tr></thead>
|
||||
<thead><tr><th>入库日期</th><th>工厂</th><th>送货日期</th><th>录入时间</th><th>数量</th><th>批次号</th><th>来源 / 供应商</th><th>经手人</th><th>备注</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in inbounds %}
|
||||
<tr>
|
||||
<td>{{ r.occur_date }}</td>
|
||||
<td>{{ fmap.get(r.factory_id, '-') }}</td>
|
||||
<td>{{ r.delivery_date or '-' }}</td>
|
||||
<td class="small muted">{{ r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '-' }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.qty) }}</td>
|
||||
<td>{{ r.batch_no or '-' }}</td>
|
||||
@@ -42,18 +44,19 @@
|
||||
<td>{{ r.remark or '-' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="muted">暂无入库记录</td></tr>
|
||||
<tr><td colspan="9" class="muted">暂无入库记录</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>出库 / 消耗流水</h3>
|
||||
<table class="tbl">
|
||||
<thead><tr><th>出库日期</th><th>录入时间</th><th>数量</th><th>用途</th><th>关联单号</th><th>经手人</th><th>备注</th></tr></thead>
|
||||
<thead><tr><th>出库日期</th><th>工厂</th><th>录入时间</th><th>数量</th><th>用途</th><th>关联单号</th><th>经手人</th><th>备注</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in outbounds %}
|
||||
<tr>
|
||||
<td>{{ r.occur_date }}</td>
|
||||
<td>{{ fmap.get(r.factory_id, '-') }}</td>
|
||||
<td class="small muted">{{ r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '-' }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.qty) }}</td>
|
||||
<td>{{ r.purpose or '-' }}</td>
|
||||
@@ -62,7 +65,7 @@
|
||||
<td>{{ r.remark or '-' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="muted">暂无出库记录</td></tr>
|
||||
<tr><td colspan="8" class="muted">暂无出库记录</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+66
-19
@@ -4,29 +4,76 @@
|
||||
<div class="toolbar">
|
||||
<h3 style="margin:0">生产订单 {{ o.order_no }}</h3>
|
||||
<span class="badge {{ 'ok' if o.status=='picked' else 'warn' }}">{{ '已领料' if o.status=='picked' else '待领料' }}</span>
|
||||
<a class="btn" href="{{ url_for('order_edit', oid=o.id) }}">编辑订单</a>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<p>BOM: <b>{{ bom.code if bom else '-' }}</b> ({{ bom.name if bom else '' }}) | 接单量: <b>{{ '%.2f'|format(o.qty) }}</b> | 经手人: {{ o.operator or '-' }}</p>
|
||||
<p>BOM: <b>{{ bom.code if bom else '-' }}</b> ({{ bom.name if bom else '' }}) | 接单量: <b>{{ '%.2f'|format(o.qty) }}</b> | 生产工厂: <b>{{ factory.code if factory else '未指定(按汇总库存)' }}</b> | 经手人: {{ o.operator or '-' }}</p>
|
||||
{% if o.remark %}<p class="small muted">备注: {{ o.remark }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
<h3>领料需求 (单套用量 × 接单量)</h3>
|
||||
<table class="tbl">
|
||||
<thead><tr><th>物料编号</th><th>型号</th><th>需求数量</th><th>当前库存</th><th>缺口</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in reqs %}
|
||||
<tr class="{{ 'row-danger' if r.short else ('row-warn' if r.have < (r.m.safety_stock or 0)) }}">
|
||||
<td>{{ r.m.part_number }}</td>
|
||||
<td>{{ r.m.manufacturer or '-' }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.need) }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.have) }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.gap) }}</td>
|
||||
<td><span class="badge {{ 'danger' if r.short else 'ok' }}">{{ '缺料' if r.short else '充足' }}</span></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="muted">该 BOM 没有组成物料</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>领料需求与调入 (单套用量 × 接单量){% if factory %} | 按 {{ factory.code }} 工厂库存核算{% endif %}</h3>
|
||||
|
||||
<form method="post" action="{{ url_for('order_alloc_save', oid=o.id) }}">
|
||||
<table class="tbl">
|
||||
<thead><tr>
|
||||
<th>物料编号</th><th>型号</th><th>需求数量</th>
|
||||
<th>调入数量</th><th>当前库存</th><th>缺口</th><th>状态</th><th>可替代料</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for r in reqs %}
|
||||
<tr class="{{ 'row-danger' if r.short else ('row-warn' if r.have < (r.m.safety_stock or 0)) }}">
|
||||
<td><a href="{{ url_for('material_detail', mid=r.m.id) }}" title="进入工厂物料">{{ r.m.part_number }}</a></td>
|
||||
<td>{{ r.m.manufacturer or '-' }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.need) }}</td>
|
||||
<td class="num">
|
||||
{% if r.posted %}
|
||||
<span class="badge ok" title="已生成入库流水">已调入 {{ '%.2f'|format(r.alloc) }}</span>
|
||||
{% else %}
|
||||
<input name="alloc_{{ r.m.id }}" type="number" step="0.01" min="0"
|
||||
value="{{ '%.2f'|format(r.alloc) if r.alloc else '' }}"
|
||||
placeholder="{{ '%.2f'|format(r.need) }}" style="width:96px">
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="num">{{ '%.2f'|format(r.have) }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.gap) }}</td>
|
||||
<td><span class="badge {{ 'danger' if r.short else 'ok' }}">{{ '缺料' if r.short else '充足' }}</span></td>
|
||||
<td class="small">
|
||||
{% if r.subs %}
|
||||
{% for s in r.subs %}
|
||||
<span class="{{ 'badge ok' if s.available else 'badge danger' }}">{{ s.pn }}·{{ s.stock }}{% if s.available %}✓{% else %}✗{% endif %}</span>
|
||||
{% endfor %}
|
||||
{% else %}<span class="muted">—</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="muted">该 BOM 没有组成物料</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="row-actions" style="flex-wrap:wrap; align-items:flex-end">
|
||||
<label style="display:flex; flex-direction:column; gap:4px; font-size:13px">
|
||||
调入来源工厂
|
||||
<select name="from_factory_id" style="padding:6px 8px; min-width:160px">
|
||||
{% for f in factories %}
|
||||
{% if f.id != (o.factory_id or 0) %}
|
||||
<option value="{{ f.id }}" {{ 'selected' if f.code=='SELF' else '' }}>{{ f.code }}({{ f.name or '—' }})</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button class="primary" type="submit">保存调入数量</button>
|
||||
<button class="secondary" type="submit"
|
||||
formaction="{{ url_for('order_alloc_post', oid=o.id) }}"
|
||||
onclick="return confirm('确认将待调入数量从上方所选来源工厂调拨到生产工厂? 总库存不变, 仅厂间转移。');">确认调入(厂间调拨)</button>
|
||||
</div>
|
||||
<p class="small muted">「确认调入」是一次厂间调拨:从来源工厂出库减、生产工厂入库加,系统总库存不变。</p>
|
||||
</form>
|
||||
|
||||
<div class="card" style="margin:14px 0">
|
||||
<b>调入总览:</b> 已调入 <b>{{ posted_count }}</b> 项,合计 <b>{{ '%.2f'|format(posted_total) }}</b> 件;
|
||||
计划调入合计 <b>{{ '%.2f'|format(planned_total) }}</b> 件。
|
||||
</div>
|
||||
|
||||
{% if o.status != 'picked' %}
|
||||
<form method="post" action="{{ url_for('order_pick', oid=o.id) }}" onsubmit="return confirm('确认按 BOM 生成领料出库? 库存不足将记为负并在看板预警。');">
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}新建生产订单{% endblock %}
|
||||
{% block title %}{{ '编辑生产订单' if o else '新建生产订单' }}{% endblock %}
|
||||
{% block content %}
|
||||
<form method="post" class="card form-card" style="max-width:520px">
|
||||
<h3>新建生产订单</h3>
|
||||
<label>订单号 (留空自动生成)</label>
|
||||
<input name="order_no" placeholder="如 WO20260911xxx">
|
||||
<form method="post" class="card form-card" style="max-width:520px"
|
||||
action="{{ url_for('order_edit', oid=o.id) if o else url_for('order_new') }}">
|
||||
<h3>{{ '编辑生产订单' if o else '新建生产订单' }}</h3>
|
||||
<label>订单号 {% if o %}(单号不可重复){% else %}(留空自动生成){% endif %}</label>
|
||||
<input name="order_no" value="{{ o.order_no if o else '' }}" placeholder="如 WO20260911xxx">
|
||||
<label>BOM 型号 *</label>
|
||||
<select name="bom_id" required>
|
||||
<option value="">-- 选择 BOM --</option>
|
||||
{% for b in boms %}
|
||||
<option value="{{ b.id }}">{{ b.code }} ({{ b.name or '-' }})</option>
|
||||
<option value="{{ b.id }}" {% if o and o.bom_id == b.id %}selected{% endif %}>{{ b.code }} ({{ b.name or '-' }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label>接单量 (台/套) *</label>
|
||||
<input name="qty" type="number" step="0.01" min="0.01" required>
|
||||
<div class="row-actions"><button class="primary">生成订单</button></div>
|
||||
<p class="small muted">生成后可在订单详情页按 BOM 展开领料需求, 并一键"确认领料"出库。</p>
|
||||
<input name="qty" type="number" step="0.01" min="0.01" required value="{{ '%.2f'|format(o.qty) if o else '' }}">
|
||||
<label>生产工厂</label>
|
||||
<select name="factory_id">
|
||||
<option value="">-- 不指定(汇总库存校验)--</option>
|
||||
{% for f in factories %}
|
||||
<option value="{{ f.id }}" {% if o and o.factory_id == f.id %}selected{% endif %}>{{ f.code }} · {{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label>备注</label>
|
||||
<input name="remark" value="{{ o.remark if o else '' }}" placeholder="选填">
|
||||
<div class="row-actions">
|
||||
<button class="primary">{{ '保存修改' if o else '生成订单' }}</button>
|
||||
<a class="btn" href="{{ url_for('orders') }}">取消</a>
|
||||
</div>
|
||||
<p class="small muted">生成后可在订单详情页按 BOM 展开领料需求, 并手动录入每个物料的「调入数量」。</p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
+12
-4
@@ -6,19 +6,27 @@
|
||||
<a class="btn primary" href="{{ url_for('order_new') }}">+ 新建订单</a>
|
||||
</div>
|
||||
<table class="tbl">
|
||||
<thead><tr><th>订单号</th><th>BOM</th><th>接单量</th><th>状态</th><th>经手人</th><th>操作</th></tr></thead>
|
||||
<thead><tr><th>订单号</th><th>BOM</th><th>接单量</th><th>生产工厂</th><th>状态</th><th>经手人</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in data %}
|
||||
<tr class="{{ 'row-danger' if d.o.status=='picked' and false }}">
|
||||
<tr>
|
||||
<td>{{ d.o.order_no }}</td>
|
||||
<td>{{ d.bom.code if d.bom else '-' }} ({{ d.bom.name if d.bom else '' }})</td>
|
||||
<td class="num">{{ '%.2f'|format(d.o.qty) }}</td>
|
||||
<td>{{ d.factory.code if d.factory else '未指定' }}</td>
|
||||
<td><span class="badge {{ 'ok' if d.o.status=='picked' else 'warn' }}">{{ '已领料' if d.o.status=='picked' else '待领料' }}</span></td>
|
||||
<td>{{ d.o.operator or '-' }}</td>
|
||||
<td><a href="{{ url_for('order_detail', oid=d.o.id) }}">查看</a></td>
|
||||
<td class="row-actions">
|
||||
<a href="{{ url_for('order_detail', oid=d.o.id) }}">查看</a>
|
||||
<a href="{{ url_for('order_edit', oid=d.o.id) }}">编辑</a>
|
||||
<form method="post" action="{{ url_for('order_delete', oid=d.o.id) }}" style="display:inline"
|
||||
onsubmit="return confirm('确认删除生产订单 {{ d.o.order_no }}? 关联出入库流水将保留。');">
|
||||
<button class="link-danger" type="submit">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="muted">还没有生产订单</td></tr>
|
||||
<tr><td colspan="7" class="muted">还没有生产订单</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}替代料关系{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="toolbar">
|
||||
<h3 style="margin:0">🔁 替代料关系</h3>
|
||||
<form method="post" action="{{ url_for('substitute_import') }}" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center">
|
||||
<span class="small muted">从「替代料」Excel 批量导入:</span>
|
||||
<input type="file" name="file" accept=".xlsx,.xls" required>
|
||||
<button class="primary" type="submit">导入</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="small muted">登记「某主料可由哪些替代料替代」。出库 / BOM 领料时,若主料库存不足,系统会自动提示可用的替代料。当前按{% if factory %}工厂 {{ factory.code }}{% else %}全部工厂汇总{% endif %}库存核算。</p>
|
||||
|
||||
<div class="card form-card" style="max-width:720px;margin-bottom:18px">
|
||||
<h4 style="margin-top:0">+ 新增替代关系</h4>
|
||||
<form method="post" action="{{ url_for('substitutes') }}">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<label>主料 *</label>
|
||||
<select name="main_id" required>
|
||||
<option value="">-- 选择主料 --</option>
|
||||
{% for m in mats %}
|
||||
<option value="{{ m.id }}">{{ m.part_number }} ({{ m.manufacturer or '-' }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label>替代料 *</label>
|
||||
<select name="sub_id" required>
|
||||
<option value="">-- 选择替代料 --</option>
|
||||
{% for m in mats %}
|
||||
<option value="{{ m.id }}">{{ m.part_number }} ({{ m.manufacturer or '-' }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label>备注</label>
|
||||
<input name="note" placeholder="如 替代型号说明">
|
||||
<div class="row-actions"><button class="primary" type="submit">添加</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h3>已登记关系({{ rows|length }} 条)</h3>
|
||||
<table class="tbl">
|
||||
<thead><tr>
|
||||
<th>主料编号</th><th>主料型号</th><th>替代料编号</th><th>替代料型号</th>
|
||||
<th>替代料当前库存</th><th>备注</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('material_detail', mid=r.main.id) }}">{{ r.main.part_number }}</a></td>
|
||||
<td>{{ r.main.manufacturer or '-' }}</td>
|
||||
<td><a href="{{ url_for('material_detail', mid=r.sub.id) }}">{{ r.sub.part_number }}</a></td>
|
||||
<td>{{ r.sub.manufacturer or '-' }}</td>
|
||||
<td class="num {{ 'row-in' if r.stock>0 else 'row-out' }}">{{ '%.2f'|format(r.stock) }}</td>
|
||||
<td>{{ r.note or '-' }}</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('substitute_delete', sid=r.id) }}" onsubmit="return confirm('确认删除该替代关系?');">
|
||||
<button class="btn danger" type="submit">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="muted">暂无替代关系,可手动新增或从 Excel 导入。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}新建厂间调拨{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="toolbar">
|
||||
<h3 style="margin:0">➡️ 新建厂间调拨</h3>
|
||||
<a class="btn" href="{{ url_for('transfers') }}">← 返回调拨记录</a>
|
||||
</div>
|
||||
|
||||
<div class="card form-card" style="max-width:560px">
|
||||
<p class="small muted">从「调出工厂」扣减库存,同时向「调入工厂」增加库存,两笔流水自动生成、可追溯对账。系统会校验调出厂库存是否充足。</p>
|
||||
<form method="post" id="tfForm">
|
||||
<label>调出工厂(源厂)*</label>
|
||||
<select name="from_factory_id" id="fromFac" onchange="refreshPreview()" required>
|
||||
<option value="">-- 选择调出工厂 --</option>
|
||||
{% for f in factories %}
|
||||
<option value="{{ f.id }}" {% if current_fid == f.id %}selected{% endif %}>{{ f.code }} · {{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label>调入工厂(目标厂)*</label>
|
||||
<select name="to_factory_id" id="toFac" required>
|
||||
<option value="">-- 选择调入工厂 --</option>
|
||||
{% for f in factories %}
|
||||
<option value="{{ f.id }}">{{ f.code }} · {{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label>物料 *</label>
|
||||
<select name="material_id" id="matSel" onchange="refreshPreview()" required>
|
||||
<option value="">-- 选择物料 --</option>
|
||||
{% for m in materials %}
|
||||
<option value="{{ m.id }}">{{ m.part_number }} ({{ m.manufacturer or '-' }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label>数量 *</label>
|
||||
<input name="qty" id="qty" type="number" step="0.01" min="0.01" oninput="refreshPreview()" required>
|
||||
|
||||
<label>经手人</label>
|
||||
<input name="operator">
|
||||
|
||||
<label>业务日期</label>
|
||||
<input name="occur_date" type="date" value="{{ today }}">
|
||||
|
||||
<label>备注</label>
|
||||
<input name="remark">
|
||||
|
||||
<div id="preview" style="margin:10px 0"></div>
|
||||
|
||||
<div class="row-actions">
|
||||
<button class="primary" type="submit">确认调拨</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function refreshPreview(){
|
||||
const mat = document.getElementById('matSel');
|
||||
const from = document.getElementById('fromFac');
|
||||
const qty = document.getElementById('qty');
|
||||
const box = document.getElementById('preview');
|
||||
if(!mat.value || !from.value || !qty.value || parseFloat(qty.value) <= 0){
|
||||
box.innerHTML = ''; return;
|
||||
}
|
||||
const u = `/api/transfer/preview?material_id=${mat.value}&from_factory_id=${from.value}&qty=${encodeURIComponent(qty.value)}`;
|
||||
fetch(u).then(r=>r.json()).then(d=>{
|
||||
const stock = d.stock;
|
||||
if(d.short){
|
||||
box.innerHTML = `<div class="flash error">⚠ 源厂库存不足: 现有 <b>${stock}</b>, 需调拨 <b>${parseFloat(qty.value)}</b>, 提交将被拦截</div>`;
|
||||
} else {
|
||||
box.innerHTML = `<div class="flash ok">源厂当前库存: <b>${stock}</b>, 调拨后剩余 <b>${(stock - parseFloat(qty.value)).toFixed(2)}</b></div>`;
|
||||
}
|
||||
}).catch(()=>{ box.innerHTML=''; });
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}厂间调拨记录{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="toolbar">
|
||||
<h3 style="margin:0">➡️ 厂间调拨记录</h3>
|
||||
{% if g.user and g.user.role in ('admin','manager') %}
|
||||
<a class="btn primary" href="{{ url_for('transfer_new') }}">+ 新建调拨</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if factory %}
|
||||
<div class="scope-banner">
|
||||
🔎 当前已选中某工厂,仅显示<b>调出或调入涉及该厂</b>的调拨记录。
|
||||
<a href="{{ url_for('set_factory') }}" style="display:none"></a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not rows %}
|
||||
<p class="muted">暂无调拨记录。点击右上角「新建调拨」即可在工厂之间调拨物料。</p>
|
||||
{% else %}
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>调拨单号</th>
|
||||
<th>调出厂 → 调入厂</th>
|
||||
<th>物料</th>
|
||||
<th>数量</th>
|
||||
<th>业务日期</th>
|
||||
<th>经手人</th>
|
||||
<th>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td>{{ r.t.no }}</td>
|
||||
<td>{{ r.ff.code if r.ff else '?' }} → {{ r.tf.code if r.tf else '?' }}</td>
|
||||
<td><a href="{{ url_for('material_detail', mid=r.m.id) }}">{{ r.m.part_number if r.m else r.t.material_id }}</a></td>
|
||||
<td class="num">{{ '%.2f'|format(r.t.qty) }}</td>
|
||||
<td>{{ r.t.occur_date }}</td>
|
||||
<td>{{ r.t.operator or '-' }}</td>
|
||||
<td>{{ r.t.remark or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -23,6 +23,13 @@
|
||||
<option value="{{ m.id }}" data-pn="{{ m.part_number }}">{{ m.part_number }} ({{ m.manufacturer or '-' }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div id="subHint" class="sub-hint" style="margin-top:8px"></div>
|
||||
<label>所属工厂 *</label>
|
||||
<select name="factory_id" required>
|
||||
{% for f in factories %}
|
||||
<option value="{{ f.id }}" {% if (current_factory_id == f.id) or (not current_factory_id and loop.first) %}selected{% endif %}>{{ f.code }} · {{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label>批次号</label>
|
||||
<input name="batch_no">
|
||||
<label>数量 *</label>
|
||||
@@ -32,6 +39,8 @@
|
||||
{% if kind=='inbound' %}
|
||||
<label>来源 / 供应商</label>
|
||||
<input name="source">
|
||||
<label>送货日期</label>
|
||||
<input name="delivery_date" type="date" value="{{ today }}">
|
||||
{% else %}
|
||||
<label>用途</label>
|
||||
<input name="purpose">
|
||||
@@ -47,6 +56,43 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if kind == 'outbound' %}
|
||||
<div class="card bom-issue-card" style="margin-top:18px">
|
||||
<h3 style="margin-top:0">🧩 按 BOM 批量操作(领料出库 / 调拨入库)</h3>
|
||||
<p class="small muted">选择 BOM 与数量,系统按单套用量自动生成每种组件的出库/入库流水,任意数量均可。</p>
|
||||
<form method="post" action="{{ url_for('bom_issue') }}" class="form-card">
|
||||
<label>操作类型 *</label>
|
||||
<select name="mode" id="bomMode" onchange="onBomModeChange();refreshBomPreview()">
|
||||
<option value="pick">按BOM齐套领料出库(扣本厂库存)</option>
|
||||
<option value="transfer">按BOM调拨入库(调入目标厂)</option>
|
||||
</select>
|
||||
<label>BOM 型号 *</label>
|
||||
<select name="bom_id" id="bomSel" onchange="refreshBomPreview()" required>
|
||||
<option value="">-- 选择 BOM --</option>
|
||||
{% for b in boms %}
|
||||
<option value="{{ b.id }}">{{ b.code }} ({{ b.name or '-' }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label>数量(台/套)*</label>
|
||||
<input name="qty" id="bomQty" type="number" step="0.01" min="0.01" oninput="refreshBomPreview()" required>
|
||||
<label id="facLabel">本厂 *</label>
|
||||
<select name="factory_id" id="bomFac" onchange="refreshBomPreview()">
|
||||
{% for f in factories %}
|
||||
<option value="{{ f.id }}" {% if (current_factory_id == f.id) or (not current_factory_id and loop.first) %}selected{% endif %}>{{ f.code }} · {{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label>经手人</label>
|
||||
<input name="operator">
|
||||
<label>业务日期</label>
|
||||
<input name="occur_date" type="date" value="{{ today }}">
|
||||
<label>备注</label>
|
||||
<input name="remark">
|
||||
<div class="row-actions"><button class="primary" type="submit">生成流水</button></div>
|
||||
</form>
|
||||
<div id="bomPreview" style="margin-top:12px"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h3>最近 {{ recs|length }} 条{{ '入库' if kind=='inbound' else '出库' }}记录</h3>
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
@@ -211,5 +257,71 @@ function commitBatch(){
|
||||
if(d.added>0) location.reload();
|
||||
}).catch(e=>alert('导入失败: '+e));
|
||||
}
|
||||
|
||||
/* ---- 出库页 BOM 卡片: 实时需求预览 ---- */
|
||||
function refreshBomPreview(){
|
||||
const bid = document.getElementById('bomSel');
|
||||
const qty = document.getElementById('bomQty');
|
||||
const fac = document.getElementById('bomFac');
|
||||
const mode = document.getElementById('bomMode');
|
||||
if(!bid || !qty || !fac || !mode) return;
|
||||
const box = document.getElementById('bomPreview');
|
||||
if(!bid.value || !qty.value || parseFloat(qty.value) <= 0){ box.innerHTML=''; return; }
|
||||
const u = `/api/bom/preview?bom_id=${bid.value}&qty=${encodeURIComponent(qty.value)}&factory_id=${fac.value}&mode=${mode.value}`;
|
||||
fetch(u).then(r=>r.json()).then(d=>{
|
||||
if(!d.items.length){ box.innerHTML='<p class="small muted">该 BOM 无组成物料</p>'; return; }
|
||||
let html = '<table class="tbl"><thead><tr><th>物料编号</th><th>型号</th><th>需求数量</th><th>工厂库存</th><th>状态</th></tr></thead><tbody>';
|
||||
d.items.forEach(it=>{
|
||||
const cls = it.short ? 'row-danger' : '';
|
||||
const tag = it.short ? '<span class="badge danger">缺料</span>' : '<span class="badge ok">充足</span>';
|
||||
html += `<tr class="${cls}"><td>${it.pn}</td><td>${it.mf}</td><td class="num">${it.need}</td><td class="num">${it.stock}</td><td>${tag}</td></tr>`;
|
||||
if(it.subs && it.subs.length){
|
||||
const avail = it.subs.filter(s=>s.available);
|
||||
const subTxt = it.subs.map(s=>`${s.pn}(库存${s.stock}${s.available?' 有货':' 无货'})`).join(',');
|
||||
const subCls = avail.length ? 'row-in' : '';
|
||||
html += `<tr class="${subCls}"><td colspan="5" class="small muted">↳ 可替代料: ${subTxt}</td></tr>`;
|
||||
}
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
if(d.any_short && mode.value === 'pick'){
|
||||
html = '<div class="flash error">存在缺料, 提交将被拦截</div>' + html;
|
||||
}
|
||||
box.innerHTML = html;
|
||||
}).catch(e=>{ box.innerHTML=''; });
|
||||
}
|
||||
function onBomModeChange(){
|
||||
const mode = document.getElementById('bomMode');
|
||||
const lbl = document.getElementById('facLabel');
|
||||
if(mode && lbl){ lbl.textContent = (mode.value === 'pick') ? '本厂 *' : '目标厂 *'; }
|
||||
}
|
||||
if(document.getElementById('bomMode')){
|
||||
onBomModeChange();
|
||||
}
|
||||
|
||||
/* ---- 出库单物料: 实时提示可替代料 ---- */
|
||||
function refreshSubHint(){
|
||||
const sel = document.getElementById('materialSelect');
|
||||
const box = document.getElementById('subHint');
|
||||
if(!sel || !box) return;
|
||||
const mid = sel.value;
|
||||
if(!mid){ box.innerHTML=''; return; }
|
||||
fetch(`/api/substitutes/${mid}`).then(r=>r.json()).then(d=>{
|
||||
if(!d.subs || !d.subs.length){
|
||||
box.innerHTML = '<p class="small muted">该物料暂无登记替代料</p>';
|
||||
return;
|
||||
}
|
||||
let html = '<div class="card" style="padding:10px 12px"><b>可替代料(库存提示):</b>'
|
||||
+ '<table class="tbl"><thead><tr><th>替代料编号</th><th>型号</th><th>当前库存</th><th>状态</th></tr></thead><tbody>';
|
||||
d.subs.forEach(s=>{
|
||||
const cls = s.available ? 'row-in' : 'row-out';
|
||||
const tag = s.available ? '<span class="badge ok">有货</span>' : '<span class="badge danger">无货</span>';
|
||||
html += `<tr class="${cls}"><td>${s.pn}</td><td>${s.mf}</td><td class="num">${s.stock}</td><td>${tag}</td></tr>`;
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
box.innerHTML = html;
|
||||
}).catch(()=>{ box.innerHTML=''; });
|
||||
}
|
||||
const _subSel = document.getElementById('materialSelect');
|
||||
if(_subSel){ _subSel.addEventListener('change', refreshSubHint); }
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+104
-16
@@ -2,7 +2,8 @@
|
||||
|
||||
> 适用对象:仓库管理员、车间计划员、系统管理员
|
||||
> 系统形态:Windows 单文件 EXE(双击即用,免安装数据库,中文界面)
|
||||
> 版本:M1 基础版 + M2 核算版 + M3 治理版
|
||||
> 版本:M1 基础版 + M2 核算版(含 M2-1 多工厂 / M2-2 厂间调拨)+ M3 治理版
|
||||
> 最近新增:多工厂与代工厂、出库 BOM 批量操作、生产订单删除/编辑/调入数量、厂间调拨
|
||||
|
||||
---
|
||||
|
||||
@@ -56,9 +57,12 @@
|
||||
| 库存看板 | ✅ | ✅ | ✅ |
|
||||
| 物料(列表 / 明细) | ✅ | ✅ | ✅ |
|
||||
| 入库 / 出库 | ✅ | ✅ | ✅ |
|
||||
| 出库里的 BOM 批量操作 | ✅ | ✅ | ❌ |
|
||||
| 物料新增 / 编辑 / 删除 | ✅ | ✅ | ❌ |
|
||||
| BOM 管理 | ✅ | ✅ | ❌ |
|
||||
| 生产订单 | ✅ | ✅ | ❌ |
|
||||
| 生产订单(新建 / 编辑 / 删除 / 调入) | ✅ | ✅ | ❌ |
|
||||
| 工厂(工厂管理 / 工厂工作台) | ✅ | ✅ | ✅(仅查看工作台,不能导入/建厂) |
|
||||
| 调拨(厂间调拨) | ✅ | ✅ | ❌ |
|
||||
| 报表 / 导出 Excel | ✅ | ✅ | ❌ |
|
||||
| 操作日志 | ✅ | ✅ | ❌ |
|
||||
| 用户管理 | ✅ | ❌ | ❌ |
|
||||
@@ -109,7 +113,7 @@
|
||||
|
||||
### 5.4 物料明细与流水查询
|
||||
点列表里的 **明细** 链接,进入单颗物料页面,顶部三张卡片显示在库 / 入库总 / 消耗总;下方两张表分别是:
|
||||
- **入库流水**:入库日期、录入时间、数量、批次、来源、经手人
|
||||
- **入库流水**:入库日期、送货日期、录入时间、数量、批次、来源、经手人
|
||||
- **出库流水**:出库日期、录入时间、数量、批次、用途、关联单号、经手人
|
||||
|
||||
> 这就是「查看每颗物料每次的入库时间」的入口。
|
||||
@@ -130,7 +134,7 @@
|
||||
表单顶部有一个 **「物料编码」输入框**(与「物料」下拉并列):
|
||||
- **直接输入或扫码**物料编码(如 `P0001`),系统会**自动定位**到下拉里对应的物料:精确命中、或编码前缀唯一时立即锁定,并把光标跳到「数量」;多个前缀候选会提示「继续输入」;编码不存在会标红提示「未找到」。
|
||||
- 也可以照旧在「物料」下拉里手动选择——手选后编码框会自动反向填入对应编码,两种方式互通。
|
||||
- 填批次号、数量、经手人、来源/供应商(出库为用途/关联单号)、业务日期、备注 → 提交。库存实时 +N(出库实时 −N,不足会拦截),并写操作日志。
|
||||
- 填送货日期、批次号、数量、经手人、来源/供应商(出库为用途/关联单号)、业务日期、备注 → 提交。库存实时 +N(出库实时 −N,不足会拦截),并写操作日志。送货日期默认当天,可改成供应商实际到货日,与「业务日期」(记账日)区分。
|
||||
|
||||
### 6.2 批量导入窗口
|
||||
点右上角 **⊞ 批量导入窗口**,支持两种方式:
|
||||
@@ -149,7 +153,21 @@
|
||||
|
||||
批量导入窗口同样可用(见 6.2),勾选「出库」时同样有库存校验。
|
||||
|
||||
---
|
||||
### 7.1 按 BOM 批量操作(admin / manager)
|
||||
|
||||
出库页底部有一个 **「🧩 按 BOM 批量操作」** 卡片(仅管理员、主管可见),提供两种模式:
|
||||
|
||||
**① 齐套领料出库**(从本厂库存发料)
|
||||
- 选 BOM + 填「生产套数」→ 点「预览需求」,系统按 `单套用量 × 套数` 列出每种料的「需求数量 / 本厂在库 / 缺料数」。
|
||||
- 缺料的行会标红;**缺口 > 0 的行不会出库**,只把够的部分发掉,避免库存变负。
|
||||
- 点「确认齐套领料」即生成一批出库流水(用途「生产领用」)。
|
||||
|
||||
**② 调拨入库(调入到工厂)**
|
||||
- 选「目标工厂」+ 选 BOM + 填套数 → 预览后点「确认调拨入库」。
|
||||
- 系统生成的是**入库流水**,直接计入目标工厂的该物料库存(来源「调拨入库」)。
|
||||
- 适合「总部有货、代工厂缺料」时,把料调拨进代工厂库存的场景。
|
||||
|
||||
> 两种模式共用同一张预览表,切换模式即可;预览实时显示缺料,确认前请先核对数量。
|
||||
|
||||
## 八、BOM 管理(admin / manager)
|
||||
|
||||
@@ -191,16 +209,75 @@ P0003 4
|
||||
|
||||
路径:导航栏 **生产订单**。
|
||||
|
||||
1. **新建订单**:选 BOM + 填接单量 → 生成订单号(如 `WO20260920xxx`)。
|
||||
2. **订单详情(用量展开)**:系统按 `需求量 = 接单量 × 单套用量` 算出每种料需要领多少,并和当前在库对比:
|
||||
- 够 → 正常;不够 → **缺料标红**,列出缺口数量。
|
||||
3. **一键领料**:点「领料」,系统按 BOM 用量自动生成一批出库流水(用途记为「生产领用」,关联订单号)。若仍有缺料会提示,但不会阻止操作——需人工确认。
|
||||
### 9.1 新建订单
|
||||
选 BOM + 选「生产工厂」+ 填接单量(+ 可选单号 / 备注)→ 生成订单号(如 `WO20260920xxx`)。订单会与所选工厂绑定,后续看库存、调入都按该工厂口径。
|
||||
|
||||
这一步等价于原来 Excel 里「接单量 × 单套用量」的手算,且自动扣减库存、留痕。
|
||||
### 9.2 编辑 / 删除订单
|
||||
- **编辑**:订单详情页点「编辑订单」→ 可改单号、BOM、生产工厂、接单量、备注。
|
||||
- **删除**:列表或详情页点「删除」→ 只删订单头与「调入计划行」,**保留**已发生的关联出入库流水(已发/已调入的料不会因删单而回滚),操作需谨慎。
|
||||
|
||||
### 9.3 订单详情(用量展开 + 调入数量)
|
||||
详情页按 `需求量 = 接单量 × 单套用量` 算出每种料需要领多少,并与**该生产工厂**当前在库对比:够 → 正常;不够 → **缺料标红**,列出缺口。特点是:
|
||||
|
||||
- **BOM 物料后新增「调入数量」列**(可手动录入):你可以逐料填「计划从别的厂/渠道调入多少」。
|
||||
- **保存调入数量**:把计划暂存(不立即动库存)。
|
||||
- **确认调入**:把每行的「调入数量」一键生成**入库流水**直接计入该生产工厂库存(来源「生产订单调入」)。已入账的行显示徽标、不可重复入账,避免重复调入。
|
||||
- 物料编号可点击直达该物料明细页。
|
||||
- 底部「**调入总览**」汇总:已调入项数 / 已调入合计件数 / 计划调入合计。
|
||||
- **一键领料**:点「领料」,系统按 BOM 用量自动生成一批出库流水(用途「生产领用」,关联订单号);仍有缺料会提示但不阻止,需人工确认。
|
||||
|
||||
这一步既保留了原来「接单量 × 单套用量」的自动展开与一键领料,又让你能手动登记「这单要从别处调多少料进来」。
|
||||
|
||||
---
|
||||
|
||||
## 十、报表与操作日志(admin / manager)
|
||||
## 十、多工厂与代工厂管理(M2-1,admin / manager)
|
||||
|
||||
如果你的业务涉及多个工厂 / 代工厂,可以用「单库多地点」模式把各厂的库存放在同一套系统里管理,既统一又互不串库。
|
||||
|
||||
### 10.1 工厂管理页
|
||||
路径:导航栏 **工厂**(仅管理员、主管可见)。
|
||||
- 列出全部工厂:编码、名称、类型(**自厂 / 代工厂**)、是否启用。
|
||||
- **新增工厂**:填编码(如 `SUB01`,唯一)、名称、类型、备注。
|
||||
- **停用 / 启用**:停用的工厂不再出现在切换器与下拉里(历史数据保留)。
|
||||
|
||||
### 10.2 顶栏工厂切换器
|
||||
页面右上角有一个 **「全部工厂 / 各工厂」** 下拉:
|
||||
- 选「全部工厂」→ 看板、物料、出入库均为**汇总口径**。
|
||||
- 选某个具体工厂 → 看板 / 物料列表 / 出入库明细 / 物料流水全部**按该厂过滤**。
|
||||
- 选择写入当前会话,刷新后保持。
|
||||
|
||||
### 10.3 工厂工作台
|
||||
在工厂管理页点某厂的「进入」,或访问 `/factory/<id>`,是一个三页签的工作台:
|
||||
- **物料看板**:仅该厂的在库与预警。
|
||||
- **BOM 齐套**:基于该厂库存判断各 BOM 是否齐套。
|
||||
- **批量导入**:可把期初物料 / 入库快速导入到该厂(仅管理员、主管可操作)。
|
||||
|
||||
> 设计原则:所有账号都能看到全部工厂数据(不做厂间权限隔离);库存聚合按 `factory_id` 实时计算,不冗余存储。
|
||||
|
||||
---
|
||||
|
||||
## 十一、厂间调拨(M2-2,admin / manager)
|
||||
|
||||
当「甲厂有货、乙厂缺料」时,用调拨把料从一个厂划到另一个厂,系统自动做**双向记账**。
|
||||
|
||||
### 11.1 新建调拨
|
||||
路径:导航栏 **调拨** → **+ 新建调拨**。
|
||||
填写:源厂、目标厂、物料、数量、业务日期、经手人、备注。
|
||||
- **库存校验**:源厂在库不足时拦截,不让调拨变负。
|
||||
- 提交后自动生成两条流水:
|
||||
- 源厂:`Outbound`(出库,库存 −N)
|
||||
- 目标厂:`Inbound`(入库,库存 +N)
|
||||
- 同时写入 `transfer` 调拨主表,作为调拨凭证。
|
||||
|
||||
### 11.2 调拨列表
|
||||
路径:导航栏 **调拨**(列表页,所有角色可查看)。
|
||||
显示每笔调拨:调拨单号、源厂→目标厂、物料、数量、经手人、业务日期、备注。可用于对账。
|
||||
|
||||
> 调拨与「出库页的调拨入库」区别:本模块是**厂与厂之间的正式调拨**(双向记账、有调拨单号);出库页的「调拨入库」是把 BOM 料直接调入某厂(单向入库)。两者都会让目标厂库存 +N,看板/物料按厂过滤后都会体现。
|
||||
|
||||
---
|
||||
|
||||
## 十二、报表与操作日志(admin / manager)
|
||||
|
||||
- **报表**:导航栏 **报表**,全量库存表,预警状态标红,可点 **⬇ 导出库存 Excel** 下载 `库存导出_日期.xlsx`。
|
||||
- **日志**:导航栏 **日志**,记录所有关键操作(新增/编辑/删除物料、入库、出库、导入、领料等),含操作人、动作、目标、明细,最多保留 200 条,用于审计追溯。
|
||||
@@ -217,7 +294,7 @@ P0003 4
|
||||
|
||||
---
|
||||
|
||||
## 十二、数据备份与维护(重要)
|
||||
## 十四、数据备份与维护(重要)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|---|---|
|
||||
@@ -249,6 +326,15 @@ P0003 4
|
||||
**Q6:忘记 admin 密码?**
|
||||
关闭程序,用备份恢复,或联系部署人重置 `app.db`(暂无自助找回,需直接改库)。
|
||||
|
||||
**Q7:顶栏工厂切换器怎么用?**
|
||||
右上角下拉选「全部工厂」看汇总,选某厂只看该厂数据;切换只影响当前浏览器会话。新增/停用工厂在「工厂」页(管理员、主管)。
|
||||
|
||||
**Q8:厂间调拨和出库里的「调拨入库」有什么不同?**
|
||||
调拨模块是厂↔厂的正式调拨(源厂减、目标厂加,有调拨单号);出库页的「调拨入库」是单向把 BOM 料直接加进某厂。两者都会让目标厂库存增加,按厂过滤后都会体现。
|
||||
|
||||
**Q9:生产订单里的「调入数量」是什么?**
|
||||
是逐料「计划从别处调多少进来」的手工登记。点「保存调入数量」暂存计划,点「确认调入」才真正生成入库流水进该生产工厂;已入账的行不可重复调入。
|
||||
|
||||
---
|
||||
|
||||
## 附:快速上手清单
|
||||
@@ -256,7 +342,9 @@ P0003 4
|
||||
1. 双击 `MaterialWMS.exe`,浏览器打开,用 `admin / admin123` 登录。
|
||||
2. 下载「物料导入模板」,把现有物料清单填好导入。
|
||||
3. 用「BOM 批量导入窗口」上传嘉立创 BOM(或粘贴文本),建好产品用料。
|
||||
4. 日常收发货:入库 / 出库 单笔或批量录入。
|
||||
5. 接单后:生产订单新建 → 看缺料 → 一键领料。
|
||||
6. 每天看一眼「库存看板」的红/黄预警,每周备份一次 `app.db`。
|
||||
7. 给同事建独立账号并分配角色(admin 专属)。
|
||||
4. 多工厂场景:进「工厂」页建好各工厂/代工厂,用顶栏切换器按厂查看。
|
||||
5. 日常收发货:入库 / 出库 单笔或批量录入;出库页可用「按 BOM 批量操作」(齐套领料 / 调拨入库)。
|
||||
6. 接单后:生产订单新建(选工厂)→ 看缺料 → 登记「调入数量」并确认调入 → 一键领料。
|
||||
7. 厂间调拨:调拨页建调拨单,系统自动源厂减、目标厂加。
|
||||
8. 每天看一眼「库存看板」的红/黄预警,每周备份一次 `app.db`。
|
||||
9. 给同事建独立账号并分配角色(admin 专属)。
|
||||
|
||||
Reference in New Issue
Block a user