增加发货单上传和确认
This commit is contained in:
parent
385b5b2ac0
commit
5bcbc8260c
@ -175,6 +175,7 @@
|
||||
<div class="topnav-dropdown">
|
||||
<a href="#/warehouse/borrow" class="dropdown-item" data-route="warehouse-borrow">借出单</a>
|
||||
<a href="#/warehouse/return" class="dropdown-item" data-route="warehouse-return">借出还入单</a>
|
||||
<a href="#/warehouse/shipment" class="dropdown-item" data-route="warehouse-shipment">发货单</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topnav-item" data-menu="finance">
|
||||
@ -533,6 +534,7 @@
|
||||
<script src="./js/components/meituan-test.js" defer></script>
|
||||
<script src="./js/components/operations-log.js" defer></script>
|
||||
<script src="./js/components/warehouse.js" defer></script>
|
||||
<script src="./js/components/shipment-orders.js" defer></script>
|
||||
<script src="./js/components/product-intro.js" defer></script>
|
||||
<script src="./js/app.js" defer></script>
|
||||
</body>
|
||||
|
||||
405
frontend/js/components/shipment-orders.js
Normal file
405
frontend/js/components/shipment-orders.js
Normal file
@ -0,0 +1,405 @@
|
||||
// 仓库管理 —— 发货单
|
||||
window.ShipmentOrders = (() => {
|
||||
let currentUser = null;
|
||||
let itemIndex = 0;
|
||||
|
||||
const isSuperadmin = () => currentUser && currentUser.role === 'superadmin';
|
||||
const isAdmin = () => currentUser && currentUser.role === 'admin';
|
||||
|
||||
const statusLabel = s => ({ pending: '待确认', approved: '已确认', rejected: '已驳回' }[s] || s);
|
||||
const statusColor = s => ({ pending: '#f59e0b', approved: '#10b981', rejected: '#ef4444' }[s] || '#94a3b8');
|
||||
|
||||
const render = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me', { credentials: 'include' });
|
||||
currentUser = res.ok ? await res.json() : null;
|
||||
} catch (e) { currentUser = null; }
|
||||
return `
|
||||
<div style="padding:20px;max-width:1100px;margin:0 auto">
|
||||
<div id="shipment-content"></div>
|
||||
</div>
|
||||
<style>
|
||||
.sh-table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.sh-table th,.sh-table td{padding:10px 14px;text-align:left;border-bottom:1px solid var(--border)}
|
||||
.sh-table th{background:var(--bg);font-weight:600;color:var(--text-secondary)}
|
||||
.sh-table tr:hover td{background:var(--bg)}
|
||||
.sh-badge{display:inline-block;border-radius:99px;padding:2px 10px;font-size:11px;font-weight:600}
|
||||
.sh-btn{padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500}
|
||||
.sh-btn-primary{background:var(--primary,#3b82f6);color:#fff}
|
||||
.sh-btn-success{background:#10b981;color:#fff}
|
||||
.sh-btn-danger{background:#ef4444;color:#fff}
|
||||
.sh-btn-secondary{background:var(--bg);color:var(--text);border:1px solid var(--border)}
|
||||
.sh-input{width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);font-size:13px;box-sizing:border-box}
|
||||
select.sh-input{height:36px;padding-top:0;padding-bottom:0;appearance:auto}
|
||||
.sh-card{background:var(--surface);border-radius:12px;border:1px solid var(--border);overflow:hidden}
|
||||
</style>
|
||||
`;
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
stopPolling();
|
||||
if (!currentUser) {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me', { credentials: 'include' });
|
||||
currentUser = res.ok ? await res.json() : null;
|
||||
} catch (e) { currentUser = null; }
|
||||
}
|
||||
const el = document.getElementById('shipment-content');
|
||||
if (!el) return;
|
||||
if (isSuperadmin()) { await renderSuperadminView(el); startPolling(); }
|
||||
else if (isAdmin()) { await renderAdminView(el); startPolling(); }
|
||||
else el.innerHTML = '<div style="color:var(--text-secondary);padding:20px">无权限</div>';
|
||||
};
|
||||
|
||||
let _modelList = [];
|
||||
|
||||
const loadModels = async () => {
|
||||
const res = await fetch('/api/warehouse/shipment-models', { credentials: 'include' });
|
||||
const data = res.ok ? await res.json() : { list: [] };
|
||||
_modelList = data.list || [];
|
||||
};
|
||||
|
||||
const refreshModelDatalist = () => {
|
||||
const dl = document.getElementById('sh-model-datalist');
|
||||
if (!dl) return;
|
||||
dl.innerHTML = _modelList.map(m => `<option value="${m.model}">`).join('');
|
||||
};
|
||||
|
||||
const refreshModelManageList = () => {
|
||||
const box = document.getElementById('sh-model-list');
|
||||
if (!box) return;
|
||||
box.innerHTML = _modelList.length === 0
|
||||
? '<span style="color:var(--text-secondary);font-size:12px">暂无型号</span>'
|
||||
: _modelList.map(m => `
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 8px;font-size:12px">
|
||||
${m.model}
|
||||
<button onclick="ShipmentOrders.deleteModel(${m.id})" style="background:none;border:none;cursor:pointer;color:#ef4444;font-size:13px;line-height:1;padding:0 2px" title="删除">×</button>
|
||||
</span>`).join('');
|
||||
};
|
||||
|
||||
const addModel = async () => {
|
||||
const input = document.getElementById('sh-new-model');
|
||||
if (!input) return;
|
||||
const model = input.value.trim();
|
||||
if (!model) return;
|
||||
const res = await fetch('/api/warehouse/shipment-models', {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.ok) {
|
||||
_modelList.push({ id: data.id, model: data.model });
|
||||
_modelList.sort((a, b) => a.model.localeCompare(b.model));
|
||||
input.value = '';
|
||||
refreshModelManageList();
|
||||
refreshModelDatalist();
|
||||
} else {
|
||||
alert(data.error || '添加失败');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteModel = async (id) => {
|
||||
if (!confirm('确定删除此型号?')) return;
|
||||
const res = await fetch(`/api/warehouse/shipment-models/${id}`, { method: 'DELETE', credentials: 'include' });
|
||||
if (res.ok) {
|
||||
_modelList = _modelList.filter(m => m.id !== id);
|
||||
refreshModelManageList();
|
||||
refreshModelDatalist();
|
||||
}
|
||||
};
|
||||
|
||||
// ── 管理员视图:上传发货数量 ─────────────────────────────────
|
||||
const renderAdminView = async (el) => {
|
||||
el.innerHTML = '<div style="color:var(--text-secondary);padding:20px">加载中...</div>';
|
||||
const [ordersRes] = await Promise.all([
|
||||
fetch('/api/warehouse/shipment-orders', { credentials: 'include' }),
|
||||
loadModels()
|
||||
]);
|
||||
const data = ordersRes.ok ? await ordersRes.json() : { list: [] };
|
||||
const list = data.list || [];
|
||||
|
||||
itemIndex = 0;
|
||||
el.innerHTML = `
|
||||
<datalist id="sh-model-datalist">${_modelList.map(m => `<option value="${m.model}">`).join('')}</datalist>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
|
||||
<span style="font-size:15px;font-weight:600">发货单</span>
|
||||
</div>
|
||||
|
||||
<!-- 新增发货单 -->
|
||||
<div class="sh-card" style="padding:20px;margin-bottom:24px">
|
||||
<div style="font-size:14px;font-weight:600;margin-bottom:16px;color:var(--text)">新增发货数量上传</div>
|
||||
<div id="sh-items-container"></div>
|
||||
<button class="sh-btn sh-btn-secondary" style="margin-top:4px" onclick="ShipmentOrders.addItem()">+ 添加产品</button>
|
||||
<div style="margin-top:16px">
|
||||
<label style="font-size:12px;color:var(--text-secondary)">备注(可选)</label>
|
||||
<textarea id="sh-note" class="sh-input" rows="2" placeholder="备注..." style="margin-top:4px;resize:vertical"></textarea>
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;justify-content:flex-end">
|
||||
<button class="sh-btn sh-btn-primary" onclick="ShipmentOrders.submit()">提交</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史记录 -->
|
||||
<div style="font-size:14px;font-weight:600;margin-bottom:12px">我的提交记录</div>
|
||||
<div class="sh-card">
|
||||
<table class="sh-table">
|
||||
<thead><tr>
|
||||
<th>单号</th><th>产品明细</th><th>备注</th><th>提交时间</th><th>状态</th><th>驳回原因</th><th>确认人</th>
|
||||
</tr></thead>
|
||||
<tbody id="sh-admin-tbody">
|
||||
${buildAdminRows(list)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
addItem();
|
||||
};
|
||||
|
||||
const buildAdminRows = (list) => {
|
||||
if (list.length === 0) return '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:30px">暂无记录</td></tr>';
|
||||
return list.map(r => `
|
||||
<tr>
|
||||
<td style="font-family:monospace;font-size:12px">${r.order_no}</td>
|
||||
<td style="font-size:12px">${(r.items || []).map(i => `${i.model} × ${i.qty}`).join('<br>')}</td>
|
||||
<td style="font-size:12px;color:var(--text-secondary)">${r.note || '—'}</td>
|
||||
<td style="font-size:12px;color:var(--text-secondary)">${(r.created_at || '').slice(0,16)}</td>
|
||||
<td><span class="sh-badge" style="background:${statusColor(r.status)}22;color:${statusColor(r.status)}">${statusLabel(r.status)}</span></td>
|
||||
<td style="font-size:12px;color:#ef4444">${r.reject_reason || '—'}</td>
|
||||
<td style="font-size:12px;color:var(--text-secondary)">${r.confirmed_by || '—'}</td>
|
||||
</tr>`).join('');
|
||||
};
|
||||
|
||||
const adminSilentRefresh = async () => {
|
||||
const tbody = document.getElementById('sh-admin-tbody');
|
||||
if (!tbody) return;
|
||||
const res = await fetch('/api/warehouse/shipment-orders', { credentials: 'include' });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
tbody.innerHTML = buildAdminRows(data.list || []);
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
const container = document.getElementById('sh-items-container');
|
||||
if (!container) return;
|
||||
const idx = itemIndex++;
|
||||
const row = document.createElement('div');
|
||||
row.id = `sh-item-${idx}`;
|
||||
row.style.cssText = 'display:flex;gap:10px;align-items:flex-end;margin-bottom:10px;flex-wrap:wrap';
|
||||
row.innerHTML = `
|
||||
<div style="flex:2 1 160px">
|
||||
<label style="font-size:12px;color:var(--text-secondary)">产品型号 *</label>
|
||||
<select class="sh-input sh-model">
|
||||
<option value="">请选择产品型号</option>
|
||||
${_modelList.map(m => `<option value="${m.model}">${m.model}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:1 1 100px">
|
||||
<label style="font-size:12px;color:var(--text-secondary)">发货数量 *</label>
|
||||
<input type="number" class="sh-input sh-qty" placeholder="数量" min="1" />
|
||||
</div>
|
||||
<button class="sh-btn sh-btn-danger" style="flex-shrink:0;margin-bottom:2px" onclick="ShipmentOrders.removeItem(${idx})">删除</button>
|
||||
`;
|
||||
container.appendChild(row);
|
||||
};
|
||||
|
||||
const removeItem = (idx) => {
|
||||
const row = document.getElementById(`sh-item-${idx}`);
|
||||
if (row) row.remove();
|
||||
const container = document.getElementById('sh-items-container');
|
||||
if (container && container.children.length === 0) addItem();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const rows = document.querySelectorAll('#sh-items-container > div');
|
||||
const items = [];
|
||||
for (const row of rows) {
|
||||
const model = row.querySelector('.sh-model').value.trim();
|
||||
const qty = parseInt(row.querySelector('.sh-qty').value);
|
||||
if (!model || !qty || qty <= 0) {
|
||||
alert('请填写所有产品型号和发货数量(数量须大于0)');
|
||||
return;
|
||||
}
|
||||
items.push({ model, qty });
|
||||
}
|
||||
if (items.length === 0) { alert('请至少添加一个产品'); return; }
|
||||
const note = document.getElementById('sh-note')?.value.trim() || '';
|
||||
const res = await fetch('/api/warehouse/shipment-orders', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items, note })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.ok) {
|
||||
alert(`提交成功,单号:${data.order_no}`);
|
||||
await renderAdminView(document.getElementById('shipment-content'));
|
||||
} else {
|
||||
alert(data.error || '提交失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 生成表格行 HTML(供首次渲染和静默刷新复用)
|
||||
const buildTableRows = (list) => {
|
||||
if (list.length === 0) return '<tr><td colspan="9" style="text-align:center;color:var(--text-secondary);padding:30px">暂无记录</td></tr>';
|
||||
return list.map(r => `
|
||||
<tr>
|
||||
<td style="font-family:monospace;font-size:12px">${r.order_no}</td>
|
||||
<td>${r.created_by}</td>
|
||||
<td style="font-size:12px">${(r.items || []).map(i => `${i.model} \u00d7 ${i.qty}`).join('<br>')}</td>
|
||||
<td style="font-size:12px;color:var(--text-secondary)">${r.note || '—'}</td>
|
||||
<td style="font-size:12px;color:var(--text-secondary)">${(r.created_at || '').slice(0,16)}</td>
|
||||
<td><span class="sh-badge" style="background:${statusColor(r.status)}22;color:${statusColor(r.status)}">${statusLabel(r.status)}</span></td>
|
||||
<td style="font-size:12px;color:#ef4444">${r.reject_reason || '—'}</td>
|
||||
<td style="font-size:12px;color:var(--text-secondary)">${r.confirmed_by || '—'}</td>
|
||||
<td style="white-space:nowrap">
|
||||
${r.status === 'pending'
|
||||
? `<button class="sh-btn sh-btn-success" style="margin-right:6px" onclick="ShipmentOrders.confirm('${r.order_no}','approve')">确认</button>
|
||||
<button class="sh-btn sh-btn-danger" onclick="ShipmentOrders.showRejectModal('${r.order_no}')">驳回</button>`
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
};
|
||||
|
||||
const buildModelManageHtml = () => `
|
||||
<div class="sh-card" style="padding:16px 20px;margin-bottom:20px">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:12px;color:var(--text)">\u4ea7\u54c1\u578b\u53f7\u7ba1\u7406</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:10px">
|
||||
<input id="sh-new-model" class="sh-input" placeholder="\u8f93\u5165\u65b0\u578b\u53f7..." style="flex:1;max-width:260px"
|
||||
onkeydown="if(event.key==='Enter')ShipmentOrders.addModel()" />
|
||||
<button class="sh-btn sh-btn-primary" onclick="ShipmentOrders.addModel()">\u6dfb\u52a0</button>
|
||||
</div>
|
||||
<div id="sh-model-list" style="display:flex;flex-wrap:wrap;gap:6px">${
|
||||
_modelList.length === 0
|
||||
? '<span style="color:var(--text-secondary);font-size:12px">\u6682\u65e0\u578b\u53f7</span>'
|
||||
: _modelList.map(m => `
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 8px;font-size:12px">
|
||||
${m.model}
|
||||
<button onclick="ShipmentOrders.deleteModel(${m.id})" style="background:none;border:none;cursor:pointer;color:#ef4444;font-size:13px;line-height:1;padding:0 2px" title="\u5220\u9664">\u00d7</button>
|
||||
</span>`).join('')
|
||||
}</div>
|
||||
</div>`;
|
||||
|
||||
// ── 超级管理员视图:首次渲染骨架 ────────────────────────────
|
||||
const renderSuperadminView = async (el) => {
|
||||
el.innerHTML = '<div style="color:var(--text-secondary);padding:20px">加载中...</div>';
|
||||
const [res] = await Promise.all([
|
||||
fetch('/api/warehouse/shipment-orders', { credentials: 'include' }),
|
||||
loadModels()
|
||||
]);
|
||||
const data = res.ok ? await res.json() : { list: [] };
|
||||
const list = data.list || [];
|
||||
const pending = list.filter(r => r.status === 'pending').length;
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:20px">
|
||||
<span style="font-size:15px;font-weight:600">发货单审批</span>
|
||||
<span id="sh-pending-badge" style="${pending > 0 ? '' : 'display:none;'}background:#f59e0b;color:#fff;border-radius:99px;padding:2px 8px;font-size:11px;font-weight:700">${pending} 待确认</span>
|
||||
</div>
|
||||
${buildModelManageHtml()}
|
||||
<div class="sh-card">
|
||||
<table class="sh-table">
|
||||
<thead><tr>
|
||||
<th>单号</th><th>申请人</th><th>产品明细</th><th>备注</th><th>提交时间</th><th>状态</th><th>驳回原因</th><th>确认人</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody id="sh-tbody">${buildTableRows(list)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 驳回弹窗 -->
|
||||
<div id="sh-reject-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;align-items:center;justify-content:center">
|
||||
<div style="background:var(--surface);border-radius:12px;padding:24px;width:420px;max-width:90vw;box-shadow:0 8px 32px rgba(0,0,0,.2)">
|
||||
<div style="font-size:15px;font-weight:600;margin-bottom:16px">驳回原因</div>
|
||||
<textarea id="sh-reject-reason" class="sh-input" rows="3" placeholder="请填写驳回原因..." style="resize:vertical"></textarea>
|
||||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px">
|
||||
<button class="sh-btn sh-btn-secondary" onclick="ShipmentOrders.closeRejectModal()">取消</button>
|
||||
<button class="sh-btn sh-btn-danger" onclick="ShipmentOrders.submitReject()">确认驳回</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
// ── 静默刷新(仅更新 tbody 和徽标,不重建整个页面)──────────
|
||||
const silentRefresh = async () => {
|
||||
const tbody = document.getElementById('sh-tbody');
|
||||
const badge = document.getElementById('sh-pending-badge');
|
||||
if (!tbody) return;
|
||||
const res = await fetch('/api/warehouse/shipment-orders', { credentials: 'include' });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const list = data.list || [];
|
||||
const pending = list.filter(r => r.status === 'pending').length;
|
||||
tbody.innerHTML = buildTableRows(list);
|
||||
if (badge) {
|
||||
badge.textContent = `${pending} 待确认`;
|
||||
badge.style.display = pending > 0 ? '' : 'none';
|
||||
}
|
||||
};
|
||||
|
||||
let _rejectOrderNo = null;
|
||||
let _pollTimer = null;
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
_pollTimer = setInterval(async () => {
|
||||
const el = document.getElementById('shipment-content');
|
||||
if (!el) { stopPolling(); return; }
|
||||
if (isSuperadmin()) {
|
||||
const modal = document.getElementById('sh-reject-modal');
|
||||
const modalOpen = modal && modal.style.display === 'flex';
|
||||
if (!modalOpen) await silentRefresh();
|
||||
} else if (isAdmin()) {
|
||||
await adminSilentRefresh();
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||
};
|
||||
|
||||
const showRejectModal = (orderNo) => {
|
||||
_rejectOrderNo = orderNo;
|
||||
const modal = document.getElementById('sh-reject-modal');
|
||||
if (modal) { modal.style.display = 'flex'; document.getElementById('sh-reject-reason').value = ''; }
|
||||
};
|
||||
|
||||
const closeRejectModal = () => {
|
||||
const modal = document.getElementById('sh-reject-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
_rejectOrderNo = null;
|
||||
};
|
||||
|
||||
const submitReject = async () => {
|
||||
const reason = document.getElementById('sh-reject-reason')?.value.trim();
|
||||
if (!reason) { alert('请填写驳回原因'); return; }
|
||||
await confirm(_rejectOrderNo, 'reject', reason);
|
||||
closeRejectModal();
|
||||
};
|
||||
|
||||
const confirm = async (orderNo, action, rejectReason = '') => {
|
||||
const res = await fetch(`/api/warehouse/shipment-orders/${orderNo}/confirm`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, reject_reason: rejectReason })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.ok) {
|
||||
await silentRefresh();
|
||||
} else {
|
||||
alert(data.error || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
return { render, init, addItem, removeItem, submit, confirm, showRejectModal, closeRejectModal, submitReject, stopPolling, addModel, deleteModel };
|
||||
})();
|
||||
|
||||
Router.register('/warehouse/shipment', async () => {
|
||||
const html = await window.ShipmentOrders.render();
|
||||
setTimeout(() => window.ShipmentOrders.init(), 0);
|
||||
return html;
|
||||
});
|
||||
|
||||
Router.onBeforeEach(() => { window.ShipmentOrders.stopPolling(); });
|
||||
@ -112,7 +112,8 @@ const Router = (() => {
|
||||
'ai-report': 'AI报表',
|
||||
warehouse: '仓库管理',
|
||||
borrow: '借出单',
|
||||
return: '借出还入单'
|
||||
return: '借出还入单',
|
||||
shipment: '发货单'
|
||||
};
|
||||
return map[key] || key;
|
||||
}
|
||||
|
||||
193
server/app.py
193
server/app.py
@ -474,6 +474,28 @@ def init_db():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 发货单产品型号管理
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS shipment_models(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
model TEXT NOT NULL UNIQUE,
|
||||
created_by TEXT,
|
||||
created_at TEXT
|
||||
)''')
|
||||
|
||||
# 发货单表(admin上传,superadmin审批)
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS shipment_orders(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_no TEXT NOT NULL UNIQUE,
|
||||
items TEXT NOT NULL,
|
||||
note TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
confirmed_by TEXT,
|
||||
confirmed_at TEXT,
|
||||
reject_reason TEXT
|
||||
)''')
|
||||
|
||||
conn.commit()
|
||||
# create default admin
|
||||
c.execute('SELECT id FROM users WHERE username=?', ('admin',))
|
||||
@ -598,6 +620,33 @@ def notify_admins(action, detail=''):
|
||||
pass
|
||||
|
||||
|
||||
def notify_user_by_username(target_username, action, detail=''):
|
||||
"""通知指定用户名的用户"""
|
||||
try:
|
||||
operator_id = session.get('user_id')
|
||||
if not operator_id:
|
||||
return
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT username FROM users WHERE id=?', (operator_id,))
|
||||
operator = c.fetchone()
|
||||
operator_name = operator['username'] if operator else ''
|
||||
c.execute('SELECT id FROM users WHERE username=?', (target_username,))
|
||||
target = c.fetchone()
|
||||
if not target:
|
||||
conn.close()
|
||||
return
|
||||
from datetime import timezone, timedelta
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
now = datetime.now(beijing_tz).isoformat()
|
||||
c.execute('INSERT INTO notifications(user_id, username, action, detail, ts, read) VALUES(?,?,?,?,?,?)',
|
||||
(target['id'], operator_name, action, detail, now, 0))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def notify_admins_by_factory(action, detail='', factory=None):
|
||||
"""为指定工厂的管理员创建通知(超级管理员操作时使用)"""
|
||||
try:
|
||||
@ -9250,6 +9299,150 @@ def unreturned_summary():
|
||||
return jsonify({'ok': True, 'summary': summary, 'total_unreturned': total_unreturned})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 仓库管理 —— 发货单
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@app.get('/api/warehouse/shipment-models')
|
||||
@require_login
|
||||
@require_any_role('admin', 'superadmin')
|
||||
def get_shipment_models():
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT id, model FROM shipment_models ORDER BY model ASC')
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'list': [dict(r) for r in rows]})
|
||||
|
||||
|
||||
@app.post('/api/warehouse/shipment-models')
|
||||
@require_login
|
||||
@require_any_role('superadmin')
|
||||
def create_shipment_model():
|
||||
data = request.get_json() or {}
|
||||
model = (data.get('model') or '').strip()
|
||||
if not model:
|
||||
return jsonify({'error': '型号不能为空'}), 400
|
||||
username = session.get('username', '')
|
||||
now = get_beijing_time()
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute('INSERT INTO shipment_models(model, created_by, created_at) VALUES(?,?,?)', (model, username, now))
|
||||
conn.commit()
|
||||
new_id = c.lastrowid
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'id': new_id, 'model': model})
|
||||
except Exception:
|
||||
conn.close()
|
||||
return jsonify({'error': '型号已存在'}), 400
|
||||
|
||||
|
||||
@app.delete('/api/warehouse/shipment-models/<int:model_id>')
|
||||
@require_login
|
||||
@require_any_role('superadmin')
|
||||
def delete_shipment_model(model_id):
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute('DELETE FROM shipment_models WHERE id=?', (model_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
@app.get('/api/warehouse/shipment-orders')
|
||||
@require_login
|
||||
@require_any_role('admin', 'superadmin')
|
||||
def get_shipment_orders():
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
user_role = session.get('role', '')
|
||||
username = session.get('username', '')
|
||||
if user_role == 'admin':
|
||||
c.execute('SELECT * FROM shipment_orders WHERE created_by=? ORDER BY created_at DESC', (username,))
|
||||
else:
|
||||
c.execute('SELECT * FROM shipment_orders ORDER BY created_at DESC')
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
result = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
try:
|
||||
d['items'] = json.loads(d['items'])
|
||||
except Exception:
|
||||
d['items'] = []
|
||||
result.append(d)
|
||||
return jsonify({'ok': True, 'list': result})
|
||||
|
||||
|
||||
@app.post('/api/warehouse/shipment-orders')
|
||||
@require_login
|
||||
@require_any_role('admin')
|
||||
def create_shipment_order():
|
||||
data = request.get_json() or {}
|
||||
items = data.get('items', [])
|
||||
note = (data.get('note') or '').strip()
|
||||
if not items or not isinstance(items, list):
|
||||
return jsonify({'error': '请至少添加一个产品'}), 400
|
||||
for item in items:
|
||||
if not item.get('model') or not item.get('qty'):
|
||||
return jsonify({'error': '每行必须填写型号和数量'}), 400
|
||||
try:
|
||||
if int(item['qty']) <= 0:
|
||||
return jsonify({'error': '发货数量必须大于0'}), 400
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': '发货数量必须是整数'}), 400
|
||||
username = session.get('username', '')
|
||||
now = get_beijing_time()
|
||||
order_no = 'SHP' + now.replace('-','').replace(' ','').replace(':','')
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
'INSERT INTO shipment_orders(order_no,items,note,status,created_by,created_at) VALUES(?,?,?,?,?,?)',
|
||||
(order_no, json.dumps(items, ensure_ascii=False), note, 'pending', username, now)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
log('create_shipment_order', f'单号:{order_no}, 申请人:{username}')
|
||||
notify_superadmin('新发货单待确认', f'单号:{order_no}, 产品数:{len(items)}, 申请人:{username}')
|
||||
return jsonify({'ok': True, 'order_no': order_no})
|
||||
|
||||
|
||||
@app.patch('/api/warehouse/shipment-orders/<order_no>/confirm')
|
||||
@require_login
|
||||
@require_any_role('superadmin')
|
||||
def confirm_shipment_order(order_no):
|
||||
data = request.get_json() or {}
|
||||
action = data.get('action', 'approve') # approve / reject
|
||||
reject_reason = (data.get('reject_reason') or '').strip()
|
||||
if action == 'reject' and not reject_reason:
|
||||
return jsonify({'error': '驳回时必须填写原因'}), 400
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT * FROM shipment_orders WHERE order_no=?', (order_no,))
|
||||
row = c.fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return jsonify({'error': '单据不存在'}), 404
|
||||
if row['status'] != 'pending':
|
||||
conn.close()
|
||||
return jsonify({'error': '该单据已处理'}), 400
|
||||
username = session.get('username', '')
|
||||
now = get_beijing_time()
|
||||
new_status = 'approved' if action == 'approve' else 'rejected'
|
||||
c.execute(
|
||||
'UPDATE shipment_orders SET status=?,confirmed_by=?,confirmed_at=?,reject_reason=? WHERE order_no=?',
|
||||
(new_status, username, now, reject_reason if action == 'reject' else None, order_no)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
action_text = '已确认' if action == 'approve' else '已驳回'
|
||||
log('confirm_shipment_order', f'单号:{order_no}, 操作:{action_text}, 操作人:{username}')
|
||||
detail_msg = f'单号:{order_no}' + (f', 驳回原因:{reject_reason}' if action == 'reject' else '')
|
||||
notify_user_by_username(row['created_by'], f'发货单{action_text}', detail_msg)
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("🚀 启动服务器(已启用静态资源缓存)...")
|
||||
app.run(host='0.0.0.0', port=int(os.environ.get('PORT', '5000')), threaded=True)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user