This commit is contained in:
zzh 2026-05-11 13:41:05 +08:00
parent 2e2b166e08
commit 573cf9feb5
7 changed files with 755 additions and 19 deletions

View File

@ -171,6 +171,9 @@ const API = (() => {
listRepairs: () => request('/list/repairs'), listRepairs: () => request('/list/repairs'),
listDefects: () => request('/list/defects'), listDefects: () => request('/list/defects'),
listShipments: () => request('/list/shipments'), listShipments: () => request('/list/shipments'),
listShipmentPlatforms: () => request('/shipments/platforms'),
addShipmentPlatform: (name) => request('/shipments/platforms', { method: 'POST', body: JSON.stringify({ name }) }),
deleteShipmentPlatform: (code) => request(`/shipments/platforms/${encodeURIComponent(code)}`, { method: 'DELETE' }),
auditPdd: (params={}) => request('/audit/pdd' + buildQuery(params)), auditPdd: (params={}) => request('/audit/pdd' + buildQuery(params)),
auditYt: (params={}) => request('/audit/yt' + buildQuery(params)), auditYt: (params={}) => request('/audit/yt' + buildQuery(params)),
auditTx: (params={}) => request('/audit/tx' + buildQuery(params)), auditTx: (params={}) => request('/audit/tx' + buildQuery(params)),

View File

@ -90,6 +90,14 @@
<div class="page-actions" style="margin-top: 16px;"> <div class="page-actions" style="margin-top: 16px;">
<button id="add-stock-btn" class="btn btn-primary" style="margin-right: 10px;">新增库存</button> <button id="add-stock-btn" class="btn btn-primary" style="margin-right: 10px;">新增库存</button>
<button id="import-stock-btn" class="btn btn-secondary" style="margin-right: 10px;">导入 Excel</button> <button id="import-stock-btn" class="btn btn-secondary" style="margin-right: 10px;">导入 Excel</button>
<div style="display: inline-block; position: relative; margin-right: 10px;">
<button id="export-btn" class="btn btn-secondary" onclick="InitialStock.toggleExportMenu(event)">导出 Excel </button>
<div id="export-menu" style="display: none; position: absolute; top: 100%; left: 0; background: white; border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 1000; min-width: 150px; margin-top: 4px;">
<button onclick="InitialStock.exportAll()" style="display: block; width: 100%; padding: 8px 16px; border: none; background: none; text-align: left; cursor: pointer; font-size: 14px;" onmouseover="this.style.background='var(--hover)'" onmouseout="this.style.background='none'">导出全部</button>
<button onclick="InitialStock.exportSelected()" style="display: block; width: 100%; padding: 8px 16px; border: none; background: none; text-align: left; cursor: pointer; font-size: 14px;" onmouseover="this.style.background='var(--hover)'" onmouseout="this.style.background='none'">导出选中</button>
<button onclick="InitialStock.exportByFactory()" style="display: block; width: 100%; padding: 8px 16px; border: none; background: none; text-align: left; cursor: pointer; font-size: 14px;" onmouseover="this.style.background='var(--hover)'" onmouseout="this.style.background='none'">按工厂导出</button>
</div>
</div>
<button id="download-template-btn" class="btn btn-secondary" style="margin-right: 10px;">下载模板</button> <button id="download-template-btn" class="btn btn-secondary" style="margin-right: 10px;">下载模板</button>
<button id="restore-backup-btn" class="btn btn-secondary" style="margin-right: 10px;">📦 数据恢复</button> <button id="restore-backup-btn" class="btn btn-secondary" style="margin-right: 10px;">📦 数据恢复</button>
<button id="batch-delete-btn" class="btn btn-danger">批量删除</button> <button id="batch-delete-btn" class="btn btn-danger">批量删除</button>
@ -325,6 +333,15 @@
document.getElementById('search-keyword')?.addEventListener('keypress', (e) => { document.getElementById('search-keyword')?.addEventListener('keypress', (e) => {
if (e.key === 'Enter') InitialStock.search(); if (e.key === 'Enter') InitialStock.search();
}); });
document.addEventListener('click', (e) => {
const exportMenu = document.getElementById('export-menu');
const exportBtn = document.getElementById('export-btn');
if (exportMenu && !exportMenu.contains(e.target) && e.target !== exportBtn) {
exportMenu.style.display = 'none';
}
});
loadList(); loadList();
loadFactories(); loadFactories();
}, 100); }, 100);
@ -656,6 +673,208 @@
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
} }
function toggleExportMenu(event) {
event.stopPropagation();
const menu = document.getElementById('export-menu');
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
}
async function ensureXLSXLoaded() {
if (typeof XLSX === 'undefined') {
try {
await loadXLSXLibrary();
} catch (e) {
throw new Error('无法加载 Excel 库,请刷新页面重试');
}
}
}
function loadXLSXLibrary() {
return new Promise((resolve, reject) => {
if (typeof XLSX !== 'undefined') {
resolve();
return;
}
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js';
script.onload = () => resolve();
script.onerror = () => reject(new Error('加载 XLSX 库失败'));
document.head.appendChild(script);
});
}
async function exportAll() {
try {
document.getElementById('export-menu').style.display = 'none';
if (stockList.length === 0) {
alert('没有数据可导出');
return;
}
await ensureXLSXLoaded();
const exportData = stockList.map(item => ({
'物料编码': item.material_code || '',
'物料名称': item.material_name || '',
'库存数量': item.stock_qty || 0,
'单位': item.unit || 'pcs',
'最小包装': item.min_package || 1,
'供应商': item.supplier || '',
'工厂': item.factory || '',
'备注': item.remark || '',
'更新时间': formatTime(item.updated_at)
}));
const ws = XLSX.utils.json_to_sheet(exportData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '期初库存');
const colWidths = [
{ wch: 15 },
{ wch: 25 },
{ wch: 10 },
{ wch: 8 },
{ wch: 10 },
{ wch: 20 },
{ wch: 15 },
{ wch: 30 },
{ wch: 18 }
];
ws['!cols'] = colWidths;
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const filename = `期初库存_全部_${timestamp}.xlsx`;
XLSX.writeFile(wb, filename);
console.log(`成功导出 ${exportData.length} 条数据到 ${filename}`);
} catch (err) {
console.error('导出失败:', err);
alert('导出失败: ' + err.message);
}
}
async function exportSelected() {
try {
document.getElementById('export-menu').style.display = 'none';
const checked = document.querySelectorAll('.row-checkbox:checked');
if (checked.length === 0) {
alert('请先选择要导出的数据');
return;
}
const selectedIds = Array.from(checked).map(cb => parseInt(cb.dataset.id));
const selectedData = stockList.filter(item => selectedIds.includes(item.id));
await ensureXLSXLoaded();
const exportData = selectedData.map(item => ({
'物料编码': item.material_code || '',
'物料名称': item.material_name || '',
'库存数量': item.stock_qty || 0,
'单位': item.unit || 'pcs',
'最小包装': item.min_package || 1,
'供应商': item.supplier || '',
'工厂': item.factory || '',
'备注': item.remark || '',
'更新时间': formatTime(item.updated_at)
}));
const ws = XLSX.utils.json_to_sheet(exportData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '期初库存');
const colWidths = [
{ wch: 15 },
{ wch: 25 },
{ wch: 10 },
{ wch: 8 },
{ wch: 10 },
{ wch: 20 },
{ wch: 15 },
{ wch: 30 },
{ wch: 18 }
];
ws['!cols'] = colWidths;
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const filename = `期初库存_选中${exportData.length}条_${timestamp}.xlsx`;
XLSX.writeFile(wb, filename);
console.log(`成功导出 ${exportData.length} 条数据到 ${filename}`);
} catch (err) {
console.error('导出失败:', err);
alert('导出失败: ' + err.message);
}
}
async function exportByFactory() {
try {
document.getElementById('export-menu').style.display = 'none';
const factory = document.getElementById('factory-filter')?.value || '';
if (!factory) {
alert('请先选择要导出的工厂');
return;
}
const factoryData = stockList.filter(item => item.factory === factory);
if (factoryData.length === 0) {
alert(`工厂 "${factory}" 没有数据可导出`);
return;
}
await ensureXLSXLoaded();
const exportData = factoryData.map(item => ({
'物料编码': item.material_code || '',
'物料名称': item.material_name || '',
'库存数量': item.stock_qty || 0,
'单位': item.unit || 'pcs',
'最小包装': item.min_package || 1,
'供应商': item.supplier || '',
'工厂': item.factory || '',
'备注': item.remark || '',
'更新时间': formatTime(item.updated_at)
}));
const ws = XLSX.utils.json_to_sheet(exportData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '期初库存');
const colWidths = [
{ wch: 15 },
{ wch: 25 },
{ wch: 10 },
{ wch: 8 },
{ wch: 10 },
{ wch: 20 },
{ wch: 15 },
{ wch: 30 },
{ wch: 18 }
];
ws['!cols'] = colWidths;
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const filename = `期初库存_${factory}_${timestamp}.xlsx`;
XLSX.writeFile(wb, filename);
console.log(`成功导出 ${exportData.length} 条数据到 ${filename}`);
} catch (err) {
console.error('导出失败:', err);
alert('导出失败: ' + err.message);
}
}
async function showRestoreDialog() { async function showRestoreDialog() {
document.getElementById('restore-modal').style.display = 'flex'; document.getElementById('restore-modal').style.display = 'flex';
await loadBackupList(); await loadBackupList();
@ -777,6 +996,10 @@
importExcel, importExcel,
closeRestoreModal, closeRestoreModal,
restoreBackup, restoreBackup,
deleteBackup deleteBackup,
toggleExportMenu,
exportAll,
exportSelected,
exportByFactory
}; };
})(); })();

