# -*- 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, or_ 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, tracking_conditions, tracking_priority) 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 material_prices import remember_approved_prices, supplier_price_map 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_key = request.args.get('tracking', 'open') tracking_query = filter_tracking(scope(db.query(Order)), tracking_key or 'open') tracking_total = tracking_query.count() tracking_rows = tracking_query.order_by(tracking_priority(), Order.id.asc()).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, tracking_key=tracking_key or 'open', tracking_total=tracking_total, 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", "") material_kw = request.args.get('material_kw', '').strip()[:100] 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 material_kw: pattern = '%%%s%%' % material_kw q = q.filter(Order.items.any((OrderItem.material_code.ilike(pattern)) | (OrderItem.material_spec.ilike(pattern)))) if u.role == "maker": q = q.filter(Order.created_by == u.id) tracking = request.args.get('tracking', '') q = filter_tracking(q, tracking) if not tracking and not kw and not material_kw and request.args.get('show_completed') != '1': q = q.filter(or_(Order.status != 'approved', ~tracking_conditions()['closed'])) rows = q.order_by(Order.id.desc()).all() matched_items = {} if material_kw and rows: for item in (db.query(OrderItem).filter(OrderItem.order_id.in_([o.id for o in rows]), (OrderItem.material_code.ilike(pattern)) | (OrderItem.material_spec.ilike(pattern))) .order_by(OrderItem.order_id, OrderItem.seq)): matched_items.setdefault(item.order_id, []).append(item) return render_template("orders.html", user=u, rows=rows, status_text=STATUS_TEXT, doc_types=DOC_TYPES, args=request.args, matched_items=matched_items) @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 'short_name' in request.form: short_name = request.form['short_name'].strip() if len(short_name) > 60: raise ValueError('供应商简称不能超过60个字符') s.short_name = short_name if not sid: db.add(s) db.commit() flash(u"已保存", "ok") return redirect(url_for("suppliers")) kw = request.args.get('kw', '').strip()[:100] query = db.query(Supplier) if kw: pattern = '%%%s%%' % kw query = query.filter(Supplier.name.ilike(pattern) | Supplier.short_name.ilike(pattern) | Supplier.code.ilike(pattern)) rows = query.order_by(Supplier.id).all() return render_template("suppliers.html", user=u, rows=rows, kw=kw) @app.route("/suppliers//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//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, short_name) = (list(r) + [None] * 9)[:9] code, name = s(code), s(name) if not name: failed += 1 errs.append(u"第%d行:供应商名称为空" % i) continue if short_name is not None and len(s(short_name)) > 60: failed += 1 errs.append(u"第%d行:供应商简称不能超过60个字符" % 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) if short_name is not None: obj.short_name = s(short_name) 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), short_name=s(short_name), 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"银行帐号", u"公司简称"]) ws.append(["S003", u"苏州精锐五金制品有限公司", u"周经理", "13800000001", u"月结30天", "91320500MA1XXXXXXX", u"中国银行苏州工业园区支行", "4562 1088 0012 3456", u"精锐五金"]) for col, w in zip("ABCDEFGHI", (10, 30, 12, 16, 14, 22, 26, 24, 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"供应商导入模板.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")) kw = request.args.get('kw', '').strip()[:100] query = db.query(Material) if kw: pattern = '%%%s%%' % kw query = query.filter(Material.code.ilike(pattern) | Material.spec.ilike(pattern)) rows = query.order_by(Material.id).all() return render_template("materials.html", user=u, rows=rows, kw=kw) @app.post('/materials/prices/from-approved-orders') @login_required @role_required('admin') def sync_approved_material_prices(): # Old approved orders may predate automatic catalog price updates. orders = (db.query(Order).filter(Order.status == 'approved') .order_by(Order.approved_at, Order.id).all()) count = sum(remember_approved_prices(db, order) for order in orders) db.commit() flash('已按审批先后同步 %s 张订单,更新 %s 条物料单价。' % (len(orders), count), 'ok') return redirect(url_for('materials')) @app.route("/materials//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//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, "short_name": s.short_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('/api/material-prices') @login_required @role_required('maker') def api_material_prices(): return jsonify(supplier_price_map(db, request.args.get('supplier_name', ''), request.args.get('supplier_tax_no', ''), current_user())) # ---------------- 用户 ---------------- @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/") @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()