Files
order_system/po_app/order_routes.py
T

325 lines
16 KiB
Python

import hashlib
import io
import json
import re
import unicodedata
from datetime import datetime
from flask import abort, flash, redirect, render_template, request, send_file, url_for
from models import (COMPANY_NAME, DEFAULT_DELIVERY_PLACE, DEFAULT_OUR_CONTACT, DEFAULT_OUR_PHONE,
DEFAULT_OUR_TAX_NO, DEFAULT_TAX_RATE, DOC_TYPES, ROLE_TEXT, STATUS_TEXT,
Material, Order, OrderItem, OrderVersion, Supplier)
from amount_cn import amount_to_cn
from excel_io import read_rows, save_upload
from order_logic import (ORDER_FIELDS, ITEM_FIELDS, add_log, can_edit, can_approve,
can_reject, clear_approval, creation_request, get_order, make_item,
next_order_no, recalc, remember_creation, save_form, snapshot, submit,
validate_order)
from pdf_export import PDF_TEMPLATE_VERSION, build_pdf, seal_layout
from order_tracking import can_follow, can_record_payment
from security import current_user, db, login_required, role_required
def pdf_bytes(order):
buf = io.BytesIO()
build_pdf(order, order.items, buf)
return buf.getvalue()
def pdf_download_name(order):
# Use current master-data aliases without regenerating an approved PDF.
company = (order.supplier_name or '').strip()
matches = db.query(Supplier.short_name, Supplier.tax_no).filter(Supplier.name == company).all()
tax_no = (order.supplier_tax_no or '').strip()
if tax_no:
same_tax = [row for row in matches if (row.tax_no or '').strip() == tax_no]
if same_tax:
matches = same_tax
aliases = {(row.short_name or '').strip() or company for row in matches}
# Ambiguous duplicate names fall back to the company recorded on the order.
supplier = next(iter(aliases)) if len(aliases) == 1 else company
def part(value, fallback, limit):
value = ''.join(c for c in str(value or '') if not unicodedata.category(c).startswith('C'))
value = re.sub(r'[<>:"/\\|?*]', '_', value)
value = re.sub(r'\s+', ' ', value).strip(' .')
# Windows counts UTF-16 units; retain Chinese and bound the full filename.
value = value.encode('utf-16-le')[:limit * 2].decode('utf-16-le', errors='ignore').rstrip(' .')
return value or fallback
return '%s_%s_%s.pdf' % (part(order.doc_type_text, '订单', 32),
part(order.order_no, '未编号', 64),
part(supplier, '未填写供应商', 80))
def register_order_routes(app):
@app.route('/orders/new', methods=['GET', 'POST'])
@login_required
@role_required('maker')
def order_new():
user = current_user()
if request.method == 'POST':
key, existing = creation_request('new')
if existing:
return redirect(url_for('order_edit', oid=existing.order_id))
doc_type = request.form.get('doc_type', 'purchase')
order = Order(order_no=next_order_no(doc_type), doc_type=doc_type, status='draft',
created_by=user.id, created_by_name=user.name, order_date=datetime.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:
supplier = db.get(Supplier, int(sid))
if not supplier or not supplier.active:
raise ValueError('请选择有效供应商')
for target, source in [('supplier_name', 'name'), ('supplier_contact', 'contact'),
('supplier_phone', 'phone'), ('supplier_tax_no', 'tax_no'),
('supplier_bank_name', 'bank_name'), ('supplier_bank_account', 'bank_account'),
('payment_terms', 'payment_terms')]:
setattr(order, target, getattr(supplier, source))
db.add(order)
remember_creation(key, 'new', order)
add_log(order, '创建草稿', user)
db.commit()
return redirect(url_for('order_edit', oid=order.id))
return render_template('order_new.html', user=user, suppliers=db.query(Supplier).filter_by(active=True).all(), 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):
user = current_user()
order = get_order(oid, mutation=request.method == 'POST')
if not can_edit(order, user):
if request.method == 'POST':
abort(403)
return redirect(url_for('order_view', oid=oid))
if request.method == 'POST':
save_form(order, request.form)
if request.form.get('intent') == 'submit':
submit(order, user, request.form.get('comment'))
message = '已保存并提交审批'
else:
add_log(order, '保存草稿', user)
message = '已保存'
db.commit()
flash(message, 'ok')
return redirect(url_for('order_view' if order.status == 'pending_approval' else 'order_edit', oid=oid))
return render_template('order_edit.html', user=user, o=order, editable=True,
suppliers=db.query(Supplier).filter_by(active=True).all(),
materials=db.query(Material).filter_by(active=True).all(), doc_types=DOC_TYPES,
total_cn=amount_to_cn(order.total_amount))
@app.route('/orders/<int:oid>/view')
@login_required
def order_view(oid):
user, order = current_user(), get_order(oid)
return render_template('order_view.html', user=user, o=order, editable=can_edit(order, user),
may_approve=can_approve(order, user),
may_follow=can_follow(order, user), may_pay=can_record_payment(order, user),
seal=seal_layout(order.our_company) if can_approve(order, user) else None,
may_reject=can_reject(order, user), total_cn=amount_to_cn(order.total_amount),
status_text=STATUS_TEXT, role_text=ROLE_TEXT)
@app.route('/orders/<int:oid>/submit', methods=['POST'])
@login_required
def order_submit(oid):
user, order = current_user(), get_order(oid, mutation=True)
if not can_edit(order, user):
abort(403)
submit(order, user, request.form.get('comment'))
db.commit()
flash('已提交审批', 'ok')
return redirect(url_for('order_view', oid=oid))
@app.route('/orders/<int:oid>/review', methods=['POST'])
@login_required
def order_review(oid):
# Never turn a stale first-stage confirmation into a final approval.
get_order(oid)
abort(409, description='审核和批准已合并,请重新打开订单,核对后进行审批')
@app.route('/orders/<int:oid>/approve', methods=['POST'])
@login_required
def order_approve(oid):
user, order = current_user(), get_order(oid, mutation=True)
if not can_approve(order, user):
abort(403)
if request.form.get('stamped') != '1':
raise ValueError('请先加盖采购专用章,再确认审批')
validate_order(order)
old = order.status
if order.revision == 0:
order.revision = 1
db.add(OrderVersion(order_id=oid, revision=1, content_json=snapshot(order), template_version='legacy-current'))
order.status = 'approved'
order.approved_by, order.approved_by_id, order.approved_at = user.name, user.id, datetime.now()
db.flush()
version = db.query(OrderVersion).filter_by(order_id=oid, revision=order.revision).one()
version.pdf_data = pdf_bytes(order)
version.pdf_sha256 = hashlib.sha256(version.pdf_data).hexdigest()
version.template_version = (PDF_TEMPLATE_VERSION + '-legacy-current'
if version.template_version == 'legacy-current' else PDF_TEMPLATE_VERSION)
add_log(order, '审批通过并归档正式 PDF', user, request.form.get('comment'), old)
db.commit()
flash('审批通过,正式 PDF 已归档', 'ok')
return redirect(url_for('order_view', oid=oid))
@app.route('/orders/<int:oid>/reject', methods=['POST'])
@login_required
def order_reject(oid):
user, order = current_user(), get_order(oid, mutation=True)
if not can_reject(order, user):
abort(403)
reason = request.form.get('comment', '').strip()
if not reason:
raise ValueError('请填写驳回原因')
old = order.status
order.status, order.reject_reason = 'rejected', reason
clear_approval(order)
add_log(order, '审批驳回', user, reason, old)
db.commit()
return redirect(url_for('order_view', oid=oid))
@app.route('/orders/<int:oid>/withdraw', methods=['POST'])
@login_required
def order_withdraw(oid):
user, order = current_user(), get_order(oid, mutation=True)
if order.status != 'pending_approval' or order.created_by != user.id:
abort(403)
reason = request.form.get('comment', '').strip()
if not reason:
raise ValueError('请填写撤回原因')
old = order.status
order.status = 'draft'
clear_approval(order)
add_log(order, '制单人撤回', user, reason, old)
db.commit()
return redirect(url_for('order_edit', oid=oid))
@app.route('/orders/<int:oid>/void', methods=['POST'])
@login_required
def order_void(oid):
user, order = current_user(), get_order(oid, mutation=True)
allowed = can_edit(order, user) or (order.status == 'approved' and user.role in ('admin', 'approver'))
if not allowed:
abort(403)
reason = request.form.get('comment', '').strip()
if not reason:
raise ValueError('请填写作废原因')
old = order.status
order.status = 'voided'
add_log(order, '作废订单', user, reason, old)
db.commit()
return redirect(url_for('order_view', oid=oid))
@app.route('/orders/<int:oid>/revise', methods=['POST'])
@login_required
def order_revise(oid):
user, order = current_user(), get_order(oid, mutation=True)
if order.status != 'approved' or user.role not in ('maker', 'admin') or (user.role != 'admin' and order.created_by != user.id):
abort(403)
reason = request.form.get('comment', '').strip()
if not reason:
raise ValueError('请填写变更原因')
if order.revision == 0:
# Preserve available legacy content without pretending it is the original submitted version.
order.revision = 1
data = pdf_bytes(order)
db.add(OrderVersion(order_id=oid, revision=1, content_json=snapshot(order), pdf_data=data,
pdf_sha256=hashlib.sha256(data).hexdigest(), template_version='legacy-current'))
order.status = 'draft'
clear_approval(order)
add_log(order, '发起变更,原批准停止使用', user, reason, 'approved')
db.commit()
flash('已进入变更草稿,须重新审批;原审批文件请停止使用', 'ok')
return redirect(url_for('order_edit', oid=oid))
@app.route('/orders/<int:oid>/versions/<int:revision>')
@login_required
def order_version(oid, revision):
order = get_order(oid)
version = db.query(OrderVersion).filter_by(order_id=oid, revision=revision).first()
if not version:
abort(404)
return render_template('order_version.html', user=current_user(), o=order, version=version,
content=json.loads(version.content_json))
@app.route('/orders/<int:oid>/copy', methods=['POST'])
@login_required
@role_required('maker')
def order_copy(oid):
user, source = current_user(), get_order(oid)
key, existing = creation_request('copy:%s' % oid)
if existing:
return redirect(url_for('order_edit', oid=existing.order_id))
if request.form.get('row_version') != str(source.row_version):
abort(409, description='源订单已更新,请刷新后重试')
order = Order(order_no=next_order_no(source.doc_type), doc_type=source.doc_type,
status='draft', created_by=user.id, created_by_name=user.name)
for field in ORDER_FIELDS:
setattr(order, field, getattr(source, field))
order.order_date = datetime.now().strftime('%Y-%m-%d')
order.items = [OrderItem(seq=i, **{field: getattr(item, field) for field in ITEM_FIELDS})
for i, item in enumerate(source.items, 1)]
recalc(order)
db.add(order)
remember_creation(key, 'copy:%s' % oid, order)
add_log(order, '复制创建草稿', user, '来源:' + source.order_no)
db.commit()
return redirect(url_for('order_edit', oid=order.id))
@app.route('/orders/<int:oid>/pdf')
@login_required
def order_pdf(oid):
order = get_order(oid)
version = db.query(OrderVersion).filter_by(order_id=oid, revision=order.revision).first()
if order.status == 'approved' and version and version.pdf_data:
data = version.pdf_data
if hashlib.sha256(data).hexdigest() != version.pdf_sha256:
abort(500, description='归档文件校验失败,请联系管理员')
else:
data = pdf_bytes(order)
response = send_file(io.BytesIO(data), mimetype='application/pdf', as_attachment=request.args.get('dl') == '1',
download_name=pdf_download_name(order))
response.headers['Cache-Control'] = 'private, no-store'
return response
@app.route('/orders/<int:oid>/import_excel', methods=['POST'])
@login_required
def import_excel(oid):
user, order = current_user(), get_order(oid, mutation=True)
if not can_edit(order, user):
abort(403)
path = save_upload(request.files.get('file'))
try:
rows = read_rows(path)
except Exception as exc:
raise ValueError('无法读取 Excel,请检查文件格式及表头') from exc
items = []
for row in rows:
if not any(x not in ('', None) for x in row):
continue
values = (list(row) + [''] * 7)[:7]
if not (values[0] or values[1]):
raise ValueError('导入明细缺少物料代码或规格')
items.append(make_item(values, len(items) + 1))
if not items:
raise ValueError('文件中没有有效明细,原明细已保留')
mode = request.form.get('mode', 'append')
if mode not in ('append', 'replace'):
raise ValueError('导入方式无效')
combined = items if mode == 'replace' else list(order.items) + items
if len(combined) > 2000:
raise ValueError('单张订单最多 2000 行明细')
for seq, item in enumerate(combined, 1):
item.seq = seq
order.items = combined
recalc(order)
order.row_version += 1
add_log(order, '导入明细', user, '%s %s 行' % (mode, len(items)))
db.commit()
flash('已导入 %s 行明细' % len(items), 'ok')
return redirect(url_for('order_edit', oid=oid))