# -*- 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/", methods=["GET", "POST"]) @app.route("/orders//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//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//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//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//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//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//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//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//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//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 = 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//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 = 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/") @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)