新增仓库管理

This commit is contained in:
zzh 2026-05-14 16:55:55 +08:00
parent 9ff71f499a
commit b44472987b
9 changed files with 795 additions and 47 deletions

View File

@ -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}

View File

@ -161,6 +161,22 @@
<a href="#/outsourcing-mgmt/wip-stock" class="dropdown-item" data-route="outsourcing-mgmt-wip-stock">委外在制库存</a>
</div>
</div>
<div class="topnav-item has-dropdown" data-menu="warehouse">
<span class="nav-left">
<span class="nav-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</span>
<span class="topnav-text">仓库管理</span>
</span>
<span class="topnav-caret"></span>
<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>
</div>
</div>
<div class="topnav-item" data-menu="finance">
<a href="#/finance" class="nav-left" data-route="finance" style="text-decoration:none;color:inherit;">
<span class="nav-icon" aria-hidden="true">
@ -516,6 +532,7 @@
<script src="./js/components/notifications.js" defer></script>
<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/product-intro.js" defer></script>
<script src="./js/app.js" defer></script>
</body>

View File

@ -197,6 +197,12 @@
window.location.hash = '#/';
return;
}
// 仓库管理仅admin和superadmin可访问
if ((path.startsWith('#/warehouse') || path.startsWith('/warehouse')) &&
currentUser.role !== 'admin' && currentUser.role !== 'superadmin') {
window.location.hash = '#/';
return;
}
} catch(e) {}
});

View File

