Compare commits

...
12 Commits
Author SHA1 Message Date
starandWorkBuddy 27fb218c40 fix: 生产订单数量放宽为正整數(≥1、不限位数、禁止小数)
- 用户澄清"保持个位"实为"不要小数点":移除 1–9 单 digit 上限,允许任意位正整数(如 12、100)
- 前端 capQty 改为仅保留数字、去小数点/负号、允许多位;输入框去掉 maxlength=1
- 后端 _bom_qty_invalid 改为校验正整数(≥1 且非小数),拒绝 1.5 等带小数点数量并提示

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-10-26 11:10:05 +08:00
starandWorkBuddy 38ee24f6ee feat: 生产订单每个 BOM 生产数量限制为 1–9 的个位正整数
- 前端: 数量输入框改为 text/inputmode=numeric/maxlength=1,新增 capQty 实时拦截非个位(去非数字、限1位、超范围清空)
- 后端: 新增 _bom_qty_invalid 校验,order_new/order_edit 拒绝非个位数量并回显已填内容,提示「每个 BOM 的生产数量需为 1–9 的个位正整数」

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-10-26 10:51:55 +08:00
starandWorkBuddy 93f3ef458b feat: 生产订单支持一次多选 BOM 且各 BOM 独立设置生产数量
- 新增中间表 prod_order_bom(order_id, bom_id, qty),一个订单可挂多个 BOM,每个 BOM 单独设生产数量
- prod_order.qty 改为各 BOM 数量汇总,bom_id 仅作主 BOM 冗余摘要
- 物料需求按 Σ(BOM 单套用量 × 各自生产数量) 跨 BOM 合并聚合,覆盖详情/领料/调入/导出
- 订单表单改为动态多行(BOM 下拉 + 数量输入 + 删除行),列表与导出展示「BOM清单(型号×数量)」
- 导出 Excel 增至 3 个 sheet:订单信息 / BOM清单 / 领料明细
- 修复 _parse_bom_pairs 表单字段名 bom_id[]/qty[] 解析;order_delete 一并级联删除 prod_order_bom
- build_exe.py 增加构建后自动拷贝 dist_pkg→dist(EXE 同目录即数据库所在)

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-10-26 10:24:25 +08:00
star 099496dec9 feat: 生产订单新增导出 Excel 与打印功能
- 订单列表页「导出 Excel」(/orders/export): 导出全部生产订单(订单号/BOM/接单量/工厂/状态/经手人/下单时间/备注)

- 订单详情页「导出 Excel」(/order/<id>/export): 导出派工单, 含「订单信息」+「领料明细」双 sheet

- 订单详情页「打印」按钮: window.print() + @media print 样式(隐藏导航/按钮, 输入框转纯文本, 隐藏提示), 输出干净派工单
2026-10-22 14:02:04 +08:00
star 5a6f4ea4df feat: 新增备料(采购下单)功能模块,含清空清单
- 独立采购备料清单:选 BOM + 输入套数一键生成计划(计划数量 = 单套用量 × 套数),可手工增删改、导出 Excel

- 备料与生产订单完全解耦,仅记录计划数量、不记账(不产生任何出入库流水)

- 新增「清空清单」功能(/prepare/clear,JS 二次确认 + 操作日志),按当前工厂范围清空

