import hashlib import io import json 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 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 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/', methods=['GET', 'POST']) @app.route('/orders//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//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//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//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//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 = 'po-v3-legacy-current' if version.template_version == 'legacy-current' else 'po-v3' 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//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//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//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//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//versions/') @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//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//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='%s %s.pdf' % (order.doc_type_text, order.order_no)) response.headers['Cache-Control'] = 'private, no-store' return response @app.route('/orders//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))