From 4aaa6c6a1486959b2fd901f97f2bb355aea1cb38 Mon Sep 17 00:00:00 2001 From: reddaniel110 Date: Wed, 14 Oct 2026 11:25:43 +0800 Subject: [PATCH] Implement issue 1 search, order reuse and tracking --- README.md | 4 + packaging/win7/README_WIN7.md | 2 + po_app/app.py | 57 +++++++++++-- po_app/material_prices.py | 52 ++++++++++++ po_app/order_routes.py | 6 ++ po_app/order_tracking.py | 12 +++ po_app/static/search_select.js | 16 ++++ po_app/static/style.css | 10 ++- po_app/templates/index.html | 4 + po_app/templates/materials.html | 11 ++- po_app/templates/order_edit.html | 24 +++++- po_app/templates/order_new.html | 7 +- po_app/templates/orders.html | 10 ++- po_app/templates/suppliers.html | 4 + tests/test_issue1.py | 134 +++++++++++++++++++++++++++++++ tools/browser_smoke.py | 25 ++++++ 运行与升级说明.md | 4 +- 17 files changed, 363 insertions(+), 19 deletions(-) create mode 100644 po_app/material_prices.py create mode 100644 po_app/static/search_select.js create mode 100644 tests/test_issue1.py diff --git a/README.md b/README.md index 14adfbb..300de87 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ Windows 7 SP1 服务器使用单独的 [Win7 离线打包说明](packaging/win7/ - 采购单和委外单,供应商/物料库、中文 XLS/XLSX 导入。 - 供应商支持维护公司简称,PDF 下载文件名采用“单据类型_订单号_供应商简称.pdf”;未填写简称时使用供应商全名,已审批文件重新下载也适用。 +- 供应商库支持按名称、简称、编号搜索;物料库与订单编辑页支持按物料代码、规格搜索,选库内物料可带出单价。 +- 订单列表支持按物料查历史订单并标出匹配明细,搜索结果包含已完成订单;可直接复制旧订单为草稿,新交期须重新填写。 +- 审批通过时将订单中有代码、可明确匹配的物料单价写入物料库,保留四位小数。管理员可在物料库点击“同步历史审批价”,补录升级前的已审批订单。选物料时优先参考本账号同供应商的最近审批价,其他情况使用物料库默认价;两者都应在提交前核对。 - 草稿可暂存,“保存并提交”保证当前修改一起进入审批。 - 数量、单价四位精度;逐行四舍五入到分再合计,页面、数据库和 PDF 使用同一口径。 - 制单与审批由不同账号完成,管理员也遵守分工;审批员一次确认即可通过或驳回。 @@ -56,6 +59,7 @@ Windows 7 SP1 服务器使用单独的 [Win7 离线打包说明](packaging/win7/ - 按状态、供应商、月份统计以及 Excel 台账导出。 - 已审批订单的预付款标记、收票与交货跟进;审批员或管理员登记付款状态与日期,支持部分付款、部分交货。 - 工作台按需预付款、已收票待付款、未交货、已付款未交齐等情况统计并跳转筛选,台账同步导出跟进状态。 +- 工作台提供“未交付(含部分交货)”“已交付已收票待付款”“已完成”三类入口;未收票、未登记和变更后待核对订单仍可在“其他待跟进”查看。 图形审批章表示系统内部审批确认,不是第三方证书数字签名。金额分级、部门审批、企业微信个人定向通知、预算和逐笔资金对账不在当前版本中。付款与交货按整张订单标注状态,部分完成的明细可填写在备注中。 diff --git a/packaging/win7/README_WIN7.md b/packaging/win7/README_WIN7.md index 1ae86d7..eff6586 100644 --- a/packaging/win7/README_WIN7.md +++ b/packaging/win7/README_WIN7.md @@ -2,6 +2,8 @@ 本包用于在你的 Windows 7 服务电脑上,从 v4 源码生成 EXE。包括 Python 3.8.10 官方完整安装程序、32 位与 64 位离线依赖、构建脚本、环境与业务自检,以及现有 16 页使用升级手册。 +2026-10-14 工单更新:供应商可按名称、简称和编号搜索;订单编辑及物料库可按物料代码、规格搜索。订单列表可按物料搜索历史订单,匹配明细显示在订单下方,已完成订单也会出现在搜索结果中;可从列表复制旧订单,新交期需重新填写。订单审批通过时将有代码且可明确匹配的物料单价更新到物料库,精度保留四位小数;管理员可在物料库手动“同步历史审批价”,用旧的已审批订单补录价格。选物料时先参考同供应商、当前账号可见的最近审批价。工作台增加“未交付”“已交付已收票待付款”“已完成”三类入口,未收票或待核对的订单在“其他待跟进”。这些改动不增加数据库结构版本;原 PDF 使用手册页面截图可能与新入口位置不同,以新版界面及本段说明为准。 + 安装脚本修复:新版解压到另一文件夹时,先检查本包及系统登记的 Python 3.8.10,确认位数和运行正常后复用;不再仅因新目录没有 `.python38` 就重复安装。只有找不到可用环境时才运行官方安装器。安装器错误码、依赖安装及自检输出会保存在 `logs/`。 2026-09-17 工作台更新:突出“需预付款”“已收票待付款”“已付款未交齐”“变更后待核对”。待跟进订单先按处理类别排列,同类较早订单优先;付款、交货、收票均完成后默认收起,仍可通过筛选查看。付款和交货分别显示三档状态;“未登记”和变更前待核对记录单独提示,不换算虚假的完成百分比。 diff --git a/po_app/app.py b/po_app/app.py index 5d5d736..73c58c0 100644 --- a/po_app/app.py +++ b/po_app/app.py @@ -25,6 +25,7 @@ from models import (COMPANY_NAME, DOC_TYPES, ROLE_TEXT, STATUS_TEXT, DEFAULT_OUR_PHONE, DEFAULT_OUR_TAX_NO, DEFAULT_TAX_RATE, ApprovalLog, Material, Order, OrderItem, Supplier, User, init_db) +from material_prices import remember_approved_prices, supplier_price_map from pdf_export import build_pdf # 打包成 EXE 后:资源在临时目录 _MEIPASS,数据必须写在 EXE 同级目录 @@ -100,6 +101,7 @@ def orders(): doc_type = request.args.get("doc_type", "") date_from = request.args.get("date_from", "") date_to = request.args.get("date_to", "") + material_kw = request.args.get('material_kw', '').strip()[:100] if kw: q = q.filter(Order.order_no.like("%%%s%%" % kw) | Order.supplier_name.like("%%%s%%" % kw)) @@ -111,15 +113,26 @@ def orders(): q = q.filter(Order.order_date >= date_from) if date_to: q = q.filter(Order.order_date <= date_to) + if material_kw: + pattern = '%%%s%%' % material_kw + q = q.filter(Order.items.any((OrderItem.material_code.ilike(pattern)) | + (OrderItem.material_spec.ilike(pattern)))) if u.role == "maker": q = q.filter(Order.created_by == u.id) tracking = request.args.get('tracking', '') q = filter_tracking(q, tracking) - if not tracking and request.args.get('show_completed') != '1': + if not tracking and not kw and not material_kw 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() + matched_items = {} + if material_kw and rows: + for item in (db.query(OrderItem).filter(OrderItem.order_id.in_([o.id for o in rows]), + (OrderItem.material_code.ilike(pattern)) | + (OrderItem.material_spec.ilike(pattern))) + .order_by(OrderItem.order_id, OrderItem.seq)): + matched_items.setdefault(item.order_id, []).append(item) return render_template("orders.html", user=u, rows=rows, status_text=STATUS_TEXT, - doc_types=DOC_TYPES, args=request.args) + doc_types=DOC_TYPES, args=request.args, matched_items=matched_items) @app.route("/import_template.xlsx") @@ -165,8 +178,14 @@ def suppliers(): db.commit() flash(u"已保存", "ok") return redirect(url_for("suppliers")) - rows = db.query(Supplier).order_by(Supplier.id).all() - return render_template("suppliers.html", user=u, rows=rows) + kw = request.args.get('kw', '').strip()[:100] + query = db.query(Supplier) + if kw: + pattern = '%%%s%%' % kw + query = query.filter(Supplier.name.ilike(pattern) | Supplier.short_name.ilike(pattern) | + Supplier.code.ilike(pattern)) + rows = query.order_by(Supplier.id).all() + return render_template("suppliers.html", user=u, rows=rows, kw=kw) @app.route("/suppliers//toggle", methods=["POST"]) @@ -307,8 +326,26 @@ def materials(): db.commit() flash(u"已保存", "ok") return redirect(url_for("materials")) - rows = db.query(Material).order_by(Material.id).all() - return render_template("materials.html", user=u, rows=rows) + kw = request.args.get('kw', '').strip()[:100] + query = db.query(Material) + if kw: + pattern = '%%%s%%' % kw + query = query.filter(Material.code.ilike(pattern) | Material.spec.ilike(pattern)) + rows = query.order_by(Material.id).all() + return render_template("materials.html", user=u, rows=rows, kw=kw) + + +@app.post('/materials/prices/from-approved-orders') +@login_required +@role_required('admin') +def sync_approved_material_prices(): + # Old approved orders may predate automatic catalog price updates. + orders = (db.query(Order).filter(Order.status == 'approved') + .order_by(Order.approved_at, Order.id).all()) + count = sum(remember_approved_prices(db, order) for order in orders) + db.commit() + flash('已按审批先后同步 %s 张订单,更新 %s 条物料单价。' % (len(orders), count), 'ok') + return redirect(url_for('materials')) @app.route("/materials//toggle", methods=["POST"]) @@ -431,6 +468,14 @@ def api_suppliers(): "payment_terms": s.payment_terms} for s in rows]) +@app.route('/api/material-prices') +@login_required +@role_required('maker') +def api_material_prices(): + return jsonify(supplier_price_map(db, request.args.get('supplier_name', ''), + request.args.get('supplier_tax_no', ''), current_user())) + + # ---------------- 用户 ---------------- @app.route("/users", methods=["GET", "POST"]) @login_required diff --git a/po_app/material_prices.py b/po_app/material_prices.py new file mode 100644 index 0000000..5ccec85 --- /dev/null +++ b/po_app/material_prices.py @@ -0,0 +1,52 @@ +"""Keep the material catalog's latest approved price and supplier-specific quotes.""" +from decimal import Decimal + +from models import Material, Order, OrderItem + + +def remember_approved_prices(db, order): + """Update only unambiguous material codes in the approval transaction.""" + updated = 0 + for item in order.items: + code = (item.material_code or '').strip() + if not code or item.price is None or item.price <= 0: + continue + matches = db.query(Material).filter(Material.code == code).all() + exact = [material for material in matches if (material.spec or '').strip() == (item.material_spec or '').strip()] + if len(exact) == 1: + material = exact[0] + elif len(matches) == 1: + material = matches[0] + elif not matches: + material = Material(code=code, spec=item.material_spec or '', unit=item.unit or '', active=True) + db.add(material) + else: + # Duplicate codes with different specs are ambiguous; preserve both records. + continue + material.price = item.price + updated += 1 + return updated + + +def supplier_price_map(db, supplier_name, supplier_tax_no='', user=None): + """Most recent approved unit price for each code from the chosen supplier.""" + name = (supplier_name or '').strip() + if not name: + return {} + query = (db.query(OrderItem.material_code, OrderItem.price, Order.supplier_tax_no) + .join(Order, OrderItem.order_id == Order.id) + .filter(Order.status == 'approved', Order.supplier_name == name, + OrderItem.material_code != '') + .order_by(Order.approved_at.desc(), Order.id.desc(), OrderItem.seq.desc())) + if user and user.role == 'maker': + query = query.filter(Order.created_by == user.id) + rows = query.all() + tax = (supplier_tax_no or '').strip() + if tax and any((row.supplier_tax_no or '').strip() == tax for row in rows): + rows = [row for row in rows if (row.supplier_tax_no or '').strip() == tax] + result = {} + for row in rows: + code = (row.material_code or '').strip() + if code and code not in result and row.price is not None: + result[code] = str(Decimal(row.price)) + return result diff --git a/po_app/order_routes.py b/po_app/order_routes.py index 6ddbcfd..aa176a6 100644 --- a/po_app/order_routes.py +++ b/po_app/order_routes.py @@ -17,6 +17,7 @@ from order_logic import (ORDER_FIELDS, ITEM_FIELDS, add_log, can_edit, can_appro validate_order) from pdf_export import PDF_TEMPLATE_VERSION, build_pdf, seal_layout from order_tracking import can_follow, can_record_payment +from material_prices import remember_approved_prices, supplier_price_map from security import current_user, db, login_required, role_required @@ -109,6 +110,7 @@ def register_order_routes(app): return render_template('order_edit.html', user=user, o=order, editable=True, suppliers=db.query(Supplier).filter_by(active=True).all(), materials=db.query(Material).filter_by(active=True).all(), doc_types=DOC_TYPES, + supplier_prices=supplier_price_map(db, order.supplier_name, order.supplier_tax_no, user), total_cn=amount_to_cn(order.total_amount)) @app.route('/orders//view') @@ -161,6 +163,7 @@ def register_order_routes(app): version.pdf_sha256 = hashlib.sha256(version.pdf_data).hexdigest() version.template_version = (PDF_TEMPLATE_VERSION + '-legacy-current' if version.template_version == 'legacy-current' else PDF_TEMPLATE_VERSION) + remember_approved_prices(db, order) add_log(order, '审批通过并归档正式 PDF', user, request.form.get('comment'), old) db.commit() flash('审批通过,正式 PDF 已归档', 'ok') @@ -263,11 +266,14 @@ def register_order_routes(app): order.order_date = datetime.now().strftime('%Y-%m-%d') order.items = [OrderItem(seq=i, **{field: getattr(item, field) for field in ITEM_FIELDS}) for i, item in enumerate(source.items, 1)] + for item in order.items: + item.delivery_date = '' recalc(order) db.add(order) remember_creation(key, 'copy:%s' % oid, order) add_log(order, '复制创建草稿', user, '来源:' + source.order_no) db.commit() + flash('已复制为新草稿。请填写新交期,并核对单价和订单条款。', 'ok') return redirect(url_for('order_edit', oid=order.id)) @app.route('/orders//pdf') diff --git a/po_app/order_tracking.py b/po_app/order_tracking.py index 963fd97..a8a2fc2 100644 --- a/po_app/order_tracking.py +++ b/po_app/order_tracking.py @@ -11,6 +11,10 @@ from security import current_user, db, login_required TRACKING_FILTERS = { 'open': '待跟进订单', + 'work_undelivered': '未交付订单(含部分交货)', + 'work_invoice_due': '已交付已收票待付款', + 'work_completed': '已完成订单', + 'work_other': '其他待跟进 / 待核对', 'prepayment': '需预付款', 'invoice_due': '已收票待付款', 'unpaid': '未付款', @@ -35,9 +39,17 @@ def tracking_conditions(): completed = and_(current_payment, current_fulfillment, Order.payment_status == 'paid', Order.delivery_status == 'delivered') closed = and_(completed, Order.invoice_status == 'received') + work_undelivered = and_(current_fulfillment, Order.delivery_status.in_(('pending', 'partial'))) + work_invoice_due = and_(current_fulfillment, current_payment, + Order.delivery_status == 'delivered', Order.invoice_status == 'received', + Order.payment_status.in_(('unpaid', 'partial'))) return { 'open': ~closed, 'closed': closed, + 'work_undelivered': work_undelivered, + 'work_invoice_due': work_invoice_due, + 'work_completed': closed, + 'work_other': and_(~closed, ~work_undelivered, ~work_invoice_due), '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'), diff --git a/po_app/static/search_select.js b/po_app/static/search_select.js new file mode 100644 index 0000000..3eac1f7 --- /dev/null +++ b/po_app/static/search_select.js @@ -0,0 +1,16 @@ +/* Filter existing supplier/material options without changing their submitted IDs. */ +window.POFilterSelect = { + bind: function (input, select) { + if (!input || !select) return; + const options = Array.from(select.options); + const normalize = value => String(value || '').trim().toLocaleLowerCase(); + input.addEventListener('input', function () { + const term = normalize(input.value); + const matches = options.filter((option, index) => index === 0 || + normalize(option.textContent + ' ' + (option.dataset.search || '')).includes(term)); + select.replaceChildren(...matches); + select.selectedIndex = 0; + select.setAttribute('aria-label', term ? `匹配 ${matches.length - 1} 项,请选择` : '请选择'); + }); + } +}; diff --git a/po_app/static/style.css b/po_app/static/style.css index b823585..689230c 100644 --- a/po_app/static/style.css +++ b/po_app/static/style.css @@ -35,7 +35,7 @@ table.grid td.num, table.grid th.num { text-align: right; } .form-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 16px; } .form-grid.two { grid-template-columns: repeat(2, 1fr); } label { display: block; font-size: 12px; color: #666; margin-bottom: 3px; } -input:not([type]), input[type=text], input[type=password], input[type=number], input[type=date], select, textarea { +input:not([type]), input[type=text], input[type=search], input[type=password], input[type=number], input[type=date], select, textarea { width: 100%; padding: 6px 8px; border: 1px solid #ccd3da; border-radius: 4px; font-size: 13px; font-family: inherit; background: #fff; } @@ -133,6 +133,13 @@ table.grid tr.row-off .btn { opacity: .8; } /* 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; } +.order-stage-links { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 14px 0 8px; } +.order-stage { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 12px; border: 1px solid #dce4ed; border-radius: 6px; color: #123a6b; } +.order-stage:hover, .order-stage.selected { background: #eef5ff; border-color: #1668dc; text-decoration: none; } +.order-stage span { white-space: nowrap; font-size: 12px; } +.matched-materials { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; color: #42526a; font-size: 12px; } +.matched-materials span { padding: 2px 6px; background: #eef5ff; border-radius: 4px; } +.copy-order-form { margin: 7px 0 4px; } .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; } @@ -196,6 +203,7 @@ table.grid tr.row-off .btn { opacity: .8; } .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); } + .order-stage-links { 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; } diff --git a/po_app/templates/index.html b/po_app/templates/index.html index 8da456e..59145b6 100644 --- a/po_app/templates/index.html +++ b/po_app/templates/index.html @@ -11,6 +11,10 @@
{% from 'tracking_macros.html' import progress, invoice, tasks, actions with context %}

付款与交货待办

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

全部订单
+ +

交货、收票或付款未登记,以及变更后待核对的订单,可在 其他待跟进({{ tracking_counts['work_other'] }})查看。

{% for key in ['prepayment','invoice_due','paid_undelivered','recheck'] %}
{{ tracking_counts[key] }}
{{ TRACKING_FILTERS[key] }}
diff --git a/po_app/templates/materials.html b/po_app/templates/materials.html index 5e5b420..38340c0 100644 --- a/po_app/templates/materials.html +++ b/po_app/templates/materials.html @@ -9,7 +9,7 @@
-
+
@@ -18,14 +18,19 @@

物料列表

+ {% if user.role == 'admin' %}
{% endif %}
+ {% for m in rows %} - +
物料代码物料规格单位默认含税单价操作
{{ m.code }}{{ m.spec }}{{ m.unit }}{{ '%.2f'|format(m.price) }}{{ '%.4f'|format(m.price) }}
{{ loop.index }} +
1 + + + {% for o in rows %} - + - + {% 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.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 %}{% if matched_items.get(o.id) %}
匹配物料:{% for it in matched_items[o.id][:5] %}{{ it.material_code or '无代码' }} · {{ it.material_spec or '未填规格' }}{% endfor %}{% if matched_items[o.id]|length > 5 %}另有 {{ matched_items[o.id]|length - 5 }} 项{% endif %}
{% endif %}
{{ o.status_text }} ¥{{ '%.2f'|format(o.total_amount) }} {{ progress(o, 'payment') }} {{ progress(o, 'delivery') }} {{ invoice(o) }}{{ tasks(o) }}{{ actions(o, user) }}下载 / 预览 PDF{{ actions(o, user) }}{% if user.role in ['maker', 'admin'] %}
{% endif %}下载 / 预览 PDF
没有符合条件的订单
diff --git a/po_app/templates/suppliers.html b/po_app/templates/suppliers.html index df13be6..29d42c8 100644 --- a/po_app/templates/suppliers.html +++ b/po_app/templates/suppliers.html @@ -25,6 +25,10 @@

供应商列表

+ {% for s in rows %} diff --git a/tests/test_issue1.py b/tests/test_issue1.py new file mode 100644 index 0000000..0c2217e --- /dev/null +++ b/tests/test_issue1.py @@ -0,0 +1,134 @@ +"""Gitea issue #1: searchable reuse, approved prices, and work stages.""" +import re +import secrets +from decimal import Decimal + +from conftest import post +from test_workflow import action, approve, form, new, order + + +def submit_item(app, clients, supplier, code, spec, price): + oid = new(app, clients['maker']) + values = form(order(app, oid)['version'], price=price, intent='submit') + values['supplier_name'] = supplier + values.setlist('item_code', [code]) + values.setlist('item_spec', [spec]) + assert post(clients['maker'], '/orders/%s/edit' % oid, values).status_code == 302 + return oid + + +def test_supplier_material_search_and_approved_price_reuse(app, clients): + from models import Material, Supplier + + with app.Session() as db: + db.add_all([ + Supplier(code='SU-01', name='苏州甲公司', short_name='甲司', active=True), + Supplier(code='SU-02', name='苏州乙公司', short_name='乙司', active=True), + Material(code='AX-55', spec='航空轴承', unit='件', price=Decimal('4.0000'), active=True), + ]) + db.commit() + supplier_page = clients['maker'].get('/suppliers?kw=甲司').get_data(as_text=True) + assert 'SU-01' in supplier_page and 'SU-02' not in supplier_page + material_page = clients['maker'].get('/materials?kw=航空').get_data(as_text=True) + assert 'AX-55' in material_page + assert 'supplierSearch' in clients['maker'].get('/orders/new').get_data(as_text=True) + + oid_a = submit_item(app, clients, '苏州甲公司', 'AX-55', '航空轴承', '12.3456') + with app.Session() as db: + assert db.query(Material).filter_by(code='AX-55').one().price == Decimal('4.0000') + approve(app, clients, oid_a) + with app.Session() as db: + assert db.query(Material).filter_by(code='AX-55').one().price == Decimal('12.3456') + + oid_b = submit_item(app, clients, '苏州乙公司', 'AX-55', '航空轴承', '18.7654') + approve(app, clients, oid_b) + with app.Session() as db: + assert db.query(Material).filter_by(code='AX-55').one().price == Decimal('18.7654') + prices = clients['maker'].get('/api/material-prices', query_string={'supplier_name': '苏州甲公司'}).json + assert prices['AX-55'] == '12.3456' + assert clients['other'].get('/api/material-prices', query_string={'supplier_name': '苏州甲公司'}).json == {} + assert clients['approver'].get('/api/material-prices', query_string={'supplier_name': '苏州甲公司'}).status_code == 403 + edit_page = clients['maker'].get('/orders/%s/edit' % new(app, clients['maker'])).get_data(as_text=True) + assert 'matsearch' in edit_page and 'search_select.js' in edit_page + + # An already completed order remains searchable by its material and reusable. + with app.Session() as db: + row = db.get(app.Order, oid_a) + row.invoice_status, row.delivery_status, row.payment_status = 'received', 'delivered', 'paid' + row.fulfillment_revision = row.payment_revision = row.revision + db.commit() + assert 'AX-55' not in clients['maker'].get('/orders').get_data(as_text=True) + result = clients['maker'].get('/orders', query_string={'material_kw': '航空轴承'}).get_data(as_text=True) + assert 'AX-55' in result and '匹配物料' in result and '复制下单' in result + assert clients['other'].get('/orders', query_string={'material_kw': '航空轴承'}).get_data(as_text=True).count('AX-55') == 0 + copied = action(app, clients['maker'], oid_a, 'copy', request_key=secrets.token_hex(16)) + assert copied.status_code == 302 + copy_id = int(re.search(r'/orders/(\d+)', copied.location)[1]) + with app.Session() as db: + clone = db.get(app.Order, copy_id) + assert clone.status == 'draft' and clone.items[0].delivery_date == '' + assert clone.items[0].price == Decimal('12.3456') + assert '请填写新交期' in clients['maker'].get(copied.location).get_data(as_text=True) + + +def test_new_material_is_added_on_approval_and_draft_does_not_change_catalog(app, clients): + from models import Material + + oid = submit_item(app, clients, '新供应商', 'NEW-314', '新规格', '3.2109') + with app.Session() as db: + assert db.query(Material).filter_by(code='NEW-314').count() == 0 + approve(app, clients, oid) + with app.Session() as db: + material = db.query(Material).filter_by(code='NEW-314').one() + assert material.price == Decimal('3.2109') and material.spec == '新规格' + + +def test_admin_can_backfill_old_approved_prices_without_changing_orders(app, clients): + from models import Material + + oid = submit_item(app, clients, '历史供应商', 'OLD-9', '历史物料', '7.4321') + approve(app, clients, oid) + pdf = clients['maker'].get('/orders/%s/pdf' % oid).data + with app.Session() as db: + db.query(Material).filter_by(code='OLD-9').one().price = Decimal('0') + db.commit() + assert post(clients['maker'], '/materials/prices/from-approved-orders').status_code == 403 + assert post(clients['admin'], '/materials/prices/from-approved-orders').status_code == 302 + with app.Session() as db: + assert db.query(Material).filter_by(code='OLD-9').one().price == Decimal('7.4321') + assert clients['maker'].get('/orders/%s/pdf' % oid).data == pdf + + +def test_work_stage_filters_leave_unclassified_orders_visible(app, clients): + from models import Order, User + + with app.Session() as db: + maker = db.query(User).filter_by(username='maker').one() + samples = [ + ('A', 'pending', 'unpaid', 'pending', 1), + ('B', 'partial', 'paid', 'received', 1), + ('C', 'delivered', 'unpaid', 'received', 1), + ('D', 'delivered', 'partial', 'received', 1), + ('E', 'delivered', 'paid', 'received', 1), + ('F', 'delivered', 'paid', 'pending', 1), + ('G', 'unknown', 'unknown', 'unknown', 1), + ('H', 'delivered', 'paid', 'received', 0), + ] + for suffix, delivery, payment, invoice, payment_revision in samples: + db.add(Order(order_no='STAGE-' + suffix, created_by=maker.id, created_by_name='maker', + supplier_name='阶段测试', status='approved', revision=1, + delivery_status=delivery, payment_status=payment, invoice_status=invoice, + fulfillment_revision=1, payment_revision=payment_revision)) + db.commit() + + expected = { + 'work_undelivered': {'A', 'B'}, + 'work_invoice_due': {'C', 'D'}, + 'work_completed': {'E'}, + 'work_other': {'F', 'G', 'H'}, + } + for key, suffixes in expected.items(): + html = clients['maker'].get('/orders', query_string={'tracking': key}).get_data(as_text=True) + found = set(re.findall(r'STAGE-([A-H])', html)) + assert found == suffixes, (key, found) + assert 'STAGE-A' not in clients['other'].get('/orders', query_string={'tracking': 'work_undelivered'}).get_data(as_text=True) diff --git a/tools/browser_smoke.py b/tools/browser_smoke.py index 1369323..4b912e3 100644 --- a/tools/browser_smoke.py +++ b/tools/browser_smoke.py @@ -123,6 +123,13 @@ def run(executable=None): response = admin.request.get(base + path) assert response.status == 200 and marker in response.body(), path maker=accounts['maker'] + maker.goto(base + '/suppliers?kw=测试供应商简称') + assert maker.locator('table.grid tbody tr').count() >= 1 + maker.goto(base + '/orders/new') + maker.locator('#supplierSearch').fill('测试供应商简称') + assert maker.locator('#supplierPickNew option').count() == 2 + maker.locator('#supplierPickNew').select_option(index=1) + assert maker.locator('[name=supplier_id]').input_value() != '' for kind,count in [('purchase',2),('outsourcing',30)]: maker.goto(base + '/orders/new') maker.locator('[name=doc_type]').select_option(kind) @@ -203,6 +210,8 @@ 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 + '/') + assert admin.locator('[data-stage="work_undelivered"] span').inner_text() == '2 单 →' + assert admin.locator('[data-stage="work_completed"] span').inner_text() == '0 单 →' 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)' @@ -212,6 +221,13 @@ def run(executable=None): 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) + maker.goto(base + '/orders?material_kw=BROWSER-2') + assert maker.locator('.orders-table tbody tr').count() == 2 + assert all('BROWSER-2' in text for text in maker.locator('.matched-materials').all_inner_texts()) + maker.screenshot(path=str(RUN/'material-order-search.png'), full_page=True) + maker.goto(base + '/materials?kw=BROWSER-2') + assert 'BROWSER-2' in maker.locator('table.grid').inner_text() + assert '0.3340' in maker.locator('table.grid').inner_text() for width in (320, 390, 768): approver.set_viewport_size({'width':width, 'height':844}) for path in (base + '/', view_url + '#order-tracking'): @@ -230,6 +246,15 @@ def run(executable=None): maker.get_by_role('button',name='导入',exact=True).click() maker.wait_for_load_state('networkidle') assert maker.locator('[name=item_date]').input_value()=='2026-10-01' + first_row = maker.locator('#itemTable tr').nth(1) + original_options = first_row.locator('.matpick option').count() + first_row.locator('.matsearch').fill('BROWSER-1') + assert 1 < first_row.locator('.matpick option').count() < original_options + target = first_row.locator('.matpick option[data-code="BROWSER-1"]').get_attribute('value') + first_row.locator('.matpick').select_option(target) + from decimal import Decimal + assert Decimal(first_row.locator('.price').input_value()) == Decimal('0.3340') + maker.screenshot(path=str(RUN/'material-picker-search.png'), full_page=True) response = admin.request.get(base + '/export/ledger') assert response.status == 200 ledger = openpyxl.load_workbook(io.BytesIO(response.body())) diff --git a/运行与升级说明.md b/运行与升级说明.md index db30121..a0c7fb7 100644 --- a/运行与升级说明.md +++ b/运行与升级说明.md @@ -10,9 +10,11 @@ 4. 在运行系统的电脑上读取 `po_app/instance/setup.token`,把初始化口令填入页面,设置管理员账号和密码。不要发送该文件给无关人员。 5. 管理员在“用户”页新增制单员、审批员,两个环节分配给不同人员。新增账号首次登录必须修改初始密码。 6. 录入真实供应商和物料,再开始制单。新数据库不再预置演示账号、演示供应商或假银行信息。 - - 在“供应商管理”填写“公司简称”,用于 PDF 导出文件名,例如 `采购单_PO202609170001_精锐五金.pdf`。未填简称时使用订单上的供应商全名;按全名匹配供应商资料,同名有歧义时结合税号匹配,仍有歧义则使用全名。 + - 在“供应商管理”填写“公司简称”,用于 PDF 导出文件名,例如 `采购单_PO202609170001_精锐五金.pdf`。未填简称时使用订单上的供应商全名;按全名匹配供应商资料,同名有歧义时结合税号匹配,仍有歧义则使用全名。供应商管理页及新建订单页均可搜索名称、简称或编号。 - 简称只用于下载命名,PDF 正文仍显示全名;已审批订单重新下载即可使用新简称,归档内容保持不变。文件名中的 Windows 不允许字符会被替换。 - Excel 供应商模板末列支持“公司简称”。旧表没有该列时保留原简称;该列留空则清除原简称。此功能使用数据库结构版本 5,旧数据库启动时先备份再增量升级。 + - 订单编辑页可搜索物料代码或规格。审批通过的订单会把有代码且可明确匹配的物料单价更新到物料库,保留四位小数;物料代码不存在时自动新增。管理员可点击物料库的“同步历史审批价”补录升级前的已审批订单价格。选物料时优先参考当前账号可见的同供应商最近审批价,再回退到物料库默认价。复制旧订单会清空旧交期,提交前须填写新交期并核对单价。 + - 订单列表可按物料代码或规格查历史订单,同时显示匹配物料;搜索范围包含已完成订单。工作台有未交付、已交付已收票待付款、已完成三个入口,其余未收票、未登记和变更后待核对订单可在“其他待跟进”查看。 系统使用 Waitress 提供服务。源码和 EXE 均支持 `PO_DATA_DIR` 指定数据目录;未设置时分别使用 `po_app` 目录和 EXE 所在目录。`PO_HOST` 默认 `0.0.0.0`;端口已占用时启动程序会在 8120 之前尝试后续端口,以控制台打印地址为准。
编号名称联系人电话付款方式税号开户银行银行帐号操作