325 lines
16 KiB
Python
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))
|