Compare commits

..
10 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
17 changed files with 1515 additions and 226 deletions
+834 -108
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -32,4 +32,14 @@ args = [
] ]
print("PyInstaller args:", args) print("PyInstaller args:", args)
run(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)
+142
View File
@@ -245,6 +245,35 @@ def export_inventory(path, rows):
wb.save(path) 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): def build_material_template(path):
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
@@ -298,6 +327,119 @@ def export_ledger(target, rows):
wb.save(target) 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): def build_bom_template(path):
"""生成与真实 BOM 模板一致的格式(Altium/嘉立创导出): """生成与真实 BOM 模板一致的格式(Altium/嘉立创导出):
每个 worksheet 一个 BOM, sheet 名即 BOM 型号; 列头 Item/Part Number/ 每个 worksheet 一个 BOM, sheet 名即 BOM 型号; 列头 Item/Part Number/
+34
View File
@@ -118,6 +118,21 @@ class ProdOrderAlloc(db.Model):
created_at = db.Column(db.DateTime, default=datetime.now) 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): class Substitute(db.Model):
"""替代料关系: 某主料(main)可由某替代料(sub)替代使用。 """替代料关系: 某主料(main)可由某替代料(sub)替代使用。
@@ -157,6 +172,25 @@ class Factory(db.Model):
created_at = db.Column(db.DateTime, default=datetime.now) 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: 厂间调拨 ---------------- # ---------------- M2-2: 厂间调拨 ----------------
class Transfer(db.Model): class Transfer(db.Model):
"""厂间调拨: 从源工厂调出、调入目标工厂(同一颗物料)。 """厂间调拨: 从源工厂调出、调入目标工厂(同一颗物料)。
+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
+17 -2
View File
@@ -44,12 +44,17 @@ label { display: block; margin: 10px 0 4px; font-size: 13px; color: #44525f; }
input, select, textarea { input, select, textarea {
width: 100%; padding: 8px 10px; border: 1px solid #cdd5df; border-radius: 6px; font-size: 14px; 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 { button, .btn {
display: inline-block; cursor: pointer; border: none; border-radius: 6px; display: inline-block; cursor: pointer; border: none; border-radius: 6px;
padding: 9px 16px; font-size: 14px; text-decoration: none; background: #eef1f5; color: #1f2733; padding: 9px 16px; font-size: 14px; text-decoration: none; background: #eef1f5; color: #1f2733;
} }
button.primary, .btn.primary { background: #1f3a5f; color: #fff; } button.primary, .btn.primary { background: #1f3a5f; color: #fff; }
button.secondary, .btn.secondary { background: #2f7d5b; 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); } 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 { background: none; border: none; color: #c0392b; cursor: pointer; padding: 0 4px; font-size: 14px; }
.link-danger:hover { text-decoration: underline; } .link-danger:hover { text-decoration: underline; }
@@ -69,11 +74,18 @@ button:hover, .btn:hover { filter: brightness(.96); }
.row-danger { background: #fff1f0; } .row-danger { background: #fff1f0; }
.row-warn { background: #fffbe6; } .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.ok { background: #e6f7ed; color: #1a7f47; }
.badge.warn { background: #fff7e6; color: #ad6800; } .badge.warn { background: #fff7e6; color: #ad6800; }
.badge.danger { background: #fff1f0; color: #cf1322; } .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 { padding: 10px 14px; border-radius: 6px; margin-bottom: 12px; font-size: 14px; }
.flash.ok { background: #e6f7ed; color: #1a7f47; } .flash.ok { background: #e6f7ed; color: #1a7f47; }
.flash.error { background: #fff1f0; color: #cf1322; } .flash.error { background: #fff1f0; color: #cf1322; }
@@ -143,6 +155,9 @@ tr.row-sim-danger td { color: #cf1322; }
/* 批量导入窗口: 多 BOM 卡片 */ /* 批量导入窗口: 多 BOM 卡片 */
.bom-card { border: 1px solid #e3e8ef; border-radius: 8px; margin-bottom: 10px; overflow: hidden; } .bom-card { border: 1px solid #e3e8ef; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
.bom-card.card-danger { border-color: #ffccc7; } .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.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; } .bom-card .tbl { box-shadow: none; border-radius: 0; margin: 0; }
+2
View File
@@ -27,6 +27,7 @@
{% if g.user and g.user.role in ('admin','manager') %} {% if g.user and g.user.role in ('admin','manager') %}
<a href="{{ url_for('boms') }}">BOM</a> <a href="{{ url_for('boms') }}">BOM</a>
<a href="{{ url_for('orders') }}">生产订单</a> <a href="{{ url_for('orders') }}">生产订单</a>
<a href="{{ url_for('prepare') }}">备料</a>
<a href="{{ url_for('report') }}">报表</a> <a href="{{ url_for('report') }}">报表</a>
<a href="{{ url_for('logs') }}">日志</a> <a href="{{ url_for('logs') }}">日志</a>
<a href="{{ url_for('factories') }}">工厂</a> <a href="{{ url_for('factories') }}">工厂</a>
@@ -47,6 +48,7 @@
{% endfor %} {% endfor %}
{% endwith %} {% endwith %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
{% block scripts %}{% endblock %}
</main> </main>
</body> </body>
</html> </html>
+1 -1
View File
@@ -4,7 +4,7 @@
<form method="post" class="card form-card" style="max-width:640px"> <form method="post" class="card form-card" style="max-width:640px">
<h3>{{ '编辑 BOM' if b else '新增 BOM' }}</h3> <h3>{{ '编辑 BOM' if b else '新增 BOM' }}</h3>
<label>BOM 型号 *</label> <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> <label>名称</label>
<input name="name" value="{{ b.name if b else '' }}"> <input name="name" value="{{ b.name if b else '' }}">
<label>备注</label> <label>备注</label>
+39 -33
View File
@@ -1,58 +1,58 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}BOM 清单{% endblock %} {% block title %}BOM 清单{% endblock %}
{% block content %} {% block content %}
<div class="toolbar"> <div class="toolbar" data-page-node-id="vZTw3kPadHZliSPinOK7Z1">
<h3 style="margin:0">BOM 清单 ({{ data|length }})</h3> <h3 style="margin:0" data-page-node-id="4wsrdtS5FO7LVSlfo7CSNG">BOM 清单 ({{ data|length }})</h3>
<div> <div data-page-node-id="wqIH8X3KYDPieHFmuHJk1U">
<a class="btn" href="{{ url_for('download_template', kind='bom') }}">⬇ 下载模板</a> <a class="btn" href="{{ url_for('download_template', kind='bom') }}" data-page-node-id="1uWZtIhFdzaXWtcVfxDuOg">⬇ 下载模板</a>
<button class="primary" type="button" onclick="openBatch()">⊞ 批量导入窗口</button> <button class="primary" type="button" onclick="openBatch()" data-page-node-id="gzZTAfVl4A7uSSGZbnvUiB">⊞ 批量导入窗口</button>
</div> </div>
</div> </div>
<table class="tbl"> <table class="tbl" data-page-node-id="b0iXNI8PpWQuniQYav5O0I">
<thead><tr><th>BOM 型号</th><th>名称</th><th>组成项数</th><th>操作</th></tr></thead> <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> <tbody data-page-node-id="GlcDs3bYGt9ktLuIMCnQ4S">
{% for d in data %} {% for d in data %}
<tr> <tr data-page-node-id="oIa73ogUxPXPW7hd6mSQqR">
<td><a href="{{ url_for('bom_detail', bid=d.b.id) }}">{{ d.b.code }}</a></td> <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>{{ d.b.name or '-' }}</td> <td data-page-node-id="4ZgT9JXaM292L2DxjmPWdn">{{ d.b.name or '-' }}</td>
<td class="num"><a href="{{ url_for('bom_detail', bid=d.b.id) }}">{{ d.count }}</a></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> <td data-page-node-id="z8hNVcUqGGY3LogEDYFO6M">
<a href="{{ url_for('bom_detail', bid=d.b.id) }}">查看</a> <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) }}">编辑</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?');"> <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">删除</button> <button class="link" data-page-node-id="fk4q7Q2tIJdpd368XDifLE">删除</button>
</form> </form>
</td> </td>
</tr> </tr>
{% else %} {% 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 %} {% endfor %}
</tbody> </tbody>
</table> </table>
<!-- 批量导入窗口(模态) --> <!-- 批量导入窗口(模态) -->
<div class="modal-mask" id="batchMask" style="display:none"> <div class="modal-mask" id="batchMask" style="display:none" data-page-node-id="na2dwI5Srtijy3wkeG1h5s">
<div class="modal"> <div class="modal" data-page-node-id="tEI8MSJjVrQXENBtLlS1dA">
<div class="modal-head"> <div class="modal-head" data-page-node-id="EHG4VZ8LF2jK6OQuZpZ1ij">
<span>批量导入 BOM · 多 BOM 清单</span> <span data-page-node-id="xHsrz06nBQLPcxe4wbnUF5">批量导入 BOM · 多 BOM 清单</span>
<button class="x" onclick="closeBatch()">✕</button> <button class="x" onclick="closeBatch()" data-page-node-id="aoDJmcOoqnCSk7ji1ol2al">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body" data-page-node-id="v1ZVsZhJV9kTmwR8H3UTll">
<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> <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 <textarea id="batchText" rows="11" placeholder="#BOM M4021 示例产品A
P0001 2 P0001 2
P0002 0.5 P0002 0.5
#BOM M4022 示例产品B #BOM M4022 示例产品B
P0003 4"></textarea> P0003 4"></textarea>
<div class="row-actions"> <div class="row-actions" data-page-node-id="Un0xhQE1iuii1GXRoLOGEy">
<button type="button" onclick="parsePaste()">解析预览</button> <button type="button" onclick="parsePaste()" data-page-node-id="2nie414a2o4ULz4FNMbFVC">解析预览</button>
<label class="btn" style="cursor:pointer">📁 上传文件 <label class="btn" style="cursor:pointer" data-page-node-id="s3iZeq1kL8Etsahvv2hGKO">📁 上传文件
<input type="file" id="batchFile" accept=".xlsx,.csv" style="display:none" onchange="parseFile()"> <input type="file" id="batchFile" accept=".xlsx,.csv" style="display:none" onchange="parseFile()" data-page-node-id="sj1omvbTTh7dteUj3QePnl">
</label> </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>
<div id="batchResult" style="margin-top:12px"></div> <div id="batchResult" style="margin-top:12px" data-page-node-id="zdtCCsWu38gEMMgkruNxOI"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -83,14 +83,20 @@ function postPreview(payload){
body:JSON.stringify(payload) body:JSON.stringify(payload)
}).then(r=>r.json()).then(renderPreview).catch(e=>alert('解析失败: '+e)); }).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){ function renderPreview(data){
lastBoms = data.boms || []; lastBoms = data.boms || [];
const valid = data.valid, invalid = data.invalid; 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>`; 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>`; 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-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>'; html += '<table class="tbl"><thead><tr><th>物料编号</th><th>型号/规格</th><th>单套用量</th><th>校验</th></tr></thead><tbody>';
b.items.forEach(it=>{ b.items.forEach(it=>{
const cls = it.ok? '' : 'row-danger'; const cls = it.ok? '' : 'row-danger';
+140 -54
View File
@@ -2,93 +2,107 @@
{% block title %}工厂工作台 · {{ f.code }}{% endblock %} {% block title %}工厂工作台 · {{ f.code }}{% endblock %}
{% block content %} {% block content %}
<div class="toolbar"> <div class="toolbar" data-page-node-id="h8tr6moQVxGSdE9dKEg6bd">
<h3 style="margin:0">工厂工作台:{{ f.code }} · {{ f.name or '' }} <h3 style="margin:0" data-page-node-id="mN5n1l6U4DnPTMkfHhjkpo">工厂工作台:{{ f.code }} · {{ f.name or '' }}
<span class="badge {{ 'ok' if f.enabled else 'warn' }}">{{ '启用' if f.enabled else '停用' }}</span> <span class="badge {{ 'ok' if f.enabled else 'warn' }}" data-page-node-id="9lYmmds1mElbCd1ib8GNYD">{{ '启用' if f.enabled else '停用' }}</span>
</h3> </h3>
<a class="btn" href="{{ url_for('factories') }}">← 返回工厂列表</a> <a class="btn" href="{{ url_for('factories') }}" data-page-node-id="gLM7aoKY7uRuTSOFXVSBaL">← 返回工厂列表</a>
</div> </div>
<div class="tabs"> <div class="tabs" data-page-node-id="ante6pBhAiQZDcPHSkk7pm">
<button class="tab active" onclick="switchTab('kanban')">📊 物料看板</button> <button class="tab active" onclick="switchTab('kanban')" data-page-node-id="t8Y9L0Rv6nJWGYeAc4Butp">📊 物料看板</button>
<button class="tab" onclick="switchTab('bom')">🧩 BOM 齐套</button> <button class="tab" onclick="switchTab('bom')" data-page-node-id="1vqssKcNPuRFxIfonFhl2W">🧩 BOM 齐套</button>
<button class="tab" onclick="switchTab('import')">⊞ 批量导入</button> <button class="tab" onclick="switchTab('import')" data-page-node-id="VulCglmTCTpl2I8BI40TEX">⊞ 批量导入</button>
</div> </div>
<!-- 物料看板 --> <!-- 物料看板 -->
<div id="tab-kanban" class="tab-pane"> <div id="tab-kanban" class="tab-pane" data-page-node-id="O5tr8yIq5PuggrETsUYwhX">
<div class="stat-row" style="margin:12px 0"> <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">
<div class="stat"><div class="num">{{ rows|length }}</div><div class="lbl">有库存物料</div></div> <input type="text" name="q" value="{{ q }}" placeholder="搜索物料编号 / 型号规格"
<div class="stat warn"><div class="num">{{ low }}</div><div class="lbl">低于安全库存</div></div> style="width:260px;padding:8px 10px;border:1px solid #cdd5df;border-radius:6px;font-size:14px" data-page-node-id="DtGH4sWm3Q8IaidV8Vcsvl">
<div class="stat danger"><div class="num">{{ negative }}</div><div class="lbl">库存为负</div></div> <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> </div>
<table class="tbl"> <table class="tbl" data-page-node-id="v2dpNrmLrYl4iBusCHbYId">
<thead><tr><th>物料编号</th><th>型号/规格</th><th>单位</th><th>安全库存</th><th>在厂库存</th><th>状态</th></tr></thead> <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> <tbody data-page-node-id="taNmAZ8i4abDFIQijzVWOs">
{% for r in rows %} {% for r in rows %}
<tr class="{{ 'row-danger' if r.status=='danger' else ('row-warn' if r.status=='warn' else '') }}"> <tr class="{{ 'row-danger' if r.status=='danger' else ('row-warn' if r.status=='warn' else '') }}"
<td>{{ r.m.part_number }}</td> data-mid="{{ r.m.id }}" data-fid="{{ f.id }}"
<td>{{ r.m.manufacturer or '-' }}</td> data-pn="{{ r.m.part_number }}" data-mf="{{ r.m.manufacturer or '' }}"
<td>{{ r.m.unit or '-' }}</td> data-unit="{{ r.m.unit or '' }}" data-ss="{{ r.m.safety_stock or 0 }}" data-page-node-id="2cidlTmV6rQwsq9PrbpeeR">
<td class="num">{{ r.m.safety_stock or 0 }}</td> <td data-page-node-id="wJDGDx053XiyTJva6zouSf">{{ r.m.part_number }}</td>
<td class="num">{{ '%.2f'|format(r.stock) }}</td> <td data-page-node-id="GLAB4ys6K8tlRL09h0SVSX">{{ r.m.manufacturer or '-' }}</td>
<td><span class="badge {{ 'danger' if r.status=='danger' else ('warn' if r.status=='warn' else 'ok') }}">{{ '负库存' if r.status=='danger' else ('低于安全库存' if r.status=='warn' else '正常') }}</span></td> <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> </tr>
{% else %} {% else %}
<tr><td colspan="6" class="muted">该厂暂无库存数据</td></tr> <tr data-page-node-id="osy9WfjSd5DiIkWUlHquBR"><td colspan="7" class="muted" data-page-node-id="TR0Ye9v5IRzsMkB8O5DadZ">该厂暂无库存数据</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- BOM 齐套 --> <!-- BOM 齐套 -->
<div id="tab-bom" class="tab-pane" style="display:none"> <div id="tab-bom" class="tab-pane" style="display:none" data-page-node-id="0dcZdYpNL1yp8lUhBww5DZ">
{% for bd in bom_data %} {% for bd in bom_data %}
<div class="card" style="margin-bottom:16px"> <div class="card" style="margin-bottom:16px" data-page-node-id="KQOJWc5nHbG4VjhSaQPuMk">
<h3 style="margin-top:0">{{ bd.b.code }} ({{ bd.b.name or '-' }}) <h3 style="margin-top:0" data-page-node-id="ycs6WD46CdMJ8UcSRCy5f6">{{ bd.b.code }} ({{ bd.b.name or '-' }})
<span class="small muted">· 单套用量 × 该厂库存</span></h3> <span class="small muted" data-page-node-id="U6NLfEkrS2iQqgZimxy4zl">· 单套用量 × 该厂库存</span></h3>
{% if bd.comps %} {% if bd.comps %}
<table class="tbl"> <table class="tbl" data-page-node-id="UdJnn9kfq0ajEZWGKyzVOL">
<thead><tr><th>物料编号</th><th>型号/规格</th><th>单套用量</th><th>该厂库存</th><th>状态</th></tr></thead> <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> <tbody data-page-node-id="mhRWoazwMuPyFzrrAIVuq7">
{% for c in bd.comps %} {% for c in bd.comps %}
<tr class="{{ 'row-danger' if c.status=='danger' else ('row-warn' if c.status=='warn' else '') }}"> <tr class="{{ 'row-danger' if c.status=='danger' else ('row-warn' if c.status=='warn' else '') }}" data-page-node-id="9d7M31Zqmc5feItheDMB71">
<td>{{ c.m.part_number }}</td> <td data-page-node-id="jv8YvfrtYpEsLkFsFyrwiO">{{ c.m.part_number }}</td>
<td>{{ c.m.manufacturer or '-' }}</td> <td data-page-node-id="pRtaop8GPgnCJGFS4Cugle">{{ c.m.manufacturer or '-' }}</td>
<td class="num">{{ '%.3f'|format(c.qty) }}</td> <td class="num" data-page-node-id="3uop7BWAV6eYdlVz5eqakf">{{ '%.3f'|format(c.qty) }}</td>
<td class="num">{{ '%.2f'|format(c.stock) }}</td> <td class="num" data-page-node-id="jfNj0QeLPUrrBSfsPjzsHT">{{ '%.2f'|format(c.stock) }}</td>
<td><span class="badge {{ 'danger' if c.status=='danger' else ('warn' if c.status=='warn' else 'ok') }}">{{ '负库存' if c.status=='danger' else ('低于安全库存' if c.status=='warn' else '正常') }}</span></td> <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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
<p class="small muted">该 BOM 没有组成物料</p> <p class="small muted" data-page-node-id="htEBvmB0x6pS6bfdx1SFaA">该 BOM 没有组成物料</p>
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}
<p class="muted">暂无 BOM</p> <p class="muted" data-page-node-id="f188trWNRvLEGIEFhxwrC1">暂无 BOM</p>
{% endfor %} {% endfor %}
</div> </div>
<!-- 批量导入 --> <!-- 批量导入 -->
<div id="tab-import" class="tab-pane" style="display:none"> <div id="tab-import" class="tab-pane" style="display:none" data-page-node-id="NRRM2DunropDEi85SDdAeY">
<div class="card" style="margin-bottom:16px"> <div class="card" style="margin-bottom:16px" data-page-node-id="pmIAFmv6QG2cAhVHKguzCC">
<h3 style="margin-top:0">导入入库流水到 {{ f.code }}</h3> <h3 style="margin-top:0" data-page-node-id="gAGSs6J0QvEnz8OmMFZdaW">导入入库流水到 {{ f.code }}</h3>
<p class="small muted">上传 Excel/CSV(列:物料编号 / 数量 / 批次 / 日期 / 备注),将生成归属 {{ f.code }} 的入库流水。</p> <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"> <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> <input type="file" name="file" accept=".xlsx,.csv" required data-page-node-id="QvuR34OQvaVh8GOCZ4g69g">
<button class="primary" type="submit">上传并导入入库</button> <button class="primary" type="submit" data-page-node-id="62RYuou3Nbtk1RAVVli1eE">上传并导入入库</button>
<a class="btn" href="{{ url_for('download_template', kind='transactions') }}">⬇ 下载模板</a> <a class="btn" href="{{ url_for('download_template', kind='transactions') }}" data-page-node-id="XukAuHeUyL7T9wJs6aDfhT">⬇ 下载模板</a>
</form> </form>
</div> </div>
<div class="card"> <div class="card" data-page-node-id="G6s6IIwsdxtO2QoglkQu5O">
<h3 style="margin-top:0">导入物料主数据(期初库存归属 {{ f.code }})</h3> <h3 style="margin-top:0" data-page-node-id="sBnAiFr1fC7vAnge2CYt9L">导入物料主数据(期初库存归属 {{ f.code }})</h3>
<p class="small muted">上传物料 Excel(列:物料编号 / 型号 / 单位 / 安全库存 / 期初库存),期初库存将写入 {{ f.code }}。</p> <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"> <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> <input type="file" name="file" accept=".xlsx,.csv" required data-page-node-id="3PqBIl65X6AWBok5cKoO4S">
<button class="primary" type="submit">上传并导入物料</button> <button class="primary" type="submit" data-page-node-id="iU26xXbC5XkB2oLWJtUVws">上传并导入物料</button>
<a class="btn" href="{{ url_for('download_template', kind='materials') }}">⬇ 下载模板</a> <a class="btn" href="{{ url_for('download_template', kind='materials') }}" data-page-node-id="o5mKNqqJtZJffB2hM595GA">⬇ 下载模板</a>
</form> </form>
</div> </div>
</div> </div>
@@ -100,5 +114,77 @@ function switchTab(name){
event.target.classList.add('active'); event.target.classList.add('active');
document.getElementById('tab-'+name).style.display='block'; 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> </script>
{% endblock %} {% endblock %}
+16
View File
@@ -27,6 +27,22 @@
<div class="stat"><div class="num">{{ '%.2f'|format(out_total) }}</div><div class="lbl">消耗总数量</div></div> <div class="stat"><div class="num">{{ '%.2f'|format(out_total) }}</div><div class="lbl">消耗总数量</div></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> <h3>入库流水(每次入库时间 / 数量)</h3>
<table class="tbl"> <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> <thead><tr><th>入库日期</th><th>工厂</th><th>送货日期</th><th>录入时间</th><th>数量</th><th>批次号</th><th>来源 / 供应商</th><th>经手人</th><th>备注</th></tr></thead>
+2 -1
View File
@@ -4,7 +4,8 @@
<form method="post" class="card form-card"> <form method="post" class="card form-card">
<h3>{{ '编辑物料' if m else '新增物料' }}</h3> <h3>{{ '编辑物料' if m else '新增物料' }}</h3>
<label>物料编号 *</label> <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> <label>型号 / 规格</label>
<input name="manufacturer" value="{{ m.manufacturer if m else '' }}"> <input name="manufacturer" value="{{ m.manufacturer if m else '' }}">
<label>单位</label> <label>单位</label>
+60 -9
View File
@@ -1,42 +1,69 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}订单 {{ o.order_no }}{% endblock %} {% block title %}订单 {{ o.order_no }}{% endblock %}
{% block content %} {% 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"> <div class="toolbar">
<h3 style="margin:0">生产订单 {{ o.order_no }}</h3> <h3 style="margin:0">生产订单 {{ o.order_no }}</h3>
<span class="badge {{ 'ok' if o.status=='picked' else 'warn' }}">{{ '已领料' if o.status=='picked' else '待领料' }}</span> <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_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>
<div class="card" style="margin-bottom:16px"> <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> | 生产工厂: <b>{{ factory.code if factory else '未指定(按汇总库存)' }}</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 %} {% if o.remark %}<p class="small muted">备注: {{ o.remark }}</p>{% endif %}
<p class="small muted print-hide" style="margin-bottom:0">提示:点击「编辑订单」保存后,该订单会冲销已产生的调入/领料流水并清空调入计划,自动回到「刚下单」状态(待领料、未调拨),可重新规划。</p>
</div> </div>
<h3>领料需求与调入 (单套用量 × 接单量){% if factory %} | 按 {{ factory.code }} 工厂库存核算{% endif %}</h3> <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) }}"> <form method="post" action="{{ url_for('order_alloc_save', oid=o.id) }}">
<table class="tbl"> <table class="tbl">
<thead><tr> <thead><tr>
<th>物料编号</th><th>型号</th><th>需求数量</th> <th>物料编号</th><th>型号</th><th>下单前库存</th>
<th>调入数量</th><th>当前库存</th><th>缺口</th><th>状态</th><th>可替代料</th> <th>需求数量</th><th>缺口</th><th>调入数量</th><th>状态</th><th>工厂库存</th><th>可替代料</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{% for r in reqs %} {% for r in reqs %}
<tr class="{{ 'row-danger' if r.short else ('row-warn' if r.have < (r.m.safety_stock or 0)) }}"> <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><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.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.need) }}</td>
<td class="num">{{ '%.2f'|format(r.gap) }}</td>
<td class="num"> <td class="num">
{% if r.posted %} {% if r.posted %}
<span class="badge ok" title="已生成入库流水">已调入 {{ '%.2f'|format(r.alloc) }}</span> <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 %} {% else %}
<input name="alloc_{{ r.m.id }}" type="number" step="0.01" min="0" <input name="alloc_{{ r.m.id }}" type="text" inputmode="decimal"
value="{{ '%.2f'|format(r.alloc) if r.alloc else '' }}" value="{{ '%.2f'|format(r.alloc) if r.alloc else '' }}"
placeholder="{{ '%.2f'|format(r.need) }}" style="width:96px"> placeholder="{{ '%.2f'|format(r.need) }}" style="width:96px">
{% endif %} {% endif %}
</td> </td>
<td class="num">{{ '%.2f'|format(r.have) }}</td>
<td class="num">{{ '%.2f'|format(r.gap) }}</td>
<td><span class="badge {{ 'danger' if r.short else 'ok' }}">{{ '缺料' if r.short else '充足' }}</span></td> <td><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"> <td class="small">
{% if r.subs %} {% if r.subs %}
{% for s in r.subs %} {% for s in r.subs %}
@@ -67,7 +94,7 @@
formaction="{{ url_for('order_alloc_post', oid=o.id) }}" formaction="{{ url_for('order_alloc_post', oid=o.id) }}"
onclick="return confirm('确认将待调入数量从上方所选来源工厂调拨到生产工厂? 总库存不变, 仅厂间转移。');">确认调入(厂间调拨)</button> onclick="return confirm('确认将待调入数量从上方所选来源工厂调拨到生产工厂? 总库存不变, 仅厂间转移。');">确认调入(厂间调拨)</button>
</div> </div>
<p class="small muted">「确认调入」是一次厂间调拨:从来源工厂出库减、生产工厂入库加,系统总库存不变。</p> <p class="small muted print-hide">「确认调入」是一次厂间调拨:从来源工厂出库减、生产工厂入库加,系统总库存不变。</p>
</form> </form>
<div class="card" style="margin:14px 0"> <div class="card" style="margin:14px 0">
@@ -82,4 +109,28 @@
{% else %} {% else %}
<p class="flash ok">该订单已领料出库。</p> <p class="flash ok">该订单已领料出库。</p>
{% endif %} {% 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 %} {% endblock %}
+48 -12
View File
@@ -1,20 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ '编辑生产订单' if o else '新建生产订单' }}{% endblock %} {% block title %}{{ '编辑生产订单' if o else '新建生产订单' }}{% endblock %}
{% block content %} {% block content %}
<form method="post" class="card form-card" style="max-width:520px" <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') }}"> 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> <h3>{{ '编辑生产订单' if o else '新建生产订单' }}</h3>
<label>订单号 {% if o %}(单号不可重复){% else %}(留空自动生成){% endif %}</label> <label>订单号 {% if o %}(单号不可重复){% else %}(留空自动生成){% endif %}</label>
<input name="order_no" value="{{ o.order_no if o else '' }}" placeholder="如 WO20260911xxx"> <input name="order_no" value="{{ o.order_no if o else '' }}" placeholder="如 WO20260911xxx">
<label>BOM 型号 *</label>
<select name="bom_id" required> <label>BOM 与生产数量 *</label>
<option value="">-- 选择 BOM --</option> <p class="small muted" style="margin:0 0 6px">可在同一订单中加入多个 BOM,每个 BOM 独立填写生产数量(限<b>正整数,不含小数点</b>,如 1、12、100);点击「+ 添加 BOM」增加一行,不需要的行可删除。</p>
{% for b in boms %} <div id="bomRows" class="bom-rows"></div>
<option value="{{ b.id }}" {% if o and o.bom_id == b.id %}selected{% endif %}>{{ b.code }} ({{ b.name or '-' }})</option> <button type="button" class="btn" style="margin:2px 0 12px" onclick="addBomRow()">+ 添加 BOM</button>
{% endfor %}
</select>
<label>接单量 (台/套) *</label>
<input name="qty" type="number" step="0.01" min="0.01" required value="{{ '%.2f'|format(o.qty) if o else '' }}">
<label>生产工厂</label> <label>生产工厂</label>
<select name="factory_id"> <select name="factory_id">
<option value="">-- 不指定(汇总库存校验)--</option> <option value="">-- 不指定(汇总库存校验)--</option>
@@ -28,6 +26,44 @@
<button class="primary">{{ '保存修改' if o else '生成订单' }}</button> <button class="primary">{{ '保存修改' if o else '生成订单' }}</button>
<a class="btn" href="{{ url_for('orders') }}">取消</a> <a class="btn" href="{{ url_for('orders') }}">取消</a>
</div> </div>
<p class="small muted">生成后可在订单详情页按 BOM 展开领料需求, 并手动录入每个物料的「调入数量」。</p> <p class="small muted">生成后可在订单详情页按各 BOM 展开领料需求, 并手动录入每个物料的「调入数量」。</p>
</form> </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 %} {% endblock %}
+5 -4
View File
@@ -4,16 +4,17 @@
<div class="toolbar"> <div class="toolbar">
<h3 style="margin:0">生产订单 ({{ data|length }})</h3> <h3 style="margin:0">生产订单 ({{ data|length }})</h3>
<a class="btn primary" href="{{ url_for('order_new') }}">+ 新建订单</a> <a class="btn primary" href="{{ url_for('order_new') }}">+ 新建订单</a>
<a class="btn" href="{{ url_for('orders_export') }}">导出 Excel</a>
</div> </div>
<table class="tbl"> <table class="tbl">
<thead><tr><th>订单号</th><th>BOM</th><th>接单量</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> <tbody>
{% for d in data %} {% for d in data %}
<tr> <tr>
<td>{{ d.o.order_no }}</td> <td>{{ d.o.order_no }}</td>
<td>{{ d.bom.code if d.bom else '-' }} ({{ d.bom.name if d.bom else '' }})</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.o.qty) }}</td> <td class="num">{{ '%.2f'|format(d.total) }}</td>
<td>{{ d.factory.code if d.factory else '未指定' }}</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><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>{{ d.o.operator or '-' }}</td>
<td class="row-actions"> <td class="row-actions">
+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 %}
+1 -1
View File
@@ -27,7 +27,7 @@
<label>所属工厂 *</label> <label>所属工厂 *</label>
<select name="factory_id" required> <select name="factory_id" required>
{% for f in factories %} {% 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> <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 %} {% endfor %}
</select> </select>
<label>批次号</label> <label>批次号</label>