Files
Inventory-Management-System/excel_io.py
T
star 7d34a481b3 feat: 库存管理系统 MaterialWMS 初始版本
- Flask + SQLAlchemy 物料/入库/出库/BOM/生产订单全模块管理
- PyInstaller 打包为单文件 EXE (双击运行, 自动建库)
- 物料维度: 入库总数量 / 消耗总数量 / 逐次入库流水(含时间)
- BOM 详情展示组成物料的规格型号与单套用量
- 支持 Altium / 嘉立创 BOM 批量导入(文本与 Excel 双通道)
- 内置端到端验证脚本(tools/_exe_util.py 等, 清理进程树防残留)
2026-09-11 15:41:56 +08:00

474 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Excel 双向导入/导出 (openpyxl)
- 物料导入 / 导出
- BOM 导入(含组成行)
- 交易(入库/出库)批量导入
- 模板下载
"""
import os
import re
import csv
from datetime import date
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, PatternFill, Alignment
from models import db, Material, Inbound, Bom, BomItem, OperationLog
# ---------------- 交易(入库/出库)批量导入 ----------------
def _cell(r, i):
"""安全取单元格, 越界返回 None"""
try:
return r[i] if i is not None else None
except (IndexError, TypeError):
return None
def read_transactions(path):
"""Excel / CSV -> [{part_number, qty, batch_no, date, remark}]
表头识别: 物料编号 / 数量 / 批次 / 日期 / 备注 (名称大小写/中英均可)。
"""
if str(path).lower().endswith(".csv"):
with open(path, newline="", encoding="utf-8-sig") as f:
rows = [r for r in csv.reader(f)]
else:
wb = load_workbook(path, data_only=True)
ws = wb.active
rows = [list(r) for r in ws.iter_rows(values_only=True)]
if not rows:
return []
header = [str(c).strip() if c is not None else "" for c in rows[0]]
idx = {h: i for i, h in enumerate(header)}
def col(*names):
for n in names:
if n in idx:
return idx[n]
return None
c_pn = col("物料编号", "Part Number", "part_number", "编号")
c_qty = col("数量", "qty", "qty_per_unit", "用量")
c_batch = col("批次", "batch_no", "batch")
c_date = col("日期", "date", "occur_date", "业务日期")
c_remark = col("备注", "remark", "note")
out = []
for r in rows[1:]:
if not any(c not in (None, "") for c in r):
continue
pn = str(_cell(r, c_pn) or "").strip()
if not pn:
continue
q = _f(_cell(r, c_qty))
batch = str(_cell(r, c_batch) or "").strip()
d = str(_cell(r, c_date) or "").strip()
remark = str(_cell(r, c_remark) or "").strip()
out.append({"part_number": pn, "qty": q, "batch_no": batch,
"date": d, "remark": remark})
return out
def build_transaction_template(path, kind="inbound"):
wb = Workbook()
ws = wb.active
ws.append(["物料编号", "数量", "批次", "日期", "备注"])
for c in ws[1]:
c.font = Font(bold=True)
ws.append(["P0001", "100", "B20260911", "2026-09-11", "示例到货"])
ws.append(["P0002", "50", "B20260911", "2026-09-11", "示例到货"])
wb.save(path)
return path
# ---------------- 物料 ----------------
def read_materials(path):
"""返回 [{part_number, manufacturer, unit, safety_stock, opening_stock}]"""
wb = load_workbook(path, data_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if not rows:
return []
header = [str(c).strip() if c is not None else "" for c in rows[0]]
idx = {h: i for i, h in enumerate(header)}
def col(*names):
for n in names:
if n in idx:
return idx[n]
return None
c_pn = col("物料编号", "Part Number", "part_number", "编号")
c_mf = col("型号", "Manufacture", "manufacturer", "规格")
c_unit = col("单位", "unit")
c_ss = col("安全库存", "safety_stock", "安全量")
c_op = col("期初库存", "opening_stock", "期初", "库存")
out = []
for r in rows[1:]:
if c_pn is None or r[c_pn] in (None, ""):
continue
pn = str(r[c_pn]).strip()
if not pn:
continue
mf = str(r[c_mf]).strip() if (c_mf is not None and r[c_mf] is not None) else ""
unit = str(r[c_unit]).strip() if (c_unit is not None and r[c_unit] is not None) else "个"
ss = _f(r[c_ss]) if c_ss is not None else 0
op = _f(r[c_op]) if c_op is not None else 0
out.append({
"part_number": pn, "manufacturer": mf,
"unit": unit or "个", "safety_stock": ss, "opening_stock": op,
})
return out
def import_materials(rows, operator="导入"):
"""upsert 物料; opening_stock>0 写入一条"期初"入库流水。返回 (新增, 更新)"""
added = 0
updated = 0
for d in rows:
m = db.session.scalar(db.select(Material).where(Material.part_number == d["part_number"]))
if m is None:
m = Material(part_number=d["part_number"], manufacturer=d.get("manufacturer", ""),
unit=d.get("unit", "个"), safety_stock=d.get("safety_stock", 0))
db.session.add(m)
db.session.flush()
added += 1
else:
m.manufacturer = d.get("manufacturer", m.manufacturer)
m.unit = d.get("unit", m.unit) or "个"
m.safety_stock = d.get("safety_stock", m.safety_stock)
updated += 1
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 == "期初"))
if exists:
exists.qty = op
else:
db.session.add(Inbound(material_id=m.id, qty=op, source="期初",
occur_date=date.today(), operator=operator))
db.session.commit()
return added, updated
# ---------------- BOM ----------------
def read_bom(path):
"""返回 {code, name, items:[{part_number, qty_per_unit}]} 或 None(格式不符)"""
wb = load_workbook(path, data_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if not rows:
return None
header = [str(c).strip() if c is not None else "" for c in rows[0]]
idx = {h: i for i, h in enumerate(header)}
def col(*names):
for n in names:
if n in idx:
return idx[n]
return None
c_code = col("BOM型号", "code", "BOM")
c_name = col("名称", "name", "产品")
c_pn = col("物料编号", "Part Number", "part_number", "编号")
c_qty = col("单套用量", "qty_per_unit", "用量")
if c_code is None or c_pn is None or c_qty is None:
return None
code = str(rows[1][c_code]).strip() if rows[1][c_code] else ""
name = str(rows[1][c_name]).strip() if (c_name is not None and rows[1][c_name]) else ""
items = []
for r in rows[1:]:
if r[c_pn] in (None, ""):
continue
pn = str(r[c_pn]).strip()
q = _f(r[c_qty])
if q <= 0:
continue
items.append({"part_number": pn, "qty_per_unit": q})
return {"code": code, "name": name, "items": items}
def import_bom(data, operator="导入", auto_create=False):
"""写入 BOM + BomItem。
auto_create=True 时, 物料主数据不存在则按 Part Number + Manufacture 自动新建(便于真实 BOM 一步导入);
否则物料不存在则该项被跳过。返回 (bom_id, 成功项, 跳过项, 自动新建物料数)。
"""
if not data or not data["code"]:
return None, 0, 0, 0
bom = db.session.scalar(db.select(Bom).where(Bom.code == data["code"]))
if bom is None:
bom = Bom(code=data["code"], name=data.get("name", ""))
db.session.add(bom)
db.session.flush()
else:
db.session.execute(db.delete(BomItem).where(BomItem.bom_id == bom.id))
ok = 0
skip = 0
created_mat = 0
for it in data["items"]:
pn = str(it["part_number"]).strip()
m = db.session.scalar(db.select(Material).where(Material.part_number == pn))
if m is None:
if not auto_create:
skip += 1
continue
m = Material(part_number=pn,
manufacturer=str(it.get("manufacture", "") or "").strip(),
unit="个")
db.session.add(m)
db.session.flush()
created_mat += 1
db.session.add(BomItem(bom_id=bom.id, material_id=m.id, qty_per_unit=it["qty_per_unit"]))
ok += 1
db.session.commit()
return bom.id, ok, skip, created_mat
# ---------------- 导出 ----------------
def export_inventory(path, rows):
"""rows: [{m, stock, status}]"""
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 = {"ok": "正常", "warn": "低于安全库存", "danger": "库存为负"}[r["status"]]
ws.append([m.part_number, m.manufacturer, m.unit, r["stock"],
m.safety_stock or 0, status])
if r["status"] == "danger":
ws.cell(row=ws.max_row, column=4).fill = PatternFill("solid", fgColor="F8CBAD")
elif r["status"] == "warn":
ws.cell(row=ws.max_row, column=4).fill = PatternFill("solid", fgColor="FFE699")
ws.freeze_panes = "A2"
wb.save(path)
def build_material_template(path):
wb = Workbook()
ws = wb.active
ws.append(["物料编号", "型号/规格", "单位", "安全库存", "期初库存"])
for c in ws[1]:
c.font = Font(bold=True)
ws.append(["P0001", "示例型号A", "个", "100", "500"])
ws.append(["P0002", "示例型号B", "卷", "50", "200"])
wb.save(path)
return path
def build_bom_template(path):
"""生成与真实 BOM 模板一致的格式(Altium/嘉立创导出):
每个 worksheet 一个 BOM, sheet 名即 BOM 型号; 列头 Item/Part Number/
Manufacture Part Number/Quantity/Reference/Part/Package。"""
wb = Workbook()
ws = wb.active
ws.title = "示例型号A"
ws.append(["Item", "Part Number", "Manufacture Part Number",
"Quantity", "Reference", "Part", "Package"])
for c in ws[1]:
c.font = Font(bold=True)
c.fill = PatternFill("solid", fgColor="DDEBF7")
ws.append([1, "1001003", "CFW32C7UL_BUA", 1, "U1", "CFW32C7UL_BUA", "CFW32C7UL_IU_SOCKET"])
ws.append([2, "1003002", "BY25Q32BSSIG", 1, "U2", "GD25Q127CSIG", "SOIC-8-S"])
ws.append([3, "1007004", "SGM2036-2.5YUDH4G/TR", 1, "U11", "SGM2036-2.5YUDH4G/TR", ""])
ws.freeze_panes = "A2"
wb.save(path)
return path
def _norm(h):
return str(h or "").strip().lower()
def _find_col(header, names):
"""返回第一个精确匹配或包含匹配的列索引; 精确优先, 避免误命中(如 'part' 命中 'Part Number')"""
low = [_norm(h) for h in header]
# 1) 精确匹配
for n in names:
nn = _norm(n)
for i, h in enumerate(low):
if h == nn:
return i
# 2) 包含匹配(子串)
for n in names:
nn = _norm(n)
if not nn:
continue
for i, h in enumerate(low):
if nn in h:
return i
return None
def _find_cols(header, names):
"""返回所有匹配的列索引(用于重复表头, 如两个 'Quantity')"""
low = [_norm(h) for h in header]
out = []
for n in names:
nn = _norm(n)
if not nn:
continue
for i, h in enumerate(low):
if h == nn or nn in h:
if i not in out:
out.append(i)
return out
def _f(v):
try:
return float(v)
except (TypeError, ValueError):
return 0
# ---------------- 批量导入窗口: 文本解析 ----------------
_ITEM_HEADER_RE = re.compile(
r"物料|编号|part\s?number|型号|manufacture|单位|unit|安全|safety|期初|库存|名称|bom|单套|用量|数量|qty",
re.I,
)
def parse_materials_text(text):
"""粘贴多行 -> [{part_number, manufacturer, unit, safety_stock, opening_stock}]
每行格式: 物料编号 型号/规格 单位 安全库存 期初库存
列之间可用 空格/制表符/逗号/中文逗号 分隔; 首列命中表头关键词的行会被跳过。
"""
out = []
for raw in (text or "").split("\n"):
s = re.sub(r"[\t,,;;]+", " ", raw).strip()
if not s:
continue
parts = s.split()
if not parts:
continue
if _ITEM_HEADER_RE.search(parts[0]):
continue # 跳过表头
pn = parts[0]
if not pn:
continue
mf = parts[1] if len(parts) > 1 else ""
unit = parts[2] if len(parts) > 2 else "个"
ss = _f(parts[3]) if len(parts) > 3 else 0
op = _f(parts[4]) if len(parts) > 4 else 0
out.append({
"part_number": pn,
"manufacturer": mf,
"unit": unit or "个",
"safety_stock": ss,
"opening_stock": op,
})
return out
def parse_boms_text(text):
"""粘贴多行(以 #BOM 标记分段) -> [{code, name, items:[{part_number, qty_per_unit}]}]
格式:
#BOM M4021 示例产品A
P0001 2
P0002 0.5
#BOM M4022 示例产品B
P0003 4
"""
boms = []
cur = None
for raw in (text or "").split("\n"):
s = re.sub(r"[\t,,;;]+", " ", raw).strip()
if not s:
continue
parts = s.split()
if not parts:
continue
is_head = s.startswith("#") or re.match(r"^(BOM|型号|MODEL)\b", parts[0], re.I) is not None
if is_head:
body = re.sub(r"^#+\s*", "", s, flags=re.I)
body = re.sub(r"^(BOM|型号|MODEL)\s*", "", body, flags=re.I).strip()
bp = body.split()
if not bp:
continue
if len(bp) > 1 and re.search(r"名称|物料|编号|单套|用量|数量", bp[1]):
continue # 表头行(如 "BOM型号 名称 物料编号 单套用量")
cur = {"code": bp[0], "name": " ".join(bp[1:]) if len(bp) > 1 else "", "items": []}
boms.append(cur)
continue
if _ITEM_HEADER_RE.search(parts[0]):
continue # 表头行
if len(parts) < 2:
continue
if cur is None:
continue
cur["items"].append({"part_number": parts[0], "qty_per_unit": _f(parts[1])})
return boms
def read_boms_multi(path):
"""Excel/CSV -> [{code, name, items:[{part_number, qty_per_unit, manufacture}]}]
适配真实 BOM 模板(Altium / 嘉立创 EDA 导出):
- 每个 worksheet 视为一个 BOM, BOM 型号 = sheet 名
- 电子料表: 表头含 'Part Number'(物料编号) + 'Quantity'(单套用量, 取第一个 Quantity 列)
- 组装表: 表头含 '物料代码'(物料编号) + '数量'(单套用量)
- 兼容旧模板: 若表头含 'BOM型号' 列, 则按该列分组(单 sheet 多 BOM)
返回的每个 item 带 manufacture(型号/规格), 便于物料不存在时自动建物料。
"""
if str(path).lower().endswith(".csv"):
with open(path, newline="", encoding="utf-8-sig") as f:
rows = [r for r in csv.reader(f)]
sheets = [("Sheet1", rows)]
else:
wb = load_workbook(path, data_only=True)
sheets = [(ws.title, list(ws.iter_rows(values_only=True))) for ws in wb.worksheets]
result = []
for sheet_name, rows in sheets:
if not rows or len(rows) < 2:
continue
header = [str(c).strip() if c is not None else "" for c in rows[0]]
if not any(header):
continue
pn_col = _find_col(header, ["part number", "partnumber", "物料编号", "物料代码"])
qty_cols = _find_cols(header, ["quantity", "数量", "单套用量", "qty", "qty_per_unit"])
mf_col = _find_col(header, ["manufacture part number", "manufacture", "型号", "part"])
code_col = _find_col(header, ["bom型号", "code", "bom"])
name_col = _find_col(header, ["名称", "name", "产品"])
if pn_col is None or not qty_cols:
continue # 该 sheet 不是物料清单, 跳过(如说明页)
qty_col = qty_cols[0] # 取第一个 Quantity 列(嘉立创常出现两个 Quantity)
items = []
for r in rows[1:]:
if not any(c not in (None, "") for c in r):
continue
pn = str(r[pn_col]).strip() if pn_col < len(r) and r[pn_col] not in (None, "") else ""
if not pn:
continue
qty = _f(r[qty_col]) if qty_col < len(r) else 0
# 嘉立创常出现两个 Quantity 列, 若首列空/0 则回退到次列, 避免丢料
if qty <= 0 and len(qty_cols) > 1 and qty_cols[1] < len(r):
q2 = _f(r[qty_cols[1]])
if q2 > 0:
qty = q2
if qty <= 0:
continue
mf = ""
if mf_col is not None and mf_col < len(r) and r[mf_col] not in (None, ""):
mf = str(r[mf_col]).strip()
items.append({"part_number": pn, "qty_per_unit": qty, "manufacture": mf})
if not items:
continue
code = sheet_name.strip()
name = ""
if code_col is not None: # 兼容旧模板: 取首行 BOM型号/名称
for r in rows[1:]:
if code_col < len(r) and r[code_col] not in (None, ""):
code = str(r[code_col]).strip()
if name_col is not None and name_col < len(r) and r[name_col] not in (None, ""):
name = str(r[name_col]).strip()
break
result.append({"code": code, "name": name, "items": items})
return result