完善采购审批流程、企业微信通知和采购专用章
This commit is contained in:
+77
-604
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user