Files
order_system/po_app/app.py
T
reddaniel110 768b4705dc 新增订单收票付款交货跟进并完善使用升级手册
增加部分付款和部分交货状态、工作台筛选、权限校验、跟进记录及 v4 数据迁移;补充 Windows EXE 构建与验收脚本。附 16 页 PDF 使用升级手册及企业微信详细配置说明、生成脚本和测试截图。验证:76 项回归测试、EXE 浏览器验收与 PDF 逐页校验通过。
2026-09-16 13:40:43 +08:00

556 lines
21 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 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 order_tracking import TRACKING_FILTERS, filter_tracking, register_tracking_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,
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
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")
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.config["MAX_CONTENT_LENGTH"] = 8 * 1024 * 1024
engine, Session = init_db(os.path.join(INSTANCE, "po.db").replace("\\", "/"))
app.config['PO_UPLOADS'] = UPLOADS
init_security(app, Session, INSTANCE)
app.jinja_env.filters['price'] = format_price
register_order_routes(app)
register_tracking_routes(app)
from notification_routes import register_notification_routes
register_notification_routes(app)
def now():
return datetime.now()
# ---------------- 首页 ----------------
@app.route("/")
@login_required
def index():
u = current_user()
todo = []
if u.role in ("approver", "admin"):
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 = (scope(db.query(Order)).filter_by(created_by=u.id)
.order_by(Order.id.desc()).limit(8).all())
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)
tracking_counts = {key: filter_tracking(scope(db.query(Order)), key).count() for key in TRACKING_FILTERS}
tracking_rows = scope(db.query(Order)).filter_by(status='approved').order_by(Order.id.desc()).limit(12).all()
return render_template("index.html", user=u, todo=todo, my_orders=my_orders,
counts=counts, month_total=month_total,
tracking_counts=tracking_counts, tracking_rows=tracking_rows,
status_text=STATUS_TEXT, role_text=ROLE_TEXT)
# ---------------- 订单 ----------------
@app.route("/orders")
@login_required
def orders():
u = current_user()
q = scope(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)
q = filter_tracking(q, request.args.get('tracking', ''))
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("/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()
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()
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 = save_upload(file)
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()
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()
m.price = number(request.form.get('price'), '默认含税单价')
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 = save_upload(file)
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):
return number(v, '导入单价')
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(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:
raise ValueError('请为新账号设置初始密码')
if uid:
t.session_version += 1
if not uid:
db.add(t)
db.commit()
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()
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)
# ---------------- 统计 ----------------
@app.route("/stats")
@login_required
def stats():
u = current_user()
by_status = (scope(db.query(Order.status, func.count(Order.id), func.sum(Order.total_amount)))
.group_by(Order.status).all())
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 = (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,
status_text=STATUS_TEXT)
@app.route("/export/ledger")
@login_required
def export_ledger():
import openpyxl
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"创建时间", '需预付款', '收票状态', '付款状态',
'付款日期', '交货状态', '采购跟进备注', '付款备注', '变更后待核对'])
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.approved_by,
o.created_at.strftime("%Y-%m-%d %H:%M") if o.created_at else "",
'是' if o.needs_prepayment else '否', o.invoice_status_text, o.payment_status_text,
o.payment_date, o.delivery_status_text, o.fulfillment_note, o.payment_note,
'、'.join(label for label, stale in [('采购跟进', o.fulfillment_stale), ('付款', o.payment_stale)] if stale)])
for col, w in zip("ABCDEFGHIJKLMNOPQ", (18, 14, 30, 12, 12, 12, 12, 12, 18, 12, 12, 12, 14, 12, 36, 36, 22)):
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):
# Legacy filenames are not an authorization boundary. Use the order PDF endpoint.
abort(404)
@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
open_browser = os.environ.get("PO_OPEN_BROWSER")
if open_browser == "1" or (getattr(sys, "frozen", False) and open_browser != "0"):
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)
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()