Files
order_system/po_app/app.py
T

1072 lines
39 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.
# -*- coding: utf-8 -*-
"""博特维采购订单管理系统:制单 → 主管审核 → 经理批准 → 导出 PDF。"""
import io
import os
import sys
from datetime import datetime
from functools import wraps
from flask import (Flask, flash, jsonify, redirect, render_template, request,
send_file, send_from_directory, session, url_for)
from sqlalchemy import func
from werkzeug.security import check_password_hash, generate_password_hash
from werkzeug.utils import secure_filename
from amount_cn import amount_to_cn
from models import (COMPANY_NAME, DOC_TYPES, ROLE_TEXT, STATUS_TEXT,
DEFAULT_DELIVERY_PLACE, DEFAULT_OUR_CONTACT,
DEFAULT_OUR_PHONE, DEFAULT_OUR_TAX_NO, DEFAULT_TAX_RATE,
ApprovalLog, Material, Order, OrderItem, Supplier, User,
init_db)
from pdf_export import build_pdf
# 打包成 EXE 后:资源在临时目录 _MEIPASS,数据必须写在 EXE 同级目录
if getattr(sys, "frozen", False):
RES_DIR = sys._MEIPASS
BASE_DIR = os.path.dirname(sys.executable)
else:
RES_DIR = os.path.dirname(os.path.abspath(__file__))
BASE_DIR = RES_DIR
INSTANCE = os.path.join(BASE_DIR, "instance")
EXPORTS = os.path.join(BASE_DIR, "exports")
UPLOADS = os.path.join(BASE_DIR, "uploads")
for d in (INSTANCE, EXPORTS, UPLOADS):
os.makedirs(d, exist_ok=True)
app = Flask(__name__,
template_folder=os.path.join(RES_DIR, "templates"),
static_folder=os.path.join(RES_DIR, "static"))
app.secret_key = "botw-po-system-2026"
app.config["MAX_CONTENT_LENGTH"] = 8 * 1024 * 1024
engine, Session = init_db(os.path.join(INSTANCE, "po.db").replace("\\", "/"))
db = Session()
def now():
return datetime.now()
# ---------------- 基础工具 ----------------
def current_user():
uid = session.get("user_id")
if not uid:
return None
return db.get(User, uid)
def login_required(f):
@wraps(f)
def wrapper(*a, **kw):
if not current_user():
return redirect(url_for("login"))
return f(*a, **kw)
return wrapper
def role_required(*roles):
def deco(f):
@wraps(f)
def wrapper(*a, **kw):
u = current_user()
if not u:
return redirect(url_for("login"))
if u.role != "admin" and u.role not in roles:
flash(u"当前账号无此操作权限", "error")
return redirect(url_for("index"))
return f(*a, **kw)
return wrapper
return deco
def add_log(order, action, user, comment=None):
db.add(ApprovalLog(order_id=order.id, action=action,
user_name=user.name if user else u"系统",
comment=comment, created_at=now()))
def can_edit(order, user):
if not user:
return False
if user.role == "admin":
return order.status in ("draft", "rejected")
return (order.status in ("draft", "rejected")
and order.created_by == user.id)
def next_order_no(doc_type):
"""单号规则:PO/WO + 年月日 + 4 位递增数字。
序号解析兼容旧格式(PO20260909-001 与 PO202609090001 都能正确读出),
这样改规则后历史单据不会导致序号从头开始。
"""
prefix = "PO" if doc_type == "purchase" else "WO"
head = "%s%s" % (prefix, now().strftime("%Y%m%d"))
seq = 0
for o in db.query(Order).filter(Order.order_no.like("%s%%" % head)).all():
tail = (o.order_no[len(head):] or "").lstrip("-")
try:
n = int(tail)
except ValueError:
continue
if n > seq:
seq = n
return "%s%04d" % (head, seq + 1)
def recalc(order):
total = 0.0
for it in order.items:
try:
qty = float(it.qty or 0)
price = float(it.price or 0)
except (TypeError, ValueError):
qty = price = 0.0
it.amount = round(qty * price, 2)
total += it.amount
order.total_amount = round(total, 2)
return total
def parse_items_from_form(form, order):
codes = form.getlist("item_code")
specs = form.getlist("item_spec")
units = form.getlist("item_unit")
qtys = form.getlist("item_qty")
prices = form.getlist("item_price")
dates = form.getlist("item_date")
remarks = form.getlist("item_remark")
order.items = []
for i in range(len(codes)):
code = (codes[i] or "").strip()
spec = (specs[i] or "").strip()
if not code and not spec:
continue
def f(v):
try:
return float(v)
except (TypeError, ValueError):
return 0.0
qty = f(qtys[i] if i < len(qtys) else 0)
price = f(prices[i] if i < len(prices) else 0)
order.items.append(OrderItem(
seq=i + 1, material_code=code, material_spec=spec,
unit=(units[i] if i < len(units) else "").strip(),
qty=qty, price=price, amount=round(qty * price, 2),
delivery_date=(dates[i] if i < len(dates) else "").strip(),
remark=(remarks[i] if i < len(remarks) else "").strip()))
recalc(order)
# ---------------- 初始化 ----------------
def seed():
if db.query(User).count():
return
users = [
("admin", "admin123", u"系统管理员", "admin"),
("maker", "123456", u"张采购", "maker"),
("reviewer", "123456", u"李主管", "reviewer"),
("approver", "123456", u"王经理", "approver"),
]
for un, pw, name, role in users:
db.add(User(username=un, password_hash=generate_password_hash(pw),
name=name, role=role))
db.add(Supplier(code="S001", name=u"苏州精锐五金制品有限公司", contact=u"周经理",
phone="13800000001", tax_no="91320500MA1XXXXXXX",
bank_name=u"中国银行苏州工业园区支行",
bank_account="4562 1088 0012 3456", payment_terms=u"月结30天"))
db.add(Supplier(code="S002", name=u"昆山宏远电子科技有限公司", contact=u"陈主管",
phone="13900000002", tax_no="91320583MA2XXXXXXX",
bank_name=u"建设银行昆山分行",
bank_account="3220 1988 2233 4455", payment_terms=u"货到付款"))
db.add(Material(code="M-1001", spec=u"铝合金支架 6061-T6 200×80×10mm", unit=u"件", price=38.5))
db.add(Material(code="M-1002", spec=u"步进电机 57BYG250 2相", unit=u"台", price=265.0))
db.add(Material(code="M-1003", spec=u"同步带 5M-500 宽15mm", unit=u"条", price=46.0))
db.commit()
seed()
# ---------------- 认证 ----------------
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
u = db.query(User).filter_by(username=request.form.get("username", "").strip()).first()
if u and u.active and check_password_hash(u.password_hash, request.form.get("password", "")):
session["user_id"] = u.id
session["user_name"] = u.name
session["role"] = u.role
return redirect(url_for("index"))
flash(u"用户名或密码错误", "error")
return render_template("login.html")
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
# ---------------- 首页 ----------------
@app.route("/")
@login_required
def index():
u = current_user()
todo = []
if u.role in ("reviewer", "admin"):
todo = db.query(Order).filter_by(status="pending_review").order_by(Order.id.desc()).all()
if u.role in ("approver", "admin"):
todo += db.query(Order).filter_by(status="pending_approval").order_by(Order.id.desc()).all()
my_orders = []
if u.role in ("maker", "admin"):
my_orders = (db.query(Order).filter_by(created_by=u.id)
.order_by(Order.id.desc()).limit(8).all())
counts = {s: db.query(Order).filter_by(status=s).count() for s in STATUS_TEXT}
month_total = (db.query(func.sum(Order.total_amount))
.filter(Order.status == "approved").scalar() or 0)
return render_template("index.html", user=u, todo=todo, my_orders=my_orders,
counts=counts, month_total=month_total,
status_text=STATUS_TEXT, role_text=ROLE_TEXT)
# ---------------- 订单 ----------------
@app.route("/orders")
@login_required
def orders():
u = current_user()
q = db.query(Order)
kw = request.args.get("kw", "").strip()
status = request.args.get("status", "")
doc_type = request.args.get("doc_type", "")
date_from = request.args.get("date_from", "")
date_to = request.args.get("date_to", "")
if kw:
q = q.filter(Order.order_no.like("%%%s%%" % kw) |
Order.supplier_name.like("%%%s%%" % kw))
if status:
q = q.filter(Order.status == status)
if doc_type:
q = q.filter(Order.doc_type == doc_type)
if date_from:
q = q.filter(Order.order_date >= date_from)
if date_to:
q = q.filter(Order.order_date <= date_to)
if u.role == "maker":
q = q.filter(Order.created_by == u.id)
rows = q.order_by(Order.id.desc()).all()
return render_template("orders.html", user=u, rows=rows, status_text=STATUS_TEXT,
doc_types=DOC_TYPES, args=request.args)
@app.route("/orders/new", methods=["GET", "POST"])
@login_required
def order_new():
u = current_user()
suppliers = db.query(Supplier).filter_by(active=True).all()
if request.method == "POST":
doc_type = request.form.get("doc_type", "purchase")
o = Order(order_no=next_order_no(doc_type), doc_type=doc_type,
status="draft", created_by=u.id, created_by_name=u.name,
created_at=now(), order_date=now().strftime("%Y-%m-%d"),
our_company=COMPANY_NAME, delivery_place=DEFAULT_DELIVERY_PLACE,
our_contact=DEFAULT_OUR_CONTACT, our_phone=DEFAULT_OUR_PHONE,
our_tax_no=DEFAULT_OUR_TAX_NO, tax_rate=DEFAULT_TAX_RATE)
sid = request.form.get("supplier_id")
if sid:
s = db.get(Supplier, int(sid))
if s:
o.supplier_name = s.name
o.supplier_contact = s.contact
o.supplier_phone = s.phone
o.supplier_tax_no = s.tax_no
o.supplier_bank_name = s.bank_name
o.supplier_bank_account = s.bank_account
o.payment_terms = s.payment_terms
db.add(o)
db.commit()
return redirect(url_for("order_edit", oid=o.id))
return render_template("order_new.html", user=u, suppliers=suppliers,
doc_types=DOC_TYPES)
@app.route("/orders/<int:oid>", methods=["GET", "POST"])
@app.route("/orders/<int:oid>/edit", methods=["GET", "POST"])
@login_required
def order_edit(oid):
u = current_user()
o = db.get(Order, oid)
if not o:
flash(u"订单不存在", "error")
return redirect(url_for("orders"))
editable = can_edit(o, u)
if request.method == "POST":
if not editable:
flash(u"当前状态不可编辑", "error")
return redirect(url_for("order_view", oid=o.id))
for f in ("supplier_name", "supplier_contact", "supplier_phone",
"supplier_tax_no", "supplier_bank_name", "supplier_bank_account",
"payment_terms", "our_company", "our_contact", "our_phone",
"our_tax_no", "tax_rate", "order_date", "delivery_place", "note"):
setattr(o, f, request.form.get(f, "").strip())
parse_items_from_form(request.form, o)
db.commit()
flash(u"已保存", "ok")
return redirect(url_for("order_edit", oid=o.id))
suppliers = db.query(Supplier).filter_by(active=True).all()
materials = db.query(Material).filter_by(active=True).all()
return render_template("order_edit.html", user=u, o=o, editable=editable,
suppliers=suppliers, materials=materials,
doc_types=DOC_TYPES, total_cn=amount_to_cn(o.total_amount))
@app.route("/orders/<int:oid>/view")
@login_required
def order_view(oid):
u = current_user()
o = db.get(Order, oid)
if not o:
flash(u"订单不存在", "error")
return redirect(url_for("orders"))
return render_template("order_view.html", user=u, o=o, editable=can_edit(o, u),
total_cn=amount_to_cn(o.total_amount),
status_text=STATUS_TEXT, role_text=ROLE_TEXT)
@app.route("/orders/<int:oid>/submit", methods=["POST"])
@login_required
def order_submit(oid):
u = current_user()
o = db.get(Order, oid)
if not o or not can_edit(o, u):
flash(u"无权限或状态不允许", "error")
return redirect(url_for("orders"))
if not o.items:
flash(u"明细为空,不能提交", "error")
return redirect(url_for("order_edit", oid=o.id))
o.status = "pending_review"
o.submitted_at = now()
o.reject_reason = None
add_log(o, u"提交审核", u, request.form.get("comment"))
db.commit()
flash(u"已提交,等待主管审核", "ok")
return redirect(url_for("order_view", oid=o.id))
@app.route("/orders/<int:oid>/review", methods=["POST"])
@login_required
@role_required("reviewer")
def order_review(oid):
u = current_user()
o = db.get(Order, oid)
if not o or o.status != "pending_review":
flash(u"状态不允许", "error")
return redirect(url_for("orders"))
# 确认前必须先加盖审核章(服务端校验,防止绕过前端直接提交)
if request.form.get("stamped") != "1":
flash(u"请先加盖审核章,再确认审核", "error")
return redirect(url_for("order_view", oid=o.id))
o.status = "pending_approval"
o.reviewed_by = u.name
o.reviewed_at = now()
add_log(o, u"主管审核通过(已加盖审核章)", u, request.form.get("comment"))
db.commit()
flash(u"审核通过,等待经理批准", "ok")
return redirect(url_for("order_view", oid=o.id))
@app.route("/orders/<int:oid>/approve", methods=["POST"])
@login_required
@role_required("approver")
def order_approve(oid):
u = current_user()
o = db.get(Order, oid)
if not o or o.status != "pending_approval":
flash(u"状态不允许", "error")
return redirect(url_for("orders"))
# 与主管审核一致:确认前必须先加盖批准章(服务端校验,防绕过)
if request.form.get("stamped") != "1":
flash(u"请先加盖批准章,再确认批准", "error")
return redirect(url_for("order_view", oid=o.id))
o.status = "approved"
o.approved_by = u.name
o.approved_at = now()
add_log(o, u"经理批准(已加盖批准章)", u, request.form.get("comment"))
db.commit()
flash(u"已批准,可以导出 PDF", "ok")
return redirect(url_for("order_view", oid=o.id))
@app.route("/orders/<int:oid>/reject", methods=["POST"])
@login_required
def order_reject(oid):
u = current_user()
o = db.get(Order, oid)
if not o:
flash(u"订单不存在", "error")
return redirect(url_for("orders"))
if u.role not in ("admin", "reviewer", "approver"):
flash(u"无权限", "error")
return redirect(url_for("order_view", oid=o.id))
if o.status not in ("pending_review", "pending_approval"):
flash(u"状态不允许驳回", "error")
return redirect(url_for("order_view", oid=o.id))
reason = request.form.get("comment", "").strip()
if not reason:
flash(u"请填写驳回原因", "error")
return redirect(url_for("order_view", oid=o.id))
from_status = o.status
o.status = "rejected"
o.reject_reason = reason
o.reviewed_by = None
o.reviewed_at = None
o.approved_by = None
o.approved_at = None
add_log(o, u"驳回(%s)" % (u"审核环节" if from_status == "pending_review" else u"批准环节"),
u, reason)
db.commit()
flash(u"已驳回给制单人", "ok")
return redirect(url_for("order_view", oid=o.id))
@app.route("/orders/<int:oid>/copy", methods=["POST"])
@login_required
def order_copy(oid):
u = current_user()
src = db.get(Order, oid)
if not src:
flash(u"订单不存在", "error")
return redirect(url_for("orders"))
o = Order(order_no=next_order_no(src.doc_type), doc_type=src.doc_type,
status="draft", created_by=u.id, created_by_name=u.name,
created_at=now(), order_date=now().strftime("%Y-%m-%d"))
for f in ("supplier_name", "supplier_contact", "supplier_phone", "supplier_tax_no",
"supplier_bank_name", "supplier_bank_account", "payment_terms",
"our_company", "our_contact", "our_phone", "our_tax_no", "tax_rate",
"delivery_place", "note"):
setattr(o, f, getattr(src, f))
# 老单据可能没存需方信息,复制时补默认值,避免新单缺项
if not o.our_contact:
o.our_contact = DEFAULT_OUR_CONTACT
if not o.our_phone:
o.our_phone = DEFAULT_OUR_PHONE
if not o.our_tax_no:
o.our_tax_no = DEFAULT_OUR_TAX_NO
if not o.tax_rate:
o.tax_rate = DEFAULT_TAX_RATE
db.add(o)
db.flush()
for it in src.items:
db.add(OrderItem(order_id=o.id, seq=it.seq, material_code=it.material_code,
material_spec=it.material_spec, unit=it.unit, qty=it.qty,
price=it.price, amount=it.amount,
delivery_date=it.delivery_date, remark=it.remark))
recalc(o)
db.commit()
flash(u"已复制为新订单 %s" % o.order_no, "ok")
return redirect(url_for("order_edit", oid=o.id))
@app.route("/orders/<int:oid>/pdf")
@login_required
def order_pdf(oid):
o = db.get(Order, oid)
if not o:
flash(u"订单不存在", "error")
return redirect(url_for("orders"))
fname = "%s_%s.pdf" % (o.order_no, now().strftime("%H%M%S"))
path = os.path.join(EXPORTS, fname)
build_pdf(o, o.items, path)
dl = request.args.get("dl") == "1"
return send_file(path, as_attachment=dl,
download_name="%s %s.pdf" % (o.doc_type_text, o.order_no))
@app.route("/orders/<int:oid>/import_excel", methods=["POST"])
@login_required
def import_excel(oid):
u = current_user()
o = db.get(Order, oid)
if not o or not can_edit(o, u):
flash(u"无权限或状态不允许", "error")
return redirect(url_for("orders"))
file = request.files.get("file")
if not file or not file.filename:
flash(u"请选择文件", "error")
return redirect(url_for("order_edit", oid=o.id))
fname = secure_filename(file.filename)
path = os.path.join(UPLOADS, "%s_%s" % (now().strftime("%H%M%S"), fname))
file.save(path)
try:
rows = read_rows(path)
except Exception as e:
flash(u"读取失败:%s" % e, "error")
return redirect(url_for("order_edit", oid=o.id))
mode = request.form.get("mode", "append")
if mode == "replace":
o.items = []
seq = len(o.items)
added = 0
for r in rows:
if not any(r):
continue
code, spec, unit, qty, price, date, remark = (list(r) + [""] * 7)[:7]
def f(v):
try:
return float(str(v).strip())
except (TypeError, ValueError):
return 0.0
seq += 1
q = f(qty)
p = f(price)
o.items.append(OrderItem(seq=seq, material_code=str(code or "").strip(),
material_spec=str(spec or "").strip(),
unit=str(unit or "").strip(), qty=q, price=p,
amount=round(q * p, 2),
delivery_date=str(date or "").strip(),
remark=str(remark or "").strip()))
added += 1
recalc(o)
db.commit()
flash(u"已导入 %d 行明细" % added, "ok")
return redirect(url_for("order_edit", oid=o.id))
def read_rows(path):
"""读取 xlsx/xls 明细,返回 [代码,规格,单位,数量,单价,交期,备注]。"""
ext = os.path.splitext(path)[1].lower()
grid = []
if ext in (".xlsx", ".xlsm"):
import openpyxl
wb = openpyxl.load_workbook(path, data_only=True)
for row in wb[wb.sheetnames[0]].iter_rows(values_only=True):
grid.append(["" if c is None else c for c in row])
else:
import xlrd
bk = xlrd.open_workbook(path)
sh = bk.sheet_by_index(0)
for r in range(sh.nrows):
grid.append([sh.cell_value(r, c) for c in range(sh.ncols)])
keys = {"code": [u"代码", u"料号", u"物料编码", u"品号"],
"spec": [u"规格", u"名称", u"品名", u"描述"],
"unit": [u"单位"],
"qty": [u"数量", u"数量(QTY)"],
"price": [u"单价", u"含税单价", u"价格"],
"date": [u"交期", u"交货日期", u"日期"],
"remark": [u"备注"]}
order = ["code", "spec", "unit", "qty", "price", "date", "remark"]
header_idx = None
for i, row in enumerate(grid[:8]):
text = " ".join(str(c) for c in row)
if any(k in text for k in keys["code"]) and any(k in text for k in keys["qty"]):
header_idx = i
break
if header_idx is None:
return [[c for c in row[:7]] for row in grid]
heads = [str(c or "").strip() for c in grid[header_idx]]
mapping = {}
for idx, h in enumerate(heads):
for k, ks in keys.items():
if any(x in h for x in ks):
mapping[k] = idx
break
out = []
for row in grid[header_idx + 1:]:
vals = []
for k in order:
i = mapping.get(k)
vals.append(row[i] if (i is not None and i < len(row)) else "")
out.append(vals)
return out
def read_table(path, keys, order):
"""通用表格读取:按关键词映射表头,返回按 order 排列的二维列表。
与 read_rows() 的区别是表头判定更宽松(命中任意关键词即可),
因为物料表 / 供应商表都没有"数量"列,套用 read_rows 会退化成按位置读。
"""
ext = os.path.splitext(path)[1].lower()
grid = []
if ext in (".xlsx", ".xlsm"):
import openpyxl
wb = openpyxl.load_workbook(path, data_only=True)
for row in wb[wb.sheetnames[0]].iter_rows(values_only=True):
grid.append(["" if c is None else c for c in row])
else:
import xlrd
bk = xlrd.open_workbook(path)
sh = bk.sheet_by_index(0)
for r in range(sh.nrows):
grid.append([sh.cell_value(r, c) for c in range(sh.ncols)])
header_idx = None
for i, row in enumerate(grid[:8]):
text = " ".join(str(c) for c in row)
if any(any(k in text for k in ks) for ks in keys.values()):
header_idx = i
break
if header_idx is None:
return [list(row[:len(order)]) for row in grid]
heads = [str(c or "").strip() for c in grid[header_idx]]
mapping = {}
for idx, h in enumerate(heads):
for k, ks in keys.items():
if k in mapping:
continue
if any(x in h for x in ks):
mapping[k] = idx
break
out = []
for row in grid[header_idx + 1:]:
vals = []
for k in order:
i = mapping.get(k)
vals.append(row[i] if (i is not None and i < len(row)) else "")
out.append(vals)
return out
MATERIAL_KEYS = {
"code": [u"物料代码", u"料号", u"物料编码", u"品号", u"代码"],
"spec": [u"规格", u"名称", u"品名", u"描述"],
"unit": [u"单位"],
"price": [u"单价", u"价格"],
}
MATERIAL_ORDER = ["code", "spec", "unit", "price"]
SUPPLIER_KEYS = {
"code": [u"编号", u"供应商编号", u"代码"],
"name": [u"名称", u"供应商名称", u"公司"],
"contact": [u"联系人"],
"phone": [u"电话", u"手机", u"联系方式"],
"payment_terms": [u"付款方式", u"结算方式", u"账期"],
"tax_no": [u"税号", u"纳税人识别号"],
"bank_name": [u"开户银行", u"银行名称"],
"bank_account": [u"银行帐号", u"银行账号", u"帐号", u"账号"],
}
SUPPLIER_ORDER = ["code", "name", "contact", "phone", "payment_terms",
"tax_no", "bank_name", "bank_account"]
def read_material_rows(path):
"""读取物料表,返回 [代码, 规格, 单位, 单价]。"""
return read_table(path, MATERIAL_KEYS, MATERIAL_ORDER)
def read_supplier_rows(path):
"""读取供应商表,返回 [编号,名称,联系人,电话,付款方式,税号,开户银行,银行帐号]。"""
return read_table(path, SUPPLIER_KEYS, SUPPLIER_ORDER)
@app.route("/import_template.xlsx")
@login_required
def import_template():
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.append([u"物料代码", u"物料规格", u"单位", u"数量", u"含税单价", u"交期", u"备注"])
ws.append(["M-1001", u"铝合金支架 6061-T6", u"件", 100, 38.5, "2026-09-20", u""])
for col, w in zip("ABCDEFG", (16, 34, 8, 10, 12, 14, 20)):
ws.column_dimensions[col].width = w
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
return send_file(buf, as_attachment=True, download_name=u"明细导入模板.xlsx",
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
# ---------------- 供应商 / 物料 ----------------
@app.route("/suppliers", methods=["GET", "POST"])
@login_required
@role_required("admin", "maker")
def suppliers():
u = current_user()
if request.method == "POST":
sid = request.form.get("id")
s = db.get(Supplier, int(sid)) if sid else Supplier()
for f in ("code", "name", "contact", "phone", "tax_no", "bank_name",
"bank_account", "payment_terms"):
setattr(s, f, request.form.get(f, "").strip())
if not sid:
db.add(s)
db.commit()
flash(u"已保存", "ok")
return redirect(url_for("suppliers"))
rows = db.query(Supplier).order_by(Supplier.id).all()
return render_template("suppliers.html", user=u, rows=rows)
@app.route("/suppliers/<int:sid>/toggle", methods=["POST"])
@login_required
@role_required("admin", "maker")
def supplier_toggle(sid):
"""停用 / 启用互切。停用后不出现在下单选择里,但保留历史数据。"""
s = db.get(Supplier, sid)
if s:
s.active = not s.active
db.commit()
flash(u"已%s:%s" % (u"启用" if s.active else u"停用", s.name), "ok")
return redirect(url_for("suppliers"))
@app.route("/suppliers/<int:sid>/delete", methods=["POST"])
@login_required
@role_required("admin")
def supplier_delete(sid):
"""彻底删除。订单里存的是供应商名称快照,删库记录不影响历史单据。"""
s = db.get(Supplier, sid)
if s:
name = s.name
db.delete(s)
db.commit()
flash(u"已删除供应商:%s" % name, "ok")
return redirect(url_for("suppliers"))
@app.route("/suppliers/import_excel", methods=["POST"])
@login_required
@role_required("admin", "maker")
def supplier_import_excel():
file = request.files.get("file")
if not file or not file.filename:
flash(u"请选择文件", "error")
return redirect(url_for("suppliers"))
path = os.path.join(UPLOADS, "%s_%s" % (now().strftime("%H%M%S"),
secure_filename(file.filename)))
file.save(path)
try:
rows = read_supplier_rows(path)
except Exception as e:
flash(u"读取失败:%s" % e, "error")
return redirect(url_for("suppliers"))
mode = request.form.get("mode", "update")
added = updated = skipped = failed = 0
errs = []
def s(v):
return str(v or "").strip()
for i, r in enumerate(rows, start=2):
if not any(r):
continue
(code, name, contact, phone, payment_terms, tax_no,
bank_name, bank_account) = (list(r) + [""] * 8)[:8]
code, name = s(code), s(name)
if not name:
failed += 1
errs.append(u"第%d行:供应商名称为空" % i)
continue
obj = db.query(Supplier).filter_by(code=code).first() if code else None
if not obj:
obj = db.query(Supplier).filter_by(name=name).first()
if obj:
if mode == "skip":
skipped += 1
continue
obj.code = code or obj.code
obj.name = name
obj.contact = s(contact)
obj.phone = s(phone)
obj.payment_terms = s(payment_terms)
obj.tax_no = s(tax_no)
obj.bank_name = s(bank_name)
obj.bank_account = s(bank_account)
obj.active = True
updated += 1
else:
db.add(Supplier(code=code, name=name, contact=s(contact),
phone=s(phone), payment_terms=s(payment_terms),
tax_no=s(tax_no), bank_name=s(bank_name),
bank_account=s(bank_account), active=True))
added += 1
db.commit()
msg = u"导入完成:新增 %d,更新 %d,跳过 %d,失败 %d" % (added, updated, skipped, failed)
if errs:
msg += u"。失败明细:" + u";".join(errs[:8])
if len(errs) > 8:
msg += u" 等共 %d 条" % len(errs)
flash(msg, "ok" if not failed else "error")
return redirect(url_for("suppliers"))
@app.route("/suppliers/import_template.xlsx")
@login_required
def supplier_import_template():
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.append([u"编号", u"名称", u"联系人", u"电话", u"付款方式", u"税号",
u"开户银行", u"银行帐号"])
ws.append(["S003", u"苏州精锐五金制品有限公司", u"周经理", "13800000001",
u"月结30天", "91320500MA1XXXXXXX", u"中国银行苏州工业园区支行",
"4562 1088 0012 3456"])
for col, w in zip("ABCDEFGH", (10, 30, 12, 16, 14, 22, 26, 24)):
ws.column_dimensions[col].width = w
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
return send_file(buf, as_attachment=True, download_name=u"供应商导入模板.xlsx",
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
@app.route("/materials", methods=["GET", "POST"])
@login_required
@role_required("admin", "maker")
def materials():
u = current_user()
if request.method == "POST":
mid = request.form.get("id")
m = db.get(Material, int(mid)) if mid else Material()
m.code = request.form.get("code", "").strip()
m.spec = request.form.get("spec", "").strip()
m.unit = request.form.get("unit", "").strip()
try:
m.price = float(request.form.get("price") or 0)
except ValueError:
m.price = 0
if not mid:
db.add(m)
db.commit()
flash(u"已保存", "ok")
return redirect(url_for("materials"))
rows = db.query(Material).order_by(Material.id).all()
return render_template("materials.html", user=u, rows=rows)
@app.route("/materials/<int:mid>/toggle", methods=["POST"])
@login_required
@role_required("admin", "maker")
def material_toggle(mid):
"""停用 / 启用互切。停用后不出现在下单物料下拉里,但保留历史数据。"""
m = db.get(Material, mid)
if m:
m.active = not m.active
db.commit()
flash(u"已%s:%s" % (u"启用" if m.active else u"停用", m.code), "ok")
return redirect(url_for("materials"))
@app.route("/materials/<int:mid>/delete", methods=["POST"])
@login_required
@role_required("admin")
def material_delete(mid):
"""彻底删除。订单明细里存的是物料快照,删库记录不影响历史单据。"""
m = db.get(Material, mid)
if m:
code = m.code
db.delete(m)
db.commit()
flash(u"已删除物料:%s" % code, "ok")
return redirect(url_for("materials"))
@app.route("/materials/import_excel", methods=["POST"])
@login_required
@role_required("admin", "maker")
def material_import_excel():
file = request.files.get("file")
if not file or not file.filename:
flash(u"请选择文件", "error")
return redirect(url_for("materials"))
path = os.path.join(UPLOADS, "%s_%s" % (now().strftime("%H%M%S"),
secure_filename(file.filename)))
file.save(path)
try:
rows = read_material_rows(path)
except Exception as e:
flash(u"读取失败:%s" % e, "error")
return redirect(url_for("materials"))
mode = request.form.get("mode", "update")
added = updated = skipped = failed = 0
errs = []
def s(v):
return str(v or "").strip()
def num(v):
try:
return float(str(v or "").strip() or 0)
except (TypeError, ValueError):
return 0.0
for i, r in enumerate(rows, start=2):
if not any(r):
continue
code, spec, unit, price = (list(r) + [""] * 4)[:4]
code = s(code)
if not code:
failed += 1
errs.append(u"第%d行:物料代码为空" % i)
continue
m = db.query(Material).filter_by(code=code).first()
if m:
if mode == "skip":
skipped += 1
continue
m.spec = s(spec)
m.unit = s(unit)
m.price = num(price)
m.active = True
updated += 1
else:
db.add(Material(code=code, spec=s(spec), unit=s(unit),
price=num(price), active=True))
added += 1
db.commit()
msg = u"导入完成:新增 %d,更新 %d,跳过 %d,失败 %d" % (added, updated, skipped, failed)
if errs:
msg += u"。失败明细:" + u";".join(errs[:8])
if len(errs) > 8:
msg += u" 等共 %d 条" % len(errs)
flash(msg, "ok" if not failed else "error")
return redirect(url_for("materials"))
@app.route("/materials/import_template.xlsx")
@login_required
def material_import_template():
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.append([u"物料代码", u"物料规格", u"单位", u"默认含税单价"])
ws.append(["M-1004", u"铝合金支架 6061-T6 200×80×10mm", u"件", 38.5])
ws.append(["M-1005", u"步进电机 57BYG250 2相", u"台", 265.0])
for col, w in zip("ABCD", (16, 34, 10, 16)):
ws.column_dimensions[col].width = w
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
return send_file(buf, as_attachment=True, download_name=u"物料导入模板.xlsx",
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
@app.route("/api/materials")
@login_required
def api_materials():
rows = db.query(Material).filter_by(active=True).all()
return jsonify([{"id": m.id, "code": m.code, "spec": m.spec,
"unit": m.unit, "price": m.price} for m in rows])
@app.route("/api/suppliers")
@login_required
def api_suppliers():
rows = db.query(Supplier).filter_by(active=True).all()
return jsonify([{"id": s.id, "name": s.name, "contact": s.contact,
"phone": s.phone, "tax_no": s.tax_no, "bank_name": s.bank_name,
"bank_account": s.bank_account,
"payment_terms": s.payment_terms} for s in rows])
# ---------------- 用户 ----------------
@app.route("/users", methods=["GET", "POST"])
@login_required
@role_required("admin")
def users():
u = current_user()
if request.method == "POST":
uid = request.form.get("id")
t = db.get(User, int(uid)) if uid else User()
t.username = request.form.get("username", "").strip()
t.name = request.form.get("name", "").strip()
t.role = request.form.get("role", "maker")
pw = request.form.get("password", "").strip()
if pw:
t.password_hash = generate_password_hash(pw)
elif not uid:
t.password_hash = generate_password_hash("123456")
if not uid:
db.add(t)
db.commit()
flash(u"已保存", "ok")
return redirect(url_for("users"))
rows = db.query(User).order_by(User.id).all()
return render_template("users.html", user=u, rows=rows, roles=ROLE_TEXT)
# ---------------- 统计 ----------------
@app.route("/stats")
@login_required
def stats():
u = current_user()
by_status = (db.query(Order.status, func.count(Order.id), func.sum(Order.total_amount))
.group_by(Order.status).all())
by_supplier = (db.query(Order.supplier_name, func.count(Order.id),
func.sum(Order.total_amount))
.filter(Order.status == "approved")
.group_by(Order.supplier_name)
.order_by(func.sum(Order.total_amount).desc()).limit(20).all())
by_month = (db.query(func.substr(Order.order_date, 1, 7), func.count(Order.id),
func.sum(Order.total_amount))
.group_by(func.substr(Order.order_date, 1, 7)).all())
return render_template("stats.html", user=u, by_status=by_status,
by_supplier=by_supplier, by_month=by_month,
status_text=STATUS_TEXT)
@app.route("/export/ledger")
@login_required
def export_ledger():
import openpyxl
rows = db.query(Order).order_by(Order.id.desc()).all()
wb = openpyxl.Workbook()
ws = wb.active
ws.append([u"采购单号", u"单据类型", u"供应商", u"采购日期", u"状态", u"金额",
u"制单人", u"审核人", u"批准人", u"创建时间"])
for o in rows:
ws.append([o.order_no, o.doc_type_text, o.supplier_name, o.order_date,
o.status_text, o.total_amount, o.created_by_name,
o.reviewed_by, o.approved_by,
o.created_at.strftime("%Y-%m-%d %H:%M") if o.created_at else ""])
for col, w in zip("ABCDEFGHIJ", (18, 14, 30, 12, 12, 12, 12, 12, 12, 18)):
ws.column_dimensions[col].width = w
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
return send_file(buf, as_attachment=True,
download_name=u"采购订单台账_%s.xlsx" % now().strftime("%Y%m%d"),
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
@app.route("/exports/<name>")
@login_required
def exports_file(name):
return send_from_directory(EXPORTS, name)
@app.context_processor
def inject_common():
return {"COMPANY_NAME": COMPANY_NAME, "STATUS_TEXT": STATUS_TEXT,
"ROLE_TEXT": ROLE_TEXT, "DOC_TYPES": DOC_TYPES}
def port_busy(p):
import socket
s = socket.socket()
s.settimeout(0.4)
try:
s.connect(("127.0.0.1", p))
return True
except Exception:
return False
finally:
s.close()
if __name__ == "__main__":
import threading
import webbrowser
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
while port_busy(port) and port < 8120:
port += 1
if getattr(sys, "frozen", False) or os.environ.get("PO_OPEN_BROWSER") == "1":
threading.Timer(1.5, lambda: webbrowser.open("http://127.0.0.1:%d" % port)).start()
print("=" * 56)
print(" 博特维采购订单管理系统已启动")
print(" 本机访问: http://127.0.0.1:%d" % port)
print(" 关闭本窗口即停止服务")
print("=" * 56)
app.run(host="0.0.0.0", port=port, debug=False, threaded=True)