Compare commits
12
Commits
439877c6ba
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27fb218c40 | ||
|
|
38ee24f6ee | ||
|
|
93f3ef458b | ||
|
|
099496dec9 | ||
|
|
5a6f4ea4df | ||
|
|
e9b605fca6 | ||
|
|
639d7f1545 | ||
|
|
012e018b12 | ||
|
|
882508aee9 | ||
|
|
574d608e11 | ||
|
|
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` 备份,可随时覆盖还原。
|
||||
|
||||
+15
-4
@@ -18,17 +18,28 @@ 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)
|
||||
run(args)
|
||||
print("BUILD DONE ->", os.path.join(HERE, "dist", "MaterialWMS.exe"))
|
||||
# 构建产物在 dist_pkg/; 统一拷贝到 dist/ (EXE 同目录即数据库所在, 才是实际发布入口)
|
||||
built = os.path.join(HERE, "dist_pkg", "MaterialWMS.exe")
|
||||
target = os.path.join(HERE, "dist", "MaterialWMS.exe")
|
||||
if os.path.exists(built):
|
||||
import shutil
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
shutil.copyfile(built, target)
|
||||
print("COPIED ->", target)
|
||||
else:
|
||||
print("WARN: built exe not found at", built)
|
||||
print("BUILD DONE ->", target)
|
||||
|
||||
+187
-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
|
||||
@@ -244,6 +245,35 @@ def export_inventory(path, rows):
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def export_prep(path, rows, scope=""):
|
||||
"""导出备料清单(采购下单用, 不记账; 手工维护, 与生产订单无关)。
|
||||
|
||||
rows: [{id, m, plan(计划数量), stock(实际库存), diff(缺口=计划-库存), short}]
|
||||
scope: 范围说明(工厂/全部)。
|
||||
"""
|
||||
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"]
|
||||
status = "需采购" if r["diff"] > 0 else ("刚好够" if r["diff"] == 0 else "已有盈余")
|
||||
ws.append([m.part_number, m.manufacturer or "", m.unit or "个",
|
||||
r["plan"], r["stock"], r["diff"], status, scope])
|
||||
# 缺口着色: >0 需采购标红; <0 盈余标绿
|
||||
if r["diff"] > 0:
|
||||
ws.cell(row=ws.max_row, column=6).fill = PatternFill("solid", fgColor="FFC7CE")
|
||||
elif r["diff"] < 0:
|
||||
ws.cell(row=ws.max_row, column=6).fill = PatternFill("solid", fgColor="C6EFCE")
|
||||
ws.freeze_panes = "A2"
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def build_material_template(path):
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
@@ -256,6 +286,160 @@ 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 export_orders(target, data):
|
||||
"""导出生产订单列表。
|
||||
|
||||
data: [{o, lines, factory, total}] (与 orders() 返回一致)。
|
||||
lines: [(Bom, qty)] 该订单的 BOM 行(可多个)。
|
||||
"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "生产订单"
|
||||
head = ["订单号", "BOM清单(型号×数量)", "生产工厂", "状态",
|
||||
"经手人", "下单时间", "备注", "总接单量"]
|
||||
ws.append(head)
|
||||
for c in ws[1]:
|
||||
c.font = Font(bold=True)
|
||||
c.fill = PatternFill("solid", fgColor="DDEBF7")
|
||||
for d in data:
|
||||
o = d["o"]
|
||||
lines = d.get("lines") or []
|
||||
fac = d["factory"]
|
||||
total = d.get("total") or 0
|
||||
bom_info = "\n".join(
|
||||
f"{b.code}{('·' + b.name) if b.name else ''}×{q}" for b, q in lines) or "—"
|
||||
status = "已领料" if o.status == "picked" else "待领料"
|
||||
ws.append([
|
||||
o.order_no,
|
||||
bom_info,
|
||||
(f"{fac.code}·{fac.name}" if fac else "未指定"),
|
||||
status,
|
||||
o.operator or "",
|
||||
o.created_at.strftime("%Y-%m-%d %H:%M") if o.created_at else "",
|
||||
o.remark or "",
|
||||
total,
|
||||
])
|
||||
if o.status == "picked":
|
||||
ws.cell(row=ws.max_row, column=4).fill = PatternFill("solid", fgColor="C6EFCE")
|
||||
else:
|
||||
ws.cell(row=ws.max_row, column=4).fill = PatternFill("solid", fgColor="FFE699")
|
||||
ws.cell(row=ws.max_row, column=2).alignment = Alignment(wrap_text=True, vertical="top")
|
||||
ws.freeze_panes = "A2"
|
||||
wb.save(target)
|
||||
|
||||
|
||||
def export_order_detail(target, o, lines, reqs, factory,
|
||||
posted_total, planned_total, posted_count):
|
||||
"""导出单个生产订单详情(派工单):订单信息 sheet + BOM清单 sheet + 领料明细 sheet。
|
||||
|
||||
lines: [(Bom, qty)] 该订单的 BOM 行(可多个); 领料明细已按全部 BOM 汇总。
|
||||
"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "订单信息"
|
||||
ws.append(["生产订单派工单"])
|
||||
ws["A1"].font = Font(bold=True, size=14)
|
||||
total_qty = sum(q for _, q in (lines or []))
|
||||
info = [
|
||||
("订单号", o.order_no),
|
||||
("BOM清单", "\n".join(
|
||||
f"{b.code}{('·' + b.name) if b.name else ''}×{q}" for b, q in (lines or [])) or "-"),
|
||||
("总接单量", total_qty),
|
||||
("生产工厂", (f"{factory.code}·{factory.name}" if factory else "未指定(按汇总库存)")),
|
||||
("经手人", o.operator or "-"),
|
||||
("下单时间", o.created_at.strftime("%Y-%m-%d %H:%M") if o.created_at else ""),
|
||||
("状态", "已领料" if o.status == "picked" else "待领料"),
|
||||
("备注", o.remark or "-"),
|
||||
("调入总览", f"已调入 {posted_count} 项 / 合计 {posted_total} 件;计划调入合计 {planned_total} 件"),
|
||||
]
|
||||
r = 3
|
||||
for k, v in info:
|
||||
ws.cell(row=r, column=1, value=k).font = Font(bold=True)
|
||||
cell = ws.cell(row=r, column=2, value=v)
|
||||
if k == "BOM清单":
|
||||
cell.alignment = Alignment(wrap_text=True, vertical="top")
|
||||
r += 1
|
||||
|
||||
ws2 = wb.create_sheet("BOM清单")
|
||||
ws2.append(["BOM型号", "BOM名称", "生产数量"])
|
||||
for c in ws2[1]:
|
||||
c.font = Font(bold=True)
|
||||
c.fill = PatternFill("solid", fgColor="DDEBF7")
|
||||
for b, q in (lines or []):
|
||||
ws2.append([b.code, b.name or "", q])
|
||||
ws2.freeze_panes = "A2"
|
||||
|
||||
ws3 = wb.create_sheet("领料明细")
|
||||
head = ["物料编号", "型号", "下单前库存", "需求数量", "缺口",
|
||||
"调入数量", "状态", "工厂库存", "可替代料"]
|
||||
ws3.append(head)
|
||||
for c in ws3[1]:
|
||||
c.font = Font(bold=True)
|
||||
c.fill = PatternFill("solid", fgColor="DDEBF7")
|
||||
for x in reqs:
|
||||
subs = "、".join(
|
||||
f"{s['pn']}·{s['stock']}{'✓' if s['available'] else '✗'}" for s in x["subs"]
|
||||
) if x["subs"] else "—"
|
||||
ws3.append([
|
||||
x["m"].part_number,
|
||||
x["m"].manufacturer or "-",
|
||||
round(x["have"], 3),
|
||||
round(x["need"], 3),
|
||||
round(x["gap"], 3),
|
||||
round(x["alloc"], 3),
|
||||
"缺料" if x["short"] else "充足",
|
||||
round(x["gap"] + x["alloc"], 3),
|
||||
subs,
|
||||
])
|
||||
if x["short"]:
|
||||
ws3.cell(row=ws3.max_row, column=7).fill = PatternFill("solid", fgColor="FFC7CE")
|
||||
else:
|
||||
ws3.cell(row=ws3.max_row, column=7).fill = PatternFill("solid", fgColor="C6EFCE")
|
||||
ws3.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,52 @@ 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 ProdOrderBom(db.Model):
|
||||
"""生产订单包含的 BOM 行: 一个订单可含多个 BOM, 每个 BOM 独立的生产数量。
|
||||
|
||||
- 这是「一订单多 BOM」的唯一数据源, 取代原 ProdOrder.bom_id 的单值绑定。
|
||||
- (order_id, bom_id) 唯一: 同一 BOM 在同一订单中只出现一行(数量可累加合并)。
|
||||
- ProdOrder.bom_id / qty 仅作为兼容旧数据与列表快速展示的冗余(主BOM / 总接单量)。
|
||||
"""
|
||||
__tablename__ = "prod_order_bom"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
order_id = db.Column(db.Integer, db.ForeignKey("prod_order.id"), nullable=False, index=True)
|
||||
bom_id = db.Column(db.Integer, db.ForeignKey("bom.id"), nullable=False, index=True)
|
||||
qty = db.Column(db.Float, nullable=False, default=0) # 该 BOM 在本订单的生产数量
|
||||
__table_args__ = (UniqueConstraint("order_id", "bom_id", name="uq_pob_order_bom"),)
|
||||
|
||||
|
||||
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 +158,56 @@ 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)
|
||||
|
||||
|
||||
# ---------------- 备料计划数量(人工录入, 不记账) ----------------
|
||||
class PrepPlan(db.Model):
|
||||
"""备料「计划数量」人工录入(不记账): 每个物料在某工厂范围下的计划采购量。
|
||||
|
||||
- (material_id, factory_id) 组合唯一; factory_id 为 NULL 表示「全部工厂(汇总)」范围的计划。
|
||||
- plan_qty 为 NULL 表示「尚未填写」; 0 表示明确计划不采购。
|
||||
- 仅用于采购下单参考, 不产生任何入库/出库流水。
|
||||
"""
|
||||
__tablename__ = "prep_plan"
|
||||
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)
|
||||
plan_qty = db.Column(db.Float, nullable=True, default=None) # 计划采购数量(空=未填写)
|
||||
note = db.Column(db.String(255), default="")
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
__table_args__ = (UniqueConstraint("material_id", "factory_id", name="uq_prep_plan_mf"),)
|
||||
|
||||
|
||||
# ---------------- 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)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
@echo off
|
||||
REM ============================================================
|
||||
REM 一键推送本地提交到 Gitea
|
||||
REM 使用前提:本机能访问公司内网 / 已连 VPN,能解析 git.btw-ocr.com
|
||||
REM Gitea 凭据由系统凭据助手(helper-selector / GCM)提供,无需手填
|
||||
REM ============================================================
|
||||
cd /d "D:\2026-09-10-task-20\material_wms"
|
||||
|
||||
echo [1/3] 拉取远程最新...
|
||||
git fetch origin
|
||||
if %errorlevel% neq 0 (
|
||||
echo fetch 失败:请确认已连内网/VPN,且能访问 git.btw-ocr.com
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [2/3] 将本地提交 rebase 到远程 main 之上...
|
||||
git rebase origin/main
|
||||
if %errorlevel% neq 0 (
|
||||
echo rebase 冲突:请手动解决冲突后 git rebase --continue,或运行 git rebase --abort 撤销
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [3/3] 推送到 Gitea...
|
||||
git push origin main
|
||||
if %errorlevel% neq 0 (
|
||||
echo push 失败,请检查凭据或网络后重试
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ===== 推送完成 =====
|
||||
echo 可在 https://git.btw-ocr.com/star/Inventory-Management-System 核对最新提交
|
||||
pause
|
||||
+44
-2
@@ -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; }
|
||||
@@ -31,13 +44,23 @@ label { display: block; margin: 10px 0 4px; font-size: 13px; color: #44525f; }
|
||||
input, select, textarea {
|
||||
width: 100%; padding: 8px 10px; border: 1px solid #cdd5df; border-radius: 6px; font-size: 14px;
|
||||
}
|
||||
.cell-inp { width: 100%; box-sizing: border-box; padding: 4px 6px; border: 1px solid #c9d2dc; border-radius: 4px; font-size: 13px; }
|
||||
.cell-inp.num { text-align: right; }
|
||||
.readonly-field { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid #dfe3e8; border-radius: 6px; background: #f1f3f6; color: #6b7280; font-size: 14px; }
|
||||
.btn.small, button.small { padding: 5px 10px; font-size: 12px; border-radius: 5px; }
|
||||
button, .btn {
|
||||
display: inline-block; cursor: pointer; border: none; border-radius: 6px;
|
||||
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.danger, .btn.danger { background: #e6453c; color: #fff; border-color: #d23b32; }
|
||||
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; }
|
||||
@@ -51,11 +74,18 @@ button:hover, .btn:hover { filter: brightness(.96); }
|
||||
.row-danger { background: #fff1f0; }
|
||||
.row-warn { background: #fffbe6; }
|
||||
|
||||
.badge { padding: 2px 8px; border-radius: 10px; font-size: 12px; }
|
||||
.badge { display: inline-block; white-space: nowrap; padding: 2px 8px; border-radius: 10px; font-size: 12px; }
|
||||
.badge.ok { background: #e6f7ed; color: #1a7f47; }
|
||||
.badge.warn { background: #fff7e6; color: #ad6800; }
|
||||
.badge.danger { background: #fff1f0; color: #cf1322; }
|
||||
|
||||
/* 备料: 计划数量差异指示 */
|
||||
.plan-input { padding: 3px 6px; border: 1px solid #d0d5dd; border-radius: 4px; font-size: 13px; }
|
||||
.plan-input:focus { outline: none; border-color: #4096ff; box-shadow: 0 0 0 2px rgba(64,150,255,.15); }
|
||||
.diff { font-variant-numeric: tabular-nums; }
|
||||
.diff-pos { color: #1a7f47; font-weight: 600; } /* 计划 > 采购(多订) 绿 */
|
||||
.diff-neg { color: #cf1322; font-weight: 600; } /* 计划 < 采购(少订) 红 */
|
||||
|
||||
.flash { padding: 10px 14px; border-radius: 6px; margin-bottom: 12px; font-size: 14px; }
|
||||
.flash.ok { background: #e6f7ed; color: #1a7f47; }
|
||||
.flash.error { background: #fff1f0; color: #cf1322; }
|
||||
@@ -70,6 +100,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; }
|
||||
|
||||
@@ -116,6 +155,9 @@ tr.row-sim-danger td { color: #cf1322; }
|
||||
/* 批量导入窗口: 多 BOM 卡片 */
|
||||
.bom-card { border: 1px solid #e3e8ef; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
|
||||
.bom-card.card-danger { border-color: #ffccc7; }
|
||||
.bom-card .bom-head { background: #f0f3f7; padding: 8px 12px; font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
||||
.bom-card .bom-head { background: #f0f3f7; padding: 8px 12px; font-size: 14px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.bom-card.card-danger .bom-head { background: #fff1f0; }
|
||||
.bom-card .bom-head label.bh-lbl { margin: 0; white-space: nowrap; color: #44525f; font-size: 12px; }
|
||||
.bom-card .bom-head input.bom-code-inp { width: 160px; flex: 0 0 auto; padding: 5px 8px; font-size: 13px; }
|
||||
.bom-card .bom-head input.bom-name-inp { flex: 1 1 220px; min-width: 180px; padding: 5px 8px; font-size: 13px; }
|
||||
.bom-card .tbl { box-shadow: none; border-radius: 0; margin: 0; }
|
||||
|
||||
@@ -9,16 +9,30 @@
|
||||
<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('prepare') }}">备料</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>
|
||||
@@ -34,6 +48,7 @@
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<form method="post" class="card form-card" style="max-width:640px">
|
||||
<h3>{{ '编辑 BOM' if b else '新增 BOM' }}</h3>
|
||||
<label>BOM 型号 *</label>
|
||||
<input name="code" value="{{ b.code if b else '' }}" required {% if b %}readonly{% endif %}>
|
||||
<input name="code" value="{{ form_code if form_code is defined else (b.code if b else '') }}" required>
|
||||
<label>名称</label>
|
||||
<input name="name" value="{{ b.name if b else '' }}">
|
||||
<label>备注</label>
|
||||
|
||||
+39
-33
@@ -1,58 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}BOM 清单{% endblock %}
|
||||
{% block content %}
|
||||
<div class="toolbar">
|
||||
<h3 style="margin:0">BOM 清单 ({{ data|length }})</h3>
|
||||
<div>
|
||||
<a class="btn" href="{{ url_for('download_template', kind='bom') }}">⬇ 下载模板</a>
|
||||
<button class="primary" type="button" onclick="openBatch()">⊞ 批量导入窗口</button>
|
||||
<div class="toolbar" data-page-node-id="vZTw3kPadHZliSPinOK7Z1">
|
||||
<h3 style="margin:0" data-page-node-id="4wsrdtS5FO7LVSlfo7CSNG">BOM 清单 ({{ data|length }})</h3>
|
||||
<div data-page-node-id="wqIH8X3KYDPieHFmuHJk1U">
|
||||
<a class="btn" href="{{ url_for('download_template', kind='bom') }}" data-page-node-id="1uWZtIhFdzaXWtcVfxDuOg">⬇ 下载模板</a>
|
||||
<button class="primary" type="button" onclick="openBatch()" data-page-node-id="gzZTAfVl4A7uSSGZbnvUiB">⊞ 批量导入窗口</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="tbl">
|
||||
<thead><tr><th>BOM 型号</th><th>名称</th><th>组成项数</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<table class="tbl" data-page-node-id="b0iXNI8PpWQuniQYav5O0I">
|
||||
<thead data-page-node-id="iYtImpRt9uTnLT1EuDXgCb"><tr data-page-node-id="9EW4NPakt3ZhDfzYevNQb6"><th data-page-node-id="sp7aqyu2wPcBXC14JySMsV">BOM 型号</th><th data-page-node-id="ON7POYFoxBWVPwDTJ5YFVh">名称</th><th data-page-node-id="H6pAPeWhIGXasJf7is1PlR">组成项数</th><th data-page-node-id="IQPsDz71IQrUtVnzjSvTqE">操作</th></tr></thead>
|
||||
<tbody data-page-node-id="GlcDs3bYGt9ktLuIMCnQ4S">
|
||||
{% for d in data %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('bom_detail', bid=d.b.id) }}">{{ d.b.code }}</a></td>
|
||||
<td>{{ d.b.name or '-' }}</td>
|
||||
<td class="num"><a href="{{ url_for('bom_detail', bid=d.b.id) }}">{{ d.count }}</a></td>
|
||||
<td>
|
||||
<a href="{{ url_for('bom_detail', bid=d.b.id) }}">查看</a>
|
||||
<a href="{{ url_for('bom_edit', bid=d.b.id) }}">编辑</a>
|
||||
<form method="post" action="{{ url_for('bom_delete', bid=d.b.id) }}" style="display:inline" onsubmit="return confirm('确认删除该 BOM?');">
|
||||
<button class="link">删除</button>
|
||||
<tr data-page-node-id="oIa73ogUxPXPW7hd6mSQqR">
|
||||
<td data-page-node-id="txkFINu0hqDcunfToMsIHJ"><a href="{{ url_for('bom_detail', bid=d.b.id) }}" data-page-node-id="wZa7pJWwB3UVob7w2rMPYi">{{ d.b.code }}</a></td>
|
||||
<td data-page-node-id="4ZgT9JXaM292L2DxjmPWdn">{{ d.b.name or '-' }}</td>
|
||||
<td class="num" data-page-node-id="81E2RDtrSvIDDtmXamJaKy"><a href="{{ url_for('bom_detail', bid=d.b.id) }}" data-page-node-id="xibV2oxiCVTyWQxHQ3GBvC">{{ d.count }}</a></td>
|
||||
<td data-page-node-id="z8hNVcUqGGY3LogEDYFO6M">
|
||||
<a href="{{ url_for('bom_detail', bid=d.b.id) }}" data-page-node-id="y99zemTyOQfPCzgAuGfoHX">查看</a>
|
||||
<a href="{{ url_for('bom_edit', bid=d.b.id) }}" data-page-node-id="Xlkt2Bi0z38orXYfmovRTF">编辑</a>
|
||||
<form method="post" action="{{ url_for('bom_delete', bid=d.b.id) }}" style="display:inline" onsubmit="return confirm('确认删除该 BOM?');" data-page-node-id="GRCffEreUmFcM3CoH1FEUC">
|
||||
<button class="link" data-page-node-id="fk4q7Q2tIJdpd368XDifLE">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="muted">还没有 BOM, 点右上角新增</td></tr>
|
||||
<tr data-page-node-id="8dATczjWBQGQA7MAsGu9f8"><td colspan="4" class="muted" data-page-node-id="7Zbca69N7opSKetW07LkHr">还没有 BOM, 点右上角新增</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 批量导入窗口(模态) -->
|
||||
<div class="modal-mask" id="batchMask" style="display:none">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<span>批量导入 BOM · 多 BOM 清单</span>
|
||||
<button class="x" onclick="closeBatch()">✕</button>
|
||||
<div class="modal-mask" id="batchMask" style="display:none" data-page-node-id="na2dwI5Srtijy3wkeG1h5s">
|
||||
<div class="modal" data-page-node-id="tEI8MSJjVrQXENBtLlS1dA">
|
||||
<div class="modal-head" data-page-node-id="EHG4VZ8LF2jK6OQuZpZ1ij">
|
||||
<span data-page-node-id="xHsrz06nBQLPcxe4wbnUF5">批量导入 BOM · 多 BOM 清单</span>
|
||||
<button class="x" onclick="closeBatch()" data-page-node-id="aoDJmcOoqnCSk7ji1ol2al">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="small muted">支持两种录入:① 直接上传 <b>Altium / 嘉立创 BOM</b>(每个 worksheet 即一个 BOM,<b>Part Number</b> 关联物料、<b>Quantity</b> 列即单套用量,下载模板即此格式);② 粘贴文本,每行一个 BOM,以 <b>#BOM <型号> <名称></b> 开头,其下为 <b>物料编号 单套用量</b>,空行分隔。物料不存在时导入会自动新建主数据。</p>
|
||||
<div class="modal-body" data-page-node-id="v1ZVsZhJV9kTmwR8H3UTll">
|
||||
<p class="small muted" data-page-node-id="uBFCxuwHOoYf4HDB7GHjqB">支持两种录入:① 直接上传 <b data-page-node-id="qPxNhhrFxrTrmWD4tIcNEf">Altium / 嘉立创 BOM</b>(每个 worksheet 即一个 BOM,<b data-page-node-id="6TXeirBIr5BJkAkpI9CMod">Part Number</b> 关联物料、<b data-page-node-id="NYIE9aPWd3xD4Tr0hLOqI3">Quantity</b> 列即单套用量,下载模板即此格式);② 粘贴文本,每行一个 BOM,以 <b data-page-node-id="jy8nTX15GDqKSV6wYSm78A">#BOM <型号> <名称></b> 开头,其下为 <b data-page-node-id="Lgr6U8MBrInBJO29ZLmnN3">物料编号 单套用量</b>,空行分隔。物料不存在时导入会自动新建主数据。</p>
|
||||
<textarea id="batchText" rows="11" placeholder="#BOM M4021 示例产品A
|
||||
P0001 2
|
||||
P0002 0.5
|
||||
|
||||
#BOM M4022 示例产品B
|
||||
P0003 4"></textarea>
|
||||
<div class="row-actions">
|
||||
<button type="button" onclick="parsePaste()">解析预览</button>
|
||||
<label class="btn" style="cursor:pointer">📁 上传文件
|
||||
<input type="file" id="batchFile" accept=".xlsx,.csv" style="display:none" onchange="parseFile()">
|
||||
<div class="row-actions" data-page-node-id="Un0xhQE1iuii1GXRoLOGEy">
|
||||
<button type="button" onclick="parsePaste()" data-page-node-id="2nie414a2o4ULz4FNMbFVC">解析预览</button>
|
||||
<label class="btn" style="cursor:pointer" data-page-node-id="s3iZeq1kL8Etsahvv2hGKO">📁 上传文件
|
||||
<input type="file" id="batchFile" accept=".xlsx,.csv" style="display:none" onchange="parseFile()" data-page-node-id="sj1omvbTTh7dteUj3QePnl">
|
||||
</label>
|
||||
<button type="button" class="primary" id="commitBtn" onclick="commitBatch()" disabled>确认导入</button>
|
||||
<button type="button" class="primary" id="commitBtn" onclick="commitBatch()" disabled data-page-node-id="LV1nhOjsxrIhBszlCoxA2s">确认导入</button>
|
||||
</div>
|
||||
<div id="batchResult" style="margin-top:12px"></div>
|
||||
<div id="batchResult" style="margin-top:12px" data-page-node-id="zdtCCsWu38gEMMgkruNxOI"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,14 +83,20 @@ function postPreview(payload){
|
||||
body:JSON.stringify(payload)
|
||||
}).then(r=>r.json()).then(renderPreview).catch(e=>alert('解析失败: '+e));
|
||||
}
|
||||
function esc(s){
|
||||
return String(s==null?'':s).replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
}
|
||||
function renderPreview(data){
|
||||
lastBoms = data.boms || [];
|
||||
const valid = data.valid, invalid = data.invalid;
|
||||
let html = `<div class="flash ${invalid? 'error':'ok'}">共 ${lastBoms.length} 个 BOM · 可导入 <b>${valid}</b> 个 · 异常 <b>${invalid}</b> 个</div>`;
|
||||
lastBoms.forEach(b=>{
|
||||
lastBoms.forEach((b, i)=>{
|
||||
const btag = b.ok? '<span class="badge ok">OK</span>' : `<span class="badge danger">${b.error||'错误'}</span>`;
|
||||
html += `<div class="bom-card ${b.ok?'':'card-danger'}">`;
|
||||
html += `<div class="bom-head"><b>${b.code||''}</b> ${b.name||''} ${btag} <span class="muted small">成功项 ${b.valid_items}/${b.items.length}</span></div>`;
|
||||
html += `<div class="bom-head">`;
|
||||
html += `<label class="bh-lbl">型号</label><input class="bom-code-inp" value="${esc(b.code)}" oninput="lastBoms[${i}].code=this.value" title="可编辑 BOM 型号">`;
|
||||
html += `<label class="bh-lbl">名称</label><input class="bom-name-inp" value="${esc(b.name)}" oninput="lastBoms[${i}].name=this.value" title="可编辑 BOM 名称(全部显示,可修改)">`;
|
||||
html += `${btag} <span class="muted small">成功项 ${b.valid_items}/${b.items.length}</span></div>`;
|
||||
html += '<table class="tbl"><thead><tr><th>物料编号</th><th>型号/规格</th><th>单套用量</th><th>校验</th></tr></thead><tbody>';
|
||||
b.items.forEach(it=>{
|
||||
const cls = it.ok? '' : 'row-danger';
|
||||
|
||||
@@ -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,190 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}工厂工作台 · {{ f.code }}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="toolbar" data-page-node-id="h8tr6moQVxGSdE9dKEg6bd">
|
||||
<h3 style="margin:0" data-page-node-id="mN5n1l6U4DnPTMkfHhjkpo">工厂工作台:{{ f.code }} · {{ f.name or '' }}
|
||||
<span class="badge {{ 'ok' if f.enabled else 'warn' }}" data-page-node-id="9lYmmds1mElbCd1ib8GNYD">{{ '启用' if f.enabled else '停用' }}</span>
|
||||
</h3>
|
||||
<a class="btn" href="{{ url_for('factories') }}" data-page-node-id="gLM7aoKY7uRuTSOFXVSBaL">← 返回工厂列表</a>
|
||||
</div>
|
||||
|
||||
<div class="tabs" data-page-node-id="ante6pBhAiQZDcPHSkk7pm">
|
||||
<button class="tab active" onclick="switchTab('kanban')" data-page-node-id="t8Y9L0Rv6nJWGYeAc4Butp">📊 物料看板</button>
|
||||
<button class="tab" onclick="switchTab('bom')" data-page-node-id="1vqssKcNPuRFxIfonFhl2W">🧩 BOM 齐套</button>
|
||||
<button class="tab" onclick="switchTab('import')" data-page-node-id="VulCglmTCTpl2I8BI40TEX">⊞ 批量导入</button>
|
||||
</div>
|
||||
|
||||
<!-- 物料看板 -->
|
||||
<div id="tab-kanban" class="tab-pane" data-page-node-id="O5tr8yIq5PuggrETsUYwhX">
|
||||
<form method="get" action="{{ url_for('factory_workbench', fid=f.id) }}" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:12px 0" data-page-node-id="DP9ksglRvmmkKQtWX8GzZC">
|
||||
<input type="text" name="q" value="{{ q }}" placeholder="搜索物料编号 / 型号规格"
|
||||
style="width:260px;padding:8px 10px;border:1px solid #cdd5df;border-radius:6px;font-size:14px" data-page-node-id="DtGH4sWm3Q8IaidV8Vcsvl">
|
||||
<button class="primary" type="submit" data-page-node-id="9zgPeVdBfILSpKfD4TsDB2">搜索</button>
|
||||
{% if q %}<a class="btn" href="{{ url_for('factory_workbench', fid=f.id) }}" data-page-node-id="y6UrlI6Z8PXiCHpif7cspa">清除</a>{% endif %}
|
||||
</form>
|
||||
{% if q %}<p class="small muted" style="margin:0 0 10px" data-page-node-id="bSjz9L1Hcc4kra4Z4DRTIT">筛选「{{ q }}」命中 <b data-page-node-id="gYjIGP7vAGwZROP3und6Mt">{{ rows|length }}</b> 条(仅含在厂库存不为 0 的物料)</p>{% endif %}
|
||||
<div class="stat-row" style="margin:12px 0" data-page-node-id="XdjnYnwPez6NSo8RabtdEw">
|
||||
<div class="stat" data-page-node-id="iWmneOIg5GaNg13oi8Tpml"><div class="num" data-page-node-id="sDfnYz7FDudHWa1F7hWxUj">{{ rows|length }}</div><div class="lbl" data-page-node-id="yUuytA7Ee2uZjYvyOVwdGW">有库存物料</div></div>
|
||||
<div class="stat warn" data-page-node-id="9WprNxfiSAhkHKJoAfKVUs"><div class="num" data-page-node-id="PFYgLN2kA2eh3ehng7sL4E">{{ low }}</div><div class="lbl" data-page-node-id="cFVCxICUvwmYDEFJZ5LDBc">低于安全库存</div></div>
|
||||
<div class="stat danger" data-page-node-id="OqyIPhwXMA2FKrB5ElPOnP"><div class="num" data-page-node-id="ymG0ZfkOIrB5d4yj8v3EMR">{{ negative }}</div><div class="lbl" data-page-node-id="EgDYjJQoOn0L8dkCLwkgE1">库存为负</div></div>
|
||||
</div>
|
||||
<table class="tbl" data-page-node-id="v2dpNrmLrYl4iBusCHbYId">
|
||||
<thead data-page-node-id="pfxxaWDexxHnrtTIFGOSvu"><tr data-page-node-id="7tQDDZE1giJATqe3rbI0V0"><th data-page-node-id="7PQzAHQJqUuBvUEQahPffK">物料编号</th><th data-page-node-id="u4s8ycQodHcEKS8Aou9MLv">型号/规格</th><th data-page-node-id="BJqNtTZALVaxrbIcqkZsb1">单位</th><th data-page-node-id="GIF5kOqgw826EHX7PEfF27">安全库存</th><th data-page-node-id="SK7R13nwY2nvGYlhCeRu37">在厂库存</th><th data-page-node-id="wImv6pW0xYFq35YHkUHEqk">状态</th><th data-page-node-id="MwGW0LuvnhgNhyhix4ge2L">操作</th></tr></thead>
|
||||
<tbody data-page-node-id="taNmAZ8i4abDFIQijzVWOs">
|
||||
{% for r in rows %}
|
||||
<tr class="{{ 'row-danger' if r.status=='danger' else ('row-warn' if r.status=='warn' else '') }}"
|
||||
data-mid="{{ r.m.id }}" data-fid="{{ f.id }}"
|
||||
data-pn="{{ r.m.part_number }}" data-mf="{{ r.m.manufacturer or '' }}"
|
||||
data-unit="{{ r.m.unit or '' }}" data-ss="{{ r.m.safety_stock or 0 }}" data-page-node-id="2cidlTmV6rQwsq9PrbpeeR">
|
||||
<td data-page-node-id="wJDGDx053XiyTJva6zouSf">{{ r.m.part_number }}</td>
|
||||
<td data-page-node-id="GLAB4ys6K8tlRL09h0SVSX">{{ r.m.manufacturer or '-' }}</td>
|
||||
<td data-page-node-id="VDNrmbPZHQlBIJ05AoQly5">{{ r.m.unit or '-' }}</td>
|
||||
<td class="num" data-page-node-id="EjDg4DmtWkB5OjEr7Jaw0J">{{ r.m.safety_stock or 0 }}</td>
|
||||
<td class="num" data-page-node-id="V5Fp579iiSV2MBYW3ZF2i6">{{ '%.2f'|format(r.stock) }}</td>
|
||||
<td data-page-node-id="z4HE32qyI5SFj3Kkb0rhS2"><span class="badge {{ 'danger' if r.status=='danger' else ('warn' if r.status=='warn' else 'ok') }}" data-page-node-id="n6KadEnTE72NuD06GKXEIx">{{ '负库存' if r.status=='danger' else ('低于安全库存' if r.status=='warn' else '正常') }}</span></td>
|
||||
<td data-page-node-id="7DvKrplCD4RKbwZvM67dCa">
|
||||
<span id="acts_{{ r.m.id }}" data-page-node-id="LvRHno48lkdWEGjdKxTHXX"><button class="btn small" type="button" onclick="editRow(this)" data-page-node-id="NSU10L6H6zY5TGeTPGQFEz">编辑</button></span>
|
||||
<button class="btn small danger" type="button" onclick="delRow(this)" data-page-node-id="0sV7BYBp0CUIRvBU73nREM">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr data-page-node-id="osy9WfjSd5DiIkWUlHquBR"><td colspan="7" class="muted" data-page-node-id="TR0Ye9v5IRzsMkB8O5DadZ">该厂暂无库存数据</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- BOM 齐套 -->
|
||||
<div id="tab-bom" class="tab-pane" style="display:none" data-page-node-id="0dcZdYpNL1yp8lUhBww5DZ">
|
||||
{% for bd in bom_data %}
|
||||
<div class="card" style="margin-bottom:16px" data-page-node-id="KQOJWc5nHbG4VjhSaQPuMk">
|
||||
<h3 style="margin-top:0" data-page-node-id="ycs6WD46CdMJ8UcSRCy5f6">{{ bd.b.code }} ({{ bd.b.name or '-' }})
|
||||
<span class="small muted" data-page-node-id="U6NLfEkrS2iQqgZimxy4zl">· 单套用量 × 该厂库存</span></h3>
|
||||
{% if bd.comps %}
|
||||
<table class="tbl" data-page-node-id="UdJnn9kfq0ajEZWGKyzVOL">
|
||||
<thead data-page-node-id="ARRG4xD4kWgudXephSkWRB"><tr data-page-node-id="nlsJQ9mpAJYIhFm75kH05N"><th data-page-node-id="V2dk9RMlqoxhgKdJtcLOMh">物料编号</th><th data-page-node-id="Kjj7qJMrh899UoknQcDLNA">型号/规格</th><th data-page-node-id="pkEIgqReAFzWQmTG58bBhE">单套用量</th><th data-page-node-id="amLBx4jB5xYtw9KX74SBbU">该厂库存</th><th data-page-node-id="4cEYL4lgAaUBmObLLkX0Km">状态</th></tr></thead>
|
||||
<tbody data-page-node-id="mhRWoazwMuPyFzrrAIVuq7">
|
||||
{% for c in bd.comps %}
|
||||
<tr class="{{ 'row-danger' if c.status=='danger' else ('row-warn' if c.status=='warn' else '') }}" data-page-node-id="9d7M31Zqmc5feItheDMB71">
|
||||
<td data-page-node-id="jv8YvfrtYpEsLkFsFyrwiO">{{ c.m.part_number }}</td>
|
||||
<td data-page-node-id="pRtaop8GPgnCJGFS4Cugle">{{ c.m.manufacturer or '-' }}</td>
|
||||
<td class="num" data-page-node-id="3uop7BWAV6eYdlVz5eqakf">{{ '%.3f'|format(c.qty) }}</td>
|
||||
<td class="num" data-page-node-id="jfNj0QeLPUrrBSfsPjzsHT">{{ '%.2f'|format(c.stock) }}</td>
|
||||
<td data-page-node-id="MToeQ16NLjIvuPdfGFLojv"><span class="badge {{ 'danger' if c.status=='danger' else ('warn' if c.status=='warn' else 'ok') }}" data-page-node-id="3a5chV8jORWtOZKE3kVKYM">{{ '负库存' if c.status=='danger' else ('低于安全库存' if c.status=='warn' else '正常') }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="small muted" data-page-node-id="htEBvmB0x6pS6bfdx1SFaA">该 BOM 没有组成物料</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted" data-page-node-id="f188trWNRvLEGIEFhxwrC1">暂无 BOM</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- 批量导入 -->
|
||||
<div id="tab-import" class="tab-pane" style="display:none" data-page-node-id="NRRM2DunropDEi85SDdAeY">
|
||||
<div class="card" style="margin-bottom:16px" data-page-node-id="pmIAFmv6QG2cAhVHKguzCC">
|
||||
<h3 style="margin-top:0" data-page-node-id="gAGSs6J0QvEnz8OmMFZdaW">导入入库流水到 {{ f.code }}</h3>
|
||||
<p class="small muted" data-page-node-id="dRwIvucQurC2wYwZd7grcI">上传 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" data-page-node-id="Oli4teU6igJvERarUlj8i8">
|
||||
<input type="file" name="file" accept=".xlsx,.csv" required data-page-node-id="QvuR34OQvaVh8GOCZ4g69g">
|
||||
<button class="primary" type="submit" data-page-node-id="62RYuou3Nbtk1RAVVli1eE">上传并导入入库</button>
|
||||
<a class="btn" href="{{ url_for('download_template', kind='transactions') }}" data-page-node-id="XukAuHeUyL7T9wJs6aDfhT">⬇ 下载模板</a>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card" data-page-node-id="G6s6IIwsdxtO2QoglkQu5O">
|
||||
<h3 style="margin-top:0" data-page-node-id="sBnAiFr1fC7vAnge2CYt9L">导入物料主数据(期初库存归属 {{ f.code }})</h3>
|
||||
<p class="small muted" data-page-node-id="NHW4ZB8CHbwjA6xqxkOAJK">上传物料 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" data-page-node-id="9Y7Ec13v0Yo6okhmgErNLS">
|
||||
<input type="file" name="file" accept=".xlsx,.csv" required data-page-node-id="3PqBIl65X6AWBok5cKoO4S">
|
||||
<button class="primary" type="submit" data-page-node-id="iU26xXbC5XkB2oLWJtUVws">上传并导入物料</button>
|
||||
<a class="btn" href="{{ url_for('download_template', kind='materials') }}" data-page-node-id="o5mKNqqJtZJffB2hM595GA">⬇ 下载模板</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';
|
||||
}
|
||||
|
||||
function esc(s){
|
||||
return (s==null?'':String(s)).replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
// 进入某行编辑态: 物料编号、型号/规格、单位、安全库存 变为输入框
|
||||
function editRow(btn){
|
||||
var tr = btn.closest('tr');
|
||||
tr.dataset.view = tr.innerHTML; // 保存视图态, 供取消还原
|
||||
var mid = tr.dataset.mid, pn = tr.dataset.pn, mf = tr.dataset.mf, unit = tr.dataset.unit, ss = tr.dataset.ss;
|
||||
var c = tr.children;
|
||||
c[0].innerHTML = '<input class="cell-inp" id="pn_'+mid+'" value="'+esc(pn)+'">';
|
||||
c[1].innerHTML = '<input class="cell-inp" id="mf_'+mid+'" value="'+esc(mf)+'">';
|
||||
c[2].innerHTML = '<input class="cell-inp" id="unit_'+mid+'" value="'+esc(unit)+'">';
|
||||
c[3].innerHTML = '<input class="cell-inp num" id="ss_'+mid+'" type="number" step="0.01" min="0" value="'+esc(ss)+'">';
|
||||
document.getElementById('acts_'+mid).innerHTML =
|
||||
'<button class="btn small primary" type="button" onclick="saveRow(this)">保存</button> '
|
||||
+ '<button class="btn small" type="button" onclick="cancelRow(this)">取消</button>';
|
||||
}
|
||||
|
||||
function cancelRow(btn){
|
||||
var tr = btn.closest('tr');
|
||||
tr.innerHTML = tr.dataset.view; // 还原视图态
|
||||
}
|
||||
|
||||
// 提交保存(可递归: 二次确认合并后重提)
|
||||
function postSave(fid, mid, fd){
|
||||
fetch('/factory/'+fid+'/material/'+mid+'/quickedit', {method:'POST', body: fd})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
if(d.ok){
|
||||
location.reload(); // 安全库存/合并可能影响状态与列表, 整页刷新保证一致
|
||||
} else if(d.needs_confirm){
|
||||
if(confirm(d.msg)){
|
||||
fd.append('confirm_merge','1'); // 用户确认把当前物料合并到已存在的号
|
||||
postSave(fid, mid, fd);
|
||||
}
|
||||
} else {
|
||||
alert('保存失败: ' + (d.msg || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(function(e){ alert('请求失败: ' + e); });
|
||||
}
|
||||
|
||||
function saveRow(btn){
|
||||
var tr = btn.closest('tr');
|
||||
var mid = tr.dataset.mid, fid = tr.dataset.fid;
|
||||
var pn = document.getElementById('pn_'+mid).value.trim();
|
||||
var mf = document.getElementById('mf_'+mid).value;
|
||||
var unit = document.getElementById('unit_'+mid).value;
|
||||
var ss = document.getElementById('ss_'+mid).value;
|
||||
if(!pn){ alert('物料编号不能为空'); return; }
|
||||
var fd = new FormData();
|
||||
fd.append('part_number', pn);
|
||||
fd.append('manufacturer', mf);
|
||||
fd.append('unit', unit);
|
||||
fd.append('safety_stock', ss);
|
||||
postSave(fid, mid, fd);
|
||||
}
|
||||
|
||||
// 删除整条物料记录(含其全部关联记录); 后端会自动备份数据库
|
||||
function delRow(btn){
|
||||
var tr = btn.closest('tr');
|
||||
var mid = tr.dataset.mid, fid = tr.dataset.fid;
|
||||
var pn = tr.dataset.pn;
|
||||
if(!confirm('确定删除物料「'+pn+'」?\n将同时删除其所有入库/出库/BOM组成/替代料/调拨/生产订单调入计划记录,'
|
||||
+ '且不可撤销(系统会自动备份数据库)。')) return;
|
||||
fetch('/factory/'+fid+'/material/'+mid+'/delete', {method:'POST'})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){ if(d.ok){ location.reload(); } else { alert('删除失败: ' + (d.msg||'未知错误')); } })
|
||||
.catch(function(e){ alert('请求失败: ' + e); });
|
||||
}
|
||||
</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 %}
|
||||
@@ -27,13 +27,31 @@
|
||||
<div class="stat"><div class="num">{{ '%.2f'|format(out_total) }}</div><div class="lbl">消耗总数量</div></div>
|
||||
</div>
|
||||
|
||||
<h3>各工厂实时库存(实时聚合)</h3>
|
||||
<table class="tbl">
|
||||
<thead><tr><th>工厂编号</th><th>工厂名称</th><th>实时库存</th></tr></thead>
|
||||
<tbody>
|
||||
{% for pf in per_factory %}
|
||||
<tr>
|
||||
<td>{{ pf.f.code }}</td>
|
||||
<td>{{ pf.f.name or '-' }}</td>
|
||||
<td class="num">{{ '%.2f'|format(pf.stock) }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" 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><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 +60,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 +81,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>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<form method="post" class="card form-card">
|
||||
<h3>{{ '编辑物料' if m else '新增物料' }}</h3>
|
||||
<label>物料编号 *</label>
|
||||
<input name="part_number" value="{{ m.part_number if m else '' }}" {{ 'readonly' if m }} required>
|
||||
<input name="part_number" value="{{ m.part_number if m else '' }}" {{ 'required' if m else 'required' }}>
|
||||
<p class="small muted" style="margin-top:2px">物料编码为唯一业务编号(不可与其他物料重复)。</p>
|
||||
<label>型号 / 规格</label>
|
||||
<input name="manufacturer" value="{{ m.manufacturer if m else '' }}">
|
||||
<label>单位</label>
|
||||
|
||||
+117
-19
@@ -1,32 +1,106 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}订单 {{ o.order_no }}{% endblock %}
|
||||
{% block content %}
|
||||
<style>
|
||||
/* 打印(派工单): 仅保留订单信息与明细表, 隐藏导航/按钮, 输入框显示为纯文本 */
|
||||
@media print {
|
||||
header.topbar { display: none !important; }
|
||||
.toolbar .btn, .toolbar a.btn, .toolbar button { display: none !important; }
|
||||
.toolbar .badge { display: inline-block !important; }
|
||||
.row-actions { display: none !important; }
|
||||
.print-hide { display: none !important; }
|
||||
input[name^="alloc_"] {
|
||||
border: none !important; background: none !important; padding: 0 !important;
|
||||
width: auto !important; -webkit-appearance: none; appearance: none;
|
||||
font: inherit; color: inherit;
|
||||
}
|
||||
form { display: block; }
|
||||
@page { margin: 12mm; }
|
||||
body { background: #fff !important; }
|
||||
}
|
||||
</style>
|
||||
<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>
|
||||
<a class="btn" href="{{ url_for('order_export', oid=o.id) }}">导出 Excel</a>
|
||||
<button class="btn" type="button" onclick="window.print()">打印</button>
|
||||
</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 class="small muted" style="margin-top:0"><b>下单信息(按下单时记录)</b></p>
|
||||
<p><b>BOM 清单:</b>{% for b, q in lines %}<span style="display:inline-block;margin-right:14px">{{ b.code }}{% if b.name %} · {{ b.name }}{% endif %} × <b>{{ '%.2f'|format(q) }}</b></span>{% else %}-{% endfor %}</p>
|
||||
<p>总接单量(合计): <b>{{ '%.2f'|format(total_qty) }}</b> | 生产工厂: <b>{% if factory %}{{ factory.code }}{% if factory.name %} · {{ factory.name }}{% endif %}{% else %}未指定(按汇总库存){% endif %}</b> | 经手人: {{ o.operator or '-' }}{% if o.created_at %} | 下单时间: {{ o.created_at.strftime('%Y-%m-%d %H:%M') }}{% endif %}</p>
|
||||
{% if o.remark %}<p class="small muted">备注: {{ o.remark }}</p>{% endif %}
|
||||
<p class="small muted print-hide" style="margin-bottom:0">提示:点击「编辑订单」保存后,该订单会冲销已产生的调入/领料流水并清空调入计划,自动回到「刚下单」状态(待领料、未调拨),可重新规划。</p>
|
||||
</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>领料需求与调入 (按各 BOM 单套用量 × 各自生产数量 汇总){% if factory %} | 按下单前库存核算{% endif %}</h3>
|
||||
<p class="small muted" style="margin-top:-6px">库存口径:下方「下单前库存」为 <b>订单下单那一刻、所属工厂的库存快照</b>(按下单时间截断,已自动排除该订单自身后续的调入/领料及下单后发生的其他出入库),用于还原下单时的可供料情况。{% if not factory %}本单未指定生产工厂,按下单前全部工厂汇总库存展示。{% endif %}</p>
|
||||
|
||||
<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><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.have) }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.need) }}</td>
|
||||
<td class="num">{{ '%.2f'|format(r.gap) }}</td>
|
||||
<td class="num">
|
||||
{% if r.posted %}
|
||||
<div style="margin-bottom:3px"><span class="badge ok" title="已生成入库流水, 可直接修改后保存/确认">已调入</span></div>
|
||||
<input name="alloc_{{ r.m.id }}" type="text" inputmode="decimal"
|
||||
value="{{ '%.2f'|format(r.alloc) }}" style="width:96px">
|
||||
{% else %}
|
||||
<input name="alloc_{{ r.m.id }}" type="text" inputmode="decimal"
|
||||
value="{{ '%.2f'|format(r.alloc) if r.alloc else '' }}"
|
||||
placeholder="{{ '%.2f'|format(r.need) }}" style="width:96px">
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="badge {{ 'danger' if r.short else 'ok' }}">{{ '缺料' if r.short else '充足' }}</span></td>
|
||||
<td class="num">{{ '%.2f'|format(r.gap + r.alloc) }}</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 print-hide">「确认调入」是一次厂间调拨:从来源工厂出库减、生产工厂入库加,系统总库存不变。</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 生成领料出库? 库存不足将记为负并在看板预警。');">
|
||||
@@ -35,4 +109,28 @@
|
||||
{% else %}
|
||||
<p class="flash ok">该订单已领料出库。</p>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
// 调入数量输入框:上下方向键在「上一行/下一行」的同列输入框之间移动焦点
|
||||
// (左右方向键保持默认行为:在文本内移动光标)
|
||||
function allocInputs(){
|
||||
return Array.prototype.slice.call(document.querySelectorAll('input[name^="alloc_"]'));
|
||||
}
|
||||
document.addEventListener('keydown', function(e){
|
||||
if(e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
var t = e.target;
|
||||
if(!t || t.tagName !== 'INPUT' || t.name.indexOf('alloc_') !== 0) return;
|
||||
var inputs = allocInputs();
|
||||
var idx = inputs.indexOf(t);
|
||||
if(idx < 0) return;
|
||||
var next;
|
||||
if(e.key === 'ArrowUp'){ if(idx <= 0) return; next = inputs[idx - 1]; }
|
||||
else { if(idx >= inputs.length - 1) return; next = inputs[idx + 1]; }
|
||||
e.preventDefault();
|
||||
next.focus();
|
||||
try { var v = next.value; next.setSelectionRange(v.length, v.length); } catch(_){}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+63
-14
@@ -1,20 +1,69 @@
|
||||
{% 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">
|
||||
<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>
|
||||
<form method="post" class="card form-card" style="max-width:720px"
|
||||
action="{{ url_for('order_edit', oid=o.id) if o else url_for('order_new') }}"
|
||||
{% if o %}onsubmit="return confirm('确认保存修改?已录入的调入数量与已入账流水将保持不变(被移除 BOM 中已入账的物料仍保留其流水)。');"{% endif %}>
|
||||
<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>
|
||||
<p class="small muted" style="margin:0 0 6px">可在同一订单中加入多个 BOM,每个 BOM 独立填写生产数量(限<b>正整数,不含小数点</b>,如 1、12、100);点击「+ 添加 BOM」增加一行,不需要的行可删除。</p>
|
||||
<div id="bomRows" class="bom-rows"></div>
|
||||
<button type="button" class="btn" style="margin:2px 0 12px" onclick="addBomRow()">+ 添加 BOM</button>
|
||||
|
||||
<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="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>
|
||||
<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>
|
||||
|
||||
{# 隐藏模板: 供 JS 克隆(含全部 BOM 选项) #}
|
||||
<template id="bomTpl">
|
||||
<div class="bom-row" style="display:flex; gap:10px; align-items:center; margin-bottom:6px">
|
||||
<select name="bom_id[]" required>
|
||||
<option value="">-- 选择 BOM --</option>
|
||||
{% for b in boms %}<option value="{{ b.id }}">{{ b.code }}{% if b.name %} · {{ b.name }}{% endif %}</option>{% endfor %}
|
||||
</select>
|
||||
<input name="qty[]" type="text" inputmode="numeric" required placeholder="数量(整数)" style="width:130px" oninput="capQty(this)">
|
||||
<button type="button" class="link-danger" onclick="this.closest('.bom-row').remove();checkRows()">删除</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
function capQty(el){
|
||||
// 仅保留数字, 禁止小数点/负号, 允许多位正整数(不限位数)
|
||||
var v = (el.value || '').replace(/[^\d]/g, '');
|
||||
el.value = v === '' ? '' : String(parseInt(v, 10));
|
||||
}
|
||||
function addBomRow(sel, qty){
|
||||
var tpl = document.getElementById('bomTpl');
|
||||
var row = tpl.content.firstElementChild.cloneNode(true);
|
||||
if (sel) row.querySelector('select').value = String(sel);
|
||||
if (qty != null && qty !== undefined) row.querySelector('input').value = qty;
|
||||
document.getElementById('bomRows').appendChild(row);
|
||||
checkRows();
|
||||
}
|
||||
function checkRows(){
|
||||
var rows = document.querySelectorAll('#bomRows .bom-row');
|
||||
rows.forEach(function(r){
|
||||
var btn = r.querySelector('button');
|
||||
if (btn) btn.style.display = rows.length > 1 ? '' : 'none';
|
||||
});
|
||||
}
|
||||
var init = {{ initial_lines|tojson }};
|
||||
if (init && init.length){ init.forEach(function(x){ addBomRow(x[0], x[1]); }); }
|
||||
else { addBomRow(); }
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+15
-6
@@ -4,21 +4,30 @@
|
||||
<div class="toolbar">
|
||||
<h3 style="margin:0">生产订单 ({{ data|length }})</h3>
|
||||
<a class="btn primary" href="{{ url_for('order_new') }}">+ 新建订单</a>
|
||||
<a class="btn" href="{{ url_for('orders_export') }}">导出 Excel</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>{% for b, q in d.lines %}<div>{{ b.code }}{% if b.name %} · {{ b.name }}{% endif %} × {{ '%.2f'|format(q) }}</div>{% else %}-{% endfor %}</td>
|
||||
<td class="num">{{ '%.2f'|format(d.total) }}</td>
|
||||
<td>{% if d.factory %}{{ d.factory.code }}{% if d.factory.name %} · {{ d.factory.name }}{% endif %}{% else %}未指定{% endif %}</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,127 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}备料清单{% endblock %}
|
||||
{% block content %}
|
||||
<div class="toolbar">
|
||||
<h3 style="margin:0">备料清单(采购下单用)</h3>
|
||||
<span class="badge warn">不记账 · 按需用×套数预填</span>
|
||||
<form method="post" action="{{ url_for('prepare_clear') }}" style="display:inline"
|
||||
onsubmit="return confirm('确定清空当前范围内的全部备料计划?此操作不可撤销。');">
|
||||
<button type="submit" class="btn danger">清空清单</button>
|
||||
</form>
|
||||
<a class="btn" href="{{ url_for('prepare_export') }}">导出 Excel</a>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<p class="small muted" style="margin-top:0">
|
||||
本页为<b>独立的采购备料清单</b>:选一个 <b>BOM</b> 并输入<b>套数</b>,即按
|
||||
<b>「计划数量 = BOM单套用量 × 套数」</b> 一键生成(可再手工改);
|
||||
也可在下方手工添加任意物料并填数量。当前范围:<b>{% if current_factory %}{{ current_factory.code }} · {{ current_factory.name }}{% else %}全部工厂(汇总){% endif %}</b>;
|
||||
「实际库存」「缺口」随顶栏工厂切换实时计算。
|
||||
</p>
|
||||
<p class="small muted" style="margin-bottom:0">
|
||||
说明:本页与 Excel 导出均<b>只读</b>,不产生任何入库 / 出库流水,仅用于向供应商下单采购。
|
||||
<b>缺口 = 计划数量 − 实际库存</b>(>0 表示还需采购,<0 表示库存已盈余)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# 添加物料 #}
|
||||
<form method="post" action="{{ url_for('prepare_add') }}" class="card" style="margin-bottom:16px;display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap">
|
||||
<div>
|
||||
<label class="small muted">物料编号</label><br>
|
||||
<input list="matlist" name="part_number" placeholder="输入或选择物料编号" style="width:240px" autocomplete="off" required>
|
||||
<datalist id="matlist">
|
||||
{% for m in materials %}<option value="{{ m.part_number }}">{{ m.manufacturer or '' }}</option>{% endfor %}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label class="small muted">计划数量</label><br>
|
||||
<input type="text" inputmode="decimal" name="qty" placeholder="可留空" style="width:120px;text-align:right">
|
||||
</div>
|
||||
<button type="submit" class="btn primary">添加物料</button>
|
||||
<span class="muted small">手工录入单个物料(计划数量可留空,稍后填)</span>
|
||||
</form>
|
||||
|
||||
{# 按 BOM 批量生成计划(计划数量 = 单套用量 × 套数) #}
|
||||
<form method="post" action="{{ url_for('prepare_add_bom') }}" class="card" style="margin-bottom:16px;display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap">
|
||||
<div>
|
||||
<label class="small muted">① 选择 BOM</label><br>
|
||||
<select id="bomSel" name="bom_id" style="width:300px">
|
||||
<option value="">-- 选择 BOM --</option>
|
||||
{% for b in bom_opts %}<option value="{{ b.id }}">{{ b.label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="small muted">② 套数</label><br>
|
||||
<input type="text" inputmode="decimal" name="sets" value="1" style="width:120px;text-align:right">
|
||||
</div>
|
||||
<button type="submit" class="btn primary">生成计划(需用×套数)</button>
|
||||
<span class="muted small">计划数量 = BOM单套用量 × 套数,生成后可在表中手工调整</span>
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{ url_for('prepare_save') }}">
|
||||
<div class="toolbar" style="justify-content:flex-end">
|
||||
<span class="muted small">修改计划数量后点此保存</span>
|
||||
<button type="submit" class="btn primary">保存计划数量</button>
|
||||
</div>
|
||||
<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 rows %}
|
||||
<tr class="{{ 'row-danger' if r.diff > 0 else '' }}">
|
||||
<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>{{ r.m.unit or '个' }}</td>
|
||||
<td>
|
||||
<input type="text" inputmode="decimal" name="plan_{{ r.id }}"
|
||||
class="plan-input" style="width:90px;text-align:right"
|
||||
value="{{ '%.2f'|format(r.plan) if r.plan else '' }}"
|
||||
data-stock="{{ '%.2f'|format(r.stock) }}">
|
||||
</td>
|
||||
<td class="num">{{ '%.2f'|format(r.stock) }}</td>
|
||||
<td class="num"><span class="diff" id="diff_{{ r.id }}">{{ '%.2f'|format(r.diff) }}</span></td>
|
||||
<td><span class="badge {{ 'danger' if r.diff > 0 else 'ok' }}">{{ '需采购' if r.diff > 0 else ('盈余' if r.diff < 0 else '刚好') }}</span></td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('prepare_delete', pid=r.id) }}" style="display:inline"
|
||||
onsubmit="return confirm('确定删除该备料物料?');">
|
||||
<button type="submit" class="btn danger small">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="muted">还没有添加任何备料物料,请选择 BOM 加入或手工录入。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// 计划数量实时计算缺口 = 计划 - 实际库存
|
||||
function recalcDiff(input) {
|
||||
var row = input.closest('tr');
|
||||
var diffEl = row.querySelector('.diff');
|
||||
if (!diffEl) return;
|
||||
var stock = parseFloat(input.getAttribute('data-stock')) || 0;
|
||||
var val = input.value.trim();
|
||||
if (val === '') { diffEl.textContent = ''; diffEl.className = 'diff'; return; }
|
||||
var plan = parseFloat(val);
|
||||
if (isNaN(plan)) { diffEl.textContent = '—'; diffEl.className = 'diff'; return; }
|
||||
var d = Math.round((plan - stock) * 100) / 100;
|
||||
diffEl.textContent = d.toFixed(2);
|
||||
diffEl.className = 'diff ' + (d < 0 ? 'diff-neg' : (d > 0 ? 'diff-pos' : ''));
|
||||
var badge = row.querySelector('.badge');
|
||||
if (badge) {
|
||||
badge.className = 'badge ' + (d > 0 ? 'danger' : 'ok');
|
||||
badge.textContent = d > 0 ? '需采购' : (d < 0 ? '盈余' : '刚好');
|
||||
}
|
||||
}
|
||||
document.querySelectorAll('.plan-input').forEach(function (inp) {
|
||||
inp.addEventListener('input', function () { recalcDiff(this); });
|
||||
recalcDiff(inp); // 初始(回填值)
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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 kind=='inbound' and default_factory_id %}{% if f.id == default_factory_id %}selected{% endif %}{% else %}{% if (current_factory_id == f.id) or (not current_factory_id and loop.first) %}selected{% endif %}{% 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