完善采购审批流程、企业微信通知和采购专用章

This commit is contained in:
reddaniel110
2026-09-16 11:45:21 +08:00
parent 4876d8805d
commit bf952196fa
58 changed files with 3451 additions and 1909 deletions
+7 -3
View File
@@ -2,6 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}采购订单管理系统{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
@@ -12,15 +13,18 @@
<nav style="display:flex;gap:18px;">
<a href="{{ url_for('index') }}">工作台</a>
<a href="{{ url_for('orders') }}">订单列表</a>
<a href="{{ url_for('order_new') }}">新建订单</a>
{% if session.get('role') in ['maker','admin'] %}<a href="{{ url_for('order_new') }}">新建订单</a>{% endif %}
<a href="{{ url_for('suppliers') }}">供应商</a>
<a href="{{ url_for('materials') }}">物料库</a>
<a href="{{ url_for('stats') }}">统计</a>
{% if session.get('role') == 'admin' %}<a href="{{ url_for('users') }}">用户</a>{% endif %}
{% if session.get('role') == 'admin' %}<a href="{{ url_for('notification_settings') }}">通知配置</a>{% endif %}
</nav>
<span class="spacer"></span>
<span class="me">{{ session.get('user_name') }}({{ ROLE_TEXT.get(session.get('role'), '') }})</span>
<a href="{{ url_for('logout') }}" style="color:#d8e6f7;">退出</a>
<a href="{{ url_for('change_password') }}" style="color:#d8e6f7;">修改密码</a>
<form method="post" action="{{ url_for('logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn sm">退出</button></form>
</div>
{% endif %}
<div class="wrap">
@@ -31,6 +35,6 @@
{% endwith %}
{% block body %}{% endblock %}
</div>
<footer class="foot">{{ COMPANY_NAME }} · 采购订单管理系统 v1.0</footer>
<footer class="foot">{{ COMPANY_NAME }} · 采购订单管理系统 v3.0</footer>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block body %}
<div class="card">
<h1>本次操作未完成</h1>
<p>{{ message }}</p>
<button type="button" class="btn" onclick="history.back()">返回检查输入</button>
<a class="btn" href="{{ url_for('index') }}">返回工作台</a>
</div>
{% endblock %}
+3 -4
View File
@@ -2,11 +2,10 @@
{% block body %}
<h1 class="page">工作台</h1>
<div class="stat-row">
<div class="stat"><div class="v">{{ counts.get('pending_review', 0) }}</div><div class="k">待主管审核</div></div>
<div class="stat"><div class="v">{{ counts.get('pending_approval', 0) }}</div><div class="k">待经理批准</div></div>
<div class="stat"><div class="v">{{ counts.get('approved', 0) }}</div><div class="k">已批准</div></div>
<div class="stat"><div class="v">{{ counts.get('pending_approval', 0) }}</div><div class="k">待审批</div></div>
<div class="stat"><div class="v">{{ counts.get('approved', 0) }}</div><div class="k">审批通过</div></div>
<div class="stat"><div class="v">{{ counts.get('draft', 0) }}</div><div class="k">草稿</div></div>
<div class="stat"><div class="v">{{ '%.2f'|format(month_total) }}</div><div class="k">已批准订单总额(元)</div></div>
<div class="stat"><div class="v">{{ '%.2f'|format(month_total) }}</div><div class="k">审批通过订单总额(元)</div></div>
</div>
{% if todo %}
+4 -4
View File
@@ -2,6 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录 · 采购订单管理系统</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
@@ -12,6 +13,8 @@
{% for cat, msg in msgs %}<div class="flash {{ cat }}">{{ msg }}</div>{% endfor %}
{% endwith %}
<form method="post">
<input type="hidden" name="next" value="{{ request.form.get('next', request.args.get('next', '/')) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field">
<label>用户名</label>
<input type="text" name="username" autofocus>
@@ -22,10 +25,7 @@
</div>
<button class="btn primary" style="width:100%;">登 录</button>
</form>
<div class="hint" style="margin-top:14px;">
演示账号:admin / admin123(管理员),maker / 123456(制单员),
reviewer / 123456(主管审核),approver / 123456(经理批准)
</div>
</div>
</body>
</html>
+5
View File
@@ -4,6 +4,7 @@
<div class="card">
<h2>新增物料</h2>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-grid">
<div><label>物料代码 *</label><input type="text" name="code" required placeholder="M-1004"></div>
<div><label>物料规格 / 名称</label><input type="text" name="spec"></div>
@@ -31,6 +32,7 @@
data-spec="{{ m.spec or '' }}" data-unit="{{ m.unit or '' }}"
data-price="{{ m.price or 0 }}">编辑</button>
<form method="post" action="{{ url_for('material_toggle', mid=m.id) }}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn sm {% if m.active %}danger{% endif %}"
onclick="return confirm('确定{{ '停用' if m.active else '启用' }}该物料?')">
{{ '停用' if m.active else '启用' }}
@@ -38,6 +40,7 @@
</form>
{% if user.role == 'admin' %}
<form method="post" action="{{ url_for('material_delete', mid=m.id) }}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn sm danger"
onclick="return confirm('确定彻底删除该物料?删除后不可恢复!')">删除</button>
</form>
@@ -55,6 +58,7 @@
<span class="modal-close" onclick="document.getElementById('matImpModal').style.display='none'">&times;</span>
</div>
<form method="post" action="{{ url_for('material_import_excel') }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-body">
<div class="hint">先下载模板按格式填好再导入。<b>物料代码</b>必填;已存在的代码按下方方式处理。</div>
<div class="field">
@@ -88,6 +92,7 @@
<span class="modal-close" onclick="document.getElementById('editModal').style.display='none'">&times;</span>
</div>
<form method="post" action="{{ url_for('materials') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="id" id="edit_id">
<div class="modal-body">
<div class="field">
+57
View File
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block body %}
<h1 class="page">企业微信审批通知</h1>
<div class="card">
<p>提交后通知审批员,一次审批通过后即可归档正式文件。结果通知群可接收审批通过、驳回、撤回、变更和作废消息,可与审批通知群设为同一群。</p>
<p class="muted">手机连接公司 Wi-Fi 后,通过通知链接登录审批。系统主机须能访问企业微信接口,手机与主机须在可互通的内网。关闭通知会暂停队列,重新启用后继续处理仍有效的通知。</p>
<form method="post" class="form-grid two">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="notification-wide"><span><input type="checkbox" name="enabled" value="1" {% if settings.enabled %}checked{% endif %}> 启用企业微信通知</span></label>
<label class="notification-wide">系统内网访问地址
<input name="base_url" value="{{ settings.base_url }}" placeholder="http://192.168.1.10:8080" maxlength="300">
<small class="muted">填写固定主机的实际 IP 与端口,或手机能解析的内网域名;不要填 localhost、127.0.0.1。</small>
</label>
{% for channel, label in channels.items() %}
<label class="notification-wide">{{ label }} Webhook {{ '(可选)' if channel == 'result' else '' }} · {{ '已配置' if settings.configured[channel] else '未配置' }}
<input type="password" name="{{ channel }}_webhook" autocomplete="new-password" placeholder="{{ '留空保留已有地址' if settings.configured[channel] else '粘贴企业微信群机器人的完整 Webhook 地址' }}">
{% if settings.configured[channel] %}<span><input type="checkbox" name="clear_{{ channel }}" value="1"> 清除此群配置</span>{% endif %}
</label>
{% endfor %}
<div class="notification-wide"><button class="btn primary">保存通知配置</button></div>
</form>
<p class="muted">Webhook 是发送凭据,保存后不显示原文。通知只包含单号、环节、提交版本、记录时间和内网链接。群内成员仍须使用各自账号,按订单权限查看和操作。</p>
</div>
<div class="card">
<h2>发送测试通知</h2>
<p>先保存配置,再选择要测试的群。消息将实际发送到该企业微信群。</p>
<div class="toolbar">
{% for channel, label in channels.items() %}
<form method="post" action="{{ url_for('notification_test') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="channel" value="{{ channel }}">
<button class="btn" {% if not settings.enabled or not settings.configured[channel] %}disabled{% endif %}>测试{{ label }}</button>
</form>
{% endfor %}
</div>
</div>
<div class="card">
<h2>最近 100 条通知 <a class="btn sm" href="{{ url_for('notification_settings') }}">刷新</a></h2>
<p class="muted">服务运行期间自动发送,每 4 秒最多尝试一条;失败最多尝试 5 次。已失效的待审批通知会跳过。网络超时后的重试可能重复通知,不会重复审批。时间以服务主机时区为准。</p>
<table class="grid">
<tr><th>时间</th><th>订单 / 环节</th><th>通知群</th><th>状态</th><th>尝试次数</th><th>说明 / 操作</th></tr>
{% for n in notices %}
<tr>
<td>{{ n.created_at|notice_time }}</td>
<td>{% if n.order_id %}<a href="{{ url_for('order_view',oid=n.order_id) }}">订单 #{{ n.order_id }}</a> · 第 {{ n.revision }} 次提交<br>{% endif %}{{ n.event }}</td>
<td>{{ channels.get(n.channel, '旧审核群') }}</td><td>{{ states[n.status] }}</td><td>{{ n.attempts }}</td>
<td>{{ n.last_error }}
{% if n.status in ['retry','failed'] %}
<form method="post" action="{{ url_for('notification_retry',nid=n.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn sm">重试</button>
</form>{% endif %}
</td>
</tr>
{% else %}<tr><td colspan="6" class="muted">暂无通知。启用后产生的新审批操作会记录在这里,历史操作不补发。</td></tr>{% endfor %}
</table>
</div>
{% endblock %}
+38 -22
View File
@@ -5,6 +5,8 @@
<div class="card">
<h2>单据信息</h2>
<form method="post" id="orderForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="row_version" value="{{ o.row_version }}">
<div class="form-grid">
<div><label>采购单号</label><input type="text" value="{{ o.order_no }}" disabled></div>
<div><label>采购日期</label><input type="date" name="order_date" value="{{ o.order_date or '' }}"></div>
@@ -66,8 +68,8 @@
</td>
<td><input type="text" name="item_spec" value="{{ it.material_spec or '' }}"></td>
<td><input type="text" name="item_unit" value="{{ it.unit or '' }}"></td>
<td><input type="number" step="0.0001" name="item_qty" class="qty" value="{{ it.qty or 0 }}"></td>
<td><input type="number" step="0.0001" name="item_price" class="price" value="{{ it.price or 0 }}"></td>
<td><input type="number" min="0" max="999999999" step="0.0001" name="item_qty" class="qty" value="{{ it.qty or 0 }}"></td>
<td><input type="number" min="0" max="999999999" step="0.0001" name="item_price" class="price" value="{{ it.price or 0 }}"></td>
<td><input type="text" class="amt" value="{{ '%.2f'|format(it.amount or 0) }}" disabled></td>
<td><input type="date" name="item_date" value="{{ it.delivery_date or '' }}"></td>
<td><input type="text" name="item_remark" value="{{ it.remark or '' }}"></td>
@@ -87,8 +89,8 @@
</td>
<td><input type="text" name="item_spec" value=""></td>
<td><input type="text" name="item_unit" value=""></td>
<td><input type="number" step="0.0001" name="item_qty" class="qty" value="0"></td>
<td><input type="number" step="0.0001" name="item_price" class="price" value="0"></td>
<td><input type="number" min="0" max="999999999" step="0.0001" name="item_qty" class="qty" value="0"></td>
<td><input type="number" min="0" max="999999999" step="0.0001" name="item_price" class="price" value="0"></td>
<td><input type="text" class="amt" value="0.00" disabled></td>
<td><input type="date" name="item_date" value=""></td>
<td><input type="text" name="item_remark" value=""></td>
@@ -100,25 +102,22 @@
<button type="button" class="btn" onclick="addRow()">+ 添加一行</button>
<span class="grow"></span>
<span class="total-bar">合计:<span class="amt" id="totalAmt">{{ '%.2f'|format(o.total_amount) }}</span> 元
<span class="muted" id="totalCn">{{ total_cn }}</span></span>
<span class="muted">逐行四舍五入到分后合计</span></span>
</div>
<h3>补充说明</h3>
<textarea name="note" placeholder="选填,会打印在 PDF 备注区">{{ o.note or '' }}</textarea>
<div style="margin-top:16px;">
<button class="btn primary" type="submit">保存</button>
<button class="btn primary" type="submit" name="intent" value="save">保存草稿</button>
<input name="comment" placeholder="提交说明(选填)" style="width:220px;">
<button class="btn ok" type="submit" name="intent" value="submit">保存并提交审批</button>
<a class="btn" href="{{ url_for('order_view', oid=o.id) }}">查看订单</a>
<a class="btn" href="{{ url_for('order_pdf', oid=o.id) }}" target="_blank">预览 PDF</a>
</div>
</form>
<form method="post" action="{{ url_for('order_submit', oid=o.id) }}" style="margin-top:10px;">
<div class="toolbar">
<input type="text" name="comment" placeholder="提交说明(选填)" style="width:260px;">
<button class="btn ok">提交主管审核</button>
</div>
</form>
</div>
<div class="card">
@@ -129,6 +128,8 @@
<a href="{{ url_for('import_template') }}">下载导入模板</a>
</div>
<form method="post" action="{{ url_for('import_excel', oid=o.id) }}" enctype="multipart/form-data" class="toolbar">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="row_version" value="{{ o.row_version }}">
<input type="file" name="file" accept=".xls,.xlsx" required style="width:280px;">
<select name="mode" style="width:140px;">
<option value="append">追加到现有明细</option>
@@ -138,6 +139,7 @@
</form>
</div>
<script src="{{ url_for('static', filename='money.js') }}"></script>
<script>
const MATERIALS = {{ materials|map(attribute='id')|list|tojson }};
function bindRow(tr){
@@ -154,20 +156,21 @@ function bindRow(tr){
});
}
function calc(){
let total = 0;
let total = 0n, valid = true;
document.querySelectorAll('#itemTable tr').forEach((tr, idx) => {
const q = parseFloat(tr.querySelector('.qty')?.value || 0);
const p = parseFloat(tr.querySelector('.price')?.value || 0);
const a = (isNaN(q) ? 0 : q) * (isNaN(p) ? 0 : p);
const amt = tr.querySelector('.amt');
if (amt) amt.value = a.toFixed(2);
total += a;
const seq = tr.querySelector('.seq');
if (seq) seq.textContent = idx;
const qty = tr.querySelector('.qty'), price = tr.querySelector('.price');
if (!qty || !price) return;
try {
const cents = POMoney.lineCents(qty.value, price.value);
tr.querySelector('.amt').value = POMoney.format(cents);
total += cents;
} catch (e) { tr.querySelector('.amt').value = '请检查'; valid = false; }
tr.querySelector('.seq').textContent = idx;
});
document.getElementById('totalAmt').textContent = total.toFixed(2);
document.getElementById('totalAmt').textContent = valid ? POMoney.format(total) : '请检查数量和单价';
}
function addRow(){
dirty = true;
// 从 <template> 克隆,避免"明细为空时没有可复制的行"导致点击无反应
const t = document.getElementById('itemTable');
const tr = document.getElementById('rowTpl').content.firstElementChild.cloneNode(true);
@@ -180,6 +183,7 @@ function addRow(){
bindRow(tr); calc();
}
function delRow(btn){
dirty = true;
btn.closest('tr').remove(); calc();
}
document.querySelectorAll('#itemTable tr').forEach(bindRow);
@@ -197,5 +201,17 @@ document.getElementById('supplierPick').addEventListener('change', async functio
document.getElementById('supplier_bank_account').value = s.bank_account || '';
document.getElementById('payment_terms').value = s.payment_terms || '';
});
let dirty = false;
const orderForm = document.getElementById('orderForm');
orderForm.addEventListener('input', () => { dirty = true; });
orderForm.addEventListener('change', () => { dirty = true; });
orderForm.addEventListener('submit', () => { dirty = false; });
document.querySelector('[action$="/import_excel"]').addEventListener('submit', e => {
if (dirty) { e.preventDefault(); alert('请先保存当前修改,再导入明细。'); }
});
window.addEventListener('beforeunload', e => { if (dirty) { e.preventDefault(); e.returnValue = ''; } });
document.querySelectorAll('a[target="_blank"]').forEach(a => a.addEventListener('click', e => {
if (dirty) { e.preventDefault(); alert('请先保存当前修改,再预览 PDF。'); }
}));
</script>
{% endblock %}
+2
View File
@@ -4,6 +4,8 @@
<div class="card">
<div class="hint">先选单据类型与供应商,系统会带出供应商资料并生成单号,下一步填写物料明细。</div>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="request_key" value="{{ request_key() }}">
<div class="form-grid two">
<div>
<label>单据类型</label>
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block body %}
<h1 class="page">{{ o.order_no }} · 第 {{ version.revision }} 次提交快照</h1>
<div class="card">
<p>提交后内容保留供核对。当前订单状态:{{ o.status_text }}。</p>
{% if 'legacy-current' in (version.template_version or '') %}<p class="flash error">此版本为升级后保存的历史订单当前内容,并非原始提交快照。</p>{% endif %}
<table class="kv-table">
{% for key,label in [('supplier_name','供应商'),('supplier_contact','供方联系人'),('supplier_phone','供方电话'),('supplier_tax_no','供方税号'),('supplier_bank_name','开户行'),('supplier_bank_account','银行账号'),('payment_terms','付款方式'),('our_company','需方'),('our_contact','需方联系人'),('our_phone','需方电话'),('our_tax_no','需方税号'),('tax_rate','税点'),('order_date','采购日期'),('delivery_place','交货地点'),('note','补充说明'),('total_amount','合计')] %}
<tr><td class="k">{{ label }}</td><td>{{ content.get(key,'') }}</td></tr>
{% endfor %}
</table>
<table class="grid">
<tr><th>物料代码</th><th>规格</th><th>单位</th><th>数量</th><th>单价</th><th>金额</th><th>交期</th><th>备注</th></tr>
{% for item in content['items'] %}<tr>
{% for field in ['material_code','material_spec','unit','qty','price','amount','delivery_date','remark'] %}<td>{{ item.get(field,'') }}</td>{% endfor %}
</tr>{% endfor %}
</table>
{% if version.pdf_sha256 %}<p>此版本已保存审批通过时的正式文件。</p>{% endif %}
<a class="btn" href="{{ url_for('order_view',oid=o.id) }}">返回当前订单</a>
</div>
{% endblock %}
+54 -72
View File
@@ -8,15 +8,20 @@
{% if editable %}<a class="btn primary" href="{{ url_for('order_edit', oid=o.id) }}">编辑</a>{% endif %}
<a class="btn" href="{{ url_for('order_pdf', oid=o.id) }}" target="_blank">导出 / 预览 PDF</a>
<a class="btn" href="{{ url_for('order_pdf', oid=o.id) }}?dl=1" download>下载 PDF</a>
{% if user.role in ['maker','admin'] %}
<form method="post" action="{{ url_for('order_copy', oid=o.id) }}" style="display:inline;">
<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">复制为新订单</button>
</form>
</form>{% endif %}
<span class="grow"></span>
<a class="btn" href="{{ url_for('orders') }}">返回列表</a>
</div>
<div class="card">
<h2>单据信息</h2>
<p class="mobile-only muted">单据信息和物料明细可左右滑动查看。</p>
<table class="kv-table" style="width:100%;">
<tr>
<td class="k">供应商</td><td>{{ o.supplier_name }}</td>
@@ -60,7 +65,7 @@
<td>{{ it.material_spec }}</td>
<td class="center">{{ it.unit }}</td>
<td class="num">{{ it.qty }}</td>
<td class="num">{{ '%.2f'|format(it.price) }}</td>
<td class="num">{{ it.price|price }}</td>
<td class="num">{{ '%.2f'|format(it.amount) }}</td>
<td>{{ it.delivery_date }}</td>
<td>{{ it.remark }}</td>
@@ -77,89 +82,38 @@
</div>
<div class="card">
<h2>审核流程</h2>
<table class="grid">
<h2>审批流程</h2>
<table class="grid approval-flow">
<tr><th>环节</th><th>操作人</th><th>时间</th></tr>
<tr><td>制单</td><td>{{ o.created_by_name }}</td><td>{{ o.created_at.strftime('%Y-%m-%d %H:%M') if o.created_at else '-' }}</td></tr>
<tr><td>主管审核</td><td>{{ o.reviewed_by or '—' }}</td><td>{{ o.reviewed_at.strftime('%Y-%m-%d %H:%M') if o.reviewed_at else '—' }}</td></tr>
<tr><td>经理批准</td><td>{{ o.approved_by or '—' }}</td><td>{{ o.approved_at.strftime('%Y-%m-%d %H:%M') if o.approved_at else '—' }}</td></tr>
{% if o.reviewed_by %}<tr><td>旧流程审核(历史)</td><td>{{ o.reviewed_by or '—' }}</td><td>{{ o.reviewed_at.strftime('%Y-%m-%d %H:%M') if o.reviewed_at else '—' }}</td></tr>{% endif %}
<tr><td>审批</td><td>{{ o.approved_by or '—' }}</td><td>{{ o.approved_at.strftime('%Y-%m-%d %H:%M') if o.approved_at else '—' }}</td></tr>
</table>
{% if user.role in ['reviewer','admin'] and o.status == 'pending_review' %}
<div style="margin-top:12px;">
<div id="stampBox" style="display:none; margin-bottom:10px;">
<div class="seal-wrap">
<div class="seal-tip">已加盖主管审核章</div>
<div class="seal-circle">
<span class="co">{{ o.our_company or COMPANY_NAME }}</span>
<span class="txt">审核专用</span>
<span class="who">{{ user.name }}<br><span id="stampTime"></span></span>
</div>
</div>
</div>
<form method="post" action="{{ url_for('order_review', oid=o.id) }}" class="toolbar"
onsubmit="return checkStamp()">
<input type="hidden" name="stamped" id="stampedInput" value="0">
<input type="text" name="comment" placeholder="审核意见(选填)" style="width:280px;">
<button type="button" class="btn" id="stampBtn" onclick="applyStamp()">加盖审核章</button>
<button type="submit" class="btn ok" id="confirmBtn" disabled
title="请先加盖审核章">确认审核通过 → 提交经理批准</button>
</form>
<div class="muted" style="margin-top:6px;">流程:先点「加盖审核章」,确认无误后再点「确认审核通过」。</div>
</div>
<script>
function applyStamp(){
document.getElementById('stampedInput').value = '1';
document.getElementById('stampBox').style.display = 'block';
const d = new Date(), p = n => String(n).padStart(2, '0');
document.getElementById('stampTime').textContent =
d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
' ' + p(d.getHours()) + ':' + p(d.getMinutes());
const c = document.getElementById('confirmBtn');
c.disabled = false;
const s = document.getElementById('stampBtn');
s.disabled = true;
s.textContent = '已加章';
}
function checkStamp(){
if (document.getElementById('stampedInput').value !== '1'){
alert('请先点击「加盖审核章」,再确认审核。');
return false;
}
return confirm('确认审核通过并提交经理批准?');
}
</script>
{% endif %}
{% if user.role in ['approver','admin'] and o.status == 'pending_approval' %}
{% if may_approve %}
<div style="margin-top:12px;">
<div id="apprStampBox" style="display:none; margin-bottom:10px;">
<div class="seal-wrap">
<div class="seal-tip">已加盖经理批准章</div>
<div class="seal-circle">
<span class="co">{{ o.our_company or COMPANY_NAME }}</span>
<span class="txt">批准专用</span>
<span class="who">{{ user.name }}<br><span id="apprStampTime"></span></span>
</div>
<div class="seal-tip">采购专用章预览(尚未提交)</div>
{% include 'seal_preview.html' %}
</div>
</div>
<form method="post" action="{{ url_for('order_approve', oid=o.id) }}" class="toolbar"
onsubmit="return checkStampA()">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="row_version" value="{{ o.row_version }}">
<input type="hidden" name="stamped" id="apprStampedInput" value="0">
<input type="text" name="comment" placeholder="批准意见(选填)" style="width:280px;">
<button type="button" class="btn" id="apprStampBtn" onclick="applyStampA()">加盖批准章</button>
<input type="text" name="comment" placeholder="审批意见(选填)" style="width:280px;">
<button type="button" class="btn" id="apprStampBtn" onclick="applyStampA()">加盖采购专用章</button>
<button type="submit" class="btn ok" id="apprConfirmBtn" disabled
title="请先加盖批准章">确认批准通过</button>
title="请先加盖采购专用章">确认审批通过</button>
</form>
<div class="muted" style="margin-top:6px;">流程:先点「加盖批准章」,确认无误后再点「确认批准通过」。</div>
<div class="muted" style="margin-top:6px;">流程:先点「加盖采购专用章」,确认无误后再点「确认审批通过」。</div>
</div>
<script>
function applyStampA(){
document.getElementById('apprStampedInput').value = '1';
document.getElementById('apprStampBox').style.display = 'block';
const d = new Date(), p = n => String(n).padStart(2, '0');
document.getElementById('apprStampTime').textContent =
d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
' ' + p(d.getHours()) + ':' + p(d.getMinutes());
document.getElementById('apprConfirmBtn').disabled = false;
const s = document.getElementById('apprStampBtn');
s.disabled = true;
@@ -167,33 +121,61 @@
}
function checkStampA(){
if (document.getElementById('apprStampedInput').value !== '1'){
alert('请先点击「加盖批准章」,再确认批准。');
alert('请先点击「加盖采购专用章」,再确认审批。');
return false;
}
return confirm('确认批准通过?批准后即可导出正式 PDF。');
return confirm('确认审批通过?审批通过后即可导出正式 PDF。');
}
</script>
{% endif %}
{% if user.role in ['reviewer','approver','admin'] and o.status in ['pending_review','pending_approval'] %}
{% if may_reject %}
<form method="post" action="{{ url_for('order_reject', oid=o.id) }}" class="toolbar" style="margin-top:8px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="row_version" value="{{ o.row_version }}">
<input type="text" name="comment" placeholder="驳回原因(必填)" style="width:280px;" required>
<button class="btn danger">驳回给制单人</button>
</form>
{% endif %}
{% if editable and o.status in ['draft','rejected'] %}
<form method="post" action="{{ url_for('order_submit', oid=o.id) }}" class="toolbar" style="margin-top:8px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="row_version" value="{{ o.row_version }}">
<input type="text" name="comment" placeholder="提交说明(选填)" style="width:280px;">
<button class="btn primary">提交审核</button>
<button class="btn primary">提交审批</button>
</form>
{% endif %}
{% if o.status == 'rejected' and o.reject_reason %}
<div class="flash error">驳回原因:{{ o.reject_reason }}</div>
{% endif %}
<h3>审核记录</h3>
{% if o.created_by == user.id and o.status == 'pending_approval' %}
<form method="post" action="{{ url_for('order_withdraw',oid=o.id) }}" class="toolbar">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="row_version" value="{{ o.row_version }}">
<input name="comment" placeholder="撤回原因(必填)" required><button class="btn">撤回修改</button>
</form>
{% endif %}
{% if o.status == 'approved' and user.role in ['maker','admin'] and (o.created_by == user.id or user.role == 'admin') %}
<form method="post" action="{{ url_for('order_revise',oid=o.id) }}" class="toolbar" onsubmit="return confirm('原审批将停止使用,修改后须重新审批。确认发起变更?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="row_version" value="{{ o.row_version }}">
<input name="comment" placeholder="变更原因(必填)" required><button class="btn">发起变更重审</button>
</form>
{% endif %}
{% if editable or (o.status == 'approved' and user.role in ['admin','approver']) %}
<form method="post" action="{{ url_for('order_void',oid=o.id) }}" class="toolbar" onsubmit="return confirm('确认作废此订单?已发出的文件也应通知接收人停止使用。')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="row_version" value="{{ o.row_version }}">
<input name="comment" placeholder="作废原因(必填)" required><button class="btn danger">作废订单</button>
</form>
{% endif %}
<h3>提交版本</h3>
{% for version in o.versions %}<p><a href="{{ url_for('order_version',oid=o.id,revision=version.revision) }}">第 {{ version.revision }} 次提交 · {{ version.created_at.strftime('%Y-%m-%d %H:%M') }}</a>{{ ' · 已归档正式文件' if version.pdf_data else '' }}</p>
{% else %}<p class="muted">尚无提交快照;升级前的审批记录可在下方查看。</p>{% endfor %}
<h3>审批记录</h3>
<ul class="timeline">
{% for lg in o.logs %}
<li>{{ lg.created_at.strftime('%Y-%m-%d %H:%M') }} · {{ lg.user_name }} · {{ lg.action }}
<li>{{ lg.created_at.strftime('%Y-%m-%d %H:%M') }} · {{ lg.user_name }} · {{ lg.action }}(第 {{ lg.revision }} 次提交)
{% if lg.comment %}({{ lg.comment }}){% endif %}</li>
{% else %}
<li class="muted">暂无记录</li>
+3 -4
View File
@@ -22,12 +22,12 @@
<button class="btn primary">查询</button>
<span class="grow"></span>
<a class="btn" href="{{ url_for('export_ledger') }}">导出 Excel 台账</a>
<a class="btn primary" href="{{ url_for('order_new') }}">新建订单</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>
<th>状态</th><th>制单</th><th>审批</th><th class="nowrap">操作</th>
</tr>
{% for o in rows %}
<tr>
@@ -38,7 +38,6 @@
<td class="num">{{ '%.2f'|format(o.total_amount) }}</td>
<td><span class="badge {{ o.status }}">{{ o.status_text }}</span></td>
<td>{{ o.created_by_name }}</td>
<td>{{ o.reviewed_by or '-' }}</td>
<td>{{ o.approved_by or '-' }}</td>
<td class="nowrap">
<a class="btn sm" href="{{ url_for('order_view', oid=o.id) }}">查看</a>
@@ -46,7 +45,7 @@
</td>
</tr>
{% else %}
<tr><td colspan="10" class="center muted">没有符合条件的订单</td></tr>
<tr><td colspan="9" class="center muted">没有符合条件的订单</td></tr>
{% endfor %}
</table>
</div>
+17
View File
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block body %}
<div class="card">
<h1>修改密码</h1>
{% if user.must_change_password %}<p>首次登录或管理员重置密码后,请先设置自己的新密码。</p>{% endif %}
<form method="post">
<input type="hidden" name="next" value="{{ request.form.get('next', request.args.get('next', '/')) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-grid">
<div><label>原密码</label><input type="password" name="old_password" required autocomplete="current-password"></div>
<div><label>新密码(10 至 128 字符)</label><input type="password" name="password" required minlength="10" maxlength="128" autocomplete="new-password"></div>
<div><label>再次输入新密码</label><input type="password" name="confirm_password" required minlength="10" maxlength="128" autocomplete="new-password"></div>
</div>
<button class="btn primary">更新密码</button>
</form>
</div>
{% endblock %}
+17
View File
@@ -0,0 +1,17 @@
<svg class="seal-preview" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"
role="img" aria-label="{{ seal.company }},{{ seal.number }},采购专用章">
<title>{{ seal.company }} · {{ seal.number }} · 采购专用章</title>
<circle cx="50" cy="50" r="46" fill="none" stroke="currentColor" stroke-width="1.5"/>
<circle cx="50" cy="50" r="44.6" fill="none" stroke="currentColor" stroke-width="0.5"/>
{% for arc in seal.arcs %}
<g class="{{ 'seal-company' if loop.first else 'seal-code' }}" font-size="{{ arc.size }}" fill="currentColor" text-anchor="middle">
{% for letter in arc.letters %}
<text transform="translate({{ letter.x }} {{ 100 - letter.y }}) rotate({{ -letter.rotation }}) scale({{ arc.scale_x }} {{ arc.scale_y }})">{{ letter.text }}</text>
{% endfor %}
</g>
{% endfor %}
{% for x, y in seal.separators %}<circle cx="{{ x }}" cy="{{ 100 - y }}" r="0.65" fill="currentColor"/>{% endfor %}
<polygon points="{% for x, y in seal.star %}{{ x }},{{ 100 - y }} {% endfor %}" fill="currentColor"/>
<text class="seal-company" transform="translate(50 66) scale({{ seal.title.scale_x }} {{ seal.title.scale_y }})"
font-size="{{ seal.title.size }}" text-anchor="middle" fill="currentColor">采购专用章</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+17
View File
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block body %}
<div class="card">
<h1>首次设置管理员</h1>
<p>请向运行系统的电脑管理员获取初始化口令,完成首次设置。</p>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-grid">
<div><label>初始化口令</label><input type="password" name="setup_token" required autocomplete="off"></div>
<div><label>管理员登录名</label><input name="username" required maxlength="50"></div>
<div><label>姓名</label><input name="name" required maxlength="50"></div>
<div><label>密码(10 至 128 字符)</label><input type="password" name="password" required minlength="10" maxlength="128" autocomplete="new-password"></div>
</div>
<button class="btn primary">创建管理员</button>
</form>
</div>
{% endblock %}
+1 -1
View File
@@ -16,7 +16,7 @@
</div>
<div class="card">
<h2>按供应商(仅已批准)</h2>
<h2>按供应商(仅审批通过)</h2>
<table class="grid">
<tr><th>供应商</th><th class="num">订单数</th><th class="num">金额合计(元)</th></tr>
{% for name, cnt, amt in by_supplier %}
+5
View File
@@ -4,6 +4,7 @@
<div class="card">
<h2>新增供应商</h2>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-grid">
<div><label>编号</label><input type="text" name="code" placeholder="S003"></div>
<div><label>名称 *</label><input type="text" name="name" required></div>
@@ -40,6 +41,7 @@
data-bank_name="{{ s.bank_name or '' }}"
data-bank_account="{{ s.bank_account or '' }}">编辑</button>
<form method="post" action="{{ url_for('supplier_toggle', sid=s.id) }}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn sm {% if s.active %}danger{% endif %}"
onclick="return confirm('确定{{ '停用' if s.active else '启用' }}该供应商?')">
{{ '停用' if s.active else '启用' }}
@@ -47,6 +49,7 @@
</form>
{% if user.role == 'admin' %}
<form method="post" action="{{ url_for('supplier_delete', sid=s.id) }}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn sm danger"
onclick="return confirm('确定彻底删除该供应商?删除后不可恢复!')">删除</button>
</form>
@@ -64,6 +67,7 @@
<span class="modal-close" onclick="document.getElementById('supImpModal').style.display='none'">&times;</span>
</div>
<form method="post" action="{{ url_for('supplier_import_excel') }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-body">
<div class="hint">先下载模板按格式填好再导入。<b>名称</b>必填;已存在的按下方方式处理(优先按编号匹配,无编号则按名称)。</div>
<div class="field">
@@ -97,6 +101,7 @@
<span class="modal-close" onclick="document.getElementById('editModal').style.display='none'">&times;</span>
</div>
<form method="post" action="{{ url_for('suppliers') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="id" id="edit_id">
<div class="modal-body">
<div class="form-grid two">
+24 -24
View File
@@ -2,38 +2,38 @@
{% block body %}
<h1 class="page">用户与权限</h1>
<div class="card">
<h2>新增用户</h2>
<form method="post">
<h2>{{ '编辑用户' if target else '新增用户' }}</h2>
<form method="post" action="{{ url_for('users') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if target %}<input type="hidden" name="id" value="{{ target.id }}">{% endif %}
<div class="form-grid">
<div><label>登录名 *</label><input type="text" name="username" required></div>
<div><label>姓名</label><input type="text" name="name"></div>
<div><label>角色</label>
<select name="role">
{% for k, v in roles.items() %}<option value="{{ k }}">{{ v }}</option>{% endfor %}
</select>
<div><label>登录名 *</label><input name="username" required maxlength="50" value="{{ target.username if target else '' }}"></div>
<div><label>姓名 *</label><input name="name" required maxlength="50" value="{{ target.name if target else '' }}"></div>
<div><label>角色</label><select name="role">
{% for k,v in roles.items() %}<option value="{{ k }}" {% if (target and target.role == k) or (not target and k == 'maker') %}selected{% endif %}>{{ v }}</option>{% endfor %}
</select></div>
<div><label>状态</label><select name="active">
<option value="1" {% if not target or target.active %}selected{% endif %}>启用</option>
<option value="0" {% if target and not target.active %}selected{% endif %}>停用</option>
</select></div>
<div><label>{{ '重置密码(留空则不修改)' if target else '初始密码 *' }}</label>
<input type="password" name="password" minlength="10" maxlength="128" autocomplete="new-password" {% if not target %}required{% endif %}>
</div>
<div><label>初始密码(默认 123456)</label><input type="text" name="password" placeholder="123456"></div>
</div>
<div style="margin-top:12px;"><button class="btn primary">新增</button></div>
<p class="hint">新增或重置密码后,用户首次登录须修改密码。编辑账号会使原有登录失效。</p>
<button class="btn primary">保存用户</button>
{% if target %}<a class="btn" href="{{ url_for('users') }}">取消编辑</a>{% endif %}
</form>
</div>
<div class="card">
<h2>用户列表</h2>
<table class="grid">
<tr><th>登录名</th><th>姓名</th><th>角色</th><th>状态</th><th>创建时间</th></tr>
{% for t in rows %}
<tr>
<td>{{ t.username }}</td><td>{{ t.name }}</td>
<td>{{ roles.get(t.role, t.role) }}</td>
<td>{{ '启用' if t.active else '停用' }}</td>
<td>{{ t.created_at.strftime('%Y-%m-%d %H:%M') if t.created_at else '-' }}</td>
</tr>
{% endfor %}
<tr><th>登录名</th><th>姓名</th><th>角色</th><th>状态</th><th>操作</th></tr>
{% for t in rows %}<tr>
<td>{{ t.username }}</td><td>{{ t.name }}</td><td>{{ roles.get(t.role,t.role) }}</td>
<td>{{ '启用' if t.active else '停用' }}</td><td><a class="btn sm" href="{{ url_for('users',edit=t.id) }}">编辑 / 重置密码</a></td>
</tr>{% endfor %}
</table>
<div class="hint" style="margin-top:12px;">
制单员:新建 / 编辑 / 提交;主管审核:审核通过或驳回;经理批准:批准后订单生效并可导出正式 PDF;
管理员:全部权限,含用户、供应商、物料维护。
</div>
<p class="hint">制单员仅访问自己的订单。审批员一次审批通过后即可生成正式文件;制单和审批须由不同账号完成,管理员也遵守此规则。</p>
</div>
{% endblock %}