From b44472987bb8b34a1368ee5d52da26f3e1835dd7 Mon Sep 17 00:00:00 2001 From: zzh Date: Thu, 14 May 2026 16:55:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BB=93=E5=BA=93=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/assets/styles.css | 4 +- frontend/index.html | 17 + frontend/js/app.js | 6 + frontend/js/components/dashboard.js | 77 ++--- frontend/js/components/notifications.js | 7 +- frontend/js/components/warehouse.js | 437 ++++++++++++++++++++++++ frontend/js/router.js | 5 +- server/app.py | 289 ++++++++++++++++ server/data.db | Bin 12632064 -> 12857344 bytes 9 files changed, 795 insertions(+), 47 deletions(-) create mode 100644 frontend/js/components/warehouse.js diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 292efb4..fd4a011 100755 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -2868,7 +2868,9 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover{ #app.trackit-layout > #sidebar.sidebar.collapsed .sidebar-search{display:none} #app.trackit-layout > #sidebar.sidebar.collapsed .sidebar-footer{border-top:0;padding-top:0} - #app.trackit-layout > #sidebar.sidebar.collapsed .sidebar-actions{flex-direction:column;gap:12px;justify-content:flex-start;padding:0 0 2px} + #app.trackit-layout > #sidebar.sidebar.collapsed .sidebar-actions{flex-direction:column;gap:12px;justify-content:flex-start;align-items:center;padding:0 0 2px} + #app.trackit-layout > #sidebar.sidebar.collapsed .sidebar-actions .notification-bell-btn{margin-left:0} + #app.trackit-layout > #sidebar.sidebar.collapsed .sidebar-actions .user-avatar-btn{width:100%;justify-content:center} #app.trackit-layout > #sidebar.sidebar.collapsed .sidebar-notification-card{display:none !important} #app.trackit-layout > #sidebar.sidebar.collapsed .brand-name{display:none} diff --git a/frontend/index.html b/frontend/index.html index 0b8bf2b..5074536 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -161,6 +161,22 @@ 委外在制库存 +
+ + + 仓库管理 + + + +
- -
+ +
-
💡 AI助手
-
智能建议
+
🏠 仓库管理
+
借出未还汇总
- - - + + +
- -
- ${(data.smartSuggestions || []).length > 0 ? - data.smartSuggestions.map(s => ` -
-
- ${s.icon} - ${s.title} -
-
${s.message}
-
- `).join('') : - '
暂无建议
一切正常运行中
' - } +
+
加载中...
diff --git a/frontend/js/components/notifications.js b/frontend/js/components/notifications.js index e8452d3..a0573d0 100755 --- a/frontend/js/components/notifications.js +++ b/frontend/js/components/notifications.js @@ -72,6 +72,7 @@ await loadNotifications(); } } else { + badge.textContent = '0'; badge.style.display = 'none'; } } catch (e) { @@ -261,14 +262,14 @@ // 初始加载未读数量 await updateUnreadCount(); - // 每5秒更新一次未读数量(实时检查新消息) + // 每2秒更新一次未读数量(实时检查新消息) if (notificationInterval) { clearInterval(notificationInterval); } - notificationInterval = setInterval(updateUnreadCount, 5000); + notificationInterval = setInterval(updateUnreadCount, 2000); isInitialized = true; - console.log('[Notifications] 初始化完成,实时轮询已启动(5秒间隔)'); + console.log('[Notifications] 初始化完成,实时轮询已启动(2秒间隔)'); } catch (e) { console.error('初始化通知系统失败:', e); } diff --git a/frontend/js/components/warehouse.js b/frontend/js/components/warehouse.js new file mode 100644 index 0000000..795afce --- /dev/null +++ b/frontend/js/components/warehouse.js @@ -0,0 +1,437 @@ +/** + * 仓库管理模块 —— 借出单 / 借出还入单 + */ +window.Warehouse = (() => { + let currentTab = 'borrow'; + let currentUser = null; + let _approvedBorrows = []; + + const statusLabel = (s) => { + const map = { pending: '待确认', approved: '已批准', rejected: '已拒绝', received: '已同意', not_received: '未收到', partial: '部分收到', returned_back: '已退回' }; + return map[s] || s; + }; + const statusColor = (s) => { + const map = { pending: '#f59e0b', approved: '#10b981', rejected: '#ef4444', received: '#10b981', not_received: '#ef4444', partial: '#f59e0b', returned_back: '#6b7280' }; + return map[s] || '#94a3b8'; + }; + + const isSuperadmin = () => currentUser && currentUser.role === 'superadmin'; + const isAdmin = () => currentUser && currentUser.role === 'admin'; + + // ── 主渲染 ────────────────────────────────── + const render = async () => { + + return ` +
+
+
+ ${tabBtn('borrow', '借出单', currentTab)} + ${tabBtn('return', '借出还入单', currentTab)} +
+
+
+
+ + `; + }; + + const tabBtn = (key, label, cur) => + ``; + + const renderUnreturnedCard = async () => { + try { + const res = await fetch('/api/warehouse/unreturned-summary', { credentials: 'include' }); + const data = res.ok ? await res.json() : { summary: [], total_unreturned: 0 }; + const summary = data.summary || []; + if (summary.length === 0) return ''; + const rows = summary.map(s => + ` + ${s.model} + 未还 ${s.unreturned} + (借 ${s.borrowed} / 还 ${s.returned}) + ` + ).join(''); + return `
+ ⚠️ 未还汇总 + ${rows} +
`; + } catch (e) { return ''; } + }; + + const init = async () => { + renderTab(); + }; + + const switchTab = (tab) => { + currentTab = tab; + document.querySelectorAll('.wh-tab-btn').forEach(b => { + b.classList.toggle('active', b.textContent.trim() === (tab === 'borrow' ? '借出单' : '借出还入单')); + }); + renderTab(); + }; + + // ── 借出单列表 ────────────────────────────── + const renderTab = async () => { + const el = document.getElementById('warehouse-content'); + if (!el) return; + if (currentTab === 'borrow') await renderBorrowTab(el); + else await renderReturnTab(el); + }; + + const renderBorrowTab = async (el) => { + el.innerHTML = '
加载中...
'; + const [res, retRes] = await Promise.all([ + fetch('/api/warehouse/borrow-orders', { credentials: 'include' }), + fetch('/api/warehouse/return-orders', { credentials: 'include' }) + ]); + const data = res.ok ? await res.json() : { list: [] }; + const retData = retRes.ok ? await retRes.json() : { list: [] }; + const list = data.list || []; + + // 按borrow_order_no聚合已同意的还入数量 + const receivedMap = {}; + (retData.list || []).forEach(r => { + if (r.status === 'received') { + receivedMap[r.borrow_order_no] = (receivedMap[r.borrow_order_no] || 0) + (r.received_qty != null ? r.received_qty : r.qty); + } + }); + + const pendingCount = list.filter(r => r.status === 'pending').length; + + el.innerHTML = ` +
+
+ 借出单列表 + ${pendingCount > 0 ? `${pendingCount} 待确认` : ''} +
+ ${isSuperadmin() ? `` : ''} +
+
+ + + + + ${isAdmin() ? '' : ''} + + + ${list.length === 0 ? `` : ''} + ${list.map(r => { + const received = receivedMap[r.order_no] || 0; + const unreturned = r.status === 'approved' ? Math.max(r.qty - received, 0) : null; + return ` + + + + + + + + + + + + ${isAdmin() ? `` : ''} + + `;}).join('')} + +
单号型号数量未还数借出理由申请人申请时间状态拒绝原因确认人操作
暂无数据
${r.order_no}${r.model}${r.qty}${unreturned == null ? '—' : unreturned}${r.reason}${r.created_by}${(r.created_at || '').replace('T',' ').slice(0, 16)}${statusLabel(r.status)}${r.reject_reason || '—'}${r.confirmed_by || '—'} + ${r.status === 'pending' && currentUser.role === 'admin' + ? ` + ` + : '—'} +
+
+ `; + }; + + // ── 还入单列表 ────────────────────────────── + const renderReturnTab = async (el) => { + el.innerHTML = '
加载中...
'; + const [retRes, borrowRes] = await Promise.all([ + fetch('/api/warehouse/return-orders', { credentials: 'include' }), + fetch('/api/warehouse/borrow-orders?status=approved', { credentials: 'include' }) + ]); + const retData = retRes.ok ? await retRes.json() : { list: [] }; + const borrowData = borrowRes.ok ? await borrowRes.json() : { list: [] }; + const list = retData.list || []; + _approvedBorrows = borrowData.list || []; + + const pendingCount = list.filter(r => r.status === 'pending').length; + + el.innerHTML = ` +
+
+ 借出还入单列表 + ${pendingCount > 0 ? `${pendingCount} 待确认` : ''} +
+ ${isSuperadmin() ? `` : ''} +
+
+ + + + + ${isAdmin() ? '' : ''} + + + ${list.length === 0 ? `` : ''} + ${list.map(r => ` + + + + + + + + + + + ${isAdmin() ? `` : ''} + + `).join('')} + +
还入单号对应借出单型号还入数申请人申请时间状态备注确认人操作
暂无数据
${r.order_no}${r.borrow_order_no}${r.model}${r.qty}${r.created_by}${(r.created_at || '').replace('T',' ').slice(0, 16)}${statusLabel(r.status)}${r.reject_reason || '—'}${r.confirmed_by || '—'} + ${r.status === 'pending' && currentUser.role === 'admin' + ? ` + ` + : '—'} +
+
+ `; + }; + + // ── 新建借出单弹窗 ────────────────────────── + const showCreateBorrowModal = () => { + const html = ` +
+
+
新建借出单
+
+
+
+
+ + +
+
+
`; + document.body.insertAdjacentHTML('beforeend', html); + }; + + const submitBorrow = async () => { + const model = document.getElementById('wh-b-model')?.value.trim(); + const qty = document.getElementById('wh-b-qty')?.value; + const reason = document.getElementById('wh-b-reason')?.value.trim(); + if (!model || !qty || !reason) { API.toast('请填写所有必填项'); return; } + const res = await fetch('/api/warehouse/borrow-orders', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, qty: parseInt(qty), reason }) + }); + const data = await res.json(); + if (data.ok) { + API.toast(`借出单 ${data.order_no} 已提交,等待仓管确认`); + closeModal(); + renderTab(); + } else { + API.toast(data.error || '提交失败'); + } + }; + + // ── 新建还入单弹窗 ────────────────────────── + const showCreateReturnModal = () => { + const options = (_approvedBorrows || []).map(b => + `` + ).join(''); + const html = ` +
+
+
新建借出还入单
+
+ + +
+
+
+
+ + +
+
+
`; + document.body.insertAdjacentHTML('beforeend', html); + }; + + const onBorrowSelect = (sel) => { + const opt = sel.options[sel.selectedIndex]; + const model = opt.getAttribute('data-model') || ''; + const modelEl = document.getElementById('wh-r-model'); + if (modelEl) modelEl.value = model; + }; + + const submitReturn = async () => { + const borrow_order_no = document.getElementById('wh-r-borrow')?.value; + const model = document.getElementById('wh-r-model')?.value.trim(); + const qty = document.getElementById('wh-r-qty')?.value; + if (!borrow_order_no || !model || !qty) { API.toast('请填写所有必填项'); return; } + const res = await fetch('/api/warehouse/return-orders', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ borrow_order_no, model, qty: parseInt(qty) }) + }); + const data = await res.json(); + if (data.ok) { + API.toast(`还入单 ${data.order_no} 已提交,等待仓管确认`); + closeModal(); + renderTab(); + } else { + API.toast(data.error || '提交失败'); + } + }; + + // ── 拒绝弹窗 ──────────────────────────────── + const showRejectModal = (type, orderNo) => { + const html = ` +
+
+
拒绝单据
+
单号:${orderNo}
+
+
+ + +
+
+
`; + document.body.insertAdjacentHTML('beforeend', html); + }; + + const doReject = async (type, orderNo) => { + const reject_reason = document.getElementById('wh-reject-reason')?.value.trim(); + if (!reject_reason) { API.toast('请填写拒绝原因'); return; } + const url = type === 'borrow' + ? `/api/warehouse/borrow-orders/${orderNo}/confirm` + : `/api/warehouse/return-orders/${orderNo}/confirm`; + const res = await fetch(url, { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'reject', reject_reason }) + }); + const data = await res.json(); + if (data.ok) { API.toast('已拒绝'); closeModal(); renderTab(); } + else API.toast(data.error || '操作失败'); + }; + + // ── 批准操作 ──────────────────────────────── + const confirmBorrow = async (orderNo, action) => { + const res = await fetch(`/api/warehouse/borrow-orders/${orderNo}/confirm`, { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }) + }); + const data = await res.json(); + if (data.ok) { API.toast('操作成功'); renderTab(); } + else API.toast(data.error || '操作失败'); + }; + + const confirmReturn = async (orderNo, action) => { + const res = await fetch(`/api/warehouse/return-orders/${orderNo}/confirm`, { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }) + }); + const data = await res.json(); + if (data.ok) { API.toast('操作成功'); renderTab(); } + else API.toast(data.error || '操作失败'); + }; + + const showNotReceivedModal = (orderNo, totalQty) => { + const html = ` +
+
+
退回还入单
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
`; + document.body.insertAdjacentHTML('beforeend', html); + }; + + const submitNotReceived = async (orderNo, totalQty) => { + const note = document.getElementById('wh-not-recv-note')?.value || ''; + const res = await fetch(`/api/warehouse/return-orders/${orderNo}/confirm`, { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'reject', note }) + }); + const data = await res.json(); + if (data.ok) { closeModal(); API.toast('已退回'); renderTab(); } + else API.toast(data.error || '操作失败'); + }; + + const closeModal = () => { + document.getElementById('wh-modal')?.remove(); + }; + + const renderPage = async (tab) => { + currentTab = tab; + try { + const res = await fetch('/api/auth/me', { credentials: 'include' }); + currentUser = res.ok ? await res.json() : null; + } catch (e) { currentUser = null; } + return render(); + }; + + return { render, renderPage, init, switchTab, showCreateBorrowModal, submitBorrow, + showCreateReturnModal, onBorrowSelect, submitReturn, + showRejectModal, doReject, confirmBorrow, confirmReturn, + showNotReceivedModal, submitNotReceived, closeModal }; +})(); + +Router.register('/warehouse/borrow', async () => { + const html = await window.Warehouse.renderPage('borrow'); + setTimeout(() => { window.Warehouse.init(); }, 0); + return html; +}); + +Router.register('/warehouse/return', async () => { + const html = await window.Warehouse.renderPage('return'); + setTimeout(() => { window.Warehouse.init(); }, 0); + return html; +}); diff --git a/frontend/js/router.js b/frontend/js/router.js index 140ec14..072ff6b 100755 --- a/frontend/js/router.js +++ b/frontend/js/router.js @@ -109,7 +109,10 @@ const Router = (() => { system: '系统', 'operations-log': '操作日志', 'material-purchase': '物料采购', - 'ai-report': 'AI报表' + 'ai-report': 'AI报表', + warehouse: '仓库管理', + borrow: '借出单', + return: '借出还入单' }; return map[key] || key; } diff --git a/server/app.py b/server/app.py index 9f69965..db20a58 100755 --- a/server/app.py +++ b/server/app.py @@ -429,6 +429,43 @@ def init_db(): FOREIGN KEY(outsourcing_order_no) REFERENCES outsourcing_orders(order_no) )''') + # 借出单表 + c.execute('''CREATE TABLE IF NOT EXISTS borrow_orders( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_no TEXT NOT NULL UNIQUE, + model TEXT NOT NULL, + qty INTEGER NOT NULL, + reason TEXT NOT NULL, + 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, + note TEXT + )''') + + # 借出还入单表 + c.execute('''CREATE TABLE IF NOT EXISTS return_orders( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_no TEXT NOT NULL UNIQUE, + borrow_order_no TEXT NOT NULL, + model TEXT NOT NULL, + qty INTEGER NOT NULL, + received_qty INTEGER, + 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, + note TEXT + )''') + try: + c.execute('ALTER TABLE return_orders ADD COLUMN received_qty INTEGER') + except Exception: + pass + conn.commit() # create default admin c.execute('SELECT id FROM users WHERE username=?', ('admin',)) @@ -8946,6 +8983,258 @@ def favicon(): return '', 404 +# ───────────────────────────────────────────── +# 仓库管理 —— 借出单 +# ───────────────────────────────────────────── + +def _make_borrow_no(): + from datetime import datetime + conn = get_db() + c = conn.cursor() + today = datetime.now().strftime('%Y%m%d') + c.execute("SELECT COUNT(*) FROM borrow_orders WHERE order_no LIKE ?", (f'BW{today}%',)) + cnt = c.fetchone()[0] + conn.close() + return f'BW{today}{cnt+1:03d}' + +def _make_return_no(): + from datetime import datetime + conn = get_db() + c = conn.cursor() + today = datetime.now().strftime('%Y%m%d') + c.execute("SELECT COUNT(*) FROM return_orders WHERE order_no LIKE ?", (f'RT{today}%',)) + cnt = c.fetchone()[0] + conn.close() + return f'RT{today}{cnt+1:03d}' + + +@app.get('/api/warehouse/borrow-orders') +@require_login +def list_borrow_orders(): + status = request.args.get('status', '') + conn = get_db() + c = conn.cursor() + if status: + c.execute('SELECT * FROM borrow_orders WHERE status=? ORDER BY created_at DESC', (status,)) + else: + c.execute('SELECT * FROM borrow_orders ORDER BY created_at DESC') + rows = [dict(r) for r in c.fetchall()] + conn.close() + return jsonify({'ok': True, 'list': rows}) + + +@app.post('/api/warehouse/borrow-orders') +@require_login +@require_any_role('superadmin') +def create_borrow_order(): + data = request.get_json() or {} + model = (data.get('model') or '').strip() + qty = data.get('qty') + reason = (data.get('reason') or '').strip() + if not model or not qty or not reason: + return jsonify({'error': '型号、数量、借出理由不能为空'}), 400 + try: + qty = int(qty) + if qty <= 0: + raise ValueError + except Exception: + return jsonify({'error': '数量必须为正整数'}), 400 + + order_no = _make_borrow_no() + now = get_beijing_time() + username = session.get('username', '') + conn = get_db() + c = conn.cursor() + c.execute( + 'INSERT INTO borrow_orders(order_no,model,qty,reason,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)', + (order_no, model, qty, reason, 'pending', username, now) + ) + conn.commit() + conn.close() + log('create_borrow_order', f'单号:{order_no}, 型号:{model}, 数量:{qty}') + notify_admins('新借出单待确认', f'单号:{order_no}, 型号:{model}, 数量:{qty}, 申请人:{username}') + return jsonify({'ok': True, 'order_no': order_no}) + + +@app.post('/api/warehouse/borrow-orders//confirm') +@require_login +@require_any_role('admin', 'superadmin') +def confirm_borrow_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 borrow_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 + + now = get_beijing_time() + username = session.get('username', '') + new_status = 'approved' if action == 'approve' else 'rejected' + c.execute( + 'UPDATE borrow_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_borrow_order', f'单号:{order_no}, 操作:{action_text}, 操作人:{username}') + notify_superadmin( + f'借出单{action_text}', + f'单号:{order_no}, 型号:{row["model"]}, 数量:{row["qty"]}, 仓管:{username}' + + (f', 拒绝原因:{reject_reason}' if action == 'reject' else '') + ) + return jsonify({'ok': True}) + + +# ───────────────────────────────────────────── +# 仓库管理 —— 借出还入单 +# ───────────────────────────────────────────── + +@app.get('/api/warehouse/return-orders') +@require_login +def list_return_orders(): + status = request.args.get('status', '') + conn = get_db() + c = conn.cursor() + if status: + c.execute('SELECT * FROM return_orders WHERE status=? ORDER BY created_at DESC', (status,)) + else: + c.execute('SELECT * FROM return_orders ORDER BY created_at DESC') + rows = [dict(r) for r in c.fetchall()] + conn.close() + return jsonify({'ok': True, 'list': rows}) + + +@app.post('/api/warehouse/return-orders') +@require_login +@require_any_role('superadmin') +def create_return_order(): + data = request.get_json() or {} + borrow_order_no = (data.get('borrow_order_no') or '').strip() + model = (data.get('model') or '').strip() + qty = data.get('qty') + if not borrow_order_no or not model or not qty: + return jsonify({'error': '借出单号、型号、数量不能为空'}), 400 + try: + qty = int(qty) + if qty <= 0: + raise ValueError + except Exception: + return jsonify({'error': '数量必须为正整数'}), 400 + + # 验证对应借出单存在且已批准 + conn = get_db() + c = conn.cursor() + c.execute('SELECT * FROM borrow_orders WHERE order_no=?', (borrow_order_no,)) + borrow = c.fetchone() + if not borrow: + conn.close() + return jsonify({'error': '对应借出单不存在'}), 400 + if borrow['status'] != 'approved': + conn.close() + return jsonify({'error': '对应借出单未批准'}), 400 + + order_no = _make_return_no() + now = get_beijing_time() + username = session.get('username', '') + c.execute( + 'INSERT INTO return_orders(order_no,borrow_order_no,model,qty,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)', + (order_no, borrow_order_no, model, qty, 'pending', username, now) + ) + conn.commit() + conn.close() + log('create_return_order', f'单号:{order_no}, 借出单:{borrow_order_no}, 型号:{model}, 数量:{qty}') + notify_admins('新还入单待确认', f'单号:{order_no}, 借出单:{borrow_order_no}, 型号:{model}, 数量:{qty}, 申请人:{username}') + return jsonify({'ok': True, 'order_no': order_no}) + + +@app.post('/api/warehouse/return-orders//confirm') +@require_login +@require_any_role('admin', 'superadmin') +def confirm_return_order(order_no): + data = request.get_json() or {} + action = data.get('action', 'approve') + not_received_qty = data.get('not_received_qty') # 未收到数量(仅action==reject时有效) + note = (data.get('note') or '').strip() + + conn = get_db() + c = conn.cursor() + c.execute('SELECT * FROM return_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 + + total_qty = row['qty'] + now = get_beijing_time() + username = session.get('username', '') + + if action == 'approve': + new_status = 'received' + received_qty = total_qty + reject_reason = None + action_text = '已同意(全部收到)' + else: + # 退回:整单不计入已还 + new_status = 'returned_back' + received_qty = 0 + reject_reason = note or '退回' + action_text = '已退回' + + c.execute( + 'UPDATE return_orders SET status=?,confirmed_by=?,confirmed_at=?,reject_reason=?,received_qty=? WHERE order_no=?', + (new_status, username, now, reject_reason, received_qty, order_no) + ) + conn.commit() + conn.close() + + log('confirm_return_order', f'单号:{order_no}, 操作:{action_text}, 操作人:{username}') + notify_superadmin( + f'还入单{action_text}', + f'单号:{order_no}, 借出单:{row["borrow_order_no"]}, 型号:{row["model"]}, 数量:{total_qty}, 仓管:{username}' + + (f', 备注:{reject_reason}' if action == 'reject' and reject_reason != '退回' else '') + ) + return jsonify({'ok': True}) + + +@app.get('/api/warehouse/unreturned-summary') +@require_login +def unreturned_summary(): + """计算各型号未还数量:已批准借出数 - 已实际收到的还入数(received_qty)""" + conn = get_db() + c = conn.cursor() + c.execute("SELECT model, SUM(qty) as total FROM borrow_orders WHERE status='approved' GROUP BY model") + borrowed = {r['model']: r['total'] for r in c.fetchall()} + # 仅计入状态为received的单据(退回的不计入) + c.execute("SELECT model, SUM(COALESCE(received_qty, qty)) as total FROM return_orders WHERE status='received' GROUP BY model") + returned = {r['model']: r['total'] for r in c.fetchall()} + conn.close() + summary = [] + all_models = set(list(borrowed.keys()) + list(returned.keys())) + for model in sorted(all_models): + b = borrowed.get(model, 0) + r = returned.get(model, 0) + unreturned = b - r + if unreturned != 0: + summary.append({'model': model, 'borrowed': b, 'returned': r, 'unreturned': unreturned}) + total_unreturned = sum(s['unreturned'] for s in summary) + return jsonify({'ok': True, 'summary': summary, 'total_unreturned': total_unreturned}) + + if __name__ == '__main__': print("🚀 启动服务器(已启用静态资源缓存)...") app.run(host='0.0.0.0', port=int(os.environ.get('PORT', '5000')), threaded=True) diff --git a/server/data.db b/server/data.db index 237d9dcbc15ab2db2ab2a7158d269100bff8dcf4..e11383711613730ed11d6cc03930ca5e7e031448 100644 GIT binary patch delta 73580 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∓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
6zft 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 delta 1540 zcmYMzdr(wW90%}wciG*$d++WAK_N|n@&!@u-evdRML|U~#1|@>dQ7?dxUwbk@R3#q z$UT9k2vJ-$6U{W0(g5}PBAn4bXr`I$^+$h+H8Q6E*ktyHHuha-n$FDUpY!~l-<;Dh z)MjsJXtKAB9?_Dis}t2YAm7#!dfWc(}FQ65)6a_S}+ule7;y%QWd|V zg@dvZh)Thj>QmH+>X)^cUzYME#p{#gNHCPI`lf~zDY2Op33(MQ6qQvBM*Sggek5P= zM`f>~MEya(Ec@iJUkiujm{0P`vKp<5Z|Ez{Je8d4V4T);_Gt&_7E_osPgRG*o0GV4 zp{Jv&NV71%^!;2Uu$gY$oJ$lf&0@{5R@cPp)J^f~4I9_4uU^MC_4Lm8Rdk+}7{;#Y z561M}Ta+m~~VJwV;Ovr+4cn+S2@h|}< z!X%gsIgkr4KpwmZFToV>f)6C{Lp}r`2r?)Tf&!Qd)1VNFU^>iznNSR~U^cuAbKn&y zfw@o$^I$$KfQ7IK%3v`pfu&Fm%V0UY3M*hGtO5e7;WbzTYoP*E2tx#-=Bqpx%x~Qz+9itj6IVy0XmKOSMMUD7$J2o0FDl^PM)vfjP}v*J!W~Dj^Q9!yB+3 zs$c_DLk-kI9c+Y6uo}i93`+J< zvY(OzlzdFdK}tTMrKGa2fF!*B#Xg`@Bp9E0QVIh=r#a0%DS7%xO1#Q)hasU7T