增加发货单上传和确认

This commit is contained in:
zzh 2026-05-18 11:35:41 +08:00
parent 385b5b2ac0
commit 5bcbc8260c
4 changed files with 602 additions and 1 deletions

View File

@ -175,6 +175,7 @@
<div class="topnav-dropdown">
<a href="#/warehouse/borrow" class="dropdown-item" data-route="warehouse-borrow">借出单</a>
<a href="#/warehouse/return" class="dropdown-item" data-route="warehouse-return">借出还入单</a>
<a href="#/warehouse/shipment" class="dropdown-item" data-route="warehouse-shipment">发货单</a>
</div>
</div>
<div class="topnav-item" data-menu="finance">
@ -533,6 +534,7 @@
<script src="./js/components/meituan-test.js" defer></script>
<script src="./js/components/operations-log.js" defer></script>
<script src="./js/components/warehouse.js" defer></script>
<script src="./js/components/shipment-orders.js" defer></script>
<script src="./js/components/product-intro.js" defer></script>
<script src="./js/app.js" defer></script>
</body>

View File

@ -0,0 +1,405 @@
// 仓库管理 —— 发货单
window.ShipmentOrders = (() => {
let currentUser = null;
let itemIndex = 0;
const isSuperadmin = () => currentUser && currentUser.role === 'superadmin';
const isAdmin = () => currentUser && currentUser.role === 'admin';
const statusLabel = s => ({ pending: '待确认', approved: '已确认', rejected: '已驳回' }[s] || s);
const statusColor = s => ({ pending: '#f59e0b', approved: '#10b981', rejected: '#ef4444' }[s] || '#94a3b8');
const render = async () => {
try {
const res = await fetch('/api/auth/me', { credentials: 'include' });
currentUser = res.ok ? await res.json() : null;
} catch (e) { currentUser = null; }
return `
<div style="padding:20px;max-width:1100px;margin:0 auto">
<div id="shipment-content"></div>
</div>
<style>
.sh-table{width:100%;border-collapse:collapse;font-size:13px}
.sh-table th,.sh-table td{padding:10px 14px;text-align:left;border-bottom:1px solid var(--border)}
.sh-table th{background:var(--bg);font-weight:600;color:var(--text-secondary)}
.sh-table tr:hover td{background:var(--bg)}
.sh-badge{display:inline-block;border-radius:99px;padding:2px 10px;font-size:11px;font-weight:600}
.sh-btn{padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500}
.sh-btn-primary{background:var(--primary,#3b82f6);color:#fff}
.sh-btn-success{background:#10b981;color:#fff}
.sh-btn-danger{background:#ef4444;color:#fff}
.sh-btn-secondary{background:var(--bg);color:var(--text);border:1px solid var(--border)}
.sh-input{width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);font-size:13px;box-sizing:border-box}
select.sh-input{height:36px;padding-top:0;padding-bottom:0;appearance:auto}
.sh-card{background:var(--surface);border-radius:12px;border:1px solid var(--border);overflow:hidden}
</style>
`;
};
const init = async () => {
stopPolling();
if (!currentUser) {
try {
const res = await fetch('/api/auth/me', { credentials: 'include' });
currentUser = res.ok ? await res.json() : null;
} catch (e) { currentUser = null; }
}
const el = document.getElementById('shipment-content');
if (!el) return;
if (isSuperadmin()) { await renderSuperadminView(el); startPolling(); }
else if (isAdmin()) { await renderAdminView(el); startPolling(); }
else el.innerHTML = '<div style="color:var(--text-secondary);padding:20px">无权限</div>';
};
let _modelList = [];
const loadModels = async () => {
const res = await fetch('/api/warehouse/shipment-models', { credentials: 'include' });
const data = res.ok ? await res.json() : { list: [] };
_modelList = data.list || [];
};
const refreshModelDatalist = () => {
const dl = document.getElementById('sh-model-datalist');
if (!dl) return;
dl.innerHTML = _modelList.map(m => `<option value="${m.model}">`).join('');
};
const refreshModelManageList = () => {
const box = document.getElementById('sh-model-list');
if (!box) return;
box.innerHTML = _modelList.length === 0
? '<span style="color:var(--text-secondary);font-size:12px">暂无型号</span>'
: _modelList.map(m => `
<span style="display:inline-flex;align-items:center;gap:4px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 8px;font-size:12px">
${m.model}
<button onclick="ShipmentOrders.deleteModel(${m.id})" style="background:none;border:none;cursor:pointer;color:#ef4444;font-size:13px;line-height:1;padding:0 2px" title="删除">×</button>
</span>`).join('');
};
const addModel = async () => {
const input = document.getElementById('sh-new-model');
if (!input) return;
const model = input.value.trim();
if (!model) return;
const res = await fetch('/api/warehouse/shipment-models', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model })
});
const data = await res.json();
if (res.ok && data.ok) {
_modelList.push({ id: data.id, model: data.model });
_modelList.sort((a, b) => a.model.localeCompare(b.model));
input.value = '';
refreshModelManageList();
refreshModelDatalist();
} else {
alert(data.error || '添加失败');
}
};
const deleteModel = async (id) => {
if (!confirm('确定删除此型号?')) return;
const res = await fetch(`/api/warehouse/shipment-models/${id}`, { method: 'DELETE', credentials: 'include' });
if (res.ok) {
_modelList = _modelList.filter(m => m.id !== id);
refreshModelManageList();
refreshModelDatalist();
}
};
// ── 管理员视图:上传发货数量 ─────────────────────────────────
const renderAdminView = async (el) => {
el.innerHTML = '<div style="color:var(--text-secondary);padding:20px">加载中...</div>';
const [ordersRes] = await Promise.all([
fetch('/api/warehouse/shipment-orders', { credentials: 'include' }),
loadModels()
]);
const data = ordersRes.ok ? await ordersRes.json() : { list: [] };
const list = data.list || [];
itemIndex = 0;
el.innerHTML = `
<datalist id="sh-model-datalist">${_modelList.map(m => `<option value="${m.model}">`).join('')}</datalist>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<span style="font-size:15px;font-weight:600">发货单</span>
</div>
<!-- 新增发货单 -->
<div class="sh-card" style="padding:20px;margin-bottom:24px">
<div style="font-size:14px;font-weight:600;margin-bottom:16px;color:var(--text)">新增发货数量上传</div>
<div id="sh-items-container"></div>
<button class="sh-btn sh-btn-secondary" style="margin-top:4px" onclick="ShipmentOrders.addItem()"> 添加产品</button>
<div style="margin-top:16px">
<label style="font-size:12px;color:var(--text-secondary)">备注可选</label>
<textarea id="sh-note" class="sh-input" rows="2" placeholder="备注..." style="margin-top:4px;resize:vertical"></textarea>
</div>
<div style="margin-top:16px;display:flex;justify-content:flex-end">
<button class="sh-btn sh-btn-primary" onclick="ShipmentOrders.submit()">提交</button>
</div>
</div>
<!-- 历史记录 -->
<div style="font-size:14px;font-weight:600;margin-bottom:12px">我的提交记录</div>
<div class="sh-card">
<table class="sh-table">
<thead><tr>
<th>单号</th><th></th><th></th><th></th><th></th><th></th><th></th>
</tr></thead>
<tbody id="sh-admin-tbody">
${buildAdminRows(list)}
</tbody>
</table>
</div>
`;
addItem();
};
const buildAdminRows = (list) => {
if (list.length === 0) return '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:30px">暂无记录</td></tr>';
return list.map(r => `
<tr>
<td style="font-family:monospace;font-size:12px">${r.order_no}</td>
<td style="font-size:12px">${(r.items || []).map(i => `${i.model} × ${i.qty}`).join('<br>')}</td>
<td style="font-size:12px;color:var(--text-secondary)">${r.note || '—'}</td>
<td style="font-size:12px;color:var(--text-secondary)">${(r.created_at || '').slice(0,16)}</td>
<td><span class="sh-badge" style="background:${statusColor(r.status)}22;color:${statusColor(r.status)}">${statusLabel(r.status)}</span></td>
<td style="font-size:12px;color:#ef4444">${r.reject_reason || '—'}</td>
<td style="font-size:12px;color:var(--text-secondary)">${r.confirmed_by || '—'}</td>
</tr>`).join('');
};
const adminSilentRefresh = async () => {
const tbody = document.getElementById('sh-admin-tbody');
if (!tbody) return;
const res = await fetch('/api/warehouse/shipment-orders', { credentials: 'include' });
if (!res.ok) return;
const data = await res.json();
tbody.innerHTML = buildAdminRows(data.list || []);
};
const addItem = () => {
const container = document.getElementById('sh-items-container');
if (!container) return;
const idx = itemIndex++;
const row = document.createElement('div');
row.id = `sh-item-${idx}`;
row.style.cssText = 'display:flex;gap:10px;align-items:flex-end;margin-bottom:10px;flex-wrap:wrap';
row.innerHTML = `
<div style="flex:2 1 160px">
<label style="font-size:12px;color:var(--text-secondary)">产品型号 *</label>
<select class="sh-input sh-model">
<option value="">请选择产品型号</option>
${_modelList.map(m => `<option value="${m.model}">${m.model}</option>`).join('')}
</select>
</div>
<div style="flex:1 1 100px">
<label style="font-size:12px;color:var(--text-secondary)">发货数量 *</label>
<input type="number" class="sh-input sh-qty" placeholder="数量" min="1" />
</div>
<button class="sh-btn sh-btn-danger" style="flex-shrink:0;margin-bottom:2px" onclick="ShipmentOrders.removeItem(${idx})">删除</button>
`;
container.appendChild(row);
};
const removeItem = (idx) => {
const row = document.getElementById(`sh-item-${idx}`);
if (row) row.remove();
const container = document.getElementById('sh-items-container');
if (container && container.children.length === 0) addItem();
};
const submit = async () => {
const rows = document.querySelectorAll('#sh-items-container > div');
const items = [];
for (const row of rows) {
const model = row.querySelector('.sh-model').value.trim();
const qty = parseInt(row.querySelector('.sh-qty').value);
if (!model || !qty || qty <= 0) {
alert('请填写所有产品型号和发货数量数量须大于0');
return;
}
items.push({ model, qty });
}
if (items.length === 0) { alert('请至少添加一个产品'); return; }
const note = document.getElementById('sh-note')?.value.trim() || '';
const res = await fetch('/api/warehouse/shipment-orders', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items, note })
});
const data = await res.json();
if (res.ok && data.ok) {
alert(`提交成功,单号:${data.order_no}`);
await renderAdminView(document.getElementById('shipment-content'));
} else {
alert(data.error || '提交失败');
}
};
// 生成表格行 HTML供首次渲染和静默刷新复用
const buildTableRows = (list) => {
if (list.length === 0) return '<tr><td colspan="9" style="text-align:center;color:var(--text-secondary);padding:30px">暂无记录</td></tr>';
return list.map(r => `
<tr>
<td style="font-family:monospace;font-size:12px">${r.order_no}</td>
<td>${r.created_by}</td>
<td style="font-size:12px">${(r.items || []).map(i => `${i.model} \u00d7 ${i.qty}`).join('<br>')}</td>
<td style="font-size:12px;color:var(--text-secondary)">${r.note || '—'}</td>
<td style="font-size:12px;color:var(--text-secondary)">${(r.created_at || '').slice(0,16)}</td>
<td><span class="sh-badge" style="background:${statusColor(r.status)}22;color:${statusColor(r.status)}">${statusLabel(r.status)}</span></td>
<td style="font-size:12px;color:#ef4444">${r.reject_reason || '—'}</td>
<td style="font-size:12px;color:var(--text-secondary)">${r.confirmed_by || '—'}</td>
<td style="white-space:nowrap">
${r.status === 'pending'
? `<button class="sh-btn sh-btn-success" style="margin-right:6px" onclick="ShipmentOrders.confirm('${r.order_no}','approve')">确认</button>
<button class="sh-btn sh-btn-danger" onclick="ShipmentOrders.showRejectModal('${r.order_no}')">驳回</button>`
: '—'}
</td>
</tr>`).join('');
};
const buildModelManageHtml = () => `
<div class="sh-card" style="padding:16px 20px;margin-bottom:20px">
<div style="font-size:13px;font-weight:600;margin-bottom:12px;color:var(--text)">\u4ea7\u54c1\u578b\u53f7\u7ba1\u7406</div>
<div style="display:flex;gap:8px;margin-bottom:10px">
<input id="sh-new-model" class="sh-input" placeholder="\u8f93\u5165\u65b0\u578b\u53f7..." style="flex:1;max-width:260px"
onkeydown="if(event.key==='Enter')ShipmentOrders.addModel()" />
<button class="sh-btn sh-btn-primary" onclick="ShipmentOrders.addModel()">\u6dfb\u52a0</button>
</div>
<div id="sh-model-list" style="display:flex;flex-wrap:wrap;gap:6px">${
_modelList.length === 0
? '<span style="color:var(--text-secondary);font-size:12px">\u6682\u65e0\u578b\u53f7</span>'
: _modelList.map(m => `
<span style="display:inline-flex;align-items:center;gap:4px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 8px;font-size:12px">
${m.model}
<button onclick="ShipmentOrders.deleteModel(${m.id})" style="background:none;border:none;cursor:pointer;color:#ef4444;font-size:13px;line-height:1;padding:0 2px" title="\u5220\u9664">\u00d7</button>
</span>`).join('')
}</div>
</div>`;
// ── 超级管理员视图:首次渲染骨架 ────────────────────────────
const renderSuperadminView = async (el) => {
el.innerHTML = '<div style="color:var(--text-secondary);padding:20px">加载中...</div>';
const [res] = await Promise.all([
fetch('/api/warehouse/shipment-orders', { credentials: 'include' }),
loadModels()
]);
const data = res.ok ? await res.json() : { list: [] };
const list = data.list || [];
const pending = list.filter(r => r.status === 'pending').length;
el.innerHTML = `
<div style="display:flex;align-items:center;gap:10px;margin-bottom:20px">
<span style="font-size:15px;font-weight:600">发货单审批</span>
<span id="sh-pending-badge" style="${pending > 0 ? '' : 'display:none;'}background:#f59e0b;color:#fff;border-radius:99px;padding:2px 8px;font-size:11px;font-weight:700">${pending} 待确认</span>
</div>
${buildModelManageHtml()}
<div class="sh-card">
<table class="sh-table">
<thead><tr>
<th>单号</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th>
</tr></thead>
<tbody id="sh-tbody">${buildTableRows(list)}</tbody>
</table>
</div>
<!-- 驳回弹窗 -->
<div id="sh-reject-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;align-items:center;justify-content:center">
<div style="background:var(--surface);border-radius:12px;padding:24px;width:420px;max-width:90vw;box-shadow:0 8px 32px rgba(0,0,0,.2)">
<div style="font-size:15px;font-weight:600;margin-bottom:16px">驳回原因</div>
<textarea id="sh-reject-reason" class="sh-input" rows="3" placeholder="请填写驳回原因..." style="resize:vertical"></textarea>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px">
<button class="sh-btn sh-btn-secondary" onclick="ShipmentOrders.closeRejectModal()">取消</button>
<button class="sh-btn sh-btn-danger" onclick="ShipmentOrders.submitReject()">确认驳回</button>
</div>
</div>
</div>
`;
};
// ── 静默刷新(仅更新 tbody 和徽标,不重建整个页面)──────────
const silentRefresh = async () => {
const tbody = document.getElementById('sh-tbody');
const badge = document.getElementById('sh-pending-badge');
if (!tbody) return;
const res = await fetch('/api/warehouse/shipment-orders', { credentials: 'include' });
if (!res.ok) return;
const data = await res.json();
const list = data.list || [];
const pending = list.filter(r => r.status === 'pending').length;
tbody.innerHTML = buildTableRows(list);
if (badge) {
badge.textContent = `${pending} 待确认`;
badge.style.display = pending > 0 ? '' : 'none';
}
};
let _rejectOrderNo = null;
let _pollTimer = null;
const startPolling = () => {
stopPolling();
_pollTimer = setInterval(async () => {
const el = document.getElementById('shipment-content');
if (!el) { stopPolling(); return; }
if (isSuperadmin()) {
const modal = document.getElementById('sh-reject-modal');
const modalOpen = modal && modal.style.display === 'flex';
if (!modalOpen) await silentRefresh();
} else if (isAdmin()) {
await adminSilentRefresh();
}
}, 5000);
};
const stopPolling = () => {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
};
const showRejectModal = (orderNo) => {
_rejectOrderNo = orderNo;
const modal = document.getElementById('sh-reject-modal');
if (modal) { modal.style.display = 'flex'; document.getElementById('sh-reject-reason').value = ''; }
};
const closeRejectModal = () => {
const modal = document.getElementById('sh-reject-modal');
if (modal) modal.style.display = 'none';
_rejectOrderNo = null;
};
const submitReject = async () => {
const reason = document.getElementById('sh-reject-reason')?.value.trim();
if (!reason) { alert('请填写驳回原因'); return; }
await confirm(_rejectOrderNo, 'reject', reason);
closeRejectModal();
};
const confirm = async (orderNo, action, rejectReason = '') => {
const res = await fetch(`/api/warehouse/shipment-orders/${orderNo}/confirm`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, reject_reason: rejectReason })
});
const data = await res.json();
if (res.ok && data.ok) {
await silentRefresh();
} else {
alert(data.error || '操作失败');
}
};
return { render, init, addItem, removeItem, submit, confirm, showRejectModal, closeRejectModal, submitReject, stopPolling, addModel, deleteModel };
})();
Router.register('/warehouse/shipment', async () => {
const html = await window.ShipmentOrders.render();
setTimeout(() => window.ShipmentOrders.init(), 0);
return html;
});
Router.onBeforeEach(() => { window.ShipmentOrders.stopPolling(); });