- 配套:PrepPlan 模型、缺口(计划-库存)实时计算、库存随工厂切换过滤
2026-10-19 15:09:20 +08:00
star e9b605fca6 feat: 生产订单调入数量输入优化(去箭头 + 上下方向键行间导航)
- 调入数量输入框由 type=number 改为 type=text inputmode=decimal,去掉浏览器原生上下调节箭头,左右方向键在文本内移动光标
- 新增脚本:在调入数量输入框内按 ↑/↓ 在上一行/下一行同列输入框之间移动焦点(首末行不越界),跳转后光标置于文本末尾,便于逐行录入
2026-10-19 09:52:26 +08:00
star 639d7f1545 feat: BOM型号可编辑 + 生产订单BOM去括号 + 物料明细各工厂实时库存
- BOM编辑页「型号」输入框取消 readonly,可修改;bom_edit 增加非空与唯一校验(重复提示「BOM 型号已存在」、空值提示「BOM 型号必填」),改名不影响关联生产订单与流水
- 生产订单列表/详情/表单中 BOM 显示由 CODE (NAME) 改为 CODE · NAME,去掉括号
- 物料明细页新增「各工厂实时库存」表(工厂编号/名称/实时库存),显示每个工厂的实时聚合库存(入−出),不受顶栏工厂过滤影响
2026-10-15 14:03:16 +08:00
star 012e018b12 feat: 生产订单库存口径/排序/编辑体验与入库默认工厂优化
- 入库登记「所属工厂」默认选中总部(SELF),出库不变
- 编辑生产订单改为仅更新字段:改单号同步关联出入库 order_ref、保留已录入调入数量与已入账流水(移除冲销逻辑)
- 生产订单列表/详情按最终订单号(WO+YYYYMMDD+序号)降序排序
- 生产订单详情库存/缺口改用「下单前库存快照」(按下单时间截断),缺口=库存-需求,升序使缺料排前
- 生产工厂列显示「编号 · 公司名称」
- 详情列表列序调整:下单前库存置需求左边、调入数量置缺口右边;状态右侧新增「工厂库存」列(=缺口+调入数量)
- 状态「缺料/充足」徽标强制单行显示(.badge white-space:nowrap)
- 已调入(已入账)数量可编辑:保存/确认后按新值自动撤销原流水并从原来源厂重新入账,源厂库存不足判定缺料保留原入账,系统总库存不变
2026-10-15 13:30:15 +08:00
star 882508aee9 feat: 物料编号可编辑 + 工作台行内编辑/搜索 + 编号冲突合并/删除
- 物料编辑页物料编号放开可编辑并校验唯一性
- 工厂工作台看板行内可编辑型号/单位/安全库存/物料编号
- 工作台新增物料编号/型号搜索过滤
- 编号冲突时支持合并到已存在物料(保留流水)或删除整条物料(删前备份)
2026-10-10 16:08:33 +08:00
star 574d608e11 feat: BOM批量导入型号/名称可编辑 + 生产订单查看按下单前库存快照 + 编辑冲销回下单状态 2026-10-10 12:26:21 +08:00
star 5060d027e5 feat: 多工厂/厂间调拨/替代料/流水台账折叠 + 重写 README
- 多工厂(factory 表) + 顶栏工厂下拉过滤, 库存按厂聚合
- 厂间调拨(transfer): 源厂减+目标厂加, 总库存不变, 双向流水对账
- 替代料关系(substitute): 出库/BOM领料/生产领料缺料时提示可替代料
- 流水台账(ledger): 入出按日期合并折叠, 按日期导出同一 Excel
- 生产订单增强: 编辑/删除(级联清关联流水)/调入数量/确认调入=厂间调拨
- 入库新增送货日期字段; 出库物料编码扫码定位
- 重写 README 补全全部模块; .gitignore 排除 dist_pkg/build_pkg/*.db
- 停止跟踪 instance/app.db(运行时自动生成)
2026-09-29 11:17:48 +08:00
star 33efc7c846 feat: 入库新增「送货日期」字段(与业务日期区分)
- models.Inbound 增加 delivery_date 列(可空, 默认 None)
- 入库单笔录入表单新增「送货日期」输入(默认当天, 可改供应商实际到货日); 出库不受影响
- 物料明细页入库流水新增「送货日期」列展示
- seed_admin 增加幂等补列: 旧库启动时自动 ALTER TABLE inbound ADD COLUMN delivery_date, 开发/EXE 均兼容
- 教程补充送货日期字段说明

(注: instance/app.db 已本地补列, 但未纳入本次提交; dist/app.db 为运行库已 gitignore)
2026-09-23 14:21:57 +08:00
27 changed files with 3330 additions and 213 deletions
+9 -1
View File
@@ -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/
+103 -48
View File
@@ -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` 备份,可随时覆盖还原。
+1682 -61
View File
File diff suppressed because it is too large Load Diff
+15 -4
View File
@@ -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
View File
@@ -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/
BIN
View File
Binary file not shown.
+105
View File
@@ -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)
+36
View File
@@ -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
View File
@@ -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; }
+15
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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 &lt;型号&gt; &lt;名称&gt;</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 &lt;型号&gt; &lt;名称&gt;</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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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';
+4
View File
@@ -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 %}
+61
View File
@@ -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 %}
+190
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 %}
+85
View File
@@ -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 }}&amp;to={{ to|urlencode }}&amp;type={{ type|urlencode }}&amp;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 %}
+23 -4
View File
@@ -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>
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+127
View File
@@ -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>(&gt;0 表示还需采购,&lt;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 %}
+65
View File
@@ -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 %}
+77
View File
@@ -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 %}
+49
View File
@@ -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 %}
+112
View File
@@ -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
View File
@@ -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 专属)。