新增仓库管理
This commit is contained in:
parent
9ff71f499a
commit
b44472987b
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {}
|
||||
});
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
437
frontend/js/components/warehouse.js
Normal file
437
frontend/js/components/warehouse.js
Normal 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;
|
||||
});
|
||||
@ -109,7 +109,10 @@ const Router = (() => {
|
||||
system: '系统',
|
||||
'operations-log': '操作日志',
|
||||
'material-purchase': '物料采购',
|
||||
'ai-report': 'AI报表'
|
||||
'ai-report': 'AI报表',
|
||||
warehouse: '仓库管理',
|
||||
borrow: '借出单',
|
||||
return: '借出还入单'
|
||||
};
|
||||
return map[key] || key;
|
||||
}
|
||||
|
||||
289
server/app.py
289
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/<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)
|
||||
|
||||
BIN
server/data.db
BIN
server/data.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user