完善采购审批流程、企业微信通知和采购专用章

This commit is contained in:
reddaniel110
2026-09-16 11:45:21 +08:00
parent 4876d8805d
commit bf952196fa
58 changed files with 3451 additions and 1909 deletions
+77 -604
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""博特维采购订单管理系统:制单 → 主管审核 → 经理批准 → 导出 PDF。"""
"""博特维采购订单管理系统:制单 → 审批 → 导出 PDF。"""
import io
import os
import sys
@@ -10,7 +10,12 @@ 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 security import db, current_user, login_required, role_required, init_security, validate_password, sign_in
from money import number, format_price
from order_logic import scope
from order_routes import register_order_routes
from excel_io import read_rows, read_material_rows, read_supplier_rows, save_upload
from flask import abort
from amount_cn import amount_to_cn
from models import (COMPANY_NAME, DOC_TYPES, ROLE_TEXT, STATUS_TEXT,
@@ -28,6 +33,7 @@ else:
RES_DIR = os.path.dirname(os.path.abspath(__file__))
BASE_DIR = RES_DIR
BASE_DIR = os.path.abspath(os.environ.get("PO_DATA_DIR", BASE_DIR))
INSTANCE = os.path.join(BASE_DIR, "instance")
EXPORTS = os.path.join(BASE_DIR, "exports")
UPLOADS = os.path.join(BASE_DIR, "uploads")
@@ -37,194 +43,36 @@ for d in (INSTANCE, EXPORTS, UPLOADS):
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()
app.config['PO_UPLOADS'] = UPLOADS
init_security(app, Session, INSTANCE)
app.jinja_env.filters['price'] = format_price
register_order_routes(app)
from notification_routes import register_notification_routes
register_notification_routes(app)
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()
todo = scope(db.query(Order)).filter(Order.status == 'pending_approval', Order.created_by != u.id).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)
my_orders = (scope(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))
counts = {s: scope(db.query(Order)).filter_by(status=s).count() for s in STATUS_TEXT}
month_total = (scope(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,
@@ -236,7 +84,7 @@ def index():
@login_required
def orders():
u = current_user()
q = db.query(Order)
q = scope(db.query(Order))
kw = request.args.get("kw", "").strip()
status = request.args.get("status", "")
doc_type = request.args.get("doc_type", "")
@@ -260,405 +108,6 @@ def orders():
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():
@@ -685,9 +134,13 @@ def suppliers():
if request.method == "POST":
sid = request.form.get("id")
s = db.get(Supplier, int(sid)) if sid else Supplier()
if s is None:
abort(404)
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 s.name:
raise ValueError('供应商名称必填')
if not sid:
db.add(s)
db.commit()
@@ -732,9 +185,7 @@ def supplier_import_excel():
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)
path = save_upload(file)
try:
rows = read_supplier_rows(path)
except Exception as e:
@@ -818,13 +269,14 @@ def materials():
if request.method == "POST":
mid = request.form.get("id")
m = db.get(Material, int(mid)) if mid else Material()
if m is None:
abort(404)
m.code = request.form.get("code", "").strip()
if not m.code:
raise ValueError('物料代码必填')
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
m.price = number(request.form.get('price'), '默认含税单价')
if not mid:
db.add(m)
db.commit()
@@ -869,9 +321,7 @@ def material_import_excel():
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)
path = save_upload(file)
try:
rows = read_material_rows(path)
except Exception as e:
@@ -885,10 +335,7 @@ def material_import_excel():
return str(v or "").strip()
def num(v):
try:
return float(str(v or "").strip() or 0)
except (TypeError, ValueError):
return 0.0
return number(v, '导入单价')
for i, r in enumerate(rows, start=2):
if not any(r):
@@ -967,22 +414,41 @@ 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()
t = db.get(User, int(uid)) if uid else User(session_version=1)
if t is None:
abort(404)
username = request.form.get('username', '').strip()
name = request.form.get('name', '').strip()
role = request.form.get('role', 'maker')
active = request.form.get('active', '1') == '1'
if not username or len(username) > 50 or not name or len(name) > 50:
raise ValueError('登录名和姓名必填,且不超过 50 字')
if role not in ROLE_TEXT:
raise ValueError('角色无效')
if uid and t.role == 'admin' and t.active and (role != 'admin' or not active):
if db.query(User).filter_by(role='admin', active=True).count() <= 1:
raise ValueError('必须保留至少一个启用的管理员')
t.username, t.name, t.role, t.active = username, name, role, active
pw = request.form.get('password', '')
if pw:
validate_password(pw)
t.password_hash = generate_password_hash(pw)
t.must_change_password = True
elif not uid:
t.password_hash = generate_password_hash("123456")
raise ValueError('请为新账号设置初始密码')
if uid:
t.session_version += 1
if not uid:
db.add(t)
db.commit()
flash(u"已保存", "ok")
if t.id == u.id:
sign_in(t)
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)
edit_id = request.args.get('edit', type=int)
target = db.get(User, edit_id) if edit_id else None
return render_template("users.html", user=u, rows=rows, roles=ROLE_TEXT, target=target)
# ---------------- 统计 ----------------
@@ -990,15 +456,15 @@ def users():
@login_required
def stats():
u = current_user()
by_status = (db.query(Order.status, func.count(Order.id), func.sum(Order.total_amount))
by_status = (scope(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))
by_supplier = (scope(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))
by_month = (scope(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,
@@ -1009,17 +475,17 @@ def stats():
@login_required
def export_ledger():
import openpyxl
rows = db.query(Order).order_by(Order.id.desc()).all()
rows = scope(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"创建时间"])
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.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)):
for col, w in zip("ABCDEFGHI", (18, 14, 30, 12, 12, 12, 12, 12, 18)):
ws.column_dimensions[col].width = w
buf = io.BytesIO()
wb.save(buf)
@@ -1032,7 +498,8 @@ def export_ledger():
@app.route("/exports/<name>")
@login_required
def exports_file(name):
return send_from_directory(EXPORTS, name)
# Legacy filenames are not an authorization boundary. Use the order PDF endpoint.
abort(404)
@app.context_processor
@@ -1068,4 +535,10 @@ if __name__ == "__main__":
print(" 本机访问: http://127.0.0.1:%d" % port)
print(" 关闭本窗口即停止服务")
print("=" * 56)
app.run(host="0.0.0.0", port=port, debug=False, threaded=True)
from waitress import serve
from notifications import start_notification_worker
notification_stop = start_notification_worker(app, Session)
try:
serve(app, host=os.environ.get('PO_HOST', '0.0.0.0'), port=port, threads=8)
finally:
notification_stop.set()