@ -1918,7 +1918,7 @@ const Dashboard = (() => {
try {
// 请求全部数据不限制传递AbortController信号
const signal = window.__auditAbortController.signal;
const [pddRes, ytRes, txRes, mtRes, dashRes] = await Promise.all([
const [pddRes, ytRes, txRes, mtRes, dashRes, unreturnedRes] = await Promise.all([
fetch('/api/audit/pdd', {
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
@ -1958,7 +1958,10 @@ const Dashboard = (() => {
}).then(r => r.ok ? r.json() : null).catch((e)=>{
if(e.name === 'AbortError') console.log('[Dashboard] Dashboard请求被取消');
return null;
})
}),
fetch('/api/warehouse/unreturned-summary', { credentials: 'include', signal })
.then(r => r.ok ? r.json() : { summary: [] })
.catch(() => ({ summary: [] }))
]);
// 实时更新良品/不良率卡片和今日产量卡片
@ -2029,24 +2032,25 @@ const Dashboard = (() => {
if(purRate) purRate.textContent = (dashRes.purchase.completion_rate || 0) + '%';
}
// 更新智能建议
if(dashRes.smartSuggestions) {
const suggestionsEl = document.getElementById('smart-suggestions');
if(suggestionsEl) {
if(dashRes.smartSuggestions.length > 0) {
suggestionsEl.innerHTML = dashRes.smartSuggestions.map(s => `
<div style="margin-bottom:10px;padding:12px;background:var(--bg);border-radius:10px;border:1px solid var(--border)">
<div style="font-weight:600;margin-bottom:6px;color:var(--text);font-size:13px;display:flex;align-items:center;gap:6px">
<span>${s.icon}</span>
<span>${s.title}</span>
</div>
<div style="color:var(--text-2);font-size:12px;line-height:1.5">${s.message}</div>
</div>
`).join('');
} else {
suggestionsEl.innerHTML = '<div style="text-align:center;padding:30px 20px;background:var(--bg);border-radius:10px;border:1px solid var(--border)"><div style="font-size:28px;margin-bottom:8px">✨</div><div style="color:var(--text);font-size:14px;font-weight:600">暂无建议</div><div style="color:var(--text-2);font-size:12px;margin-top:4px">一切正常运行中</div></div>';
}
}
}
// 更新仓库未还汇总使用并行请求结果独立于dashRes
const unreturnedEl = document.getElementById('dashboard-unreturned');
if(unreturnedEl) {
const summary = (unreturnedRes && unreturnedRes.summary) || [];
if(summary.length === 0) {
unreturnedEl.innerHTML = '<div style="text-align:center;padding:30px 20px;background:var(--bg);border-radius:10px;border:1px solid var(--border)"><div style="font-size:28px;margin-bottom:8px">✅</div><div style="color:var(--text);font-size:14px;font-weight:600">暂无未还记录</div><div style="color:var(--text-2);font-size:12px;margin-top:4px">所有借出物品均已归还</div></div>';
} else {
unreturnedEl.innerHTML = summary.map(s => `
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 12px;background:var(--bg);border-radius:10px;border:1px solid var(--border);margin-bottom:8px">
<div style="font-weight:600;font-size:13px;color:var(--text)">${s.model}</div>
<div style="display:flex;gap:12px;font-size:12px">
<span style="color:var(--text-2)">借出 <b>${s.borrowed}</b></span>
<span style="color:#10b981">已还 <b>${s.returned}</b></span>
<span style="color:#ef4444;font-weight:700">未还 <b>${s.unreturned}</b></span>
</div>
</div>
`).join('');
}
}
@ -2145,7 +2149,8 @@ const Dashboard = (() => {
localStorage.setItem('history_initialized', 'true');
}
// 刷新间隔10秒
// 立即执行一次然后每10秒刷新
refreshAll();
window.__auditTimer=setInterval(refreshAll, 10000);
};
@ -2358,34 +2363,22 @@ const Dashboard = (() => {
</div>
</div>
<!-- 智能建议卡片 -->
<div class="card" style="padding:20px;border-radius:16px;background:var(--surface);border:1px solid var(--border);box-shadow:0 2px 8px rgba(0,0,0,0.04);display:flex;flex-direction:column">
<!-- 仓库未还汇总卡片 -->
<div class="card" style="padding:20px;border-radius:16px;background:var(--surface);border:1px solid var(--border);box-shadow:0 2px 8px rgba(0,0,0,0.04);display:flex;flex-direction:column;cursor:pointer" onclick="location.hash='#/warehouse/return'">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px">
<div>
<div style="font-size:13px;font-weight:500;color:var(--text-2);margin-bottom:4px">💡 AI助手</div>
<div style="font-size:16px;font-weight:700;color:var(--text)">智能建议</div>
<div style="font-size:13px;font-weight:500;color:var(--text-2);margin-bottom:4px">🏠 仓库管理</div>
<div style="font-size:16px;font-weight:700;color:var(--text)">借出未还汇总</div>
</div>
<div style="width:44px;height:44px;background:var(--bg);border-radius:12px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border)">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
<path d="M2 17l10 5 10-5M2 12l10 5 10-5"></path>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</div>
</div>
<div id="smart-suggestions" style="flex:1;overflow-y:auto;min-height:0">
${(data.smartSuggestions || []).length > 0 ?
data.smartSuggestions.map(s => `
<div style="margin-bottom:10px;padding:12px;background:var(--bg);border-radius:10px;border:1px solid var(--border)">
<div style="font-weight:600;margin-bottom:6px;color:var(--text);font-size:13px;display:flex;align-items:center;gap:6px">
<span>${s.icon}</span>
<span>${s.title}</span>
</div>
<div style="color:var(--text-2);font-size:12px;line-height:1.5">${s.message}</div>
</div>
`).join('') :
'<div style="text-align:center;padding:30px 20px;background:var(--bg);border-radius:10px;border:1px solid var(--border)"><div style="font-size:28px;margin-bottom:8px">✨</div><div style="color:var(--text);font-size:14px;font-weight:600">暂无建议</div><div style="color:var(--text-2);font-size:12px;margin-top:4px">一切正常运行中</div></div>'
}
<div id="dashboard-unreturned" style="flex:1;overflow-y:auto;min-height:0">
<div style="text-align:center;padding:20px;color:var(--text-2);font-size:13px">加载中...</div>
</div>
</div>
</div>

View File

@ -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);
}

View File

@ -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 `
<div style="padding:20px;max-width:1200px;margin:0 auto">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
<div style="display:flex;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:4px">
${tabBtn('borrow', '借出单', currentTab)}
${tabBtn('return', '借出还入单', currentTab)}
</div>
</div>
<div id="warehouse-content"></div>
</div>
<style>
.wh-tab-btn { padding:8px 18px;border:none;border-radius:7px;cursor:pointer;font-size:13px;font-weight:500;transition:all .15s;background:transparent;color:var(--text-secondary); }
.wh-tab-btn.active { background:var(--primary,#3b82f6);color:#fff; }
.wh-table { width:100%;border-collapse:collapse;font-size:13px; }
.wh-table th,.wh-table td { padding:11px 14px;text-align:left;border-bottom:1px solid var(--border); }
.wh-table th { background:var(--bg);font-weight:600;color:var(--text-secondary); }
.wh-table tr:hover td { background:var(--bg); }
.wh-btn { padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500; }
.wh-btn-primary { background:var(--primary,#3b82f6);color:#fff; }
.wh-btn-success { background:#10b981;color:#fff; }
.wh-btn-danger { background:#ef4444;color:#fff; }
.wh-btn-ghost { background:var(--bg);color:var(--text);border:1px solid var(--border); }
.wh-modal-overlay { position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:2000; }
.wh-modal { background:var(--surface);border-radius:16px;padding:28px;width:90%;max-width:480px;box-shadow:0 20px 60px rgba(0,0,0,.25); }
.wh-form-row { margin-bottom:14px; }
.wh-form-row label { display:block;font-size:12px;font-weight:600;color:var(--text-secondary);margin-bottom:5px; }
.wh-form-row input,.wh-form-row textarea,.wh-form-row select { width:100%;padding:9px 12px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--text);font-size:13px;box-sizing:border-box; }
.wh-form-row textarea { height:80px;resize:vertical; }
.wh-status-badge { display:inline-block;padding:3px 9px;border-radius:99px;font-size:11px;font-weight:600; }
</style>
`;
};
const tabBtn = (key, label, cur) =>
`<button class="wh-tab-btn${cur === key ? ' active' : ''}" onclick="Warehouse.switchTab('${key}')">${label}</button>`;
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 =>
`<span style="display:inline-flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:4px 12px;font-size:12px">
<span style="font-weight:600">${s.model}</span>
<span style="color:#ef4444;font-weight:700">未还 ${s.unreturned}</span>
<span style="color:var(--text-secondary)">( ${s.borrowed} / ${s.returned})</span>
</span>`
).join('');
return `<div style="background:#fff7ed;border:1px solid #fed7aa;border-radius:10px;padding:12px 16px;margin-bottom:16px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<span style="font-size:13px;font-weight:700;color:#ea580c;margin-right:4px"> 未还汇总</span>
${rows}
</div>`;
} 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 = '<div style="color:var(--text-secondary);padding:20px">加载中...</div>';
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 = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:15px;font-weight:600">借出单列表</span>
${pendingCount > 0 ? `<span style="background:#f59e0b;color:#fff;border-radius:99px;padding:2px 8px;font-size:11px;font-weight:700">${pendingCount} 待确认</span>` : ''}
</div>
${isSuperadmin() ? `<button class="wh-btn wh-btn-primary" onclick="Warehouse.showCreateBorrowModal()">+ 新建借出单</button>` : ''}
</div>
<div style="background:var(--surface);border-radius:12px;overflow:hidden;border:1px solid var(--border)">
<table class="wh-table">
<thead><tr>
<th>单号</th><th></th><th></th><th></th><th></th>
<th>申请人</th><th></th><th></th><th></th><th></th>
${isAdmin() ? '<th>操作</th>' : ''}
</tr></thead>
<tbody>
${list.length === 0 ? `<tr><td colspan="10" style="text-align:center;color:var(--text-secondary);padding:30px">暂无数据</td></tr>` : ''}
${list.map(r => {
const received = receivedMap[r.order_no] || 0;
const unreturned = r.status === 'approved' ? Math.max(r.qty - received, 0) : null;
return `
<tr>
<td style="font-family:monospace;font-size:12px">${r.order_no}</td>
<td>${r.model}</td>
<td>${r.qty}</td>
<td style="font-weight:700;color:${unreturned > 0 ? '#ef4444' : (unreturned === 0 ? '#10b981' : 'var(--text-secondary)')}">${unreturned == null ? '—' : unreturned}</td>
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${r.reason}">${r.reason}</td>
<td>${r.created_by}</td>
<td style="font-size:12px;color:var(--text-secondary)">${(r.created_at || '').replace('T',' ').slice(0, 16)}</td>
<td><span class="wh-status-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>
${isAdmin() ? `<td style="white-space:nowrap">
${r.status === 'pending' && currentUser.role === 'admin'
? `<button class="wh-btn wh-btn-success" style="margin-right:6px" onclick="Warehouse.confirmBorrow('${r.order_no}','approve')">批准</button>
<button class="wh-btn wh-btn-danger" onclick="Warehouse.showRejectModal('borrow','${r.order_no}')">拒绝</button>`
: '—'}
</td>` : ''}
</tr>
`;}).join('')}
</tbody>
</table>
</div>
`;
};
// ── 还入单列表 ──────────────────────────────
const renderReturnTab = async (el) => {
el.innerHTML = '<div style="color:var(--text-secondary);padding:20px">加载中...</div>';
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 = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:15px;font-weight:600">借出还入单列表</span>
${pendingCount > 0 ? `<span style="background:#f59e0b;color:#fff;border-radius:99px;padding:2px 8px;font-size:11px;font-weight:700">${pendingCount} 待确认</span>` : ''}
</div>
${isSuperadmin() ? `<button class="wh-btn wh-btn-primary" onclick="Warehouse.showCreateReturnModal()">+ 新建还入单</button>` : ''}
</div>
<div style="background:var(--surface);border-radius:12px;overflow:hidden;border:1px solid var(--border)">
<table class="wh-table">
<thead><tr>
<th>还入单号</th><th></th><th></th><th></th>
<th>申请人</th><th></th><th></th><th></th><th></th>
${isAdmin() ? '<th>操作</th>' : ''}
</tr></thead>
<tbody>
${list.length === 0 ? `<tr><td colspan="9" style="text-align:center;color:var(--text-secondary);padding:30px">暂无数据</td></tr>` : ''}
${list.map(r => `
<tr>
<td style="font-family:monospace;font-size:12px">${r.order_no}</td>
<td style="font-family:monospace;font-size:12px">${r.borrow_order_no}</td>
<td>${r.model}</td>
<td>${r.qty}</td>
<td>${r.created_by}</td>
<td style="font-size:12px;color:var(--text-secondary)">${(r.created_at || '').replace('T',' ').slice(0, 16)}</td>
<td><span class="wh-status-badge" style="background:${statusColor(r.status)}22;color:${statusColor(r.status)}">${statusLabel(r.status)}</span></td>
<td style="font-size:12px;color:var(--text-secondary)">${r.reject_reason || '—'}</td>
<td style="font-size:12px;color:var(--text-secondary)">${r.confirmed_by || '—'}</td>
${isAdmin() ? `<td style="white-space:nowrap">
${r.status === 'pending' && currentUser.role === 'admin'
? `<button class="wh-btn wh-btn-success" style="margin-right:6px" onclick="Warehouse.confirmReturn('${r.order_no}','approve')">同意</button>
<button class="wh-btn wh-btn-danger" onclick="Warehouse.showNotReceivedModal('${r.order_no}',${r.qty})">退回</button>`
: '—'}
</td>` : ''}
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
};
// ── 新建借出单弹窗 ──────────────────────────
const showCreateBorrowModal = () => {
const html = `
<div class="wh-modal-overlay" id="wh-modal">
<div class="wh-modal">
<div style="font-size:16px;font-weight:700;margin-bottom:20px">新建借出单</div>
<div class="wh-form-row"><label>借出型号 *</label><input id="wh-b-model" placeholder=""></div>
<div class="wh-form-row"><label>数量 *</label><input id="wh-b-qty" type="number" min="1" placeholder=""></div>
<div class="wh-form-row"><label>借出理由 *</label><textarea id="wh-b-reason" placeholder=""></textarea></div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px">
<button class="wh-btn wh-btn-ghost" onclick="Warehouse.closeModal()">取消</button>
<button class="wh-btn wh-btn-primary" onclick="Warehouse.submitBorrow()">提交</button>
</div>
</div>
</div>`;
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 =>
`<option value="${b.order_no}" data-model="${b.model}">${b.order_no}${b.model} × ${b.qty}</option>`
).join('');
const html = `
<div class="wh-modal-overlay" id="wh-modal">
<div class="wh-modal">
<div style="font-size:16px;font-weight:700;margin-bottom:20px">新建借出还入单</div>
<div class="wh-form-row">
<label>对应借出单 *</label>
<select id="wh-r-borrow" onchange="Warehouse.onBorrowSelect(this)">
<option value="">-- 请选择已批准的借出单 --</option>
${options || '<option disabled>暂无已批准的借出单</option>'}
</select>
</div>
<div class="wh-form-row"><label>还入型号 *</label><input id="wh-r-model" placeholder=""></div>
<div class="wh-form-row"><label>还入数量 *</label><input id="wh-r-qty" type="number" min="1" placeholder=""></div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px">
<button class="wh-btn wh-btn-ghost" onclick="Warehouse.closeModal()">取消</button>
<button class="wh-btn wh-btn-primary" onclick="Warehouse.submitReturn()">提交</button>
</div>
</div>
</div>`;
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 = `
<div class="wh-modal-overlay" id="wh-modal">
<div class="wh-modal">
<div style="font-size:16px;font-weight:700;margin-bottom:20px;color:#ef4444">拒绝单据</div>
<div style="font-size:13px;color:var(--text-secondary);margin-bottom:16px">单号${orderNo}</div>
<div class="wh-form-row"><label>拒绝原因 *</label><textarea id="wh-reject-reason" placeholder=""></textarea></div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px">
<button class="wh-btn wh-btn-ghost" onclick="Warehouse.closeModal()">取消</button>
<button class="wh-btn wh-btn-danger" onclick="Warehouse.doReject('${type}','${orderNo}')">确认拒绝</button>
</div>
</div>
</div>`;
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 = `
<div class="wh-modal-overlay" id="wh-modal">
<div class="wh-modal">
<div style="font-size:16px;font-weight:700;margin-bottom:20px">退回还入单</div>
<div class="wh-form-row">
<label>还入单号</label>
<input value="${orderNo}" disabled style="background:var(--bg);color:var(--text-secondary)">
</div>
<div class="wh-form-row">
<label>还入申请数量</label>
<input value="${totalQty}" disabled style="background:var(--bg);color:var(--text-secondary)">
</div>
<div class="wh-form-row">
<label>退回备注可选</label>
<textarea id="wh-not-recv-note" placeholder="说明退回原因"></textarea>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px">
<button class="wh-btn wh-btn-ghost" onclick="Warehouse.closeModal()">取消</button>
<button class="wh-btn wh-btn-danger" onclick="Warehouse.submitNotReceived('${orderNo}',${totalQty})">确认退回</button>
</div>
</div>
</div>`;
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;
});

View File

@ -109,7 +109,10 @@ const Router = (() => {
system: '系统',
'operations-log': '操作日志',
'material-purchase': '物料采购',
'ai-report': 'AI报表'
'ai-report': 'AI报表',
warehouse: '仓库管理',
borrow: '借出单',
return: '借出还入单'
};
return map[key] || key;
}

View File

@ -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/<order_no>/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/<order_no>/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)

Binary file not shown.