diff --git a/packaging/win7/README_WIN7.md b/packaging/win7/README_WIN7.md index bc8a6e1..1ae86d7 100644 --- a/packaging/win7/README_WIN7.md +++ b/packaging/win7/README_WIN7.md @@ -4,6 +4,10 @@ 安装脚本修复:新版解压到另一文件夹时,先检查本包及系统登记的 Python 3.8.10,确认位数和运行正常后复用;不再仅因新目录没有 `.python38` 就重复安装。只有找不到可用环境时才运行官方安装器。安装器错误码、依赖安装及自检输出会保存在 `logs/`。 +2026-09-17 工作台更新:突出“需预付款”“已收票待付款”“已付款未交齐”“变更后待核对”。待跟进订单先按处理类别排列,同类较早订单优先;付款、交货、收票均完成后默认收起,仍可通过筛选查看。付款和交货分别显示三档状态;“未登记”和变更前待核对记录单独提示,不换算虚假的完成百分比。 + +在工作台或订单列表点击“登记收票 / 交货”“登记付款”,可直接在弹窗中保存,无需进入订单详情。制单员登记自己的采购跟进,审批员登记付款,管理员可登记两者。弹窗保存后保留当前筛选;如果其他人已更新订单,会提示重新读取并核对状态,避免覆盖。订单详情顶部显示最近经办人、时间和备注。手机端订单自动排成卡片。本次不增加业务字段,不改变已有审批或 PDF 归档;原 PDF 手册中工作台截图为旧布局,操作以本段说明及新版页面为准。 + 2026-09-17 文件名更新:在“供应商管理”的新增或编辑窗口填写“公司简称”,PDF 文件名即为“采购单_订单号_简称.pdf”(委外单使用对应类型名称);未填写简称时使用供应商全名。已审批订单重新下载也使用新文件名,归档 PDF 内容不变,无需重新审批。Excel 模板新增末列“公司简称”,旧表缺少该列时保留原简称。本次启动会先备份数据库,再升级到数据库结构版本 5,新增简称字段。 2026-09-17 排版更新:公司抬头从 12.5 磅加大到 20 磅,保持黑体居中。采购专用章直径增加 30%,固定在右侧需方签章空位;供方和需方签章区之间留出 20 毫米间隔。采购单与委外订单均已调整,示例见 `samples/`。本次 PDF 模板版本为 `po-v5`。 diff --git a/po_app/app.py b/po_app/app.py index d2ac177..5d5d736 100644 --- a/po_app/app.py +++ b/po_app/app.py @@ -8,13 +8,14 @@ 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 sqlalchemy import func, or_ from werkzeug.security import check_password_hash, generate_password_hash 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 order_tracking import TRACKING_FILTERS, filter_tracking, register_tracking_routes +from order_tracking import (TRACKING_FILTERS, filter_tracking, register_tracking_routes, + tracking_conditions, tracking_priority) from excel_io import read_rows, read_material_rows, read_supplier_rows, save_upload from flask import abort @@ -77,10 +78,14 @@ def index(): month_total = (scope(db.query(func.sum(Order.total_amount))) .filter(Order.status == "approved").scalar() or 0) tracking_counts = {key: filter_tracking(scope(db.query(Order)), key).count() for key in TRACKING_FILTERS} - tracking_rows = scope(db.query(Order)).filter_by(status='approved').order_by(Order.id.desc()).limit(12).all() + tracking_key = request.args.get('tracking', 'open') + tracking_query = filter_tracking(scope(db.query(Order)), tracking_key or 'open') + tracking_total = tracking_query.count() + tracking_rows = tracking_query.order_by(tracking_priority(), Order.id.asc()).limit(12).all() return render_template("index.html", user=u, todo=todo, my_orders=my_orders, counts=counts, month_total=month_total, tracking_counts=tracking_counts, tracking_rows=tracking_rows, + tracking_key=tracking_key or 'open', tracking_total=tracking_total, status_text=STATUS_TEXT, role_text=ROLE_TEXT) @@ -108,7 +113,10 @@ def orders(): q = q.filter(Order.order_date <= date_to) if u.role == "maker": q = q.filter(Order.created_by == u.id) - q = filter_tracking(q, request.args.get('tracking', '')) + tracking = request.args.get('tracking', '') + q = filter_tracking(q, tracking) + if not tracking and request.args.get('show_completed') != '1': + q = q.filter(or_(Order.status != 'approved', ~tracking_conditions()['closed'])) 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) diff --git a/po_app/order_tracking.py b/po_app/order_tracking.py index f5b94a0..963fd97 100644 --- a/po_app/order_tracking.py +++ b/po_app/order_tracking.py @@ -1,8 +1,8 @@ """Internal order follow-up; approved documents stay frozen.""" from datetime import date -from flask import abort, flash, redirect, request, url_for -from sqlalchemy import and_, or_ +from flask import abort, flash, jsonify, redirect, render_template, request, url_for +from sqlalchemy import and_, case, or_ from models import DELIVERY_STATUS, INVOICE_STATUS, PAYMENT_STATUS, Order from order_logic import add_log, get_order, normalize_date @@ -10,6 +10,7 @@ from security import current_user, db, login_required TRACKING_FILTERS = { + 'open': '待跟进订单', 'prepayment': '需预付款', 'invoice_due': '已收票待付款', 'unpaid': '未付款', @@ -22,6 +23,7 @@ TRACKING_FILTERS = { 'completed': '已付款且已交货', 'unrecorded': '跟进待登记', 'recheck': '变更后待核对', + 'closed': '付款、交货、收票均完成', } @@ -30,7 +32,12 @@ def tracking_conditions(): current_payment = or_(Order.payment_revision < 0, Order.payment_revision == Order.revision) payment_due = and_(current_payment, Order.payment_status.in_(('unpaid', 'partial'))) delivery_open = and_(current_fulfillment, Order.delivery_status.in_(('pending', 'partial'))) + completed = and_(current_payment, current_fulfillment, Order.payment_status == 'paid', + Order.delivery_status == 'delivered') + closed = and_(completed, Order.invoice_status == 'received') return { + 'open': ~closed, + 'closed': closed, 'prepayment': and_(current_fulfillment, Order.needs_prepayment.is_(True), payment_due), 'invoice_due': and_(current_fulfillment, Order.invoice_status == 'received', payment_due), 'unpaid': and_(current_payment, Order.payment_status == 'unpaid'), @@ -40,8 +47,7 @@ def tracking_conditions(): 'partial_delivery': and_(current_fulfillment, Order.delivery_status == 'partial'), 'delivered': and_(current_fulfillment, Order.delivery_status == 'delivered'), 'paid_undelivered': and_(current_payment, Order.payment_status == 'paid', delivery_open), - 'completed': and_(current_payment, current_fulfillment, Order.payment_status == 'paid', - Order.delivery_status == 'delivered'), + 'completed': completed, 'unrecorded': or_(Order.invoice_status == 'unknown', Order.delivery_status == 'unknown', Order.payment_status == 'unknown'), 'recheck': or_(~current_fulfillment, ~current_payment), @@ -57,6 +63,48 @@ def filter_tracking(query, key): return query.filter(Order.status == 'approved', conditions[key]) +def tracking_priority(): + conditions = tracking_conditions() + return case(*[(conditions[key], rank) for rank, key in enumerate( + ('recheck', 'prepayment', 'invoice_due', 'paid_undelivered', 'unrecorded'))], else_=5) + + +def tracking_tasks(order): + if order.status != 'approved': + return [] + if order.fulfillment_stale or order.payment_stale: + return [('recheck', '变更后待核对')] + tasks = [] + if order.payment_status in ('unpaid', 'partial'): + if order.needs_prepayment: + tasks.append(('prepayment', '安排预付款' if order.payment_status == 'unpaid' else '核对预付款及剩余付款安排')) + elif order.invoice_status == 'received': + tasks.append(('invoice_due', '已收票,跟进付款' if order.payment_status == 'unpaid' else '跟进剩余付款')) + else: + tasks.append(('payment', '跟进付款安排')) + if order.delivery_status in ('pending', 'partial'): + tasks.append(('delivery', '跟进剩余交货' if order.delivery_status == 'partial' else '跟进交货')) + if order.invoice_status == 'pending': + tasks.append(('invoice', '跟进收票')) + if 'unknown' in (order.payment_status, order.delivery_status, order.invoice_status): + tasks.append(('unrecorded', '补充跟进登记')) + return tasks or [('closed', '付款、交货、收票均完成')] + + +def latest_tracking(order): + latest = {} + for log in reversed(order.logs): + if log.action in ('更新采购跟进', '登记付款状态') and log.action not in latest: + latest[log.action] = log + return latest + + +def tracking_response(oid): + if request.headers.get('X-Tracking-Modal') == '1': + return jsonify(ok=True) + return redirect(url_for('order_view', oid=oid) + '#order-tracking') + + def can_follow(order, user): return (order.status == 'approved' and user.role in ('maker', 'admin') and (user.role == 'admin' or order.created_by == user.id)) @@ -84,7 +132,21 @@ def register_tracking_routes(app): @app.context_processor def tracking_context(): return {'INVOICE_STATUS': INVOICE_STATUS, 'DELIVERY_STATUS': DELIVERY_STATUS, - 'PAYMENT_STATUS': PAYMENT_STATUS, 'TRACKING_FILTERS': TRACKING_FILTERS} + 'PAYMENT_STATUS': PAYMENT_STATUS, 'TRACKING_FILTERS': TRACKING_FILTERS, + 'can_follow': can_follow, 'can_record_payment': can_record_payment, + 'tracking_tasks': tracking_tasks, 'latest_tracking': latest_tracking} + + @app.get('/orders//tracking/') + @login_required + def tracking_editor(oid, kind): + if kind not in ('follow-up', 'payment'): + abort(404) + order, user = get_order(oid), current_user() + allowed = can_follow(order, user) if kind == 'follow-up' else can_record_payment(order, user) + if not allowed: + abort(403) + template = 'tracking_editor_fragment.html' if request.args.get('fragment') == '1' else 'tracking_editor.html' + return render_template(template, o=order, user=user, kind=kind) @app.post('/orders//follow-up') @login_required @@ -124,7 +186,7 @@ def register_tracking_routes(app): add_log(order, '更新采购跟进', user, ';'.join(changes + (['更正原因:' + reason] if reason else []))) db.commit() flash('采购跟进已保存', 'ok') - return redirect(url_for('order_view', oid=oid) + '#order-tracking') + return tracking_response(oid) @app.post('/orders//payment') @login_required @@ -160,4 +222,4 @@ def register_tracking_routes(app): add_log(order, '登记付款状态', user, ';'.join(changes + (['更正原因:' + reason] if reason else []))) db.commit() flash('付款状态已保存', 'ok') - return redirect(url_for('order_view', oid=oid) + '#order-tracking') + return tracking_response(oid) diff --git a/po_app/static/style.css b/po_app/static/style.css index 4965933..b823585 100644 --- a/po_app/static/style.css +++ b/po_app/static/style.css @@ -130,6 +130,58 @@ table.grid tr.row-off .btn { opacity: .8; } .badge.track-partial { background: #e6f0ff; color: #14509b; } .badge.track-received, .badge.track-delivered, .badge.track-paid { background: #e8f6ee; color: #12703f; } .mobile-only { display: none; } +/* Payment / delivery workspace: independent states, task-first rows. */ +.tracking-dashboard .card-head { align-items: flex-start; } +.tracking-dashboard .card-head > a { flex-shrink: 0; white-space: nowrap; } +.tracking-dashboard .card-head p { margin: 6px 0 0; } +.tracking-focus .stat { position: relative; border-top: 3px solid #d99a30; padding-right: 28px; } +.tracking-focus .focus-paid_undelivered { border-top-color: #1668dc; } +.tracking-focus .focus-recheck { border-top-color: #a65d26; } +.tracking-focus .stat.selected { background: #eef5ff; border-color: #1668dc; } +.tracking-focus .stat .k { color: #42526a; } +.stat-arrow { position: absolute; right: 12px; top: 24px; color: #697a91; } +.tracking-board-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; } +.tracking-board-head h3 { margin: 0; } +.tracking-filter { margin: 0 0 10px; flex-wrap: nowrap; } +.tracking-filter select { width: auto; max-width: 240px; } +.tracking-filter label { white-space: nowrap; } +.status-steps { display: flex; list-style: none; margin: 0; padding: 0; gap: 3px; } +.status-steps li { flex: 1; white-space: nowrap; text-align: center; padding: 6px 3px; border: 1px solid #e2e6ea; border-radius: 4px; font-size: 11px; color: #7b8490; background: #f7f8fa; } +.status-steps .current { font-weight: 600; border-color: currentColor; } +.status-steps .track-unpaid, .status-steps .track-pending { background: #fff4e0; color: #935100; } +.status-steps .track-partial { background: #e6f0ff; color: #14509b; } +.status-steps .track-paid, .status-steps .track-delivered { background: #e8f6ee; color: #12703f; } +.order-progress { min-width: 154px; } +.order-progress .badge, .order-progress .tracking-warning { display: inline-block; margin-bottom: 5px; } +.order-progress.is-stale .current { color: #777; background: #eef0f2; } +.tracking-tasks { display: flex; flex-wrap: wrap; gap: 4px; margin: 5px 0 9px; } +.task-tag { color: #586577; font-size: 12px; line-height: 1.6; } +.task-tag + .task-tag::before { content: '·'; margin-right: 4px; } +.task-prepayment, .task-invoice_due, .task-recheck { color: #935100; font-weight: 600; } +.task-closed { color: #12703f; } +.tracking-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; } +.tracking-actions .btn { white-space: nowrap; } +.tracking-detail-link { font-size: 12px; display: inline-block; padding: 4px 0; } +.supplier-name { margin: 5px 0; overflow-wrap: anywhere; } +.order-identity { max-width: 240px; } +.tracking-table td, .orders-table td { padding-top: 12px !important; padding-bottom: 12px !important; } +.tracking-completed-link { border-top: 1px solid #e2e6ea; margin-top: 12px; padding-top: 12px; font-size: 12px; } +.completed-toggle { display: flex; align-items: center; gap: 4px; margin: 0; color: #555; } +.tracking-panels { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 12px 0 20px; } +.tracking-panel { padding: 16px; border: 1px solid #dfe5ec; border-radius: 6px; min-width: 0; } +.tracking-panel h3 { margin: 0 0 12px; } +.tracking-panel .status-steps li { font-size: 13px; padding: 9px 3px; } +.follow-note { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.7; } +.update-meta { color: #637187; font-size: 12px; margin-bottom: 0; } +#tracking-dialog { width: 540px; max-width: calc(100% - 24px); max-height: calc(100% - 32px); padding: 0; border: 0; border-radius: 10px; box-shadow: 0 16px 60px #0004; } +#tracking-dialog::backdrop { background: #15253b80; } +#tracking-dialog .modal-head { position: sticky; top: 0; background: white; z-index: 1; } +#tracking-dialog h3 { margin: 14px 0; font-size: 15px; } +#tracking-reload { margin-bottom: 12px; } +.editor-order { line-height: 1.8; margin: 0; overflow-wrap: anywhere; } +.tracking-editor-page { max-width: 600px; } +.btn:disabled { opacity: .55; cursor: wait; } +[hidden] { display: none !important; } @media (max-width: 800px) { .topbar { height: auto; min-height: 52px; flex-wrap: wrap; gap: 12px; padding: 12px 16px; } .topbar .brand { width: 100%; } @@ -143,6 +195,24 @@ table.grid tr.row-off .btn { opacity: .8; } .stat-row { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .tracking-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .tracking-forms { grid-template-columns: minmax(0, 1fr); } + .tracking-panels { grid-template-columns: minmax(0, 1fr); } + table.grid.order-cards { display: block; min-width: 0; } + table.order-cards thead { display: none; } + table.order-cards tbody { display: block; } + table.order-cards tr { display: block; border: 1px solid #dfe5ec; border-radius: 8px; padding: 10px 12px; margin-bottom: 12px; background: #fff; } + table.order-cards td { display: flex; gap: 10px; align-items: center; justify-content: space-between; border: 0; padding: 7px 0 !important; text-align: left; min-width: 0; max-width: none; } + table.order-cards td::before { content: attr(data-label); font-size: 12px; color: #637187; flex: 0 0 55px; } + table.order-cards .order-identity { display: block; padding-bottom: 10px !important; border-bottom: 1px solid #edf0f4; margin-bottom: 5px; } + table.order-cards .order-identity::before, table.order-cards .order-next::before { display: none; } + table.order-cards .order-next { display: block; border-top: 1px solid #edf0f4; margin-top: 5px; padding-top: 10px !important; } + table.order-cards .order-progress { flex: 1; min-width: 0; max-width: 320px; } + table.order-cards .tracking-tasks { flex: 1; } + table.order-cards td[data-label="收票 / 待办"] { flex-wrap: wrap; } + table.order-cards .empty-row td { display: block; } + .tracking-actions .btn { display: inline-flex; align-items: center; } + .tracking-filter { width: 100%; } + .tracking-filter select { flex: 1; max-width: none; min-width: 0; } + .tracking-panel { padding: 12px; } table.grid { min-width: 680px; } table.approval-flow { min-width: 0; } .mobile-only { display: block; } diff --git a/po_app/static/tracking.js b/po_app/static/tracking.js new file mode 100644 index 0000000..8d66764 --- /dev/null +++ b/po_app/static/tracking.js @@ -0,0 +1,85 @@ +/* Reuse server forms and existing permissions; keep edits on the current list. */ +(function () { + 'use strict'; + var dialog = document.getElementById('tracking-dialog'); + if (!dialog || typeof dialog.showModal !== 'function' || !window.fetch) return; + var content = document.getElementById('tracking-dialog-content'); + var errorBox = document.getElementById('tracking-dialog-error'); + var reload = document.getElementById('tracking-reload'); + var close = dialog.querySelector('[data-tracking-close]'); + var editorUrl = '', serial = 0, saving = false, opener; + + function showError(message) { + errorBox.textContent = message; + errorBox.hidden = false; + reload.hidden = false; + } + function errorText(html) { + var doc = new DOMParser().parseFromString(html, 'text/html'); + var message = doc.querySelector('.wrap .card p, body > p'); + return message ? message.textContent : '暂时无法完成操作,请重新读取当前状态后再试。'; + } + async function loadEditor() { + var current = ++serial; + errorBox.hidden = reload.hidden = true; + content.textContent = '正在读取最新状态…'; + try { + var response = await fetch(editorUrl + '?fragment=1', {cache: 'no-store', credentials: 'same-origin'}); + var html = await response.text(); + if (current !== serial || !dialog.open) return; + if (response.redirected) { window.location.assign(response.url); return; } + if (!response.ok) { content.textContent = ''; showError(errorText(html)); return; } + content.innerHTML = html; + var field = content.querySelector('select, input:not([type=hidden])'); + if (field) field.focus(); + } catch (error) { + if (current === serial && dialog.open) { content.textContent = ''; showError('读取失败,请检查连接后重试。'); } + } + } + document.addEventListener('click', function (event) { + var link = event.target.closest('[data-quick-tracking]'); + if (!link || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + opener = link; + editorUrl = link.href; + dialog.showModal(); + loadEditor(); + }); + close.addEventListener('click', function () { if (!saving) dialog.close(); }); + dialog.addEventListener('cancel', function (event) { if (saving) event.preventDefault(); }); + dialog.addEventListener('close', function () { + ++serial; + content.textContent = ''; + if (opener) opener.focus(); + }); + reload.addEventListener('click', loadEditor); + dialog.addEventListener('submit', async function (event) { + var form = event.target; + event.preventDefault(); + if (saving) return; + var data = new FormData(form); + var submit = form.querySelector('[type=submit]'); + saving = true; + close.disabled = reload.disabled = submit.disabled = true; + errorBox.hidden = reload.hidden = true; + var retry = true; + try { + var response = await fetch(form.action, {method: 'POST', body: data, + credentials: 'same-origin', headers: {'X-Tracking-Modal': '1'}}); + if (response.redirected) { window.location.assign(response.url); return; } + if (response.ok && (response.headers.get('Content-Type') || '').indexOf('application/json') >= 0) { + var result = await response.json(); + if (result.ok) { window.location.reload(); return; } + } + retry = response.status === 400; + showError(errorText(await response.text())); + } catch (error) { + retry = false; + showError('连接中断,无法确认是否保存成功。请重新读取当前状态,核对后再操作。'); + } finally { + saving = false; + close.disabled = reload.disabled = false; + submit.disabled = !retry; + } + }); +})(); diff --git a/po_app/templates/base.html b/po_app/templates/base.html index a2c461e..70a6376 100644 --- a/po_app/templates/base.html +++ b/po_app/templates/base.html @@ -36,5 +36,16 @@ {% block body %}{% endblock %}
{{ COMPANY_NAME }} · 采购订单管理系统 v4.0
+{% if session.get('user_id') %} + + + + + +{% endif %} diff --git a/po_app/templates/index.html b/po_app/templates/index.html index 9169f7d..8da456e 100644 --- a/po_app/templates/index.html +++ b/po_app/templates/index.html @@ -8,29 +8,38 @@
{{ '%.2f'|format(month_total) }}
审批通过订单总额(元)
-
-

付款与交货概况

查看全部
-

统计当前可见、审批通过的订单。各类数量可能重叠;部分付款和部分交货按订单状态统计。

-
- {% for key in ['prepayment','invoice_due','unpaid','paid','undelivered','partial_delivery','paid_undelivered','completed'] %} -
{{ tracking_counts[key] }}
{{ TRACKING_FILTERS[key] }}
+
+ {% from 'tracking_macros.html' import progress, invoice, tasks, actions with context %} +

付款与交货待办

先核对变更,再跟进付款与交货。同一订单可能出现在多个提醒中。

全部订单
+
+ {% for key in ['prepayment','invoice_due','paid_undelivered','recheck'] %} +
{{ tracking_counts[key] }}
{{ TRACKING_FILTERS[key] }}
{% endfor %}
-
{% for key in ['partial_payment','delivered','unrecorded','recheck'] %}{{ TRACKING_FILTERS[key] }}({{ tracking_counts[key] }}){% endfor %}
-

最近审批通过的订单

- - - {% for o in tracking_rows %} - - - - - - - +
+

{{ TRACKING_FILTERS[tracking_key] }} 共 {{ tracking_total }} 单

同类优先显示较早订单;付款、交货、收票均完成后默认收起。

+
+ + + + +
+
订单 / 供应商订单金额预付款收票付款交货操作
{{ o.order_no }}
{{ o.supplier_name }}
{% if o.fulfillment_stale or o.payment_stale %}
变更后待核对
{% endif %}
{{ '%.2f'|format(o.total_amount) }}{{ '需要' if o.needs_prepayment else '—' }}{{ o.invoice_status_text }}{{ o.payment_status_text }}{{ o.delivery_status_text }}查看跟进
+ + {% for o in tracking_rows %} + + + + + + + - {% else %}{% endfor %} + {% else %}{% endfor %}
订单 / 供应商金额付款进度交货进度收票待办 / 操作
{{ o.order_no }}
{{ o.supplier_name }}
{% if o.needs_prepayment %}需提前付款{{ '(待核对)' if o.fulfillment_stale else '' }}{% endif %}
¥{{ '%.2f'|format(o.total_amount) }}{{ progress(o, 'payment') }}{{ progress(o, 'delivery') }}{{ invoice(o) }}{{ tasks(o) }}{{ actions(o, user) }}
暂无审批通过的订单
当前分类暂无订单。可切换分类查看其他订单。
+ {% if tracking_total > tracking_rows|length %}

查看全部 {{ tracking_total }} 单 →

{% endif %} + {% if tracking_key != 'open' %}

返回全部待跟进({{ tracking_counts['open'] }})

{% endif %} +
{% if todo %} diff --git a/po_app/templates/order_tracking.html b/po_app/templates/order_tracking.html index 384c892..5b58f9e 100644 --- a/po_app/templates/order_tracking.html +++ b/po_app/templates/order_tracking.html @@ -1,44 +1,30 @@ +{% from 'tracking_macros.html' import progress, invoice, tasks, form with context %} +{% set recent = latest_tracking(o) %}
-

付款与交货跟进

-
-
{{ '需提前付款' if o.needs_prepayment else '正常付款' }}
-
{{ o.invoice_status_text }}
-
{{ o.payment_status_text }}{% if o.payment_date %}
{{ o.payment_date }}
{% endif %}
-
{{ o.delivery_status_text }}
-
- {% if o.fulfillment_stale or o.payment_stale %} -

订单内容已变更,以下记录来自变更前:{{ '采购跟进' if o.fulfillment_stale else '' }}{{ '、' if o.fulfillment_stale and o.payment_stale else '' }}{{ '付款情况' if o.payment_stale else '' }}。请对应经办人核对后保存确认。

- {% endif %} +

付款与交货跟进

{{ '需提前付款' if o.needs_prepayment else '正常付款' }}{{ '(待核对)' if o.fulfillment_stale else '' }}
+ {{ tasks(o) }} + {% if o.fulfillment_stale or o.payment_stale %}

订单内容已变更,下面保留变更前记录,请对应经办人核对后保存确认。

{% endif %} {% if o.status != 'approved' %}

审批通过后可登记付款、收票和交货情况。已有跟进记录保留供查询。

{% endif %} - {% if o.fulfillment_note %}

采购跟进备注:{{ o.fulfillment_note }}

{% endif %} - {% if o.payment_note %}

付款备注:{{ o.payment_note }}

{% endif %} +
+
+

付款情况

+ {{ progress(o, 'payment') }} + {% if o.payment_date %}

实际付款日期:{{ o.payment_date }}

{% endif %} + {% if o.payment_note %}{% endif %} + {% set log = recent.get('登记付款状态') %} +

{% if log %}最近登记:{{ log.created_at.strftime('%Y-%m-%d %H:%M') }} · {{ log.user_name }}{% else %}尚无付款登记记录{% endif %}

+
+
+

交货与收票

+ {{ progress(o, 'delivery') }} +

收票:{{ invoice(o) }}

+ {% if o.fulfillment_note %}{% endif %} + {% set log = recent.get('更新采购跟进') %} +

{% if log %}最近跟进:{{ log.created_at.strftime('%Y-%m-%d %H:%M') }} · {{ log.user_name }}{% else %}尚无采购跟进记录{% endif %}

+
+
- {% if may_follow %} -
-

制单员跟进

- - -
-
-
-
-
-
-
- -
- {% endif %} - {% if may_pay %} -
-

审批员登记付款

- - -
-
-
-
- -
- {% endif %} + {% if may_follow %}{{ form(o, 'follow-up') }}{% endif %} + {% if may_pay %}{{ form(o, 'payment') }}{% endif %}
diff --git a/po_app/templates/order_view.html b/po_app/templates/order_view.html index 001dd6a..81fe3d4 100644 --- a/po_app/templates/order_view.html +++ b/po_app/templates/order_view.html @@ -19,6 +19,8 @@ 返回列表
+{% if o.status == 'approved' %}{% include 'order_tracking.html' %}{% endif %} +

单据信息

单据信息和物料明细可左右滑动查看。

@@ -81,7 +83,7 @@ {% if o.note %}

补充说明:{{ o.note }}

{% endif %}
-{% include 'order_tracking.html' %} +{% if o.status != 'approved' %}{% include 'order_tracking.html' %}{% endif %}

审批流程

diff --git a/po_app/templates/orders.html b/po_app/templates/orders.html index e3c7c79..6fb62e3 100644 --- a/po_app/templates/orders.html +++ b/po_app/templates/orders.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block body %} +{% from 'tracking_macros.html' import progress, invoice, tasks, actions with context %}

订单列表

@@ -23,41 +24,26 @@ {% for key, label in TRACKING_FILTERS.items() %}{% endfor %} + 导出 Excel 台账 {% if user.role in ['maker','admin'] %}新建订单{% endif %}
- - - - + {% if not args.get('tracking') and args.get('show_completed') != '1' %}

已收起付款、交货、收票均完成的订单。勾选“显示全部完成订单”可查看。

{% endif %} +
单号类型供应商采购日期金额审批状态付款与交货制单审批操作
+ + {% for o in rows %} + + + + + + + + - {% for o in rows %} - - - - - - - - - - - - - {% else %} - - {% endfor %} + {% else %}{% endfor %}
订单 / 供应商审批状态金额付款进度交货进度收票 / 待办操作
{{ o.order_no }}
{{ o.supplier_name }}
{{ o.doc_type_text }} · {{ o.order_date }}
制单 {{ o.created_by_name }} · 审批 {{ o.approved_by or '—' }}
{% if o.needs_prepayment %}需提前付款{{ '(待核对)' if o.fulfillment_stale else '' }}{% endif %}
{{ o.status_text }}¥{{ '%.2f'|format(o.total_amount) }}{{ progress(o, 'payment') }}{{ progress(o, 'delivery') }}{{ invoice(o) }}{{ tasks(o) }}{{ actions(o, user) }}下载 / 预览 PDF
{{ o.order_no }}{{ o.doc_type_text }}{{ o.supplier_name }}{{ o.order_date }}{{ '%.2f'|format(o.total_amount) }}{{ o.status_text }} - {% if o.needs_prepayment %}需提前付款{% endif %} - 收票:{{ o.invoice_status_text }} - 付款:{{ o.payment_status_text }} - 交货:{{ o.delivery_status_text }} - {% if o.fulfillment_stale or o.payment_stale %}
变更后待核对
{% endif %} -
{{ o.created_by_name }}{{ o.approved_by or '-' }} - 查看 - PDF -
没有符合条件的订单
没有符合条件的订单
{% endblock %} diff --git a/po_app/templates/tracking_editor.html b/po_app/templates/tracking_editor.html new file mode 100644 index 0000000..0e3b938 --- /dev/null +++ b/po_app/templates/tracking_editor.html @@ -0,0 +1,6 @@ +{% extends 'base.html' %} +{% block body %} +

{{ '登记付款' if kind == 'payment' else '登记收票 / 交货' }}

+
{% include 'tracking_editor_fragment.html' %}
+返回订单 +{% endblock %} diff --git a/po_app/templates/tracking_editor_fragment.html b/po_app/templates/tracking_editor_fragment.html new file mode 100644 index 0000000..10212c7 --- /dev/null +++ b/po_app/templates/tracking_editor_fragment.html @@ -0,0 +1,5 @@ +{% from 'tracking_macros.html' import form with context %} +
+

{{ o.order_no }}
{{ o.supplier_name }} · ¥{{ '%.2f'|format(o.total_amount) }}

+ {{ form(o, kind) }} +
diff --git a/po_app/templates/tracking_macros.html b/po_app/templates/tracking_macros.html new file mode 100644 index 0000000..98fc200 --- /dev/null +++ b/po_app/templates/tracking_macros.html @@ -0,0 +1,53 @@ +{% macro progress(o, kind) -%} + {% set payment = kind == 'payment' %} + {% set state = o.payment_status if payment else o.delivery_status %} + {% set stale = o.payment_stale if payment else o.fulfillment_stale %} + {% set steps = [('unpaid', '未付款'), ('partial', '部分付款'), ('paid', '全部付款')] if payment else [('pending', '未交货'), ('partial', '部分交货'), ('delivered', '全部交货')] %} +
+ {% if stale %}变更前记录 · 待核对{% elif state == 'unknown' %}未登记{% endif %} +
    + {% for value, label in steps %}
  1. {{ label }}
  2. {% endfor %} +
+
+{%- endmacro %} + +{% macro invoice(o) -%} + {{ o.invoice_status_text }}{{ '(待核对)' if o.fulfillment_stale else '' }} +{%- endmacro %} + +{% macro tasks(o) -%} +
{% for key, text in tracking_tasks(o) %}{{ text }}{% endfor %}
+{%- endmacro %} + +{% macro actions(o, user) -%} +
+ {% if can_follow(o, user) %}登记收票 / 交货{% endif %} + {% if can_record_payment(o, user) %}登记付款{% endif %} + 详情与记录 +
+{%- endmacro %} + +{% macro form(o, kind) -%} +{% set payment = kind == 'payment' %} +
+

{{ '审批员登记付款' if payment else '制单员跟进' }}

+ {% if (payment and o.payment_stale) or (not payment and o.fulfillment_stale) %}

订单内容已变更,请核对以下变更前记录。保存即确认本次订单的跟进情况。

{% endif %} + + + {% if payment %} +
+
+
+
+ {% else %} +
+
+
+
+
+
+
+ {% endif %} + +
+{%- endmacro %} diff --git a/tests/test_tracking.py b/tests/test_tracking.py index b36a3cb..52e2421 100644 --- a/tests/test_tracking.py +++ b/tests/test_tracking.py @@ -56,7 +56,7 @@ def test_follow_payment_dashboard_and_archived_pdf(app, clients): assert not contains(clients['approver'], 'paid_undelivered', number) assert clients['maker'].get('/orders/%s/pdf' % oid).data == pdf dashboard = clients['admin'].get('/').get_data(as_text=True) - assert re.search(r'data-tracking="completed"[^>]*>
1
', dashboard) + assert re.search(r'data-tracking="closed"[^>]*>查看已全部完成(1)', dashboard) other_dashboard = clients['other'].get('/').get_data(as_text=True) assert number not in other_dashboard with app.Session() as db: @@ -160,3 +160,63 @@ def test_admin_can_follow_and_pay_and_invalid_filter_is_rejected(app, clients): assert follow(app, clients, oid, actor='admin').status_code == 302 assert pay(app, clients, oid, actor='admin').status_code == 302 assert clients['admin'].get('/orders?tracking=invalid').status_code == 400 + + +def test_action_queue_keeps_old_open_orders_and_unreceived_invoices(app, clients): + with app.Session() as db: + maker = db.query(app.User).filter_by(username='maker').one() + for i in range(16): + db.add(app.Order(order_no='QUEUE-%02d' % i, created_by=maker.id, + supplier_name='待办测试', status='approved', revision=1, + payment_status='paid', delivery_status='delivered', + invoice_status='received', fulfillment_revision=1, payment_revision=1)) + db.flush() + rows = db.query(app.Order).order_by(app.Order.id).all() + rows[0].payment_status = 'unpaid' + rows[0].needs_prepayment = True + rows[1].invoice_status = 'pending' + rows[2].payment_revision = 0 + rows[3].payment_status = 'unknown' + db.commit() + html = clients['maker'].get('/').get_data(as_text=True) + board = html.split('')[1].split('
')[0] + assert all('QUEUE-%02d' % i in board for i in range(4)) + assert 'QUEUE-15' not in board + assert board.index('QUEUE-02') < board.index('QUEUE-00') + assert '跟进收票' in board and '变更前记录 · 待核对' in board + assert 'QUEUE-15' not in clients['maker'].get('/orders').get_data(as_text=True) + assert 'QUEUE-15' in clients['maker'].get('/orders?show_completed=1').get_data(as_text=True) + assert 'QUEUE-15' in clients['maker'].get('/orders?tracking=closed').get_data(as_text=True) + assert 'QUEUE-00' not in clients['other'].get('/').get_data(as_text=True) + assert clients['admin'].get('/?tracking=invalid').status_code == 400 + + +@pytest.mark.parametrize('actor,kind,status', [ + ('maker', 'payment', 403), ('approver', 'follow-up', 403), + ('other', 'follow-up', 404), ('maker', 'follow-up', 200), + ('approver', 'payment', 200), ('admin', 'follow-up', 200), +]) +def test_quick_editor_checks_permissions(app, clients, actor, kind, status): + oid = approved(app, clients) + for suffix in ('', '?fragment=1'): + assert clients[actor].get('/orders/%s/tracking/%s%s' % (oid, kind, suffix)).status_code == status + + +def test_quick_payment_preserves_concurrency_archive_and_latest_actor(app, clients): + oid = approved(app, clients) + pdf = clients['maker'].get('/orders/%s/pdf' % oid).data + version = order(app, oid)['version'] + values = {'row_version': str(version), 'payment_status': 'partial', + 'payment_date': date.today().isoformat(), 'payment_note': '已付首款,尾款待交齐'} + response = post(clients['approver'], '/orders/%s/payment' % oid, values, + headers={'X-Tracking-Modal': '1'}) + assert response.status_code == 200 and response.json == {'ok': True} + stale = post(clients['approver'], '/orders/%s/payment' % oid, values, + headers={'X-Tracking-Modal': '1'}) + assert stale.status_code == 409 + assert follow(app, clients, oid).status_code == 302 + html = clients['maker'].get('/orders/%s/view' % oid).get_data(as_text=True) + assert '最近登记:' in html and '最近跟进:' in html + assert '已付首款,尾款待交齐' in html and '已收第一批货' in html + assert html.index('付款与交货跟进') < html.index('单据信息') + assert clients['maker'].get('/orders/%s/pdf' % oid).data == pdf diff --git a/tools/browser_smoke.py b/tools/browser_smoke.py index 9bed37a..1369323 100644 --- a/tools/browser_smoke.py +++ b/tools/browser_smoke.py @@ -203,13 +203,14 @@ def run(executable=None): approver.screenshot(path=str(RUN / (kind+'-mobile-tracking.png'))) assert hashlib.sha256(approver.request.get(view_url.replace('/view','/pdf')).body()).hexdigest() == archives[-1][1] admin.goto(base + '/') - for key in ('prepayment', 'invoice_due', 'partial_delivery', 'paid_undelivered'): + for key in ('prepayment', 'invoice_due', 'paid_undelivered'): assert admin.locator('[data-tracking="'+key+'"] .v').inner_text() == '1', key + assert admin.locator('#board-filter option[value="partial_delivery"]').inner_text() == '部分交货(1)' admin.screenshot(path=str(RUN / 'tracking-dashboard.png'), full_page=True) admin.locator('[data-tracking="paid_undelivered"]').click() admin.wait_for_load_state('networkidle') - assert admin.locator('table.grid tr').count() == 2 - assert 'WO' in admin.locator('table.grid tr').nth(1).inner_text() + assert admin.locator('.tracking-table tbody tr').count() == 1 + assert 'WO' in admin.locator('.tracking-table tbody tr').first.inner_text() admin.screenshot(path=str(RUN / 'tracking-filter.png'), full_page=True) for width in (320, 390, 768): approver.set_viewport_size({'width':width, 'height':844}) diff --git a/tools/tracking_browser_smoke.py b/tools/tracking_browser_smoke.py new file mode 100644 index 0000000..a02a7fb --- /dev/null +++ b/tools/tracking_browser_smoke.py @@ -0,0 +1,140 @@ +"""Quick tracking dialogs and responsive cards, using isolated sample orders.""" +import json +import os +import sys +import tempfile +import threading +from datetime import date, datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RUN = Path(tempfile.mkdtemp(prefix='tracking-ui-', dir=ROOT/'tools/out')) +os.environ['PO_DATA_DIR'] = str(RUN) +sys.path.insert(0, str(ROOT/'po_app')) +from app import app, Session +from models import ApprovalLog, Order, User +from playwright.sync_api import sync_playwright, expect +from waitress.server import create_server +from werkzeug.security import generate_password_hash + +PASSWORD = 'Isolated-Tracking-2026' +with Session() as db: + users = {} + for name, role in [('采购员', 'maker'), ('审批员', 'approver'), ('管理员', 'admin')]: + user = User(username=role, name=name, role=role, active=True, + must_change_password=False, session_version=1, + password_hash=generate_password_hash(PASSWORD)) + db.add(user) + db.flush() + users[role] = user.id + samples = [ + ('博远精密(测试)', 'unpaid', 'pending', 'pending', True), + ('华星电子(测试)', 'partial', 'delivered', 'received', False), + ('恒达机电(测试)', 'paid', 'partial', 'received', False), + ('新力材料(测试)', 'paid', 'delivered', 'received', False), + ('中科配件(测试)', 'unknown', 'unknown', 'unknown', False), + ('宏光模具(测试)', 'paid', 'delivered', 'pending', False), + ] + for i, (supplier, payment, delivery, invoice, prepay) in enumerate(samples, 1): + order = Order(order_no='PO20260917-%03d' % i, supplier_name=supplier, + created_by=users['maker'], created_by_name='采购员', + status='approved', order_date='2026-09-17', total_amount=1200*i, + revision=1, approved_by='审批员', approved_at=datetime.now(), + payment_status=payment, delivery_status=delivery, invoice_status=invoice, + needs_prepayment=prepay, fulfillment_revision=1, payment_revision=1) + if i == 4: + order.fulfillment_revision = 0 + db.add(order) + db.commit() + +server = create_server(app, host='127.0.0.1', port=0) +threading.Thread(target=server.run, daemon=True).start() +base = 'http://127.0.0.1:%s' % server.effective_port +errors = [] +try: + with sync_playwright() as p: + browser = p.chromium.launch(channel='msedge', headless=True) + def login(role): + page = browser.new_page(viewport={'width': 1440, 'height': 1100}) + page.on('pageerror', lambda error: errors.append(str(error))) + page.goto(base+'/login') + page.locator('[name=username]').fill(role) + page.locator('[name=password]').fill(PASSWORD) + page.get_by_role('button', name='登 录').click() + page.wait_for_url(base+'/') + return page + maker, approver, admin = [login(role) for role in ('maker', 'approver', 'admin')] + admin.screenshot(path=str(RUN/'desktop.png'), full_page=True) + assert maker.locator('[data-quick-tracking][href$="/payment"]').count() == 0 + assert approver.locator('[data-quick-tracking][href$="/follow-up"]').count() == 0 + assert admin.locator('.tracking-table tbody tr').first.get_attribute('data-order-id') == '4' + for width in (320, 390, 768, 1024): + approver.set_viewport_size({'width': width, 'height': 844}) + for path in ('/', '/orders', '/orders/2/view'): + approver.goto(base+path) + assert approver.evaluate('document.documentElement.scrollWidth <= innerWidth'), (width, path) + if width <= 768 and path != '/orders/2/view': + assert approver.locator('table.order-cards').evaluate('(el) => el.scrollWidth <= el.clientWidth + 1'), (width, path) + approver.set_viewport_size({'width': 390, 'height': 844}) + approver.goto(base+'/') + approver.screenshot(path=str(RUN/'mobile.png'), full_page=True) + # Inline validation retains user input; a stale form cannot overwrite a newer record. + approver.locator('tr[data-order-id="2"] [data-quick-tracking]').click() + dialog = approver.locator('#tracking-dialog') + expect(dialog.locator('[name=payment_status]')).to_have_value('partial') + dialog.locator('[name=payment_status]').select_option('unpaid') + dialog.get_by_role('button', name='保存付款状态').click() + expect(dialog.locator('#tracking-dialog-error')).to_contain_text('更正原因') + expect(dialog.locator('[name=payment_status]')).to_have_value('unpaid') + dialog.locator('[name=payment_status]').select_option('paid') + dialog.locator('[name=payment_date]').fill(date.today().isoformat()) + dialog.locator('[name=payment_note]').fill('款项已付清') + # Another authorized user changes the same order while this dialog is open. + admin.locator('tr[data-order-id="2"] a[href$="/payment"]').click() + ad = admin.locator('#tracking-dialog') + ad.locator('[name=payment_date]').fill(date.today().isoformat()) + ad.locator('[name=payment_note]').fill('另一位经办人已核对首款') + ad.get_by_role('button', name='保存付款状态').click() + expect(ad).not_to_be_visible() + dialog.get_by_role('button', name='保存付款状态').click() + expect(dialog.locator('#tracking-dialog-error')).to_contain_text('已经更新') + expect(dialog.get_by_role('button', name='保存付款状态')).to_be_disabled() + dialog.locator('#tracking-reload').click() + expect(dialog.locator('[name=payment_note]')).to_have_value('另一位经办人已核对首款') + dialog.locator('[name=payment_status]').select_option('paid') + dialog.locator('[name=payment_note]').fill('款项已付清') + approver.screenshot(path=str(RUN/'payment-dialog-mobile.png')) + dialog.get_by_role('button', name='保存付款状态').click() + expect(dialog).not_to_be_visible() + expect(approver.locator('.tracking-table tr[data-order-id="2"]')).to_have_count(0) + assert approver.url == base+'/' + # Maker updates delivery and invoice directly from the order list. + maker.goto(base+'/orders?kw=001') + maker.locator('a[data-quick-tracking]').click() + md = maker.locator('#tracking-dialog') + md.locator('[name=invoice_status]').select_option('received') + md.locator('[name=delivery_status]').select_option('partial') + md.locator('[name=fulfillment_note]').fill('首批货已到,剩余预计周五交齐。') + md.get_by_role('button', name='保存采购跟进').click() + expect(md).not_to_be_visible() + assert maker.url == base+'/orders?kw=001' + expect(maker.locator('[data-progress=delivery] [aria-current=step]')).to_have_text('部分交货') + maker.goto(base+'/orders/1/view') + expect(maker.locator('#order-tracking')).to_contain_text('最近跟进:') + expect(maker.locator('#order-tracking')).to_contain_text('首批货已到,剩余预计周五交齐。') + maker.locator('#order-tracking').screenshot(path=str(RUN/'detail.png')) + # A standard page remains usable when JavaScript is unavailable. + fallback = browser.new_context(java_script_enabled=False) + fallback.add_cookies(maker.context.cookies()) + tab = fallback.new_page() + tab.goto(base+'/orders/1/tracking/follow-up') + assert tab.locator('#follow-up-form').count() == 1 + assert not errors, errors + browser.close() + with Session() as db: + assert db.get(Order, 2).payment_status == 'paid' + assert db.get(Order, 1).delivery_status == 'partial' + assert db.query(ApprovalLog).filter_by(user_name='采购员', action='更新采购跟进').count() == 1 + print(json.dumps({'result': 'PASS', 'output': str(RUN), 'js_errors': errors}, ensure_ascii=False)) +finally: + server.close()