1072 lines
39 KiB
Python
1072 lines
39 KiB
Python
# -*- 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)
|