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'),
listDefects: () => request('/list/defects'),
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)),
auditYt: (params={}) => request('/audit/yt' + buildQuery(params)),
auditTx: (params={}) => request('/audit/tx' + buildQuery(params)),

View File

@ -90,6 +90,14 @@
<div class="page-actions" style="margin-top: 16px;">
<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>
<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="restore-backup-btn" class="btn btn-secondary" style="margin-right: 10px;">📦 数据恢复</button>
<button id="batch-delete-btn" class="btn btn-danger">批量删除</button>
@ -325,6 +333,15 @@
document.getElementById('search-keyword')?.addEventListener('keypress', (e) => {
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();
loadFactories();
}, 100);
@ -656,6 +673,208 @@
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() {
document.getElementById('restore-modal').style.display = 'flex';
await loadBackupList();
@ -777,6 +996,10 @@
importExcel,
closeRestoreModal,
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> ${data.date}</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>
`;
@ -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">机种:</span> <span class="badge">${record.platform_name || ''}</span></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>
`).join('');

View File

@ -1,6 +1,7 @@
Router.register('/shipments/summary', async () => {
setTimeout(async () => {
const queryBtn = document.getElementById('summary-query-btn');
const exportBtn = document.getElementById('summary-export-btn');
const startDateInput = document.getElementById('start-date');
const endDateInput = document.getElementById('end-date');
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);
exportBtn?.addEventListener('click', performExport);
// 自动执行一次查询
performQuery();
@ -163,8 +220,9 @@ Router.register('/shipments/summary', async () => {
</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" id="summary-export-btn" style="background:#10b981;color:white">导出Excel</button>
</div>
</div>

View File

@ -1,6 +1,108 @@
Router.register('/upload/shipments', async () => {
setTimeout(() => {
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');
@ -8,6 +110,11 @@ Router.register('/upload/shipments', async () => {
const today = new Date().toISOString().split('T')[0];
dateInput.value = today;
}
// 动态加载机种下拉,并支持自定义新增
loadPlatforms();
document.getElementById('ship-manage-platforms')?.addEventListener('click', showManageModal);
// 手动录入提交
const btn = document.getElementById('ship-upload');
@ -54,7 +161,7 @@ Router.register('/upload/shipments', async () => {
await API.uploadShipments(payload);
const platformName = {pdd: '拼多多', yt: '圆通', tx: '兔喜', mt: '美团', drf: '大润发', std: '标准版'}[platform] || platform;
const platformName = defaultPlatformNameMap[platform] || platform;
manualStatus.textContent = `✓ 录入成功!机种:${platformName},数量:${qty}`;
manualStatus.className = 'success';
@ -155,7 +262,7 @@ Router.register('/upload/shipments', async () => {
const data = await res.json();
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.className = 'success';
fileInput.value = '';
@ -234,6 +341,9 @@ Router.register('/upload/shipments', async () => {
<div id="shipments-page">
<div class="page-header">
<h1>发货记录</h1>
<div class="page-actions">
<button class="btn btn-secondary" id="ship-manage-platforms" type="button">机种管理</button>
</div>
</div>
<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')
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.config['SECRET_KEY'] = os.environ.get('APP_SECRET', 'change-me')
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 限制上传文件大小为50MB
@ -110,6 +120,25 @@ def init_db():
date TEXT,
qty INTEGER,
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
)''')
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))
conn.commit()
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}')
return jsonify({'ok': True, 'avatar_url': avatar_url})
@ -2688,7 +2760,7 @@ def upload_shipments():
conn.commit()
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}
if box_no:
log_data['box_no'] = box_no
@ -2698,6 +2770,91 @@ def upload_shipments():
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
@app.get('/api/collect/devices')
@require_login
@ -3764,7 +3921,7 @@ def query_shipment_by_sn():
# 解析 JSON 数据
shipment_info = json.loads(result)
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({
'found': True,
'sn': sn,
@ -3772,7 +3929,9 @@ def query_shipment_by_sn():
'box': shipment_info.get('box'),
'platform': platform,
'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:
return jsonify({
@ -3809,14 +3968,16 @@ def query_shipment_by_box():
shipment_info = json.loads(data)
if shipment_info.get('box') == box_no:
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({
'sn': sn.decode('utf-8') if isinstance(sn, bytes) else sn,
'date': shipment_info.get('date'),
'box': shipment_info.get('box'),
'platform': platform,
'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:
continue
@ -4022,6 +4183,130 @@ def clear_shipments_redis():
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'])
@require_login
@require_any_role('admin','superadmin')
@ -4033,8 +4318,9 @@ def upload_shipments_file():
if not f:
return jsonify({'error': '请选择文件'}), 400
if not platform or platform not in ['pdd', 'yt', 'tx']:
return jsonify({'error': '请选择机种(拼多多/圆通/兔喜)'}), 400
allowed = set([o['code'] for o in list_shipment_platforms()])
if not platform or platform not in allowed:
return jsonify({'error': '请选择有效的机种类型(可在下拉中新增自定义机种)'}), 400
name = secure_filename(f.filename or '')
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)
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:
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_indices = sorted(sn_cols, key=lambda x: int(x[1][2:])) # 按 SN 后面的数字排序
# 记录上一个有效的日期(用于处理合并单元格)
# 记录上一个有效的日期、EMMC、DDR(用于处理合并单元格)
last_valid_date = None
last_valid_emmc = None
last_valid_ddr = None
for line in lines[1:]:
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 ''
# 处理 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:
continue
@ -4101,7 +4407,9 @@ def upload_shipments_file():
'date': date,
'box': box,
'sns': sns,
'qty': len(sns)
'qty': len(sns),
'emmc': emmc,
'ddr': ddr
})
else:
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)
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:
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_indices = sorted(sn_cols, key=lambda x: int(x[1][2:])) # 按 SN 后面的数字排序
# 记录上一个有效的日期(用于处理合并单元格)
# 记录上一个有效的日期、EMMC、DDR(用于处理合并单元格)
last_valid_date = None
last_valid_emmc = None
last_valid_ddr = None
# 读取数据行
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 ''
# 处理 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':
continue
@ -4186,7 +4516,9 @@ def upload_shipments_file():
'date': date,
'box': box,
'sns': sns,
'qty': len(sns)
'qty': len(sns),
'emmc': emmc,
'ddr': ddr
})
wb.close()
@ -4202,8 +4534,10 @@ def upload_shipments_file():
total_qty = 0
for r in rows:
receiver_info = f"箱号:{r['box']}"
c.execute('INSERT INTO shipments(date, qty, receiver, ts) VALUES(?,?,?,?)',
(r['date'], r['qty'], receiver_info, now))
emmc_value = r.get('emmc', '')
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']
conn.commit()
@ -4224,14 +4558,18 @@ def upload_shipments_file():
sns = row_data['sns']
# 为每个 SN/MAC 创建映射记录
emmc = row_data.get('emmc', '')
ddr = row_data.get('ddr', '')
for sn in sns:
if sn: # 确保 SN 不为空
# 存储格式: MAC -> JSON(date, box, platform, timestamp)
# 存储格式: MAC -> JSON(date, box, platform, timestamp, emmc, ddr)
shipment_info = json.dumps({
'date': date,
'box': box,
'platform': platform,
'ts': now
'ts': now,
'emmc': emmc,
'ddr': ddr
}, ensure_ascii=False)
pipe.hset(redis_key, sn, shipment_info)
redis_count += 1

Binary file not shown.