Implement issue 1 search, order reuse and tracking
This commit is contained in:
@@ -43,6 +43,9 @@ Windows 7 SP1 服务器使用单独的 [Win7 离线打包说明](packaging/win7/
|
|||||||
|
|
||||||
- 采购单和委外单,供应商/物料库、中文 XLS/XLSX 导入。
|
- 采购单和委外单,供应商/物料库、中文 XLS/XLSX 导入。
|
||||||
- 供应商支持维护公司简称,PDF 下载文件名采用“单据类型_订单号_供应商简称.pdf”;未填写简称时使用供应商全名,已审批文件重新下载也适用。
|
- 供应商支持维护公司简称,PDF 下载文件名采用“单据类型_订单号_供应商简称.pdf”;未填写简称时使用供应商全名,已审批文件重新下载也适用。
|
||||||
|
- 供应商库支持按名称、简称、编号搜索;物料库与订单编辑页支持按物料代码、规格搜索,选库内物料可带出单价。
|
||||||
|
- 订单列表支持按物料查历史订单并标出匹配明细,搜索结果包含已完成订单;可直接复制旧订单为草稿,新交期须重新填写。
|
||||||
|
- 审批通过时将订单中有代码、可明确匹配的物料单价写入物料库,保留四位小数。管理员可在物料库点击“同步历史审批价”,补录升级前的已审批订单。选物料时优先参考本账号同供应商的最近审批价,其他情况使用物料库默认价;两者都应在提交前核对。
|
||||||
- 草稿可暂存,“保存并提交”保证当前修改一起进入审批。
|
- 草稿可暂存,“保存并提交”保证当前修改一起进入审批。
|
||||||
- 数量、单价四位精度;逐行四舍五入到分再合计,页面、数据库和 PDF 使用同一口径。
|
- 数量、单价四位精度;逐行四舍五入到分再合计,页面、数据库和 PDF 使用同一口径。
|
||||||
- 制单与审批由不同账号完成,管理员也遵守分工;审批员一次确认即可通过或驳回。
|
- 制单与审批由不同账号完成,管理员也遵守分工;审批员一次确认即可通过或驳回。
|
||||||
@@ -56,6 +59,7 @@ Windows 7 SP1 服务器使用单独的 [Win7 离线打包说明](packaging/win7/
|
|||||||
- 按状态、供应商、月份统计以及 Excel 台账导出。
|
- 按状态、供应商、月份统计以及 Excel 台账导出。
|
||||||
- 已审批订单的预付款标记、收票与交货跟进;审批员或管理员登记付款状态与日期,支持部分付款、部分交货。
|
- 已审批订单的预付款标记、收票与交货跟进;审批员或管理员登记付款状态与日期,支持部分付款、部分交货。
|
||||||
- 工作台按需预付款、已收票待付款、未交货、已付款未交齐等情况统计并跳转筛选,台账同步导出跟进状态。
|
- 工作台按需预付款、已收票待付款、未交货、已付款未交齐等情况统计并跳转筛选,台账同步导出跟进状态。
|
||||||
|
- 工作台提供“未交付(含部分交货)”“已交付已收票待付款”“已完成”三类入口;未收票、未登记和变更后待核对订单仍可在“其他待跟进”查看。
|
||||||
|
|
||||||
图形审批章表示系统内部审批确认,不是第三方证书数字签名。金额分级、部门审批、企业微信个人定向通知、预算和逐笔资金对账不在当前版本中。付款与交货按整张订单标注状态,部分完成的明细可填写在备注中。
|
图形审批章表示系统内部审批确认,不是第三方证书数字签名。金额分级、部门审批、企业微信个人定向通知、预算和逐笔资金对账不在当前版本中。付款与交货按整张订单标注状态,部分完成的明细可填写在备注中。
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
本包用于在你的 Windows 7 服务电脑上,从 v4 源码生成 EXE。包括 Python 3.8.10 官方完整安装程序、32 位与 64 位离线依赖、构建脚本、环境与业务自检,以及现有 16 页使用升级手册。
|
本包用于在你的 Windows 7 服务电脑上,从 v4 源码生成 EXE。包括 Python 3.8.10 官方完整安装程序、32 位与 64 位离线依赖、构建脚本、环境与业务自检,以及现有 16 页使用升级手册。
|
||||||
|
|
||||||
|
2026-10-14 工单更新:供应商可按名称、简称和编号搜索;订单编辑及物料库可按物料代码、规格搜索。订单列表可按物料搜索历史订单,匹配明细显示在订单下方,已完成订单也会出现在搜索结果中;可从列表复制旧订单,新交期需重新填写。订单审批通过时将有代码且可明确匹配的物料单价更新到物料库,精度保留四位小数;管理员可在物料库手动“同步历史审批价”,用旧的已审批订单补录价格。选物料时先参考同供应商、当前账号可见的最近审批价。工作台增加“未交付”“已交付已收票待付款”“已完成”三类入口,未收票或待核对的订单在“其他待跟进”。这些改动不增加数据库结构版本;原 PDF 使用手册页面截图可能与新入口位置不同,以新版界面及本段说明为准。
|
||||||
|
|
||||||
安装脚本修复:新版解压到另一文件夹时,先检查本包及系统登记的 Python 3.8.10,确认位数和运行正常后复用;不再仅因新目录没有 `.python38` 就重复安装。只有找不到可用环境时才运行官方安装器。安装器错误码、依赖安装及自检输出会保存在 `logs/`。
|
安装脚本修复:新版解压到另一文件夹时,先检查本包及系统登记的 Python 3.8.10,确认位数和运行正常后复用;不再仅因新目录没有 `.python38` 就重复安装。只有找不到可用环境时才运行官方安装器。安装器错误码、依赖安装及自检输出会保存在 `logs/`。
|
||||||
|
|
||||||
2026-09-17 工作台更新:突出“需预付款”“已收票待付款”“已付款未交齐”“变更后待核对”。待跟进订单先按处理类别排列,同类较早订单优先;付款、交货、收票均完成后默认收起,仍可通过筛选查看。付款和交货分别显示三档状态;“未登记”和变更前待核对记录单独提示,不换算虚假的完成百分比。
|
2026-09-17 工作台更新:突出“需预付款”“已收票待付款”“已付款未交齐”“变更后待核对”。待跟进订单先按处理类别排列,同类较早订单优先;付款、交货、收票均完成后默认收起,仍可通过筛选查看。付款和交货分别显示三档状态;“未登记”和变更前待核对记录单独提示,不换算虚假的完成百分比。
|
||||||
|
|||||||
+51
-6
@@ -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,
|
DEFAULT_OUR_PHONE, DEFAULT_OUR_TAX_NO, DEFAULT_TAX_RATE,
|
||||||
ApprovalLog, Material, Order, OrderItem, Supplier, User,
|
ApprovalLog, Material, Order, OrderItem, Supplier, User,
|
||||||
init_db)
|
init_db)
|
||||||
|
from material_prices import remember_approved_prices, supplier_price_map
|
||||||
from pdf_export import build_pdf
|
from pdf_export import build_pdf
|
||||||
|
|
||||||
# 打包成 EXE 后:资源在临时目录 _MEIPASS,数据必须写在 EXE 同级目录
|
# 打包成 EXE 后:资源在临时目录 _MEIPASS,数据必须写在 EXE 同级目录
|
||||||
@@ -100,6 +101,7 @@ def orders():
|
|||||||
doc_type = request.args.get("doc_type", "")
|
doc_type = request.args.get("doc_type", "")
|
||||||
date_from = request.args.get("date_from", "")
|
date_from = request.args.get("date_from", "")
|
||||||
date_to = request.args.get("date_to", "")
|
date_to = request.args.get("date_to", "")
|
||||||
|
material_kw = request.args.get('material_kw', '').strip()[:100]
|
||||||
if kw:
|
if kw:
|
||||||
q = q.filter(Order.order_no.like("%%%s%%" % kw) |
|
q = q.filter(Order.order_no.like("%%%s%%" % kw) |
|
||||||
Order.supplier_name.like("%%%s%%" % kw))
|
Order.supplier_name.like("%%%s%%" % kw))
|
||||||
@@ -111,15 +113,26 @@ def orders():
|
|||||||
q = q.filter(Order.order_date >= date_from)
|
q = q.filter(Order.order_date >= date_from)
|
||||||
if date_to:
|
if date_to:
|
||||||
q = q.filter(Order.order_date <= 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":
|
if u.role == "maker":
|
||||||
q = q.filter(Order.created_by == u.id)
|
q = q.filter(Order.created_by == u.id)
|
||||||
tracking = request.args.get('tracking', '')
|
tracking = request.args.get('tracking', '')
|
||||||
q = filter_tracking(q, 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']))
|
q = q.filter(or_(Order.status != 'approved', ~tracking_conditions()['closed']))
|
||||||
rows = q.order_by(Order.id.desc()).all()
|
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,
|
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")
|
@app.route("/import_template.xlsx")
|
||||||
@@ -165,8 +178,14 @@ def suppliers():
|
|||||||
db.commit()
|
db.commit()
|
||||||
flash(u"已保存", "ok")
|
flash(u"已保存", "ok")
|
||||||
return redirect(url_for("suppliers"))
|
return redirect(url_for("suppliers"))
|
||||||
rows = db.query(Supplier).order_by(Supplier.id).all()
|
kw = request.args.get('kw', '').strip()[:100]
|
||||||
return render_template("suppliers.html", user=u, rows=rows)
|
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/<int:sid>/toggle", methods=["POST"])
|
@app.route("/suppliers/<int:sid>/toggle", methods=["POST"])
|
||||||
@@ -307,8 +326,26 @@ def materials():
|
|||||||
db.commit()
|
db.commit()
|
||||||
flash(u"已保存", "ok")
|
flash(u"已保存", "ok")
|
||||||
return redirect(url_for("materials"))
|
return redirect(url_for("materials"))
|
||||||
rows = db.query(Material).order_by(Material.id).all()
|
kw = request.args.get('kw', '').strip()[:100]
|
||||||
return render_template("materials.html", user=u, rows=rows)
|
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/<int:mid>/toggle", methods=["POST"])
|
@app.route("/materials/<int:mid>/toggle", methods=["POST"])
|
||||||
@@ -431,6 +468,14 @@ def api_suppliers():
|
|||||||
"payment_terms": s.payment_terms} for s in rows])
|
"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"])
|
@app.route("/users", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -17,6 +17,7 @@ from order_logic import (ORDER_FIELDS, ITEM_FIELDS, add_log, can_edit, can_appro
|
|||||||
validate_order)
|
validate_order)
|
||||||
from pdf_export import PDF_TEMPLATE_VERSION, build_pdf, seal_layout
|
from pdf_export import PDF_TEMPLATE_VERSION, build_pdf, seal_layout
|
||||||
from order_tracking import can_follow, can_record_payment
|
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
|
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,
|
return render_template('order_edit.html', user=user, o=order, editable=True,
|
||||||
suppliers=db.query(Supplier).filter_by(active=True).all(),
|
suppliers=db.query(Supplier).filter_by(active=True).all(),
|
||||||
materials=db.query(Material).filter_by(active=True).all(), doc_types=DOC_TYPES,
|
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))
|
total_cn=amount_to_cn(order.total_amount))
|
||||||
|
|
||||||
@app.route('/orders/<int:oid>/view')
|
@app.route('/orders/<int:oid>/view')
|
||||||
@@ -161,6 +163,7 @@ def register_order_routes(app):
|
|||||||
version.pdf_sha256 = hashlib.sha256(version.pdf_data).hexdigest()
|
version.pdf_sha256 = hashlib.sha256(version.pdf_data).hexdigest()
|
||||||
version.template_version = (PDF_TEMPLATE_VERSION + '-legacy-current'
|
version.template_version = (PDF_TEMPLATE_VERSION + '-legacy-current'
|
||||||
if version.template_version == 'legacy-current' else PDF_TEMPLATE_VERSION)
|
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)
|
add_log(order, '审批通过并归档正式 PDF', user, request.form.get('comment'), old)
|
||||||
db.commit()
|
db.commit()
|
||||||
flash('审批通过,正式 PDF 已归档', 'ok')
|
flash('审批通过,正式 PDF 已归档', 'ok')
|
||||||
@@ -263,11 +266,14 @@ def register_order_routes(app):
|
|||||||
order.order_date = datetime.now().strftime('%Y-%m-%d')
|
order.order_date = datetime.now().strftime('%Y-%m-%d')
|
||||||
order.items = [OrderItem(seq=i, **{field: getattr(item, field) for field in ITEM_FIELDS})
|
order.items = [OrderItem(seq=i, **{field: getattr(item, field) for field in ITEM_FIELDS})
|
||||||
for i, item in enumerate(source.items, 1)]
|
for i, item in enumerate(source.items, 1)]
|
||||||
|
for item in order.items:
|
||||||
|
item.delivery_date = ''
|
||||||
recalc(order)
|
recalc(order)
|
||||||
db.add(order)
|
db.add(order)
|
||||||
remember_creation(key, 'copy:%s' % oid, order)
|
remember_creation(key, 'copy:%s' % oid, order)
|
||||||
add_log(order, '复制创建草稿', user, '来源:' + source.order_no)
|
add_log(order, '复制创建草稿', user, '来源:' + source.order_no)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
flash('已复制为新草稿。请填写新交期,并核对单价和订单条款。', 'ok')
|
||||||
return redirect(url_for('order_edit', oid=order.id))
|
return redirect(url_for('order_edit', oid=order.id))
|
||||||
|
|
||||||
@app.route('/orders/<int:oid>/pdf')
|
@app.route('/orders/<int:oid>/pdf')
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ from security import current_user, db, login_required
|
|||||||
|
|
||||||
TRACKING_FILTERS = {
|
TRACKING_FILTERS = {
|
||||||
'open': '待跟进订单',
|
'open': '待跟进订单',
|
||||||
|
'work_undelivered': '未交付订单(含部分交货)',
|
||||||
|
'work_invoice_due': '已交付已收票待付款',
|
||||||
|
'work_completed': '已完成订单',
|
||||||
|
'work_other': '其他待跟进 / 待核对',
|
||||||
'prepayment': '需预付款',
|
'prepayment': '需预付款',
|
||||||
'invoice_due': '已收票待付款',
|
'invoice_due': '已收票待付款',
|
||||||
'unpaid': '未付款',
|
'unpaid': '未付款',
|
||||||
@@ -35,9 +39,17 @@ def tracking_conditions():
|
|||||||
completed = and_(current_payment, current_fulfillment, Order.payment_status == 'paid',
|
completed = and_(current_payment, current_fulfillment, Order.payment_status == 'paid',
|
||||||
Order.delivery_status == 'delivered')
|
Order.delivery_status == 'delivered')
|
||||||
closed = and_(completed, Order.invoice_status == 'received')
|
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 {
|
return {
|
||||||
'open': ~closed,
|
'open': ~closed,
|
||||||
'closed': 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),
|
'prepayment': and_(current_fulfillment, Order.needs_prepayment.is_(True), payment_due),
|
||||||
'invoice_due': and_(current_fulfillment, Order.invoice_status == 'received', payment_due),
|
'invoice_due': and_(current_fulfillment, Order.invoice_status == 'received', payment_due),
|
||||||
'unpaid': and_(current_payment, Order.payment_status == 'unpaid'),
|
'unpaid': and_(current_payment, Order.payment_status == 'unpaid'),
|
||||||
|
|||||||
@@ -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} 项,请选择` : '请选择');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 16px; }
|
||||||
.form-grid.two { grid-template-columns: repeat(2, 1fr); }
|
.form-grid.two { grid-template-columns: repeat(2, 1fr); }
|
||||||
label { display: block; font-size: 12px; color: #666; margin-bottom: 3px; }
|
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;
|
width: 100%; padding: 6px 8px; border: 1px solid #ccd3da; border-radius: 4px;
|
||||||
font-size: 13px; font-family: inherit; background: #fff;
|
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. */
|
/* Payment / delivery workspace: independent states, task-first rows. */
|
||||||
.tracking-dashboard .card-head { align-items: flex-start; }
|
.tracking-dashboard .card-head { align-items: flex-start; }
|
||||||
.tracking-dashboard .card-head > a { flex-shrink: 0; white-space: nowrap; }
|
.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-dashboard .card-head p { margin: 6px 0 0; }
|
||||||
.tracking-focus .stat { position: relative; border-top: 3px solid #d99a30; padding-right: 28px; }
|
.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-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-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.tracking-forms { grid-template-columns: minmax(0, 1fr); }
|
.tracking-forms { grid-template-columns: minmax(0, 1fr); }
|
||||||
.tracking-panels { 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.grid.order-cards { display: block; min-width: 0; }
|
||||||
table.order-cards thead { display: none; }
|
table.order-cards thead { display: none; }
|
||||||
table.order-cards tbody { display: block; }
|
table.order-cards tbody { display: block; }
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
<div class="card tracking-dashboard" id="tracking-board">
|
<div class="card tracking-dashboard" id="tracking-board">
|
||||||
{% from 'tracking_macros.html' import progress, invoice, tasks, actions with context %}
|
{% 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="card-head"><div><h2>付款与交货待办</h2><p class="muted">先核对变更,再跟进付款与交货。同一订单可能出现在多个提醒中。</p></div><a href="{{ url_for('orders') }}">全部订单</a></div>
|
||||||
|
<div class="order-stage-links" aria-label="按订单进展查看">
|
||||||
|
{% for key in ['work_undelivered', 'work_invoice_due', 'work_completed'] %}<a class="order-stage {{ 'selected' if tracking_key == key else '' }}" data-stage="{{ key }}" href="{{ url_for('index', tracking=key) }}#tracking-board"><b>{{ TRACKING_FILTERS[key] }}</b><span>{{ tracking_counts[key] }} 单 →</span></a>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="muted">交货、收票或付款未登记,以及变更后待核对的订单,可在 <a href="{{ url_for('index', tracking='work_other') }}#tracking-board">其他待跟进({{ tracking_counts['work_other'] }})</a>查看。</p>
|
||||||
<div class="stat-row tracking-focus">
|
<div class="stat-row tracking-focus">
|
||||||
{% for key in ['prepayment','invoice_due','paid_undelivered','recheck'] %}
|
{% 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>
|
<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>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<div><label>物料代码 *</label><input type="text" name="code" required placeholder="M-1004"></div>
|
<div><label>物料代码 *</label><input type="text" name="code" required placeholder="M-1004"></div>
|
||||||
<div><label>物料规格 / 名称</label><input type="text" name="spec"></div>
|
<div><label>物料规格 / 名称</label><input type="text" name="spec"></div>
|
||||||
<div><label>单位</label><input type="text" name="unit" placeholder="件"></div>
|
<div><label>单位</label><input type="text" name="unit" placeholder="件"></div>
|
||||||
<div><label>默认含税单价</label><input type="number" step="0.01" name="price" value="0"></div>
|
<div><label>默认含税单价(审批后更新)</label><input type="number" step="0.0001" name="price" value="0"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:12px;"><button class="btn primary">新增</button></div>
|
<div style="margin-top:12px;"><button class="btn primary">新增</button></div>
|
||||||
</form>
|
</form>
|
||||||
@@ -18,14 +18,19 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h2>物料列表</h2>
|
<h2>物料列表</h2>
|
||||||
|
{% if user.role == 'admin' %}<form method="post" action="{{ url_for('sync_approved_material_prices') }}" onsubmit="return confirm('将按审批先后用已审批订单价格更新物料库默认价。是否继续?')"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button type="submit" class="btn sm">同步历史审批价</button></form>{% endif %}
|
||||||
<button type="button" class="btn primary sm" onclick="document.getElementById('matImpModal').style.display='flex'">批量导入 Excel</button>
|
<button type="button" class="btn primary sm" onclick="document.getElementById('matImpModal').style.display='flex'">批量导入 Excel</button>
|
||||||
</div>
|
</div>
|
||||||
|
<form method="get" class="toolbar" role="search">
|
||||||
|
<input type="search" name="kw" value="{{ kw }}" placeholder="搜索物料代码或规格" aria-label="搜索物料库" style="max-width:320px;">
|
||||||
|
<button class="btn primary">搜索</button>{% if kw %}<a class="btn" href="{{ url_for('materials') }}">清除</a>{% endif %}
|
||||||
|
</form>
|
||||||
<table class="grid">
|
<table class="grid">
|
||||||
<tr><th>物料代码</th><th>物料规格</th><th>单位</th><th class="num">默认含税单价</th><th>操作</th></tr>
|
<tr><th>物料代码</th><th>物料规格</th><th>单位</th><th class="num">默认含税单价</th><th>操作</th></tr>
|
||||||
{% for m in rows %}
|
{% for m in rows %}
|
||||||
<tr class="{% if not m.active %}row-off{% endif %}">
|
<tr class="{% if not m.active %}row-off{% endif %}">
|
||||||
<td>{{ m.code }}</td><td>{{ m.spec }}</td><td class="center">{{ m.unit }}</td>
|
<td>{{ m.code }}</td><td>{{ m.spec }}</td><td class="center">{{ m.unit }}</td>
|
||||||
<td class="num">{{ '%.2f'|format(m.price) }}</td>
|
<td class="num">{{ '%.4f'|format(m.price) }}</td>
|
||||||
<td class="nowrap">
|
<td class="nowrap">
|
||||||
<button type="button" class="btn sm" onclick="openEdit(this)"
|
<button type="button" class="btn sm" onclick="openEdit(this)"
|
||||||
data-id="{{ m.id }}" data-code="{{ m.code }}"
|
data-id="{{ m.id }}" data-code="{{ m.code }}"
|
||||||
@@ -105,7 +110,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-grid two">
|
<div class="form-grid two">
|
||||||
<div><label>单位</label><input type="text" name="unit" id="edit_unit"></div>
|
<div><label>单位</label><input type="text" name="unit" id="edit_unit"></div>
|
||||||
<div><label>默认含税单价</label><input type="number" step="0.01" name="price" id="edit_price" value="0"></div>
|
<div><label>默认含税单价</label><input type="number" step="0.0001" name="price" id="edit_price" value="0"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
|
|||||||
@@ -18,9 +18,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<label>供应商</label>
|
<label>供应商</label>
|
||||||
<input type="text" name="supplier_name" id="supplier_name" value="{{ o.supplier_name or '' }}">
|
<input type="text" name="supplier_name" id="supplier_name" value="{{ o.supplier_name or '' }}">
|
||||||
|
<input type="search" id="supplierSearch" placeholder="搜索供应商名称、简称或编号" aria-label="搜索供应商" style="margin-top:4px;">
|
||||||
<select id="supplierPick" style="margin-top:4px;">
|
<select id="supplierPick" style="margin-top:4px;">
|
||||||
<option value="">-- 从供应商库带入 --</option>
|
<option value="">-- 从供应商库带入 --</option>
|
||||||
{% for s in suppliers %}<option value="{{ s.id }}">{{ s.name }}</option>{% endfor %}
|
{% for s in suppliers %}<option value="{{ s.id }}" data-search="{{ s.short_name or '' }} {{ s.code or '' }}">{{ s.name }}{{ '(' ~ s.short_name ~ ')' if s.short_name else '' }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div><label>联系人</label><input type="text" name="supplier_contact" id="supplier_contact" value="{{ o.supplier_contact or '' }}"></div>
|
<div><label>联系人</label><input type="text" name="supplier_contact" id="supplier_contact" value="{{ o.supplier_contact or '' }}"></div>
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="center seq">{{ loop.index }}</td>
|
<td class="center seq">{{ loop.index }}</td>
|
||||||
<td><input type="text" name="item_code" value="{{ it.material_code or '' }}">
|
<td><input type="text" name="item_code" value="{{ it.material_code or '' }}">
|
||||||
|
<input type="search" class="matsearch" placeholder="搜索代码或规格" aria-label="搜索物料" style="margin-top:3px;">
|
||||||
<select class="matpick" style="margin-top:3px;">
|
<select class="matpick" style="margin-top:3px;">
|
||||||
<option value="">-- 物料库 --</option>
|
<option value="">-- 物料库 --</option>
|
||||||
{% for m in materials %}<option value="{{ m.id }}" data-code="{{ m.code }}" data-spec="{{ m.spec }}" data-unit="{{ m.unit }}" data-price="{{ m.price }}">{{ m.code }} {{ m.spec }}</option>{% endfor %}
|
{% for m in materials %}<option value="{{ m.id }}" data-code="{{ m.code }}" data-spec="{{ m.spec }}" data-unit="{{ m.unit }}" data-price="{{ m.price }}">{{ m.code }} {{ m.spec }}</option>{% endfor %}
|
||||||
@@ -82,6 +84,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="center seq">1</td>
|
<td class="center seq">1</td>
|
||||||
<td><input type="text" name="item_code" value="">
|
<td><input type="text" name="item_code" value="">
|
||||||
|
<input type="search" class="matsearch" placeholder="搜索代码或规格" aria-label="搜索物料" style="margin-top:3px;">
|
||||||
<select class="matpick" style="margin-top:3px;">
|
<select class="matpick" style="margin-top:3px;">
|
||||||
<option value="">-- 物料库 --</option>
|
<option value="">-- 物料库 --</option>
|
||||||
{% for m in materials %}<option value="{{ m.id }}" data-code="{{ m.code }}" data-spec="{{ m.spec }}" data-unit="{{ m.unit }}" data-price="{{ m.price }}">{{ m.code }} {{ m.spec }}</option>{% endfor %}
|
{% for m in materials %}<option value="{{ m.id }}" data-code="{{ m.code }}" data-spec="{{ m.spec }}" data-unit="{{ m.unit }}" data-price="{{ m.price }}">{{ m.code }} {{ m.spec }}</option>{% endfor %}
|
||||||
@@ -140,18 +143,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='money.js') }}"></script>
|
<script src="{{ url_for('static', filename='money.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='search_select.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
const MATERIALS = {{ materials|map(attribute='id')|list|tojson }};
|
const MATERIALS = {{ materials|map(attribute='id')|list|tojson }};
|
||||||
|
let supplierPrices = {{ supplier_prices|tojson }};
|
||||||
|
POFilterSelect.bind(document.getElementById('supplierSearch'), document.getElementById('supplierPick'));
|
||||||
|
async function updateSupplierPrices(){
|
||||||
|
const name = document.getElementById('supplier_name').value.trim();
|
||||||
|
const tax = document.getElementById('supplier_tax_no').value.trim();
|
||||||
|
if (!name) { supplierPrices = {}; return; }
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams({supplier_name: name, supplier_tax_no: tax});
|
||||||
|
const response = await fetch('{{ url_for('api_material_prices') }}?' + query.toString());
|
||||||
|
if (response.ok) supplierPrices = await response.json();
|
||||||
|
} catch (error) { /* The catalog default remains usable offline. */ }
|
||||||
|
}
|
||||||
function bindRow(tr){
|
function bindRow(tr){
|
||||||
tr.querySelectorAll('.qty, .price').forEach(i => i.addEventListener('input', calc));
|
tr.querySelectorAll('.qty, .price').forEach(i => i.addEventListener('input', calc));
|
||||||
const pick = tr.querySelector('.matpick');
|
const pick = tr.querySelector('.matpick');
|
||||||
|
POFilterSelect.bind(tr.querySelector('.matsearch'), pick);
|
||||||
if (pick) pick.addEventListener('change', function(){
|
if (pick) pick.addEventListener('change', function(){
|
||||||
const opt = this.selectedOptions[0];
|
const opt = this.selectedOptions[0];
|
||||||
if (!opt || !opt.value) return;
|
if (!opt || !opt.value) return;
|
||||||
tr.querySelector('[name=item_code]').value = opt.dataset.code || '';
|
tr.querySelector('[name=item_code]').value = opt.dataset.code || '';
|
||||||
tr.querySelector('[name=item_spec]').value = opt.dataset.spec || '';
|
tr.querySelector('[name=item_spec]').value = opt.dataset.spec || '';
|
||||||
tr.querySelector('[name=item_unit]').value = opt.dataset.unit || '';
|
tr.querySelector('[name=item_unit]').value = opt.dataset.unit || '';
|
||||||
tr.querySelector('.price').value = opt.dataset.price || 0;
|
tr.querySelector('.price').value = supplierPrices[opt.dataset.code] || opt.dataset.price || 0;
|
||||||
calc();
|
calc();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -187,6 +204,8 @@ function delRow(btn){
|
|||||||
btn.closest('tr').remove(); calc();
|
btn.closest('tr').remove(); calc();
|
||||||
}
|
}
|
||||||
document.querySelectorAll('#itemTable tr').forEach(bindRow);
|
document.querySelectorAll('#itemTable tr').forEach(bindRow);
|
||||||
|
document.getElementById('supplier_name').addEventListener('change', updateSupplierPrices);
|
||||||
|
document.getElementById('supplier_tax_no').addEventListener('change', updateSupplierPrices);
|
||||||
calc();
|
calc();
|
||||||
document.getElementById('supplierPick').addEventListener('change', async function(){
|
document.getElementById('supplierPick').addEventListener('change', async function(){
|
||||||
if (!this.value) return;
|
if (!this.value) return;
|
||||||
@@ -200,6 +219,7 @@ document.getElementById('supplierPick').addEventListener('change', async functio
|
|||||||
document.getElementById('supplier_bank_name').value = s.bank_name || '';
|
document.getElementById('supplier_bank_name').value = s.bank_name || '';
|
||||||
document.getElementById('supplier_bank_account').value = s.bank_account || '';
|
document.getElementById('supplier_bank_account').value = s.bank_account || '';
|
||||||
document.getElementById('payment_terms').value = s.payment_terms || '';
|
document.getElementById('payment_terms').value = s.payment_terms || '';
|
||||||
|
await updateSupplierPrices();
|
||||||
});
|
});
|
||||||
let dirty = false;
|
let dirty = false;
|
||||||
const orderForm = document.getElementById('orderForm');
|
const orderForm = document.getElementById('orderForm');
|
||||||
|
|||||||
@@ -15,10 +15,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>供应商(留空则手工填写)</label>
|
<label>供应商(留空则手工填写)</label>
|
||||||
<select name="supplier_id">
|
<input type="search" id="supplierSearch" placeholder="搜索名称、简称或编号" aria-label="搜索供应商">
|
||||||
|
<select name="supplier_id" id="supplierPickNew" style="margin-top:4px;">
|
||||||
<option value="">-- 手工填写 --</option>
|
<option value="">-- 手工填写 --</option>
|
||||||
{% for s in suppliers %}
|
{% for s in suppliers %}
|
||||||
<option value="{{ s.id }}">{{ s.name }}</option>
|
<option value="{{ s.id }}" data-search="{{ s.short_name or '' }} {{ s.code or '' }}">{{ s.name }}{{ '(' ~ s.short_name ~ ')' if s.short_name else '' }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,4 +30,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="{{ url_for('static', filename='search_select.js') }}"></script>
|
||||||
|
<script>POFilterSelect.bind(document.getElementById('supplierSearch'), document.getElementById('supplierPickNew'));</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
<h1 class="page">订单列表</h1>
|
<h1 class="page">订单列表</h1>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<form class="toolbar" method="get">
|
<form class="toolbar" method="get">
|
||||||
<input type="text" name="kw" placeholder="单号 / 供应商" value="{{ args.get('kw','') }}" style="width:180px;">
|
<input type="search" name="kw" placeholder="单号 / 供应商" value="{{ args.get('kw','') }}" aria-label="搜索单号或供应商" style="width:180px;">
|
||||||
|
<input type="search" name="material_kw" placeholder="物料代码 / 规格" value="{{ args.get('material_kw','') }}" aria-label="按物料搜索订单" style="width:180px;">
|
||||||
<select name="status" style="width:140px;">
|
<select name="status" style="width:140px;">
|
||||||
<option value="">全部状态</option>
|
<option value="">全部状态</option>
|
||||||
{% for k, v in STATUS_TEXT.items() %}
|
{% for k, v in STATUS_TEXT.items() %}
|
||||||
@@ -30,18 +31,19 @@
|
|||||||
<a class="btn" href="{{ url_for('export_ledger') }}">导出 Excel 台账</a>
|
<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 %}
|
{% if user.role in ['maker','admin'] %}<a class="btn primary" href="{{ url_for('order_new') }}">新建订单</a>{% endif %}
|
||||||
</form>
|
</form>
|
||||||
{% if not args.get('tracking') and args.get('show_completed') != '1' %}<p class="muted">已收起付款、交货、收票均完成的订单。勾选“显示全部完成订单”可查看。</p>{% endif %}
|
{% if not args.get('tracking') and not args.get('kw') and not args.get('material_kw') and args.get('show_completed') != '1' %}<p class="muted">已收起付款、交货、收票均完成的订单。搜索时会同时查找历史已完成订单。</p>{% endif %}
|
||||||
|
{% if args.get('material_kw') %}<p class="hint">物料搜索结果同时包含历史已完成订单。匹配的物料显示在订单下方。</p>{% endif %}
|
||||||
<table class="grid orders-table order-cards">
|
<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>
|
<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 %}
|
<tbody>{% for o in rows %}
|
||||||
<tr data-order-id="{{ o.id }}">
|
<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="订单" 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 %}{% if matched_items.get(o.id) %}<div class="matched-materials"><b>匹配物料:</b>{% for it in matched_items[o.id][:5] %}<span>{{ it.material_code or '无代码' }} · {{ it.material_spec or '未填规格' }}</span>{% endfor %}{% if matched_items[o.id]|length > 5 %}<span>另有 {{ matched_items[o.id]|length - 5 }} 项</span>{% endif %}</div>{% endif %}</td>
|
||||||
<td data-label="审批"><span class="badge {{ o.status }}">{{ o.status_text }}</span></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="订单金额" class="num">¥{{ '%.2f'|format(o.total_amount) }}</td>
|
||||||
<td data-label="付款">{{ progress(o, 'payment') }}</td>
|
<td data-label="付款">{{ progress(o, 'payment') }}</td>
|
||||||
<td data-label="交货">{{ progress(o, 'delivery') }}</td>
|
<td data-label="交货">{{ progress(o, 'delivery') }}</td>
|
||||||
<td data-label="收票 / 待办">{{ invoice(o) }}{{ tasks(o) }}</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>
|
<td data-label="操作" class="order-next">{{ actions(o, user) }}{% if user.role in ['maker', 'admin'] %}<form method="post" action="{{ url_for('order_copy', oid=o.id) }}" class="copy-order-form"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="row_version" value="{{ o.row_version }}"><input type="hidden" name="request_key" value="{{ request_key() }}"><button class="btn sm" type="submit">复制下单</button></form>{% endif %}<a class="tracking-detail-link" href="{{ url_for('order_pdf', oid=o.id) }}" target="_blank">下载 / 预览 PDF</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}<tr class="empty-row"><td colspan="7" class="center muted">没有符合条件的订单</td></tr>{% endfor %}</tbody>
|
{% else %}<tr class="empty-row"><td colspan="7" class="center muted">没有符合条件的订单</td></tr>{% endfor %}</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
<h2>供应商列表</h2>
|
<h2>供应商列表</h2>
|
||||||
<button type="button" class="btn primary sm" onclick="document.getElementById('supImpModal').style.display='flex'">批量导入 Excel</button>
|
<button type="button" class="btn primary sm" onclick="document.getElementById('supImpModal').style.display='flex'">批量导入 Excel</button>
|
||||||
</div>
|
</div>
|
||||||
|
<form method="get" class="toolbar" role="search">
|
||||||
|
<input type="search" name="kw" value="{{ kw }}" placeholder="搜索名称、简称或编号" aria-label="搜索供应商" style="max-width:320px;">
|
||||||
|
<button class="btn primary">搜索</button>{% if kw %}<a class="btn" href="{{ url_for('suppliers') }}">清除</a>{% endif %}
|
||||||
|
</form>
|
||||||
<table class="grid">
|
<table class="grid">
|
||||||
<tr><th>编号</th><th>名称</th><th>联系人</th><th>电话</th><th>付款方式</th><th>税号</th><th>开户银行</th><th>银行帐号</th><th>操作</th></tr>
|
<tr><th>编号</th><th>名称</th><th>联系人</th><th>电话</th><th>付款方式</th><th>税号</th><th>开户银行</th><th>银行帐号</th><th>操作</th></tr>
|
||||||
{% for s in rows %}
|
{% for s in rows %}
|
||||||
|
|||||||
@@ -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'<b>STAGE-([A-H])</b>', 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)
|
||||||
@@ -123,6 +123,13 @@ def run(executable=None):
|
|||||||
response = admin.request.get(base + path)
|
response = admin.request.get(base + path)
|
||||||
assert response.status == 200 and marker in response.body(), path
|
assert response.status == 200 and marker in response.body(), path
|
||||||
maker=accounts['maker']
|
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)]:
|
for kind,count in [('purchase',2),('outsourcing',30)]:
|
||||||
maker.goto(base + '/orders/new')
|
maker.goto(base + '/orders/new')
|
||||||
maker.locator('[name=doc_type]').select_option(kind)
|
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')))
|
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]
|
assert hashlib.sha256(approver.request.get(view_url.replace('/view','/pdf')).body()).hexdigest() == archives[-1][1]
|
||||||
admin.goto(base + '/')
|
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'):
|
for key in ('prepayment', 'invoice_due', 'paid_undelivered'):
|
||||||
assert admin.locator('[data-tracking="'+key+'"] .v').inner_text() == '1', key
|
assert admin.locator('[data-tracking="'+key+'"] .v').inner_text() == '1', key
|
||||||
assert admin.locator('#board-filter option[value="partial_delivery"]').inner_text() == '部分交货(1)'
|
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 admin.locator('.tracking-table tbody tr').count() == 1
|
||||||
assert 'WO' in admin.locator('.tracking-table tbody tr').first.inner_text()
|
assert 'WO' in admin.locator('.tracking-table tbody tr').first.inner_text()
|
||||||
admin.screenshot(path=str(RUN / 'tracking-filter.png'), full_page=True)
|
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):
|
for width in (320, 390, 768):
|
||||||
approver.set_viewport_size({'width':width, 'height':844})
|
approver.set_viewport_size({'width':width, 'height':844})
|
||||||
for path in (base + '/', view_url + '#order-tracking'):
|
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.get_by_role('button',name='导入',exact=True).click()
|
||||||
maker.wait_for_load_state('networkidle')
|
maker.wait_for_load_state('networkidle')
|
||||||
assert maker.locator('[name=item_date]').input_value()=='2026-10-01'
|
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')
|
response = admin.request.get(base + '/export/ledger')
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
ledger = openpyxl.load_workbook(io.BytesIO(response.body()))
|
ledger = openpyxl.load_workbook(io.BytesIO(response.body()))
|
||||||
|
|||||||
+3
-1
@@ -10,9 +10,11 @@
|
|||||||
4. 在运行系统的电脑上读取 `po_app/instance/setup.token`,把初始化口令填入页面,设置管理员账号和密码。不要发送该文件给无关人员。
|
4. 在运行系统的电脑上读取 `po_app/instance/setup.token`,把初始化口令填入页面,设置管理员账号和密码。不要发送该文件给无关人员。
|
||||||
5. 管理员在“用户”页新增制单员、审批员,两个环节分配给不同人员。新增账号首次登录必须修改初始密码。
|
5. 管理员在“用户”页新增制单员、审批员,两个环节分配给不同人员。新增账号首次登录必须修改初始密码。
|
||||||
6. 录入真实供应商和物料,再开始制单。新数据库不再预置演示账号、演示供应商或假银行信息。
|
6. 录入真实供应商和物料,再开始制单。新数据库不再预置演示账号、演示供应商或假银行信息。
|
||||||
- 在“供应商管理”填写“公司简称”,用于 PDF 导出文件名,例如 `采购单_PO202609170001_精锐五金.pdf`。未填简称时使用订单上的供应商全名;按全名匹配供应商资料,同名有歧义时结合税号匹配,仍有歧义则使用全名。
|
- 在“供应商管理”填写“公司简称”,用于 PDF 导出文件名,例如 `采购单_PO202609170001_精锐五金.pdf`。未填简称时使用订单上的供应商全名;按全名匹配供应商资料,同名有歧义时结合税号匹配,仍有歧义则使用全名。供应商管理页及新建订单页均可搜索名称、简称或编号。
|
||||||
- 简称只用于下载命名,PDF 正文仍显示全名;已审批订单重新下载即可使用新简称,归档内容保持不变。文件名中的 Windows 不允许字符会被替换。
|
- 简称只用于下载命名,PDF 正文仍显示全名;已审批订单重新下载即可使用新简称,归档内容保持不变。文件名中的 Windows 不允许字符会被替换。
|
||||||
- Excel 供应商模板末列支持“公司简称”。旧表没有该列时保留原简称;该列留空则清除原简称。此功能使用数据库结构版本 5,旧数据库启动时先备份再增量升级。
|
- Excel 供应商模板末列支持“公司简称”。旧表没有该列时保留原简称;该列留空则清除原简称。此功能使用数据库结构版本 5,旧数据库启动时先备份再增量升级。
|
||||||
|
- 订单编辑页可搜索物料代码或规格。审批通过的订单会把有代码且可明确匹配的物料单价更新到物料库,保留四位小数;物料代码不存在时自动新增。管理员可点击物料库的“同步历史审批价”补录升级前的已审批订单价格。选物料时优先参考当前账号可见的同供应商最近审批价,再回退到物料库默认价。复制旧订单会清空旧交期,提交前须填写新交期并核对单价。
|
||||||
|
- 订单列表可按物料代码或规格查历史订单,同时显示匹配物料;搜索范围包含已完成订单。工作台有未交付、已交付已收票待付款、已完成三个入口,其余未收票、未登记和变更后待核对订单可在“其他待跟进”查看。
|
||||||
|
|
||||||
系统使用 Waitress 提供服务。源码和 EXE 均支持 `PO_DATA_DIR` 指定数据目录;未设置时分别使用 `po_app` 目录和 EXE 所在目录。`PO_HOST` 默认 `0.0.0.0`;端口已占用时启动程序会在 8120 之前尝试后续端口,以控制台打印地址为准。
|
系统使用 Waitress 提供服务。源码和 EXE 均支持 `PO_DATA_DIR` 指定数据目录;未设置时分别使用 `po_app` 目录和 EXE 所在目录。`PO_HOST` 默认 `0.0.0.0`;端口已占用时启动程序会在 8120 之前尝试后续端口,以控制台打印地址为准。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user