View File

@ -91,6 +91,8 @@ Router.register('/shipments/query', async () => {
<div class="result-item"><span class="label">机种:</span> <span class="badge">${data.platform_name || ''}</span></div> <div class="result-item"><span class="label">机种:</span> <span class="badge">${data.platform_name || ''}</span></div>
<div class="result-item"><span class="label">出货日期:</span> ${data.date}</div> <div class="result-item"><span class="label">出货日期:</span> ${data.date}</div>
<div class="result-item"><span class="label">箱号:</span> ${data.box}</div> <div class="result-item"><span class="label">箱号:</span> ${data.box}</div>
${data.emmc ? `<div class="result-item"><span class="label">EMMC:</span> <span style="font-weight:600;color:var(--primary)">${data.emmc}</span></div>` : ''}
${data.ddr ? `<div class="result-item"><span class="label">DDR:</span> <span style="font-weight:600;color:var(--primary)">${data.ddr}</span></div>` : ''}
<div class="result-item"><span class="label">记录时间:</span> ${data.ts}</div> <div class="result-item"><span class="label">记录时间:</span> ${data.ts}</div>
</div> </div>
`; `;
@ -117,6 +119,8 @@ Router.register('/shipments/query', async () => {
<div class="result-item"><span class="label">SN/MAC:</span> ${record.sn}</div> <div class="result-item"><span class="label">SN/MAC:</span> ${record.sn}</div>
<div class="result-item"><span class="label">机种:</span> <span class="badge">${record.platform_name || ''}</span></div> <div class="result-item"><span class="label">机种:</span> <span class="badge">${record.platform_name || ''}</span></div>
<div class="result-item"><span class="label">出货日期:</span> ${record.date}</div> <div class="result-item"><span class="label">出货日期:</span> ${record.date}</div>
${record.emmc ? `<div class="result-item"><span class="label">EMMC:</span> <span style="font-weight:600;color:var(--primary)">${record.emmc}</span></div>` : ''}
${record.ddr ? `<div class="result-item"><span class="label">DDR:</span> <span style="font-weight:600;color:var(--primary)">${record.ddr}</span></div>` : ''}
<div class="result-item"><span class="label">记录时间:</span> ${record.ts}</div> <div class="result-item"><span class="label">记录时间:</span> ${record.ts}</div>
</div> </div>
`).join(''); `).join('');

View File

@ -1,6 +1,7 @@
Router.register('/shipments/summary', async () => { Router.register('/shipments/summary', async () => {
setTimeout(async () => { setTimeout(async () => {
const queryBtn = document.getElementById('summary-query-btn'); const queryBtn = document.getElementById('summary-query-btn');
const exportBtn = document.getElementById('summary-export-btn');
const startDateInput = document.getElementById('start-date'); const startDateInput = document.getElementById('start-date');
const endDateInput = document.getElementById('end-date'); const endDateInput = document.getElementById('end-date');
const resultDiv = document.getElementById('summary-result'); const resultDiv = document.getElementById('summary-result');
@ -89,7 +90,63 @@ Router.register('/shipments/summary', async () => {
} }
}; };
const performExport = async () => {
const startDate = startDateInput?.value;
const endDate = endDateInput?.value;
if (!startDate || !endDate) {
alert('请选择开始和结束日期');
return;
}
try {
exportBtn.disabled = true;
exportBtn.textContent = '导出中...';
const url = `/api/shipments/export?start=${encodeURIComponent(startDate)}&end=${encodeURIComponent(endDate)}`;
// 使用 fetch 下载文件
const res = await fetch(url, {
credentials: 'include'
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || '导出失败');
}
// 获取文件名
const contentDisposition = res.headers.get('Content-Disposition');
let filename = `发货记录_${startDate}_至_${endDate}.xlsx`;
if (contentDisposition) {
const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition);
if (matches && matches[1]) {
filename = matches[1].replace(/['"]/g, '');
}
}
// 下载文件
const blob = await res.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(downloadUrl);
document.body.removeChild(a);
alert('导出成功!');
} catch (e) {
alert('导出失败:' + e.message);
} finally {
exportBtn.disabled = false;
exportBtn.textContent = '导出Excel';
}
};
queryBtn?.addEventListener('click', performQuery); queryBtn?.addEventListener('click', performQuery);
exportBtn?.addEventListener('click', performExport);
// 自动执行一次查询 // 自动执行一次查询
performQuery(); performQuery();
@ -163,8 +220,9 @@ Router.register('/shipments/summary', async () => {
</div> </div>
</div> </div>
<div class="actions"> <div class="actions" style="display:flex;gap:12px">
<button class="btn btn-primary" id="summary-query-btn">查询</button> <button class="btn btn-primary" id="summary-query-btn">查询</button>
<button class="btn" id="summary-export-btn" style="background:#10b981;color:white">导出Excel</button>
</div> </div>
</div> </div>

View File

@ -1,6 +1,108 @@
Router.register('/upload/shipments', async () => { Router.register('/upload/shipments', async () => {
setTimeout(() => { setTimeout(() => {
const manualStatus = document.getElementById('ship-manual-status'); const manualStatus = document.getElementById('ship-manual-status');
const defaultPlatformNameMap = {pdd: '拼多多', yt: '圆通', tx: '兔喜', mt: '美团', drf: '大润发', std: '标准版'};
const renderPlatformOptions = (selectEl, list, selectedValue = '') => {
if (!selectEl) return;
const optsHtml = [
'<option value="">请选择机种</option>',
...(list || []).map(o => `<option value="${o.value}">${o.label}</option>`)
].join('');
selectEl.innerHTML = optsHtml;
if (selectedValue) selectEl.value = selectedValue;
};
const loadPlatforms = async (selectedManual = '', selectedImport = '') => {
try {
const res = await API.listShipmentPlatforms();
const list = (res && res.list) ? res.list : [];
renderPlatformOptions(document.getElementById('ship-manual-platform'), list, selectedManual);
renderPlatformOptions(document.getElementById('ship-platform'), list, selectedImport);
} catch (e) {
// 失败时不阻塞页面,保持当前 options可能是旧的静态内容
console.warn('加载机种列表失败:', e);
}
};
const showManageModal = async () => {
let list = [];
try {
const res = await API.listShipmentPlatforms();
list = (res && res.list) ? res.list : [];
} catch (e) {
API.toast('加载机种失败');
return;
}
const custom = list.filter(x => !x.is_default);
const modal = document.createElement('div');
modal.id = 'ship-platform-manage-modal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;display:flex;align-items:center;justify-content:center;padding:20px';
modal.innerHTML = `
<div style="background:var(--surface);border-radius:12px;width:90%;max-width:520px;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 10px 30px rgba(0,0,0,0.2)">
<div style="padding:14px 18px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
<div style="font-weight:700">机种管理仅自定义可删除</div>
<button id="ship-platform-manage-close" class="btn btn-secondary" style="padding:4px 10px">关闭</button>
</div>
<div style="padding:16px;overflow:auto;flex:1">
${custom.length ? `
<div style="font-size:12px;color:var(--text-2);margin-bottom:10px"> ${custom.length} 个自定义机种</div>
<ul class="list" style="margin:0">
${custom.map(x => `
<li style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div style="min-width:0">
<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${x.label}</div>
<div style="font-size:11px;color:var(--text-2);font-family:monospace">${x.value}</div>
</div>
<button class="btn btn-secondary ship-platform-delete" data-code="${x.value}" style="background:var(--danger);color:white">删除</button>
</li>
`).join('')}
</ul>
` : `
<div style="text-align:center;padding:30px 0;color:var(--text-2)">暂无自定义机种</div>
`}
</div>
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:10px">
<button class="btn" id="ship-platform-manage-add">新增机种</button>
</div>
</div>
`;
document.body.appendChild(modal);
const close = () => modal.remove();
modal.addEventListener('click', (e) => { if (e.target === modal) close(); });
modal.querySelector('#ship-platform-manage-close')?.addEventListener('click', close);
modal.querySelector('#ship-platform-manage-add')?.addEventListener('click', async () => {
const name = (prompt('请输入自定义机种名称') || '').trim();
if (!name) return;
try {
const added = await API.addShipmentPlatform(name);
if (added && added.ok) {
API.toast('已添加机种:' + (added.label || name));
close();
await loadPlatforms(added.value, added.value);
}
} catch (e) {}
});
modal.querySelectorAll('.ship-platform-delete').forEach(btn => {
btn.addEventListener('click', async () => {
const code = btn.dataset.code;
const label = btn.closest('li')?.querySelector('div div')?.textContent || code;
if (!confirm(`确定删除自定义机种:${label}\n\n删除后下拉将不再显示该机种。`)) return;
try {
const r = await API.deleteShipmentPlatform(code);
if (r && r.ok) {
API.toast('删除成功');
close();
await loadPlatforms();
}
} catch (e) {}
});
});
};
// 设置默认日期为今天 // 设置默认日期为今天
const dateInput = document.getElementById('ship-date'); const dateInput = document.getElementById('ship-date');
@ -8,6 +110,11 @@ Router.register('/upload/shipments', async () => {
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
dateInput.value = today; dateInput.value = today;
} }
// 动态加载机种下拉,并支持自定义新增
loadPlatforms();
document.getElementById('ship-manage-platforms')?.addEventListener('click', showManageModal);
// 手动录入提交 // 手动录入提交
const btn = document.getElementById('ship-upload'); const btn = document.getElementById('ship-upload');
@ -54,7 +161,7 @@ Router.register('/upload/shipments', async () => {
await API.uploadShipments(payload); await API.uploadShipments(payload);
const platformName = {pdd: '拼多多', yt: '圆通', tx: '兔喜', mt: '美团', drf: '大润发', std: '标准版'}[platform] || platform; const platformName = defaultPlatformNameMap[platform] || platform;
manualStatus.textContent = `✓ 录入成功!机种:${platformName},数量:${qty}`; manualStatus.textContent = `✓ 录入成功!机种:${platformName},数量:${qty}`;
manualStatus.className = 'success'; manualStatus.className = 'success';
@ -155,7 +262,7 @@ Router.register('/upload/shipments', async () => {
const data = await res.json(); const data = await res.json();
if (data.ok) { if (data.ok) {
const platformName = {pdd: '拼多多', yt: '圆通', tx: '兔喜', mt: '美团', drf: '大润发', std: '标准版'}[platform] || platform; const platformName = defaultPlatformNameMap[platform] || platform;
fileStatus.textContent = `✓ 上传成功!机种:${platformName},共导入${data.count}个箱次,${data.total_qty}个SN`; fileStatus.textContent = `✓ 上传成功!机种:${platformName},共导入${data.count}个箱次,${data.total_qty}个SN`;
fileStatus.className = 'success'; fileStatus.className = 'success';
fileInput.value = ''; fileInput.value = '';
@ -234,6 +341,9 @@ Router.register('/upload/shipments', async () => {
<div id="shipments-page"> <div id="shipments-page">
<div class="page-header"> <div class="page-header">
<h1>发货记录</h1> <h1>发货记录</h1>
<div class="page-actions">
<button class="btn btn-secondary" id="ship-manage-platforms" type="button">机种管理</button>
</div>
</div> </div>
<div class="content-area"> <div class="content-area">

View File

@ -19,6 +19,16 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, 'data.db') DB_PATH = os.path.join(BASE_DIR, 'data.db')
FRONTEND_DIR = os.path.join(os.path.dirname(BASE_DIR), 'frontend') FRONTEND_DIR = os.path.join(os.path.dirname(BASE_DIR), 'frontend')
# 发货机种(默认选项 + 自定义表)
DEFAULT_SHIPMENT_PLATFORMS = {
'pdd': '拼多多',
'yt': '圆通',
'tx': '兔喜',
'mt': '美团',
'drf': '大润发',
'std': '标准版',
}
app = Flask(__name__, static_folder=FRONTEND_DIR, static_url_path='') app = Flask(__name__, static_folder=FRONTEND_DIR, static_url_path='')
app.config['SECRET_KEY'] = os.environ.get('APP_SECRET', 'change-me') app.config['SECRET_KEY'] = os.environ.get('APP_SECRET', 'change-me')
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 限制上传文件大小为50MB app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 限制上传文件大小为50MB
@ -110,6 +120,25 @@ def init_db():
date TEXT, date TEXT,
qty INTEGER, qty INTEGER,
receiver TEXT, receiver TEXT,
ts TEXT,
emmc TEXT,
ddr TEXT
)''')
# 为已存在的表添加 emmc 和 ddr 列(如果不存在)
try:
c.execute('ALTER TABLE shipments ADD COLUMN emmc TEXT')
except:
pass
try:
c.execute('ALTER TABLE shipments ADD COLUMN ddr TEXT')
except:
pass
# 发货机种类型(自定义项)
c.execute('''CREATE TABLE IF NOT EXISTS shipment_platforms(
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
ts TEXT ts TEXT
)''') )''')
c.execute('''CREATE TABLE IF NOT EXISTS devices( c.execute('''CREATE TABLE IF NOT EXISTS devices(
@ -917,6 +946,49 @@ def upload_avatar():
c.execute('UPDATE users SET avatar=? WHERE id=?', (avatar_url, uid)) c.execute('UPDATE users SET avatar=? WHERE id=?', (avatar_url, uid))
conn.commit() conn.commit()
conn.close() conn.close()
def _make_platform_code():
"""生成一个短且稳定可用的机种 code避免与默认 code 冲突)"""
# 12位随机 hex 足够用且不会太长
import secrets
return 'cust_' + secrets.token_hex(6)
def list_shipment_platforms():
"""返回发货机种选项(包含默认 + 自定义)"""
opts = [{'code': k, 'name': v, 'is_default': True} for k, v in DEFAULT_SHIPMENT_PLATFORMS.items()]
try:
conn = get_db()
c = conn.cursor()
c.execute('SELECT code, name FROM shipment_platforms ORDER BY id DESC')
rows = [dict(r) for r in c.fetchall()]
conn.close()
# 自定义排在后面,防止覆盖默认同 code理论上不会发生
for r in rows:
opts.append({'code': r['code'], 'name': r['name'], 'is_default': False})
except Exception:
pass
return opts
def shipment_platform_display_name(code):
"""将 platform code 映射为展示名称(默认映射 + 自定义表)"""
if not code:
return ''
if code in DEFAULT_SHIPMENT_PLATFORMS:
return DEFAULT_SHIPMENT_PLATFORMS[code]
try:
conn = get_db()
c = conn.cursor()
c.execute('SELECT name FROM shipment_platforms WHERE code=?', (code,))
row = c.fetchone()
conn.close()
if row and row['name']:
return row['name']
except Exception:
pass
return code
log('upload_avatar', f'上传头像: {new_filename}') log('upload_avatar', f'上传头像: {new_filename}')
return jsonify({'ok': True, 'avatar_url': avatar_url}) return jsonify({'ok': True, 'avatar_url': avatar_url})
@ -2688,7 +2760,7 @@ def upload_shipments():
conn.commit() conn.commit()
conn.close() conn.close()
platform_name = {'pdd': '拼多多', 'yt': '圆通', 'tx': '兔喜', 'mt': '美团', 'drf': '大润发', 'std': '标准版'}.get(platform, platform) platform_name = shipment_platform_display_name(platform)
log_data = {'date': date, 'qty': qty, 'to': to, 'platform': platform} log_data = {'date': date, 'qty': qty, 'to': to, 'platform': platform}
if box_no: if box_no:
log_data['box_no'] = box_no log_data['box_no'] = box_no
@ -2698,6 +2770,91 @@ def upload_shipments():
return jsonify({'ok': True}) return jsonify({'ok': True})
@app.get('/api/shipments/platforms')
@require_login
def get_shipment_platforms():
"""获取发货机种选项(默认 + 自定义)"""
opts = list_shipment_platforms()
# 前端需要统一格式
return jsonify({'list': [{'value': o['code'], 'label': o['name'], 'is_default': o['is_default']} for o in opts]})
@app.post('/api/shipments/platforms')
@require_login
@require_any_role('admin','superadmin')
def add_shipment_platform():
"""添加自定义发货机种"""
data = request.get_json() or {}
name = (data.get('name') or '').strip()
if not name:
return jsonify({'error': 'invalid payload'}), 400
if len(name) > 50:
return jsonify({'error': '机种名称过长'}), 400
# 若与默认名称完全一致,直接返回默认项
for code, label in DEFAULT_SHIPMENT_PLATFORMS.items():
if label == name:
return jsonify({'ok': True, 'value': code, 'label': label, 'is_default': True})
conn = get_db()
c = conn.cursor()
try:
# 若已存在同名自定义项,直接返回已有 code
c.execute('SELECT code, name FROM shipment_platforms WHERE name=?', (name,))
row = c.fetchone()
if row:
conn.close()
return jsonify({'ok': True, 'value': row['code'], 'label': row['name'], 'is_default': False})
code = _make_platform_code()
c.execute('INSERT INTO shipment_platforms(code, name, ts) VALUES(?,?,?)', (code, name, get_beijing_time()))
conn.commit()
conn.close()
log('add_shipment_platform', json.dumps({'code': code, 'name': name}))
notify_superadmin('新增发货机种', f"机种: {name}{code}")
return jsonify({'ok': True, 'value': code, 'label': name, 'is_default': False})
except Exception as e:
try:
conn.close()
except Exception:
pass
return jsonify({'error': str(e)}), 500
@app.delete('/api/shipments/platforms/<code>')
@require_login
@require_any_role('admin','superadmin')
def delete_shipment_platform(code):
"""删除自定义发货机种(默认机种不可删除)"""
code = (code or '').strip()
if not code:
return jsonify({'error': 'invalid payload'}), 400
if code in DEFAULT_SHIPMENT_PLATFORMS:
return jsonify({'error': '默认机种不可删除'}), 400
conn = get_db()
c = conn.cursor()
try:
c.execute('SELECT id, name FROM shipment_platforms WHERE code=?', (code,))
row = c.fetchone()
if not row:
conn.close()
return jsonify({'ok': True, 'deleted': 0})
c.execute('DELETE FROM shipment_platforms WHERE code=?', (code,))
conn.commit()
conn.close()
log('delete_shipment_platform', json.dumps({'code': code, 'name': row['name']}))
notify_superadmin('删除发货机种', f"机种: {row['name']}{code}")
return jsonify({'ok': True, 'deleted': 1})
except Exception as e:
try:
conn.close()
except Exception:
pass
return jsonify({'error': str(e)}), 500
# collect # collect
@app.get('/api/collect/devices') @app.get('/api/collect/devices')
@require_login @require_login
@ -3764,7 +3921,7 @@ def query_shipment_by_sn():
# 解析 JSON 数据 # 解析 JSON 数据
shipment_info = json.loads(result) shipment_info = json.loads(result)
platform = shipment_info.get('platform', 'pdd') # 默认拼多多 platform = shipment_info.get('platform', 'pdd') # 默认拼多多
platform_name = {'pdd': '拼多多', 'yt': '圆通', 'tx': '兔喜', 'mt': '美团', 'drf': '大润发', 'std': '标准版'}.get(platform, platform) platform_name = shipment_platform_display_name(platform)
return jsonify({ return jsonify({
'found': True, 'found': True,
'sn': sn, 'sn': sn,
@ -3772,7 +3929,9 @@ def query_shipment_by_sn():
'box': shipment_info.get('box'), 'box': shipment_info.get('box'),
'platform': platform, 'platform': platform,
'platform_name': platform_name, 'platform_name': platform_name,
'ts': shipment_info.get('ts') 'ts': shipment_info.get('ts'),
'emmc': shipment_info.get('emmc', ''),
'ddr': shipment_info.get('ddr', '')
}) })
else: else:
return jsonify({ return jsonify({
@ -3809,14 +3968,16 @@ def query_shipment_by_box():
shipment_info = json.loads(data) shipment_info = json.loads(data)
if shipment_info.get('box') == box_no: if shipment_info.get('box') == box_no:
platform = shipment_info.get('platform', 'pdd') platform = shipment_info.get('platform', 'pdd')
platform_name = {'pdd': '拼多多', 'yt': '圆通', 'tx': '兔喜', 'mt': '美团', 'drf': '大润发', 'std': '标准版'}.get(platform, platform) platform_name = shipment_platform_display_name(platform)
matched_records.append({ matched_records.append({
'sn': sn.decode('utf-8') if isinstance(sn, bytes) else sn, 'sn': sn.decode('utf-8') if isinstance(sn, bytes) else sn,
'date': shipment_info.get('date'), 'date': shipment_info.get('date'),
'box': shipment_info.get('box'), 'box': shipment_info.get('box'),
'platform': platform, 'platform': platform,
'platform_name': platform_name, 'platform_name': platform_name,
'ts': shipment_info.get('ts') 'ts': shipment_info.get('ts'),
'emmc': shipment_info.get('emmc', ''),
'ddr': shipment_info.get('ddr', '')
}) })
except: except:
continue continue
@ -4022,6 +4183,130 @@ def clear_shipments_redis():
return jsonify({'error': f'清空失败:{str(e)}'}), 500 return jsonify({'error': f'清空失败:{str(e)}'}), 500
@app.get('/api/shipments/export')
@require_login
def export_shipments():
"""导出发货记录为Excel文件"""
try:
start_date = request.args.get('start')
end_date = request.args.get('end')
if not start_date or not end_date:
return jsonify({'error': '请提供开始和结束日期'}), 400
import openpyxl
from openpyxl.styles import Font, Alignment, PatternFill
import json
conn = get_db()
c = conn.cursor()
# 查询指定日期范围内的发货记录(按箱号分组)
c.execute('''
SELECT id, date, receiver, emmc, ddr, ts
FROM shipments
WHERE date >= ? AND date <= ?
ORDER BY date, id
''', (start_date, end_date))
records = [dict(r) for r in c.fetchall()]
conn.close()
if not records:
return jsonify({'error': '该时间段内没有发货记录'}), 404
# 从 Redis 获取 SN 数据
try:
r = get_redis()
redis_key = 'shipment_sn_mapping'
all_sn_data = r.hgetall(redis_key)
except:
all_sn_data = {}
# 创建 Excel 工作簿
wb = openpyxl.Workbook()
ws = wb.active
ws.title = '发货记录'
# 设置表头样式
header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
header_font = Font(bold=True, color='FFFFFF')
header_alignment = Alignment(horizontal='center', vertical='center')
# 创建表头
headers = ['出货日期', '箱号'] + [f'SN{i}' for i in range(1, 21)] + ['EMMC', 'DDR']
for col_idx, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_idx, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = header_alignment
# 填充数据
row_idx = 2
for record in records:
# 提取箱号
receiver = record['receiver'] or ''
box_no = receiver.replace('箱号:', '').strip()
# 从 Redis 获取该箱号的所有 SN
sns = []
for sn, data in all_sn_data.items():
try:
sn_str = sn.decode('utf-8') if isinstance(sn, bytes) else sn
data_str = data.decode('utf-8') if isinstance(data, bytes) else data
info = json.loads(data_str)
if info.get('box') == box_no:
sns.append(sn_str)
except:
continue
# 写入数据行
ws.cell(row=row_idx, column=1, value=record['date'])
ws.cell(row=row_idx, column=2, value=box_no)
# 写入 SN最多20个
for i, sn in enumerate(sns[:20], 3):
ws.cell(row=row_idx, column=i, value=sn)
# 写入 EMMC 和 DDR
ws.cell(row=row_idx, column=23, value=record.get('emmc', ''))
ws.cell(row=row_idx, column=24, value=record.get('ddr', ''))
row_idx += 1
# 调整列宽
ws.column_dimensions['A'].width = 12 # 出货日期
ws.column_dimensions['B'].width = 20 # 箱号
for i in range(3, 23): # SN1-SN20
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = 25
ws.column_dimensions['W'].width = 10 # EMMC
ws.column_dimensions['X'].width = 10 # DDR
# 保存到内存
from io import BytesIO
output = BytesIO()
wb.save(output)
output.seek(0)
# 生成文件名
filename = f'发货记录_{start_date}_至_{end_date}.xlsx'
log('export_shipments', f'start={start_date}, end={end_date}, records={len(records)}')
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
except Exception as e:
log('export_shipments_error', str(e))
import traceback
traceback.print_exc()
return jsonify({'error': f'导出失败:{str(e)}'}), 500
@app.route('/api/upload/shipments-file', methods=['POST']) @app.route('/api/upload/shipments-file', methods=['POST'])
@require_login @require_login
@require_any_role('admin','superadmin') @require_any_role('admin','superadmin')
@ -4033,8 +4318,9 @@ def upload_shipments_file():
if not f: if not f:
return jsonify({'error': '请选择文件'}), 400 return jsonify({'error': '请选择文件'}), 400
if not platform or platform not in ['pdd', 'yt', 'tx']: allowed = set([o['code'] for o in list_shipment_platforms()])
return jsonify({'error': '请选择机种(拼多多/圆通/兔喜)'}), 400 if not platform or platform not in allowed:
return jsonify({'error': '请选择有效的机种类型(可在下拉中新增自定义机种)'}), 400
name = secure_filename(f.filename or '') name = secure_filename(f.filename or '')
ext = (name.split('.')[-1] or '').lower() ext = (name.split('.')[-1] or '').lower()
@ -4056,6 +4342,8 @@ def upload_shipments_file():
# 找到列索引 # 找到列索引
date_idx = next((i for i, h in enumerate(header) if '出货日期' in h or '发货日期' in h or 'date' in header_lower[i]), None) date_idx = next((i for i, h in enumerate(header) if '出货日期' in h or '发货日期' in h or 'date' in header_lower[i]), None)
box_idx = next((i for i, h in enumerate(header) if '箱号' in h or 'box' in header_lower[i]), None) box_idx = next((i for i, h in enumerate(header) if '箱号' in h or 'box' in header_lower[i]), None)
emmc_idx = next((i for i, h in enumerate(header) if 'emmc' in h.lower()), None)
ddr_idx = next((i for i, h in enumerate(header) if 'ddr' in h.lower()), None)
if date_idx is None or box_idx is None: if date_idx is None or box_idx is None:
return jsonify({'error': '缺少必需的列'}), 400 return jsonify({'error': '缺少必需的列'}), 400
@ -4064,8 +4352,10 @@ def upload_shipments_file():
sn_cols = [(i, h) for i, h in enumerate(header) if h.startswith('SN') and h[2:].isdigit()] sn_cols = [(i, h) for i, h in enumerate(header) if h.startswith('SN') and h[2:].isdigit()]
sn_indices = sorted(sn_cols, key=lambda x: int(x[1][2:])) # 按 SN 后面的数字排序 sn_indices = sorted(sn_cols, key=lambda x: int(x[1][2:])) # 按 SN 后面的数字排序
# 记录上一个有效的日期(用于处理合并单元格) # 记录上一个有效的日期、EMMC、DDR(用于处理合并单元格)
last_valid_date = None last_valid_date = None
last_valid_emmc = None
last_valid_ddr = None
for line in lines[1:]: for line in lines[1:]:
parts = [p.strip() for p in line.split(',')] parts = [p.strip() for p in line.split(',')]
@ -4085,6 +4375,22 @@ def upload_shipments_file():
# 处理箱号 # 处理箱号
box = parts[box_idx] if box_idx < len(parts) and parts[box_idx] else '' box = parts[box_idx] if box_idx < len(parts) and parts[box_idx] else ''
# 处理 EMMC支持合并单元格
current_emmc = parts[emmc_idx] if emmc_idx is not None and emmc_idx < len(parts) and parts[emmc_idx] else ''
if current_emmc:
last_valid_emmc = current_emmc
emmc = current_emmc
else:
emmc = last_valid_emmc or ''
# 处理 DDR支持合并单元格
current_ddr = parts[ddr_idx] if ddr_idx is not None and ddr_idx < len(parts) and parts[ddr_idx] else ''
if current_ddr:
last_valid_ddr = current_ddr
ddr = current_ddr
else:
ddr = last_valid_ddr or ''
# 如果没有日期或箱号,跳过这行 # 如果没有日期或箱号,跳过这行
if not date or not box: if not date or not box:
continue continue
@ -4101,7 +4407,9 @@ def upload_shipments_file():
'date': date, 'date': date,
'box': box, 'box': box,
'sns': sns, 'sns': sns,
'qty': len(sns) 'qty': len(sns),
'emmc': emmc,
'ddr': ddr
}) })
else: else:
import openpyxl import openpyxl
@ -4120,6 +4428,8 @@ def upload_shipments_file():
# 找到列索引 # 找到列索引
date_idx = next((i for i, h in enumerate(header) if '出货日期' in h or '发货日期' in h or 'date' in header_lower[i]), None) date_idx = next((i for i, h in enumerate(header) if '出货日期' in h or '发货日期' in h or 'date' in header_lower[i]), None)
box_idx = next((i for i, h in enumerate(header) if '箱号' in h or 'box' in header_lower[i]), None) box_idx = next((i for i, h in enumerate(header) if '箱号' in h or 'box' in header_lower[i]), None)
emmc_idx = next((i for i, h in enumerate(header) if 'emmc' in h.lower()), None)
ddr_idx = next((i for i, h in enumerate(header) if 'ddr' in h.lower()), None)
if date_idx is None or box_idx is None: if date_idx is None or box_idx is None:
wb.close() wb.close()
@ -4129,8 +4439,10 @@ def upload_shipments_file():
sn_cols = [(i, h) for i, h in enumerate(header) if h.startswith('SN') and h[2:].isdigit()] sn_cols = [(i, h) for i, h in enumerate(header) if h.startswith('SN') and h[2:].isdigit()]
sn_indices = sorted(sn_cols, key=lambda x: int(x[1][2:])) # 按 SN 后面的数字排序 sn_indices = sorted(sn_cols, key=lambda x: int(x[1][2:])) # 按 SN 后面的数字排序
# 记录上一个有效的日期(用于处理合并单元格) # 记录上一个有效的日期、EMMC、DDR(用于处理合并单元格)
last_valid_date = None last_valid_date = None
last_valid_emmc = None
last_valid_ddr = None
# 读取数据行 # 读取数据行
for row in ws.iter_rows(min_row=2, values_only=True): for row in ws.iter_rows(min_row=2, values_only=True):
@ -4168,6 +4480,24 @@ def upload_shipments_file():
# 处理箱号 # 处理箱号
box = str(row[box_idx]).strip() if box_idx < len(row) and row[box_idx] else '' box = str(row[box_idx]).strip() if box_idx < len(row) and row[box_idx] else ''
# 处理 EMMC支持合并单元格
emmc_value = row[emmc_idx] if emmc_idx is not None and emmc_idx < len(row) else None
current_emmc = str(emmc_value).strip() if emmc_value and str(emmc_value) != 'None' else ''
if current_emmc:
last_valid_emmc = current_emmc
emmc = current_emmc
else:
emmc = last_valid_emmc or ''
# 处理 DDR支持合并单元格
ddr_value = row[ddr_idx] if ddr_idx is not None and ddr_idx < len(row) else None
current_ddr = str(ddr_value).strip() if ddr_value and str(ddr_value) != 'None' else ''
if current_ddr:
last_valid_ddr = current_ddr
ddr = current_ddr
else:
ddr = last_valid_ddr or ''
# 如果没有日期或箱号,跳过这行 # 如果没有日期或箱号,跳过这行
if not date or not box or box == 'None': if not date or not box or box == 'None':
continue continue
@ -4186,7 +4516,9 @@ def upload_shipments_file():
'date': date, 'date': date,
'box': box, 'box': box,
'sns': sns, 'sns': sns,
'qty': len(sns) 'qty': len(sns),
'emmc': emmc,
'ddr': ddr
}) })
wb.close() wb.close()
@ -4202,8 +4534,10 @@ def upload_shipments_file():
total_qty = 0 total_qty = 0
for r in rows: for r in rows:
receiver_info = f"箱号:{r['box']}" receiver_info = f"箱号:{r['box']}"
c.execute('INSERT INTO shipments(date, qty, receiver, ts) VALUES(?,?,?,?)', emmc_value = r.get('emmc', '')
(r['date'], r['qty'], receiver_info, now)) ddr_value = r.get('ddr', '')
c.execute('INSERT INTO shipments(date, qty, receiver, ts, emmc, ddr) VALUES(?,?,?,?,?,?)',
(r['date'], r['qty'], receiver_info, now, emmc_value, ddr_value))
total_qty += r['qty'] total_qty += r['qty']
conn.commit() conn.commit()
@ -4224,14 +4558,18 @@ def upload_shipments_file():
sns = row_data['sns'] sns = row_data['sns']
# 为每个 SN/MAC 创建映射记录 # 为每个 SN/MAC 创建映射记录
emmc = row_data.get('emmc', '')
ddr = row_data.get('ddr', '')
for sn in sns: for sn in sns:
if sn: # 确保 SN 不为空 if sn: # 确保 SN 不为空
# 存储格式: MAC -> JSON(date, box, platform, timestamp) # 存储格式: MAC -> JSON(date, box, platform, timestamp, emmc, ddr)
shipment_info = json.dumps({ shipment_info = json.dumps({
'date': date, 'date': date,
'box': box, 'box': box,
'platform': platform, 'platform': platform,
'ts': now 'ts': now,
'emmc': emmc,
'ddr': ddr
}, ensure_ascii=False) }, ensure_ascii=False)
pipe.hset(redis_key, sn, shipment_info) pipe.hset(redis_key, sn, shipment_info)
redis_count += 1 redis_count += 1

Binary file not shown.