优化付款交货待办展示并支持列表快捷登记
This commit is contained in:
@@ -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`。
|
||||
|
||||
+12
-4
@@ -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)
|
||||
|
||||
@@ -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/<int:oid>/tracking/<kind>')
|
||||
@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/<int:oid>/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/<int:oid>/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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -36,5 +36,16 @@
|
||||
{% block body %}{% endblock %}
|
||||
</div>
|
||||
<footer class="foot">{{ COMPANY_NAME }} · 采购订单管理系统 v4.0</footer>
|
||||
{% if session.get('user_id') %}
|
||||
<dialog id="tracking-dialog" aria-labelledby="tracking-dialog-title">
|
||||
<div class="modal-head"><span id="tracking-dialog-title">快捷登记</span><button type="button" class="btn sm" data-tracking-close aria-label="关闭快捷登记">关闭</button></div>
|
||||
<div class="modal-body">
|
||||
<div id="tracking-dialog-error" class="flash error" role="alert" hidden></div>
|
||||
<button type="button" class="btn sm" id="tracking-reload" hidden>重新读取当前状态</button>
|
||||
<div id="tracking-dialog-content" aria-live="polite"></div>
|
||||
</div>
|
||||
</dialog>
|
||||
<script src="{{ url_for('static', filename='tracking.js') }}" defer></script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+28
-19
@@ -8,29 +8,38 @@
|
||||
<div class="stat"><div class="v">{{ '%.2f'|format(month_total) }}</div><div class="k">审批通过订单总额(元)</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card tracking-dashboard">
|
||||
<div class="card-head"><h2>付款与交货概况</h2><a href="{{ url_for('orders', status='approved') }}">查看全部</a></div>
|
||||
<p class="muted">统计当前可见、审批通过的订单。各类数量可能重叠;部分付款和部分交货按订单状态统计。</p>
|
||||
<div class="stat-row">
|
||||
{% for key in ['prepayment','invoice_due','unpaid','paid','undelivered','partial_delivery','paid_undelivered','completed'] %}
|
||||
<a class="stat tracking-stat" data-tracking="{{ key }}" href="{{ url_for('orders', tracking=key) }}"><div class="v">{{ tracking_counts[key] }}</div><div class="k">{{ TRACKING_FILTERS[key] }}</div></a>
|
||||
<div class="card tracking-dashboard" id="tracking-board">
|
||||
{% from 'tracking_macros.html' import progress, invoice, tasks, actions with context %}
|
||||
<div class="card-head"><div><h2>付款与交货待办</h2><p class="muted">先核对变更,再跟进付款与交货。同一订单可能出现在多个提醒中。</p></div><a href="{{ url_for('orders') }}">全部订单</a></div>
|
||||
<div class="stat-row tracking-focus">
|
||||
{% for key in ['prepayment','invoice_due','paid_undelivered','recheck'] %}
|
||||
<a class="stat tracking-stat focus-{{ key }} {{ 'selected' if tracking_key == key else '' }}" data-tracking="{{ key }}" href="{{ url_for('index', tracking=key) }}#tracking-board"><div class="v">{{ tracking_counts[key] }}</div><div class="k">{{ TRACKING_FILTERS[key] }}</div><span class="stat-arrow" aria-hidden="true">→</span></a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="toolbar tracking-shortcuts">{% for key in ['partial_payment','delivered','unrecorded','recheck'] %}<a class="btn sm" data-tracking="{{ key }}" href="{{ url_for('orders', tracking=key) }}">{{ TRACKING_FILTERS[key] }}({{ tracking_counts[key] }})</a>{% endfor %}</div>
|
||||
<h3>最近审批通过的订单</h3>
|
||||
<table class="grid tracking-table">
|
||||
<tr><th>订单 / 供应商</th><th class="num">订单金额</th><th>预付款</th><th>收票</th><th>付款</th><th>交货</th><th>操作</th></tr>
|
||||
{% for o in tracking_rows %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('order_view', oid=o.id) }}#order-tracking">{{ o.order_no }}</a><div class="muted">{{ o.supplier_name }}</div>{% if o.fulfillment_stale or o.payment_stale %}<div class="tracking-warning">变更后待核对</div>{% endif %}</td>
|
||||
<td class="num">{{ '%.2f'|format(o.total_amount) }}</td><td>{{ '需要' if o.needs_prepayment else '—' }}</td>
|
||||
<td><span class="badge track-{{ o.invoice_status }}">{{ o.invoice_status_text }}</span></td>
|
||||
<td><span class="badge track-{{ o.payment_status }}">{{ o.payment_status_text }}</span></td>
|
||||
<td><span class="badge track-{{ o.delivery_status }}">{{ o.delivery_status_text }}</span></td>
|
||||
<td><a class="btn sm" href="{{ url_for('order_view', oid=o.id) }}#order-tracking">查看跟进</a></td>
|
||||
<div class="tracking-board-head">
|
||||
<div><h3>{{ TRACKING_FILTERS[tracking_key] }} <span class="muted">共 {{ tracking_total }} 单</span></h3><p class="muted">同类优先显示较早订单;付款、交货、收票均完成后默认收起。</p></div>
|
||||
<form method="get" action="{{ url_for('index') }}#tracking-board" class="toolbar tracking-filter">
|
||||
<label for="board-filter">查看</label>
|
||||
<select id="board-filter" name="tracking">{% for key, label in TRACKING_FILTERS.items() %}<option value="{{ key }}" {{ 'selected' if tracking_key == key else '' }}>{{ label }}({{ tracking_counts[key] }})</option>{% endfor %}</select>
|
||||
<button class="btn sm">筛选</button>
|
||||
</form>
|
||||
</div>
|
||||
<table class="grid tracking-table order-cards">
|
||||
<thead><tr><th>订单 / 供应商</th><th class="num">金额</th><th>付款进度</th><th>交货进度</th><th>收票</th><th>待办 / 操作</th></tr></thead>
|
||||
<tbody>{% for o in tracking_rows %}
|
||||
<tr data-order-id="{{ o.id }}">
|
||||
<td data-label="订单" class="order-identity"><a href="{{ url_for('order_view', oid=o.id) }}#order-tracking"><b>{{ o.order_no }}</b></a><div class="supplier-name">{{ o.supplier_name }}</div>{% if o.needs_prepayment %}<span class="badge track-partial">需提前付款{{ '(待核对)' if o.fulfillment_stale else '' }}</span>{% endif %}</td>
|
||||
<td data-label="订单金额" class="num">¥{{ '%.2f'|format(o.total_amount) }}</td>
|
||||
<td data-label="付款">{{ progress(o, 'payment') }}</td>
|
||||
<td data-label="交货">{{ progress(o, 'delivery') }}</td>
|
||||
<td data-label="收票">{{ invoice(o) }}</td>
|
||||
<td data-label="待办 / 操作" class="order-next">{{ tasks(o) }}{{ actions(o, user) }}</td>
|
||||
</tr>
|
||||
{% else %}<tr><td colspan="7" class="center muted">暂无审批通过的订单</td></tr>{% endfor %}
|
||||
{% else %}<tr class="empty-row"><td colspan="6" class="center muted">当前分类暂无订单。可切换分类查看其他订单。</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
{% if tracking_total > tracking_rows|length %}<p class="right"><a href="{{ url_for('orders', tracking=tracking_key) }}">查看全部 {{ tracking_total }} 单 →</a></p>{% endif %}
|
||||
{% if tracking_key != 'open' %}<p><a href="{{ url_for('index') }}#tracking-board">返回全部待跟进({{ tracking_counts['open'] }})</a></p>{% endif %}
|
||||
<div class="tracking-completed-link"><a data-tracking="closed" href="{{ url_for('index', tracking='closed') }}#tracking-board">查看已全部完成({{ tracking_counts['closed'] }})</a></div>
|
||||
</div>
|
||||
|
||||
{% if todo %}
|
||||
|
||||
@@ -1,44 +1,30 @@
|
||||
{% from 'tracking_macros.html' import progress, invoice, tasks, form with context %}
|
||||
{% set recent = latest_tracking(o) %}
|
||||
<div class="card" id="order-tracking">
|
||||
<h2>付款与交货跟进</h2>
|
||||
<div class="tracking-summary">
|
||||
<div><label>预付款安排</label><b>{{ '需提前付款' if o.needs_prepayment else '正常付款' }}</b></div>
|
||||
<div><label>收票情况</label><span class="badge track-{{ o.invoice_status }}">{{ o.invoice_status_text }}</span></div>
|
||||
<div><label>付款情况</label><span class="badge track-{{ o.payment_status }}">{{ o.payment_status_text }}</span>{% if o.payment_date %}<div class="muted">{{ o.payment_date }}</div>{% endif %}</div>
|
||||
<div><label>交货情况</label><span class="badge track-{{ o.delivery_status }}">{{ o.delivery_status_text }}</span></div>
|
||||
</div>
|
||||
{% if o.fulfillment_stale or o.payment_stale %}
|
||||
<p class="hint">订单内容已变更,以下记录来自变更前:{{ '采购跟进' if o.fulfillment_stale else '' }}{{ '、' if o.fulfillment_stale and o.payment_stale else '' }}{{ '付款情况' if o.payment_stale else '' }}。请对应经办人核对后保存确认。</p>
|
||||
{% endif %}
|
||||
<div class="card-head"><h2>付款与交货跟进</h2><span class="badge track-{{ 'partial' if o.needs_prepayment else 'unknown' }}">{{ '需提前付款' if o.needs_prepayment else '正常付款' }}{{ '(待核对)' if o.fulfillment_stale else '' }}</span></div>
|
||||
{{ tasks(o) }}
|
||||
{% if o.fulfillment_stale or o.payment_stale %}<p class="hint">订单内容已变更,下面保留变更前记录,请对应经办人核对后保存确认。</p>{% endif %}
|
||||
{% if o.status != 'approved' %}<p class="muted">审批通过后可登记付款、收票和交货情况。已有跟进记录保留供查询。</p>{% endif %}
|
||||
{% if o.fulfillment_note %}<p>采购跟进备注:{{ o.fulfillment_note }}</p>{% endif %}
|
||||
{% if o.payment_note %}<p>付款备注:{{ o.payment_note }}</p>{% endif %}
|
||||
<div class="tracking-panels">
|
||||
<section class="tracking-panel">
|
||||
<h3>付款情况</h3>
|
||||
{{ progress(o, 'payment') }}
|
||||
{% if o.payment_date %}<p class="muted">实际付款日期:{{ o.payment_date }}</p>{% endif %}
|
||||
{% if o.payment_note %}<p class="follow-note">{{ o.payment_note }}</p>{% endif %}
|
||||
{% set log = recent.get('登记付款状态') %}
|
||||
<p class="update-meta">{% if log %}最近登记:{{ log.created_at.strftime('%Y-%m-%d %H:%M') }} · {{ log.user_name }}{% else %}尚无付款登记记录{% endif %}</p>
|
||||
</section>
|
||||
<section class="tracking-panel">
|
||||
<h3>交货与收票</h3>
|
||||
{{ progress(o, 'delivery') }}
|
||||
<p>收票:{{ invoice(o) }}</p>
|
||||
{% if o.fulfillment_note %}<p class="follow-note">{{ o.fulfillment_note }}</p>{% endif %}
|
||||
{% set log = recent.get('更新采购跟进') %}
|
||||
<p class="update-meta">{% if log %}最近跟进:{{ log.created_at.strftime('%Y-%m-%d %H:%M') }} · {{ log.user_name }}{% else %}尚无采购跟进记录{% endif %}</p>
|
||||
</section>
|
||||
</div>
|
||||
<div class="tracking-forms">
|
||||
{% if may_follow %}
|
||||
<form id="follow-up-form" method="post" action="{{ url_for('order_follow_up', oid=o.id) }}">
|
||||
<h3>制单员跟进</h3>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="row_version" value="{{ o.row_version }}">
|
||||
<div class="field"><label for="needs-prepayment">付款安排</label><select id="needs-prepayment" name="needs_prepayment"><option value="0" {{ 'selected' if not o.needs_prepayment else '' }}>正常付款</option><option value="1" {{ 'selected' if o.needs_prepayment else '' }}>需提前付款</option></select></div>
|
||||
<div class="form-grid two">
|
||||
<div class="field"><label for="invoice-status">收票情况</label><select id="invoice-status" name="invoice_status">{% for key, label in INVOICE_STATUS.items() %}<option value="{{ key }}" {{ 'selected' if o.invoice_status == key else '' }}>{{ label }}</option>{% endfor %}</select></div>
|
||||
<div class="field"><label for="delivery-status">交货情况</label><select id="delivery-status" name="delivery_status">{% for key, label in DELIVERY_STATUS.items() %}<option value="{{ key }}" {{ 'selected' if o.delivery_status == key else '' }}>{{ label }}</option>{% endfor %}</select></div>
|
||||
</div>
|
||||
<div class="field"><label for="fulfillment-note">跟进备注(选填)</label><textarea id="fulfillment-note" name="fulfillment_note" maxlength="500" placeholder="例如:先付30%预付款,已收到第一批货">{{ o.fulfillment_note }}</textarea></div>
|
||||
<div class="field"><label for="follow-up-reason">更正原因(回退状态时必填)</label><input id="follow-up-reason" name="reason" maxlength="500"></div>
|
||||
<button class="btn primary">保存采购跟进</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if may_pay %}
|
||||
<form id="payment-form" method="post" action="{{ url_for('order_payment', oid=o.id) }}">
|
||||
<h3>审批员登记付款</h3>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="row_version" value="{{ o.row_version }}">
|
||||
<div class="field"><label for="payment-status">付款状态</label><select id="payment-status" name="payment_status">{% for key, label in PAYMENT_STATUS.items() %}<option value="{{ key }}" {{ 'selected' if o.payment_status == key else '' }}>{{ label }}</option>{% endfor %}</select></div>
|
||||
<div class="field"><label for="payment-date">实际付款日期(部分付款或已付款时必填)</label><input id="payment-date" type="date" name="payment_date" value="{{ o.payment_date }}"></div>
|
||||
<div class="field"><label for="payment-note">付款备注(选填)</label><textarea id="payment-note" name="payment_note" maxlength="500" placeholder="例如:已付首款,剩余尾款待交货后支付">{{ o.payment_note }}</textarea></div>
|
||||
<div class="field"><label for="payment-reason">更正原因(回退状态时必填)</label><input id="payment-reason" name="reason" maxlength="500"></div>
|
||||
<button class="btn primary">保存付款状态</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if may_follow %}{{ form(o, 'follow-up') }}{% endif %}
|
||||
{% if may_pay %}{{ form(o, 'payment') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
<a class="btn" href="{{ url_for('orders') }}">返回列表</a>
|
||||
</div>
|
||||
|
||||
{% if o.status == 'approved' %}{% include 'order_tracking.html' %}{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<h2>单据信息</h2>
|
||||
<p class="mobile-only muted">单据信息和物料明细可左右滑动查看。</p>
|
||||
@@ -81,7 +83,7 @@
|
||||
{% if o.note %}<p class="muted">补充说明:{{ o.note }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
{% include 'order_tracking.html' %}
|
||||
{% if o.status != 'approved' %}{% include 'order_tracking.html' %}{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<h2>审批流程</h2>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
{% from 'tracking_macros.html' import progress, invoice, tasks, actions with context %}
|
||||
<h1 class="page">订单列表</h1>
|
||||
<div class="card">
|
||||
<form class="toolbar" method="get">
|
||||
@@ -23,41 +24,26 @@
|
||||
<option value="">全部跟进情况</option>
|
||||
{% for key, label in TRACKING_FILTERS.items() %}<option value="{{ key }}" {{ 'selected' if args.get('tracking') == key else '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<label class="completed-toggle"><input type="checkbox" name="show_completed" value="1" {{ 'checked' if args.get('show_completed') == '1' else '' }}> 显示全部完成订单</label>
|
||||
<button class="btn primary">查询</button>
|
||||
<span class="grow"></span>
|
||||
<a class="btn" href="{{ url_for('export_ledger') }}">导出 Excel 台账</a>
|
||||
{% if user.role in ['maker','admin'] %}<a class="btn primary" href="{{ url_for('order_new') }}">新建订单</a>{% endif %}
|
||||
</form>
|
||||
<table class="grid">
|
||||
<tr>
|
||||
<th>单号</th><th>类型</th><th>供应商</th><th>采购日期</th><th class="num">金额</th>
|
||||
<th>审批状态</th><th>付款与交货</th><th>制单</th><th>审批</th><th class="nowrap">操作</th>
|
||||
{% if not args.get('tracking') and args.get('show_completed') != '1' %}<p class="muted">已收起付款、交货、收票均完成的订单。勾选“显示全部完成订单”可查看。</p>{% endif %}
|
||||
<table class="grid orders-table order-cards">
|
||||
<thead><tr><th>订单 / 供应商</th><th>审批状态</th><th class="num">金额</th><th>付款进度</th><th>交货进度</th><th>收票 / 待办</th><th>操作</th></tr></thead>
|
||||
<tbody>{% for o in rows %}
|
||||
<tr data-order-id="{{ o.id }}">
|
||||
<td data-label="订单" class="order-identity"><a href="{{ url_for('order_view', oid=o.id) }}"><b>{{ o.order_no }}</b></a><div class="supplier-name">{{ o.supplier_name }}</div><div class="muted">{{ o.doc_type_text }} · {{ o.order_date }}</div><div class="muted">制单 {{ o.created_by_name }} · 审批 {{ o.approved_by or '—' }}</div>{% if o.needs_prepayment %}<span class="badge track-partial">需提前付款{{ '(待核对)' if o.fulfillment_stale else '' }}</span>{% endif %}</td>
|
||||
<td data-label="审批"><span class="badge {{ o.status }}">{{ o.status_text }}</span></td>
|
||||
<td data-label="订单金额" class="num">¥{{ '%.2f'|format(o.total_amount) }}</td>
|
||||
<td data-label="付款">{{ progress(o, 'payment') }}</td>
|
||||
<td data-label="交货">{{ progress(o, 'delivery') }}</td>
|
||||
<td data-label="收票 / 待办">{{ invoice(o) }}{{ tasks(o) }}</td>
|
||||
<td data-label="操作" class="order-next">{{ actions(o, user) }}<a class="tracking-detail-link" href="{{ url_for('order_pdf', oid=o.id) }}" target="_blank">下载 / 预览 PDF</a></td>
|
||||
</tr>
|
||||
{% for o in rows %}
|
||||
<tr>
|
||||
<td class="nowrap">{{ o.order_no }}</td>
|
||||
<td>{{ o.doc_type_text }}</td>
|
||||
<td>{{ o.supplier_name }}</td>
|
||||
<td class="nowrap">{{ o.order_date }}</td>
|
||||
<td class="num">{{ '%.2f'|format(o.total_amount) }}</td>
|
||||
<td><span class="badge {{ o.status }}">{{ o.status_text }}</span></td>
|
||||
<td>
|
||||
{% if o.needs_prepayment %}<span class="badge track-partial">需提前付款</span>{% endif %}
|
||||
<span class="badge track-{{ o.invoice_status }}">收票:{{ o.invoice_status_text }}</span>
|
||||
<span class="badge track-{{ o.payment_status }}">付款:{{ o.payment_status_text }}</span>
|
||||
<span class="badge track-{{ o.delivery_status }}">交货:{{ o.delivery_status_text }}</span>
|
||||
{% if o.fulfillment_stale or o.payment_stale %}<div class="tracking-warning">变更后待核对</div>{% endif %}
|
||||
</td>
|
||||
<td>{{ o.created_by_name }}</td>
|
||||
<td>{{ o.approved_by or '-' }}</td>
|
||||
<td class="nowrap">
|
||||
<a class="btn sm" href="{{ url_for('order_view', oid=o.id) }}">查看</a>
|
||||
<a class="btn sm" href="{{ url_for('order_pdf', oid=o.id) }}" target="_blank">PDF</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="10" class="center muted">没有符合条件的订单</td></tr>
|
||||
{% endfor %}
|
||||
{% else %}<tr class="empty-row"><td colspan="7" class="center muted">没有符合条件的订单</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block body %}
|
||||
<h1 class="page">{{ '登记付款' if kind == 'payment' else '登记收票 / 交货' }}</h1>
|
||||
<div class="card tracking-editor-page">{% include 'tracking_editor_fragment.html' %}</div>
|
||||
<a class="btn" href="{{ url_for('order_view', oid=o.id) }}#order-tracking">返回订单</a>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{% from 'tracking_macros.html' import form with context %}
|
||||
<div data-tracking-editor>
|
||||
<p class="editor-order"><b>{{ o.order_no }}</b><br>{{ o.supplier_name }} · ¥{{ '%.2f'|format(o.total_amount) }}</p>
|
||||
{{ form(o, kind) }}
|
||||
</div>
|
||||
@@ -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', '全部交货')] %}
|
||||
<div class="order-progress {{ 'is-stale' if stale else '' }}" data-progress="{{ kind }}">
|
||||
{% if stale %}<span class="tracking-warning">变更前记录 · 待核对</span>{% elif state == 'unknown' %}<span class="badge track-unknown">未登记</span>{% endif %}
|
||||
<ol class="status-steps" aria-label="{{ '付款' if payment else '交货' }}状态{{ '(变更前,待核对)' if stale else '' }}">
|
||||
{% for value, label in steps %}<li class="{{ 'current track-' ~ state if state == value else '' }}" {% if state == value %}aria-current="step"{% endif %}>{{ label }}</li>{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro invoice(o) -%}
|
||||
<span class="badge track-{{ 'unknown' if o.fulfillment_stale else o.invoice_status }}">{{ o.invoice_status_text }}{{ '(待核对)' if o.fulfillment_stale else '' }}</span>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro tasks(o) -%}
|
||||
<div class="tracking-tasks">{% for key, text in tracking_tasks(o) %}<span class="task-tag task-{{ key }}">{{ text }}</span>{% endfor %}</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro actions(o, user) -%}
|
||||
<div class="tracking-actions">
|
||||
{% if can_follow(o, user) %}<a class="btn sm" data-quick-tracking href="{{ url_for('tracking_editor', oid=o.id, kind='follow-up') }}">登记收票 / 交货</a>{% endif %}
|
||||
{% if can_record_payment(o, user) %}<a class="btn sm primary" data-quick-tracking href="{{ url_for('tracking_editor', oid=o.id, kind='payment') }}">登记付款</a>{% endif %}
|
||||
<a class="tracking-detail-link" href="{{ url_for('order_view', oid=o.id) }}#order-tracking">详情与记录</a>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro form(o, kind) -%}
|
||||
{% set payment = kind == 'payment' %}
|
||||
<form id="{{ 'payment-form' if payment else 'follow-up-form' }}" method="post" action="{{ url_for('order_payment' if payment else 'order_follow_up', oid=o.id) }}">
|
||||
<h3>{{ '审批员登记付款' if payment else '制单员跟进' }}</h3>
|
||||
{% if (payment and o.payment_stale) or (not payment and o.fulfillment_stale) %}<p class="hint">订单内容已变更,请核对以下变更前记录。保存即确认本次订单的跟进情况。</p>{% endif %}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="row_version" value="{{ o.row_version }}">
|
||||
{% if payment %}
|
||||
<div class="field"><label for="payment-status">付款状态</label><select id="payment-status" name="payment_status">{% for key, label in PAYMENT_STATUS.items() %}<option value="{{ key }}" {{ 'selected' if o.payment_status == key else '' }}>{{ label }}</option>{% endfor %}</select></div>
|
||||
<div class="field"><label for="payment-date">实际付款日期(部分付款或已付款时必填)</label><input id="payment-date" type="date" name="payment_date" value="{{ o.payment_date }}"></div>
|
||||
<div class="field"><label for="payment-note">付款备注(选填)</label><textarea id="payment-note" name="payment_note" maxlength="500" placeholder="例如:已付首款,剩余尾款待交货后支付">{{ o.payment_note }}</textarea></div>
|
||||
<div class="field"><label for="payment-reason">更正原因(回退状态时必填)</label><input id="payment-reason" name="reason" maxlength="500"></div>
|
||||
{% else %}
|
||||
<div class="field"><label for="needs-prepayment">付款安排</label><select id="needs-prepayment" name="needs_prepayment"><option value="0" {{ 'selected' if not o.needs_prepayment else '' }}>正常付款</option><option value="1" {{ 'selected' if o.needs_prepayment else '' }}>需提前付款</option></select></div>
|
||||
<div class="form-grid two">
|
||||
<div class="field"><label for="invoice-status">收票情况</label><select id="invoice-status" name="invoice_status">{% for key, label in INVOICE_STATUS.items() %}<option value="{{ key }}" {{ 'selected' if o.invoice_status == key else '' }}>{{ label }}</option>{% endfor %}</select></div>
|
||||
<div class="field"><label for="delivery-status">交货情况</label><select id="delivery-status" name="delivery_status">{% for key, label in DELIVERY_STATUS.items() %}<option value="{{ key }}" {{ 'selected' if o.delivery_status == key else '' }}>{{ label }}</option>{% endfor %}</select></div>
|
||||
</div>
|
||||
<div class="field"><label for="fulfillment-note">跟进备注(选填)</label><textarea id="fulfillment-note" name="fulfillment_note" maxlength="500" placeholder="例如:首批货已到,剩余预计周五交齐">{{ o.fulfillment_note }}</textarea></div>
|
||||
<div class="field"><label for="follow-up-reason">更正原因(回退状态时必填)</label><input id="follow-up-reason" name="reason" maxlength="500"></div>
|
||||
{% endif %}
|
||||
<button class="btn primary" type="submit">{{ '保存付款状态' if payment else '保存采购跟进' }}</button>
|
||||
</form>
|
||||
{%- endmacro %}
|
||||
+61
-1
@@ -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"[^>]*><div class="v">1</div>', 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('<table class="grid tracking-table order-cards">')[1].split('</table>')[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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user