View File

@ -112,7 +112,8 @@ const Router = (() => {
'ai-report': 'AI报表',
warehouse: '仓库管理',
borrow: '借出单',
return: '借出还入单'
return: '借出还入单',
shipment: '发货单'
};
return map[key] || key;
}

View File

@ -474,6 +474,28 @@ def init_db():
except Exception:
pass
# 发货单产品型号管理
c.execute('''CREATE TABLE IF NOT EXISTS shipment_models(
id INTEGER PRIMARY KEY AUTOINCREMENT,
model TEXT NOT NULL UNIQUE,
created_by TEXT,
created_at TEXT
)''')
# 发货单表admin上传superadmin审批
c.execute('''CREATE TABLE IF NOT EXISTS shipment_orders(
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT NOT NULL UNIQUE,
items TEXT NOT NULL,
note TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
confirmed_by TEXT,
confirmed_at TEXT,
reject_reason TEXT
)''')
conn.commit()
# create default admin
c.execute('SELECT id FROM users WHERE username=?', ('admin',))
@ -598,6 +620,33 @@ def notify_admins(action, detail=''):
pass
def notify_user_by_username(target_username, action, detail=''):
"""通知指定用户名的用户"""
try:
operator_id = session.get('user_id')
if not operator_id:
return
conn = get_db()
c = conn.cursor()
c.execute('SELECT username FROM users WHERE id=?', (operator_id,))
operator = c.fetchone()
operator_name = operator['username'] if operator else ''
c.execute('SELECT id FROM users WHERE username=?', (target_username,))
target = c.fetchone()
if not target:
conn.close()
return
from datetime import timezone, timedelta
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz).isoformat()
c.execute('INSERT INTO notifications(user_id, username, action, detail, ts, read) VALUES(?,?,?,?,?,?)',
(target['id'], operator_name, action, detail, now, 0))
conn.commit()
conn.close()
except Exception:
pass
def notify_admins_by_factory(action, detail='', factory=None):
"""为指定工厂的管理员创建通知(超级管理员操作时使用)"""
try:
@ -9250,6 +9299,150 @@ def unreturned_summary():
return jsonify({'ok': True, 'summary': summary, 'total_unreturned': total_unreturned})
# ─────────────────────────────────────────────
# 仓库管理 —— 发货单
# ─────────────────────────────────────────────
@app.get('/api/warehouse/shipment-models')
@require_login
@require_any_role('admin', 'superadmin')
def get_shipment_models():
conn = get_db()
c = conn.cursor()
c.execute('SELECT id, model FROM shipment_models ORDER BY model ASC')
rows = c.fetchall()
conn.close()
return jsonify({'ok': True, 'list': [dict(r) for r in rows]})
@app.post('/api/warehouse/shipment-models')
@require_login
@require_any_role('superadmin')
def create_shipment_model():
data = request.get_json() or {}
model = (data.get('model') or '').strip()
if not model:
return jsonify({'error': '型号不能为空'}), 400
username = session.get('username', '')
now = get_beijing_time()
conn = get_db()
c = conn.cursor()
try:
c.execute('INSERT INTO shipment_models(model, created_by, created_at) VALUES(?,?,?)', (model, username, now))
conn.commit()
new_id = c.lastrowid
conn.close()
return jsonify({'ok': True, 'id': new_id, 'model': model})
except Exception:
conn.close()
return jsonify({'error': '型号已存在'}), 400
@app.delete('/api/warehouse/shipment-models/<int:model_id>')
@require_login
@require_any_role('superadmin')
def delete_shipment_model(model_id):
conn = get_db()
c = conn.cursor()
c.execute('DELETE FROM shipment_models WHERE id=?', (model_id,))
conn.commit()
conn.close()
return jsonify({'ok': True})
@app.get('/api/warehouse/shipment-orders')
@require_login
@require_any_role('admin', 'superadmin')
def get_shipment_orders():
conn = get_db()
c = conn.cursor()
user_role = session.get('role', '')
username = session.get('username', '')
if user_role == 'admin':
c.execute('SELECT * FROM shipment_orders WHERE created_by=? ORDER BY created_at DESC', (username,))
else:
c.execute('SELECT * FROM shipment_orders ORDER BY created_at DESC')
rows = c.fetchall()
conn.close()
result = []
for r in rows:
d = dict(r)
try:
d['items'] = json.loads(d['items'])
except Exception:
d['items'] = []
result.append(d)
return jsonify({'ok': True, 'list': result})
@app.post('/api/warehouse/shipment-orders')
@require_login
@require_any_role('admin')
def create_shipment_order():
data = request.get_json() or {}
items = data.get('items', [])
note = (data.get('note') or '').strip()
if not items or not isinstance(items, list):
return jsonify({'error': '请至少添加一个产品'}), 400
for item in items:
if not item.get('model') or not item.get('qty'):
return jsonify({'error': '每行必须填写型号和数量'}), 400
try:
if int(item['qty']) <= 0:
return jsonify({'error': '发货数量必须大于0'}), 400
except (ValueError, TypeError):
return jsonify({'error': '发货数量必须是整数'}), 400
username = session.get('username', '')
now = get_beijing_time()
order_no = 'SHP' + now.replace('-','').replace(' ','').replace(':','')
conn = get_db()
c = conn.cursor()
c.execute(
'INSERT INTO shipment_orders(order_no,items,note,status,created_by,created_at) VALUES(?,?,?,?,?,?)',
(order_no, json.dumps(items, ensure_ascii=False), note, 'pending', username, now)
)
conn.commit()
conn.close()
log('create_shipment_order', f'单号:{order_no}, 申请人:{username}')
notify_superadmin('新发货单待确认', f'单号:{order_no}, 产品数:{len(items)}, 申请人:{username}')
return jsonify({'ok': True, 'order_no': order_no})
@app.patch('/api/warehouse/shipment-orders/<order_no>/confirm')
@require_login
@require_any_role('superadmin')
def confirm_shipment_order(order_no):
data = request.get_json() or {}
action = data.get('action', 'approve') # approve / reject
reject_reason = (data.get('reject_reason') or '').strip()
if action == 'reject' and not reject_reason:
return jsonify({'error': '驳回时必须填写原因'}), 400
conn = get_db()
c = conn.cursor()
c.execute('SELECT * FROM shipment_orders WHERE order_no=?', (order_no,))
row = c.fetchone()
if not row:
conn.close()
return jsonify({'error': '单据不存在'}), 404
if row['status'] != 'pending':
conn.close()
return jsonify({'error': '该单据已处理'}), 400
username = session.get('username', '')
now = get_beijing_time()
new_status = 'approved' if action == 'approve' else 'rejected'
c.execute(
'UPDATE shipment_orders SET status=?,confirmed_by=?,confirmed_at=?,reject_reason=? WHERE order_no=?',
(new_status, username, now, reject_reason if action == 'reject' else None, order_no)
)
conn.commit()
conn.close()
action_text = '已确认' if action == 'approve' else '已驳回'
log('confirm_shipment_order', f'单号:{order_no}, 操作:{action_text}, 操作人:{username}')
detail_msg = f'单号:{order_no}' + (f', 驳回原因:{reject_reason}' if action == 'reject' else '')
notify_user_by_username(row['created_by'], f'发货单{action_text}', detail_msg)
return jsonify({'ok': True})
if __name__ == '__main__':
print("🚀 启动服务器(已启用静态资源缓存)...")
app.run(host='0.0.0.0', port=int(os.environ.get('PORT', '5000')), threaded=True)