From a12f769f1508bd83f5972c64b82de48194bdbb1e Mon Sep 17 00:00:00 2001 From: zzh Date: Tue, 25 Nov 2025 10:35:02 +0800 Subject: [PATCH] =?UTF-8?q?=20=E5=A2=9E=E5=8A=A0=E5=AF=B9=E8=B4=A6?= =?UTF-8?q?=E5=8D=95=E4=BC=98=E5=8C=96=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + 25年11月份对账单-易泰勒.xlsx | Bin 0 -> 17540 bytes add_customer_name_column.py | 37 ++ check_reconciliations.py | 31 ++ frontend/index.html | 5 + frontend/js/components/customer-order.js | 348 ++++++++++++ frontend/js/components/reconciliation.js | 559 ++++++++++++++++++++ frontend/js/router.js | 1 + import_reconciliation_excel.py | 144 +++++ init_customer_orders.py | 99 ++++ init_reconciliations.py | 124 +++++ server/=2.0.0 | 0 server/app.py | 643 ++++++++++++++++++++++- server/requirements.txt | 1 + test_py/batch_import.py | 149 ++++++ test_py/check_excel.py | 46 ++ test_py/check_login.py | 222 ++++++++ test_py/create_shipments_template.py | 107 ++++ test_py/reset_all_passwords.py | 76 +++ test_py/reset_password.py | 47 ++ test_py/test-theme.html | 71 +++ test_py/test_captcha.png | Bin 0 -> 2132 bytes test_py/test_captcha.py | 88 ++++ test_py/test_import.py | 59 +++ test_py/test_login.html | 0 test_py/test_sop_feature.py | 173 ++++++ test_py/validate_excel.py | 59 +++ test_shipment_upload.py | 162 ++++++ 发货单-20251121.xls | Bin 0 -> 31744 bytes 29 files changed, 3232 insertions(+), 22 deletions(-) create mode 100644 25年11月份对账单-易泰勒.xlsx create mode 100644 add_customer_name_column.py create mode 100644 check_reconciliations.py create mode 100644 frontend/js/components/customer-order.js create mode 100644 frontend/js/components/reconciliation.js create mode 100644 import_reconciliation_excel.py create mode 100644 init_customer_orders.py create mode 100644 init_reconciliations.py create mode 100644 server/=2.0.0 create mode 100644 test_py/batch_import.py create mode 100644 test_py/check_excel.py create mode 100755 test_py/check_login.py create mode 100644 test_py/create_shipments_template.py create mode 100755 test_py/reset_all_passwords.py create mode 100755 test_py/reset_password.py create mode 100644 test_py/test-theme.html create mode 100644 test_py/test_captcha.png create mode 100644 test_py/test_captcha.py create mode 100644 test_py/test_import.py create mode 100644 test_py/test_login.html create mode 100755 test_py/test_sop_feature.py create mode 100644 test_py/validate_excel.py create mode 100644 test_shipment_upload.py create mode 100644 发货单-20251121.xls diff --git a/.gitignore b/.gitignore index d0d6a7c..79bad42 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ dump.rdb *.tmp *.bak *.cache + +# Documentation +README/ diff --git a/25年11月份对账单-易泰勒.xlsx b/25年11月份对账单-易泰勒.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..c5635a8cad36f332bbdf608c0e5bc457d60389b1 GIT binary patch literal 17540 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 Rv7ZMEVE9P_D1kq&{vRh;q%{Bl literal 0 HcmV?d00001 diff --git a/add_customer_name_column.py b/add_customer_name_column.py new file mode 100644 index 0000000..fe5aaef --- /dev/null +++ b/add_customer_name_column.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +为 customer_orders 表添加 customer_name 列 +""" + +import sqlite3 +import os + +# 数据库路径 +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DB_PATH = os.path.join(BASE_DIR, 'server', 'data.db') + +def add_column(): + """添加 customer_name 列""" + if not os.path.exists(DB_PATH): + print(f"错误: 数据库文件不存在: {DB_PATH}") + return + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + try: + # 尝试添加列 + c.execute('ALTER TABLE customer_orders ADD COLUMN customer_name TEXT') + conn.commit() + print("成功添加 customer_name 列") + except Exception as e: + if 'duplicate column name' in str(e).lower(): + print("customer_name 列已存在") + else: + print(f"添加列失败: {e}") + + conn.close() + +if __name__ == '__main__': + add_column() diff --git a/check_reconciliations.py b/check_reconciliations.py new file mode 100644 index 0000000..9b824c8 --- /dev/null +++ b/check_reconciliations.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""检查对账单数据""" + +import sqlite3 + +conn = sqlite3.connect('server/data.db') +c = conn.cursor() + +# 统计总数 +c.execute('SELECT COUNT(*) FROM reconciliations') +total = c.fetchone()[0] +print(f'对账单总数: {total}') + +# 查看最新10条记录 +c.execute(''' + SELECT id, order_date, contract_no, material_name, spec_model, + quantity, unit, unit_price, total_amount, delivery_date + FROM reconciliations + ORDER BY id DESC + LIMIT 10 +''') + +print('\n最新10条记录:') +print('-' * 120) +for row in c.fetchall(): + print(f"ID: {row[0]}, 下单: {row[1]}, 合同: {row[2]}, 物料: {row[3]}, 规格: {row[4]}") + print(f" 数量: {row[5]} {row[6]}, 单价: {row[7]}, 金额: {row[8]}, 交货: {row[9]}") + print('-' * 120) + +conn.close() diff --git a/frontend/index.html b/frontend/index.html index b34130d..27c54d4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -107,6 +107,10 @@ 📋 客户订单 + + 💰 + 对账单 + @@ -226,6 +230,7 @@ + diff --git a/frontend/js/components/customer-order.js b/frontend/js/components/customer-order.js new file mode 100644 index 0000000..68b85b1 --- /dev/null +++ b/frontend/js/components/customer-order.js @@ -0,0 +1,348 @@ +// 客户订单管理 +(() => { + Router.register('/plan-mgmt/customer-order', async () => { + // 先返回 HTML + const html = ` + + +
+
+
+ + + + + + + + + + + + + + + + + +
下单时间订单编号客户名称物料订单数量单价操作
加载中...
+
+
+
+ + + + `; + + // DOM 渲染后初始化 + setTimeout(() => { + const addBtn = document.getElementById('add-order-btn'); + if (addBtn) { + addBtn.addEventListener('click', () => { + openModal(); + }); + } + loadOrders(); + }, 100); + + return html; + }); + + async function loadOrders() { + try { + console.log('开始加载订单列表...'); + const res = await fetch('/api/customer-orders'); + console.log('API响应状态:', res.status); + const data = await res.json(); + console.log('订单数据:', data); + + const tbody = document.getElementById('order-list'); + if (!tbody) { + console.error('找不到 order-list 元素'); + return; + } + + if (!data.list || data.list.length === 0) { + tbody.innerHTML = '暂无数据'; + return; + } + + tbody.innerHTML = data.list.map(order => ` + + ${order.order_date || '—'} + ${order.order_no || '—'} + ${order.customer_name || '—'} + ${order.material || '—'} + ${order.quantity || 0} + ${order.unit_price || 0} + + + + + `).join(''); + console.log('订单列表加载完成'); + } catch (err) { + console.error('加载订单失败:', err); + const tbody = document.getElementById('order-list'); + if (tbody) { + tbody.innerHTML = '加载失败,请刷新重试'; + } + API.toast('加载订单失败', 'error'); + } + } + + let materialRowIndex = 0; + + function addMaterialRow(material = '', quantity = '', unitPrice = '') { + const container = document.getElementById('materials-container'); + const index = materialRowIndex++; + + const row = document.createElement('div'); + row.className = 'material-row'; + row.id = `material-row-${index}`; + row.style.cssText = 'display: grid; grid-template-columns: 2fr 1fr 1fr auto; gap: 12px; margin-bottom: 12px; padding: 16px; background: var(--surface-2); border-radius: 8px; border: 1px solid var(--border);'; + + row.innerHTML = ` +
+ + +
+
+ + +
+
+ + +
+
+ +
+ `; + + container.appendChild(row); + } + + function removeMaterialRow(index) { + const row = document.getElementById(`material-row-${index}`); + if (row) { + row.remove(); + } + + // 如果没有物料行了,至少保留一行 + const container = document.getElementById('materials-container'); + if (container.children.length === 0) { + addMaterialRow(); + } + } + + function openModal(order = null) { + const modal = document.getElementById('order-modal'); + const title = document.getElementById('modal-title'); + + title.textContent = '新增订单'; + document.getElementById('order-form').reset(); + + // 设置默认日期为今天 + const today = new Date().toISOString().split('T')[0]; + document.getElementById('order-date').value = today; + + // 清空物料列表并添加一行 + const container = document.getElementById('materials-container'); + container.innerHTML = ''; + materialRowIndex = 0; + addMaterialRow(); + + modal.style.display = 'flex'; + } + + function closeModal() { + const modal = document.getElementById('order-modal'); + modal.style.display = 'none'; + document.getElementById('order-form').reset(); + + // 清空物料列表 + const container = document.getElementById('materials-container'); + if (container) { + container.innerHTML = ''; + } + materialRowIndex = 0; + } + + async function saveOrder() { + const orderDate = document.getElementById('order-date').value.trim(); + const orderNo = document.getElementById('order-no').value.trim(); + const customerName = document.getElementById('customer-name').value.trim(); + + if (!orderDate || !orderNo || !customerName) { + API.toast('请填写订单基本信息', 'error'); + return; + } + + // 收集所有物料信息 + const materials = []; + const materialRows = document.querySelectorAll('.material-row'); + + if (materialRows.length === 0) { + API.toast('请至少添加一个物料', 'error'); + return; + } + + for (const row of materialRows) { + const materialName = row.querySelector('.material-name').value.trim(); + const quantity = parseInt(row.querySelector('.material-quantity').value); + const unitPrice = parseFloat(row.querySelector('.material-price').value); + + if (!materialName || !quantity || isNaN(unitPrice)) { + API.toast('请填写所有物料信息', 'error'); + return; + } + + if (quantity <= 0) { + API.toast('物料数量必须大于0', 'error'); + return; + } + + if (unitPrice < 0) { + API.toast('单价不能为负数', 'error'); + return; + } + + materials.push({ + material: materialName, + quantity: quantity, + unit_price: unitPrice + }); + } + + try { + // 为每个物料创建一条订单记录 + let successCount = 0; + for (const mat of materials) { + const res = await fetch('/api/customer-orders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + order_date: orderDate, + order_no: orderNo, + customer_name: customerName, + material: mat.material, + quantity: mat.quantity, + unit_price: mat.unit_price + }) + }); + + const data = await res.json(); + + if (res.ok && data.ok) { + successCount++; + } else { + console.error('保存物料失败:', mat.material, data.error); + } + } + + if (successCount === materials.length) { + API.toast(`订单保存成功,共 ${successCount} 个物料`, 'success'); + closeModal(); + await loadOrders(); + } else if (successCount > 0) { + API.toast(`部分保存成功(${successCount}/${materials.length})`, 'warning'); + closeModal(); + await loadOrders(); + } else { + API.toast('保存失败', 'error'); + } + } catch (err) { + console.error('保存订单失败:', err); + API.toast('保存失败', 'error'); + } + } + + async function deleteOrder(id) { + if (!confirm('确定要删除这条订单吗?')) { + return; + } + + try { + const res = await fetch(`/api/customer-orders/${id}`, { + method: 'DELETE' + }); + + const data = await res.json(); + + if (res.ok && data.ok) { + API.toast('删除成功', 'success'); + await loadOrders(); + } else { + API.toast(data.error || '删除失败', 'error'); + } + } catch (err) { + console.error('删除订单失败:', err); + API.toast('删除失败', 'error'); + } + } + + // 暴露给全局 + window.CustomerOrder = { + openModal, + closeModal, + saveOrder, + deleteOrder, + addMaterialRow, + removeMaterialRow + }; +})(); diff --git a/frontend/js/components/reconciliation.js b/frontend/js/components/reconciliation.js new file mode 100644 index 0000000..2a299f1 --- /dev/null +++ b/frontend/js/components/reconciliation.js @@ -0,0 +1,559 @@ +// 对账单管理 +(() => { + Router.register('/plan-mgmt/reconciliation', async () => { + const html = ` + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + 序号下单时间合同编号物料名称规格型号运输单号数量单位含税单价含税金额交货日期出货日期操作
加载中...
+
+
+
+ + + `; + + setTimeout(() => { + const addBtn = document.getElementById('add-reconciliation-btn'); + if (addBtn) { + addBtn.addEventListener('click', () => { + openModal(); + }); + } + + // 全选/取消全选 + const selectAllCheckbox = document.getElementById('select-all-reconciliation'); + if (selectAllCheckbox) { + selectAllCheckbox.addEventListener('change', (e) => { + const checkboxes = document.querySelectorAll('.reconciliation-checkbox'); + checkboxes.forEach(cb => cb.checked = e.target.checked); + updateBatchDeleteButton(); + }); + } + + // 批量删除按钮 + const batchDeleteBtn = document.getElementById('batch-delete-btn'); + if (batchDeleteBtn) { + batchDeleteBtn.addEventListener('click', async () => { + const selectedIds = getSelectedReconciliationIds(); + if (selectedIds.length === 0) { + API.toast('请选择要删除的对账单', 'warning'); + return; + } + + if (!confirm(`确定要删除选中的 ${selectedIds.length} 条对账单吗?`)) { + return; + } + + await batchDeleteReconciliations(selectedIds); + }); + } + + const exportBtn = document.getElementById('export-reconciliation-btn'); + if (exportBtn) { + exportBtn.addEventListener('click', async () => { + try { + API.toast('正在导出对账单...', 'info'); + + const res = await fetch('/api/reconciliations/export'); + + if (!res.ok) { + const data = await res.json(); + API.toast(data.error || '导出失败', 'error'); + return; + } + + // 下载文件 + const blob = await res.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + + // 从响应头获取文件名,如果没有则使用默认名称 + const contentDisposition = res.headers.get('Content-Disposition'); + let filename = '对账单.xlsx'; + if (contentDisposition) { + const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition); + if (matches != null && matches[1]) { + filename = matches[1].replace(/['"]/g, ''); + // 解码 URL 编码的文件名 + filename = decodeURIComponent(filename); + } + } + + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + API.toast('导出成功', 'success'); + } catch (err) { + console.error('导出对账单失败:', err); + API.toast('导出失败', 'error'); + } + }); + } + + const uploadBtn = document.getElementById('upload-shipment-btn'); + const fileInput = document.getElementById('shipment-file-input'); + + if (uploadBtn && fileInput) { + uploadBtn.addEventListener('click', () => { + fileInput.click(); + }); + + fileInput.addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + + // 验证文件类型 + const fileName = file.name.toLowerCase(); + if (!fileName.endsWith('.xls') && !fileName.endsWith('.xlsx')) { + API.toast('请上传 XLS 或 XLSX 格式的发货单', 'error'); + fileInput.value = ''; + return; + } + + // 显示上传中提示 + API.toast('正在解析发货单...', 'info'); + + const formData = new FormData(); + formData.append('file', file); + + try { + const res = await fetch('/api/reconciliations/upload-shipment', { + method: 'POST', + body: formData + }); + + const data = await res.json(); + + if (res.ok && data.ok) { + let message = data.message; + if (data.errors && data.errors.length > 0) { + message += '\n\n错误详情:\n' + data.errors.join('\n'); + API.toast(message, 'warning'); + } else { + API.toast(message, 'success'); + } + await loadReconciliations(); + } else { + API.toast(data.error || '上传失败', 'error'); + } + } catch (err) { + console.error('上传发货单失败:', err); + API.toast('上传失败', 'error'); + } finally { + fileInput.value = ''; + } + }); + } + + const quantityInput = document.getElementById('quantity'); + const unitPriceInput = document.getElementById('unit-price'); + const totalAmountInput = document.getElementById('total-amount'); + + const calculateTotal = () => { + const qty = parseFloat(quantityInput.value) || 0; + const price = parseFloat(unitPriceInput.value) || 0; + const total = qty * price; + totalAmountInput.value = total.toFixed(1); + }; + + if (quantityInput) quantityInput.addEventListener('input', calculateTotal); + if (unitPriceInput) unitPriceInput.addEventListener('input', calculateTotal); + + loadReconciliations(); + }, 100); + + return html; + }); + + async function loadReconciliations() { + try { + const res = await fetch('/api/reconciliations'); + const data = await res.json(); + + const tbody = document.getElementById('reconciliation-list'); + if (!tbody) return; + + if (!data.list || data.list.length === 0) { + tbody.innerHTML = '暂无数据'; + return; + } + + tbody.innerHTML = data.list.map((item, index) => { + // 格式化数字:去掉不必要的小数点和尾随零 + const formatNumber = (num) => { + if (!num && num !== 0) return '—'; + const n = parseFloat(num); + // 先四舍五入到2位小数,避免浮点数精度问题 + const rounded = Math.round(n * 100) / 100; + // 如果是整数,直接返回整数 + if (Number.isInteger(rounded)) return rounded.toString(); + // 否则返回字符串,自动去掉尾随的0 + return rounded.toString(); + }; + + return ` + + + + + ${index + 1} + ${item.order_date || '—'} + ${item.contract_no || '—'} + ${item.material_name || '—'} + ${item.spec_model || '—'} + ${item.transport_no || '—'} + ${item.quantity || 0} + ${item.unit || '—'} + ${formatNumber(item.unit_price)} + ${formatNumber(item.total_amount)} + ${item.delivery_date || '—'} + ${item.shipment_date || '—'} + + + + + + `; + }).join(''); + + // 重置全选状态 + const selectAllCheckbox = document.getElementById('select-all-reconciliation'); + if (selectAllCheckbox) { + selectAllCheckbox.checked = false; + } + updateBatchDeleteButton(); + } catch (err) { + console.error('加载对账单失败:', err); + const tbody = document.getElementById('reconciliation-list'); + if (tbody) { + tbody.innerHTML = '加载失败,请刷新重试'; + } + API.toast('加载对账单失败', 'error'); + } + } + + let currentEditId = null; + + // 日期格式转换:YYYY/MM/DD -> YYYY-MM-DD (用于input[type=date]) + function formatDateForInput(dateStr) { + if (!dateStr) return ''; + return dateStr.replace(/\//g, '-'); + } + + function openModal(item = null) { + const modal = document.getElementById('reconciliation-modal'); + const title = document.getElementById('modal-title'); + + if (item) { + // 编辑模式 + title.textContent = '编辑对账单'; + currentEditId = item.id; + + document.getElementById('order-date').value = formatDateForInput(item.order_date) || ''; + document.getElementById('contract-no').value = item.contract_no || ''; + document.getElementById('material-name').value = item.material_name || ''; + document.getElementById('spec-model').value = item.spec_model || ''; + document.getElementById('transport-no').value = item.transport_no || ''; + document.getElementById('quantity').value = item.quantity || ''; + document.getElementById('unit').value = item.unit || 'pcs'; + document.getElementById('unit-price').value = item.unit_price || ''; + document.getElementById('total-amount').value = item.total_amount || ''; + document.getElementById('delivery-date').value = formatDateForInput(item.delivery_date) || ''; + document.getElementById('shipment-date').value = formatDateForInput(item.shipment_date) || ''; + } else { + // 新增模式 + title.textContent = '新增对账单'; + currentEditId = null; + document.getElementById('reconciliation-form').reset(); + + const today = new Date().toISOString().split('T')[0]; + document.getElementById('order-date').value = today; + document.getElementById('unit').value = 'pcs'; + } + + modal.style.display = 'flex'; + } + + function closeModal() { + const modal = document.getElementById('reconciliation-modal'); + modal.style.display = 'none'; + document.getElementById('reconciliation-form').reset(); + currentEditId = null; + } + + async function saveReconciliation() { + const orderDate = document.getElementById('order-date').value.trim(); + const contractNo = document.getElementById('contract-no').value.trim(); + const materialName = document.getElementById('material-name').value.trim(); + const specModel = document.getElementById('spec-model').value.trim(); + const transportNo = document.getElementById('transport-no').value.trim(); + const quantity = parseInt(document.getElementById('quantity').value); + const unit = document.getElementById('unit').value.trim(); + const unitPrice = parseFloat(document.getElementById('unit-price').value); + const totalAmount = parseFloat(document.getElementById('total-amount').value); + const deliveryDate = document.getElementById('delivery-date').value.trim(); + const shipmentDate = document.getElementById('shipment-date').value.trim(); + + if (!orderDate || !contractNo || !materialName || !specModel || !quantity || !unit || isNaN(unitPrice)) { + API.toast('请填写必填项', 'error'); + return; + } + + if (quantity <= 0) { + API.toast('数量必须大于0', 'error'); + return; + } + + if (unitPrice < 0) { + API.toast('单价不能为负数', 'error'); + return; + } + + const payload = { + order_date: orderDate, + contract_no: contractNo, + material_name: materialName, + spec_model: specModel, + transport_no: transportNo, + quantity: quantity, + unit: unit, + unit_price: unitPrice, + total_amount: totalAmount, + delivery_date: deliveryDate || null, + shipment_date: shipmentDate || null + }; + + try { + const url = currentEditId ? `/api/reconciliations/${currentEditId}` : '/api/reconciliations'; + const method = currentEditId ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method: method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const data = await res.json(); + + if (res.ok && data.ok) { + API.toast(currentEditId ? '更新成功' : '保存成功', 'success'); + closeModal(); + await loadReconciliations(); + } else { + API.toast(data.error || '保存失败', 'error'); + } + } catch (err) { + console.error('保存对账单失败:', err); + API.toast('保存失败', 'error'); + } + } + + async function editReconciliation(id) { + try { + const res = await fetch('/api/reconciliations'); + const data = await res.json(); + + const item = data.list.find(r => r.id === id); + if (!item) { + API.toast('对账单不存在', 'error'); + return; + } + + openModal(item); + } catch (err) { + console.error('加载对账单失败:', err); + API.toast('加载失败', 'error'); + } + } + + async function deleteReconciliation(id) { + if (!confirm('确定要删除这条对账单吗?')) { + return; + } + + try { + const res = await fetch(`/api/reconciliations/${id}`, { + method: 'DELETE' + }); + + const data = await res.json(); + + if (res.ok && data.ok) { + API.toast('删除成功', 'success'); + await loadReconciliations(); + } else { + API.toast(data.error || '删除失败', 'error'); + } + } catch (err) { + console.error('删除对账单失败:', err); + API.toast('删除失败', 'error'); + } + } + + // 获取选中的对账单ID列表 + function getSelectedReconciliationIds() { + const checkboxes = document.querySelectorAll('.reconciliation-checkbox:checked'); + return Array.from(checkboxes).map(cb => parseInt(cb.dataset.id)); + } + + // 更新批量删除按钮的显示状态 + function updateBatchDeleteButton() { + const selectedIds = getSelectedReconciliationIds(); + const batchDeleteBtn = document.getElementById('batch-delete-btn'); + if (batchDeleteBtn) { + if (selectedIds.length > 0) { + batchDeleteBtn.style.display = 'inline-block'; + batchDeleteBtn.textContent = `批量删除 (${selectedIds.length})`; + } else { + batchDeleteBtn.style.display = 'none'; + } + } + + // 更新全选框状态 + const selectAllCheckbox = document.getElementById('select-all-reconciliation'); + const allCheckboxes = document.querySelectorAll('.reconciliation-checkbox'); + if (selectAllCheckbox && allCheckboxes.length > 0) { + const allChecked = Array.from(allCheckboxes).every(cb => cb.checked); + const someChecked = Array.from(allCheckboxes).some(cb => cb.checked); + selectAllCheckbox.checked = allChecked; + selectAllCheckbox.indeterminate = someChecked && !allChecked; + } + } + + // 批量删除对账单 + async function batchDeleteReconciliations(ids) { + try { + API.toast('正在删除...', 'info'); + + const res = await fetch('/api/reconciliations/batch-delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids }) + }); + + const data = await res.json(); + + if (res.ok && data.ok) { + API.toast(`成功删除 ${ids.length} 条对账单`, 'success'); + await loadReconciliations(); + } else { + API.toast(data.error || '批量删除失败', 'error'); + } + } catch (err) { + console.error('批量删除对账单失败:', err); + API.toast('批量删除失败', 'error'); + } + } + + window.Reconciliation = { + openModal, + closeModal, + saveReconciliation, + editReconciliation, + deleteReconciliation, + updateBatchDeleteButton + }; +})(); diff --git a/frontend/js/router.js b/frontend/js/router.js index ef63576..ca5c9b3 100644 --- a/frontend/js/router.js +++ b/frontend/js/router.js @@ -86,6 +86,7 @@ const Router = (() => { 'plan-mgmt': '计划管理', 'material-purchase': '物料清单-采购', 'customer-order': '客户订单', + 'reconciliation': '对账单', export: '导出', settings: '设置' }; diff --git a/import_reconciliation_excel.py b/import_reconciliation_excel.py new file mode 100644 index 0000000..867940a --- /dev/null +++ b/import_reconciliation_excel.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""从Excel导入对账单数据""" + +import pandas as pd +import sqlite3 +import os +from datetime import datetime, timezone, timedelta + +# 数据库路径 +DB_PATH = os.path.join(os.path.dirname(__file__), 'server', 'data.db') +EXCEL_FILE = '25年11月份对账单-易泰勒.xlsx' + +def get_beijing_time(): + """获取北京时间(UTC+8)的ISO格式字符串""" + beijing_tz = timezone(timedelta(hours=8)) + return datetime.now(beijing_tz).isoformat() + +def import_from_excel(): + """从Excel导入对账单数据""" + # 读取Excel文件 + df = pd.read_excel(EXCEL_FILE) + + # 打印前20行查看结构 + print("Excel文件结构:") + print("=" * 80) + for i in range(min(20, len(df))): + print(f"第{i}行: {df.iloc[i].tolist()}") + print("=" * 80) + + # 查找表头行(包含"序号"的行) + header_row = None + for i in range(len(df)): + row_values = df.iloc[i].tolist() + if any(str(val).strip() == '序号' for val in row_values if pd.notna(val)): + header_row = i + print(f"\n找到表头行: 第{i}行") + print(f"表头内容: {row_values}") + break + + if header_row is None: + print("❌ 未找到表头行(包含'序号'的行)") + return + + # 重新读取,跳过前面的行,使用找到的行作为表头 + df = pd.read_excel(EXCEL_FILE, skiprows=header_row) + + # 设置第一行为列名 + df.columns = df.iloc[0] + df = df[1:] # 删除第一行(已经作为列名) + df = df.reset_index(drop=True) + + print(f"\n数据形状: {df.shape}") + print(f"列名: {df.columns.tolist()}") + print(f"\n前5行数据:") + print(df.head()) + + # 连接数据库 + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + now = get_beijing_time() + imported_count = 0 + + # 遍历数据行 + for idx, row in df.iterrows(): + # 跳过空行或无效行 + if pd.isna(row.get('序号')): + continue + + try: + # 提取数据 + # 处理日期格式 + def format_date(date_val): + if pd.isna(date_val): + return '' + if isinstance(date_val, datetime): + return date_val.strftime('%Y/%m/%d') + date_str = str(date_val).strip() + # 如果已经是 YYYY/MM/DD 格式,保持不变 + if '/' in date_str: + return date_str + # 如果是 YYYY-MM-DD 格式,转换为 YYYY/MM/DD + if '-' in date_str: + return date_str.split()[0].replace('-', '/') + return date_str + + order_date = format_date(row.get('下单时间')) + contract_no = str(row.get('合同编号', '')).strip() if pd.notna(row.get('合同编号')) else '' + material_name = str(row.get('物料名称', '')).strip() if pd.notna(row.get('物料名称')) else '' + spec_model = str(row.get('规格型号', '')).strip() if pd.notna(row.get('规格型号')) else '' + transport_no = str(row.get('运输单号', '')).strip() if pd.notna(row.get('运输单号')) else '' + quantity = int(row.get('数量', 0)) if pd.notna(row.get('数量')) else 0 + unit = str(row.get('单位', 'pcs')).strip() if pd.notna(row.get('单位')) else 'pcs' + unit_price = float(row.get('含税单价', 0)) if pd.notna(row.get('含税单价')) else 0.0 + total_amount = float(row.get('含税金额', 0)) if pd.notna(row.get('含税金额')) else 0.0 + delivery_date = format_date(row.get('交货日期')) + shipment_date = format_date(row.get('出货日期')) + + # 验证必填字段 + if not all([order_date, contract_no, material_name, spec_model, quantity, unit, unit_price]): + print(f"⚠️ 跳过第{idx+1}行: 缺少必填字段") + continue + + # 插入数据库 + c.execute(''' + INSERT INTO reconciliations( + order_date, contract_no, material_name, spec_model, transport_no, + quantity, unit, unit_price, total_amount, delivery_date, shipment_date, + created_by, created_at, updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ''', ( + order_date, + contract_no, + material_name, + spec_model, + transport_no, + quantity, + unit, + unit_price, + total_amount, + delivery_date, + shipment_date, + 'admin', + now, + now + )) + + imported_count += 1 + print(f"✅ 导入第{idx+1}行: {contract_no} - {material_name}") + + except Exception as e: + print(f"❌ 导入第{idx+1}行失败: {e}") + continue + + conn.commit() + conn.close() + + print(f"\n{'='*80}") + print(f"✅ 成功导入 {imported_count} 条对账单数据") + print(f"{'='*80}") + +if __name__ == '__main__': + import_from_excel() diff --git a/init_customer_orders.py b/init_customer_orders.py new file mode 100644 index 0000000..cb7b94d --- /dev/null +++ b/init_customer_orders.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +初始化客户订单数据 +根据图片中的数据填充客户订单表 +""" + +import sqlite3 +import os +from datetime import datetime, timezone, timedelta + +# 数据库路径 +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DB_PATH = os.path.join(BASE_DIR, 'server', 'data.db') + +def get_beijing_time(): + """获取北京时间(UTC+8)的ISO格式字符串""" + beijing_tz = timezone(timedelta(hours=8)) + return datetime.now(beijing_tz).isoformat() + +# 从图片中提取的订单数据 - 客户:易泰勒 +orders_data = [ + # 2025/4/28 - CGDD001695 + {'order_date': '2025-04-28', 'order_no': 'CGDD001695', 'customer_name': '易泰勒', 'material': 'ETAP05\n基站-5.0\nETAP05', 'quantity': 950, 'unit_price': 315.19}, + {'order_date': '2025-04-28', 'order_no': 'CGDD001695', 'customer_name': '易泰勒', 'material': 'WD1MK0SMD0551\n蓝牙模块\nPCBCOMPONENT_1_-_DUPLICATE', 'quantity': 950, 'unit_price': 1.3}, + {'order_date': '2025-04-28', 'order_no': 'CGDD001695', 'customer_name': '易泰勒', 'material': 'WA0000000040\nPCBA', 'quantity': 4750, 'unit_price': 8.05}, + {'order_date': '2025-04-28', 'order_no': 'CGDD001695', 'customer_name': '易泰勒', 'material': 'CH6121-MODULE-V14', 'quantity': 4750, 'unit_price': 8.05}, + + # 2025/9/4 - CGDD002429 + {'order_date': '2025-09-04', 'order_no': 'CGDD002429', 'customer_name': '易泰勒', 'material': 'ETAP05\n基站-5.0\nETAP05', 'quantity': 1500, 'unit_price': 315.19}, + {'order_date': '2025-09-04', 'order_no': 'CGDD002429', 'customer_name': '易泰勒', 'material': 'WD1MK0SMD0551\n蓝牙模块\nPCBCOMPONENT_1_-_DUPLICATE', 'quantity': 1500, 'unit_price': 1.3}, + {'order_date': '2025-09-04', 'order_no': 'CGDD002429', 'customer_name': '易泰勒', 'material': 'WA0000000040\nPCBA', 'quantity': 7500, 'unit_price': 8.05}, + {'order_date': '2025-09-04', 'order_no': 'CGDD002429', 'customer_name': '易泰勒', 'material': 'CH6121-MODULE-V14', 'quantity': 7500, 'unit_price': 8.05}, + {'order_date': '2025-09-04', 'order_no': 'CGDD002429', 'customer_name': '易泰勒', 'material': 'AP-ET010\n基站-5.0\nETAP05', 'quantity': 500, 'unit_price': 315.19}, + {'order_date': '2025-09-04', 'order_no': 'CGDD002429', 'customer_name': '易泰勒', 'material': 'WD1MK0SMD0551\n蓝牙模块\nPCBCOMPONENT_1_-_DUPLICATE', 'quantity': 500, 'unit_price': 1.3}, + {'order_date': '2025-09-04', 'order_no': 'CGDD002429', 'customer_name': '易泰勒', 'material': 'WA0000000040\nPCBA\nCH6121-MODULE-V14', 'quantity': 2500, 'unit_price': 8.05}, + + # 2025/10/23 - CGDD002878 + {'order_date': '2025-10-23', 'order_no': 'CGDD002878', 'customer_name': '易泰勒', 'material': 'AP-DZ006\n智能灯条基站\nETAP05-D1', 'quantity': 4000, 'unit_price': 239.2}, + {'order_date': '2025-10-23', 'order_no': 'CGDD002878', 'customer_name': '易泰勒', 'material': 'WD1MK0SMD0551\n蓝牙模块\nPCBCOMPONENT_1_-_DUPLICATE', 'quantity': 12000, 'unit_price': 1.1}, + + # 2025/11/13 - CGDD003037 + {'order_date': '2025-11-13', 'order_no': 'CGDD003037', 'customer_name': '易泰勒', 'material': 'AP-DZ009\n智能灯条基站\nETAP05-D1', 'quantity': 500, 'unit_price': 229.61}, + {'order_date': '2025-11-13', 'order_no': 'CGDD003037', 'customer_name': '易泰勒', 'material': 'WD1MK0SMD0551\n蓝牙模块\nPCBCOMPONENT_1_-_DUPLICAT', 'quantity': 1500, 'unit_price': 1.1}, + {'order_date': '2025-11-13', 'order_no': 'CGDD003037', 'customer_name': '易泰勒', 'material': 'AP-DZ006\n智能灯条基站\nETAP05-D1', 'quantity': 4000, 'unit_price': 239.2}, + {'order_date': '2025-11-13', 'order_no': 'CGDD003037', 'customer_name': '易泰勒', 'material': 'WD1MK0SMD0551\n蓝牙模块\nPCBCOMPONENT_1_-_DUPLICATE', 'quantity': 12000, 'unit_price': 1.1}, +] + +def init_orders(): + """初始化客户订单数据""" + if not os.path.exists(DB_PATH): + print(f"错误: 数据库文件不存在: {DB_PATH}") + return + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # 检查表是否存在 + c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='customer_orders'") + if not c.fetchone(): + print("错误: customer_orders 表不存在,请先运行服务器以创建表") + conn.close() + return + + # 清空现有数据(可选) + c.execute('DELETE FROM customer_orders') + print("已清空现有订单数据") + + # 插入新数据 + now = get_beijing_time() + inserted_count = 0 + + for order in orders_data: + try: + c.execute('''INSERT INTO customer_orders( + order_date, order_no, customer_name, material, quantity, unit_price, + created_by, created_at, updated_at + ) VALUES(?,?,?,?,?,?,?,?,?)''', ( + order['order_date'], + order['order_no'], + order['customer_name'], + order['material'], + order['quantity'], + order['unit_price'], + 'admin', # 创建者 + now, + now + )) + inserted_count += 1 + except Exception as e: + print(f"插入订单失败: {order['order_no']} - {e}") + + conn.commit() + conn.close() + + print(f"成功插入 {inserted_count} 条订单数据") + +if __name__ == '__main__': + init_orders() diff --git a/init_reconciliations.py b/init_reconciliations.py new file mode 100644 index 0000000..ad6fa77 --- /dev/null +++ b/init_reconciliations.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""初始化对账单示例数据""" + +import sqlite3 +import os +from datetime import datetime, timezone, timedelta + +# 数据库路径 +DB_PATH = os.path.join(os.path.dirname(__file__), 'server', 'data.db') + +def get_beijing_time(): + """获取北京时间(UTC+8)的ISO格式字符串""" + beijing_tz = timezone(timedelta(hours=8)) + return datetime.now(beijing_tz).isoformat() + +def init_reconciliations(): + """初始化对账单示例数据""" + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # 示例数据(根据图片中的数据) + sample_data = [ + { + 'order_date': '2025/10/31', + 'contract_no': 'CGDD002876', + 'material_name': '扩产-9988 红黑线', + 'spec_model': 'PCXK0P0NSNT_1_2.54*1C14TE', + 'transport_no': '快递上门', + 'quantity': 45, + 'unit': 'pcs', + 'unit_price': 239.2, + 'total_amount': 10764, + 'delivery_date': '2025/11/3', + 'shipment_date': '2025/11/3' + }, + { + 'order_date': '2025/9/20', + 'contract_no': 'CGDD004562', + 'material_name': '扩产-9988 红黑线', + 'spec_model': 'M1H0EM0N511 PCXK0P0NSNT_1_2.54*1C14TE', + 'transport_no': '快递上门', + 'quantity': 355, + 'unit': 'pcs', + 'unit_price': 1.1, + 'total_amount': 390.5, + 'delivery_date': '2025/11/3', + 'shipment_date': '2025/11/3' + }, + { + 'order_date': '2025/9/20', + 'contract_no': 'CGDD004562', + 'material_name': '扩产-9988 红黑线', + 'spec_model': 'ETAP05-01', + 'transport_no': '快递上门', + 'quantity': 2, + 'unit': 'pcs', + 'unit_price': 245.46, + 'total_amount': 490.92, + 'delivery_date': '2025/11/3', + 'shipment_date': '2025/11/3' + }, + { + 'order_date': '2025/9/20', + 'contract_no': 'CGDD004562', + 'material_name': 'M1H0EM0N511 红黑线', + 'spec_model': 'PCXK0P0NSNT_1_2.54*1C14TE', + 'transport_no': '快递上门', + 'quantity': 6, + 'unit': 'pcs', + 'unit_price': 1.1, + 'total_amount': 6.6, + 'delivery_date': '2025/11/3', + 'shipment_date': '2025/11/3' + }, + { + 'order_date': '2025/10/11', + 'contract_no': 'CGDD002717', + 'material_name': '扩产-9988 红黑线', + 'spec_model': 'ETAP05-01', + 'transport_no': '快递上门', + 'quantity': 500, + 'unit': 'pcs', + 'unit_price': 228.45, + 'total_amount': 114225, + 'delivery_date': '2025/11/3', + 'shipment_date': '2025/11/3' + } + ] + + now = get_beijing_time() + + for data in sample_data: + c.execute(''' + INSERT INTO reconciliations( + order_date, contract_no, material_name, spec_model, transport_no, + quantity, unit, unit_price, total_amount, delivery_date, shipment_date, + created_by, created_at, updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ''', ( + data['order_date'], + data['contract_no'], + data['material_name'], + data['spec_model'], + data['transport_no'], + data['quantity'], + data['unit'], + data['unit_price'], + data['total_amount'], + data['delivery_date'], + data['shipment_date'], + 'admin', + now, + now + )) + + conn.commit() + count = len(sample_data) + conn.close() + + print(f'✅ 成功初始化 {count} 条对账单示例数据') + +if __name__ == '__main__': + init_reconciliations() diff --git a/server/=2.0.0 b/server/=2.0.0 new file mode 100644 index 0000000..e69de29 diff --git a/server/app.py b/server/app.py index 5256b6a..f2e9fe8 100644 --- a/server/app.py +++ b/server/app.py @@ -200,6 +200,23 @@ def init_db(): created_at TEXT, updated_at TEXT )''') + c.execute('''CREATE TABLE IF NOT EXISTS reconciliations( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_date TEXT NOT NULL, + contract_no TEXT NOT NULL, + material_name TEXT NOT NULL, + spec_model TEXT NOT NULL, + transport_no TEXT, + quantity INTEGER NOT NULL, + unit TEXT NOT NULL, + unit_price REAL NOT NULL, + total_amount REAL NOT NULL, + delivery_date TEXT, + shipment_date TEXT, + created_by TEXT, + created_at TEXT, + updated_at TEXT + )''') # 为已存在的表添加列(如果不存在) try: c.execute('ALTER TABLE customer_orders ADD COLUMN customer_name TEXT') @@ -394,6 +411,19 @@ def get_beijing_time(): beijing_tz = timezone(timedelta(hours=8)) return datetime.now(beijing_tz).isoformat() +def format_date_to_slash(date_str): + """将日期格式统一转换为 YYYY/MM/DD 格式,空值返回None""" + if not date_str or str(date_str).strip() == '': + return None + date_str = str(date_str).strip() + # 去掉时间部分 + if ' ' in date_str: + date_str = date_str.split()[0] + # 将 YYYY-MM-DD 转换为 YYYY/MM/DD + if '-' in date_str: + return date_str.replace('-', '/') + return date_str + def parse_audit_line(s): if not s: return {'ts_cn': None, 'batch': None, 'mac': None, 'note': None} @@ -2039,21 +2069,32 @@ def validate_mac_file(): mac_col = 'MAC' if has_mac else 'SN_MAC' return jsonify({'valid': True, 'message': f'文件格式正确,包含列:{mac_col} 和 批次号,共{data_rows}行数据'}), 200 else: - import openpyxl - wb = openpyxl.load_workbook(f) - ws = wb.active + # 使用pandas读取Excel文件,支持.xlsx和.xls格式 + import pandas as pd + import io - if ws.max_row < 2: - wb.close() + # 将文件流保存到BytesIO对象 + file_content = f.stream.read() + f.stream.seek(0) # 重置流位置 + file_io = io.BytesIO(file_content) + + try: + # 根据扩展名选择引擎 + if ext == 'xls': + df = pd.read_excel(file_io, engine='xlrd') + else: + df = pd.read_excel(file_io) + except Exception as e: + return jsonify({'valid': False, 'message': f'读取Excel文件失败:{str(e)}'}), 200 + + if len(df) == 0: return jsonify({'valid': False, 'message': '文件为空,没有数据'}), 200 - if ws.max_column != 2: - wb.close() - return jsonify({'valid': False, 'message': f'文件应该只包含2列数据,当前有{ws.max_column}列'}), 200 + if len(df.columns) != 2: + return jsonify({'valid': False, 'message': f'文件应该只包含2列数据,当前有{len(df.columns)}列'}), 200 # 检查表头 - header_row = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0] - header = [str(h).strip() if h else '' for h in header_row] + header = [str(h).strip() for h in df.columns] # 记录表头用于调试 log('validate_mac_file', f'headers: {header}') @@ -2065,15 +2106,12 @@ def validate_mac_file(): has_batch = any('批次' in h or 'batch' in h for h in header_lower) if not (has_mac or has_sn_mac): - wb.close() return jsonify({'valid': False, 'message': f'缺少必需的列:MAC 或 SN_MAC(当前列:{", ".join(header)})'}), 200 if not has_batch: - wb.close() return jsonify({'valid': False, 'message': f'缺少必需的列:批次号(当前列:{", ".join(header)})'}), 200 - data_rows = ws.max_row - 1 + data_rows = len(df) mac_col = 'MAC' if has_mac else 'SN_MAC' - wb.close() return jsonify({'valid': True, 'message': f'文件格式正确,包含列:{mac_col} 和 批次号,共{data_rows}行数据'}), 200 except Exception as e: @@ -2101,18 +2139,33 @@ def upload_mac_file(): temp_dir = '/home/hyx/work/batch_import_xlsx' os.makedirs(temp_dir, exist_ok=True) - # 根据类型确定文件名 - if upload_type == 'yt': - temp_path = os.path.join(temp_dir, 'sn_test_yt.xlsx') - elif upload_type == 'pdd': - temp_path = os.path.join(temp_dir, 'sn_test_pdd.xlsx') - else: - temp_path = os.path.join(temp_dir, 'sn_test_tx.xlsx') + # 检测文件扩展名,保持原始格式 + original_ext = '.xls' if name.lower().endswith('.xls') and not name.lower().endswith('.xlsx') else '.xlsx' + # 根据类型确定基础文件名 + if upload_type == 'yt': + base_name = 'sn_test_yt' + elif upload_type == 'pdd': + base_name = 'sn_test_pdd' + else: + base_name = 'sn_test_tx' + + # 删除旧文件(.xlsx 和 .xls 都删除,确保只保留最新的) + for old_ext in ['.xlsx', '.xls']: + old_path = os.path.join(temp_dir, f'{base_name}{old_ext}') + if os.path.exists(old_path): + try: + os.remove(old_path) + log('upload_mac_file', f'removed old file: {old_path}') + except Exception as e: + log('upload_mac_file_error', f'failed to remove old file: {e}') + + # 保存新文件 + temp_path = os.path.join(temp_dir, f'{base_name}{original_ext}') f.save(temp_path) # 调用batch_import.py脚本 - script_path = '/home/hyx/work/生产管理系统/batch_import.py' + script_path = '/home/hyx/work/生产管理系统/test_py/batch_import.py' python_path = '/home/hyx/work/.venv/bin/python' try: result = subprocess.run( @@ -3659,6 +3712,552 @@ def delete_customer_order(order_id): return jsonify({'ok': True, 'message': '订单删除成功'}) +# 对账单管理 +@app.get('/api/reconciliations') +@require_login +def get_reconciliations(): + """获取对账单列表""" + conn = get_db() + c = conn.cursor() + c.execute(''' + SELECT id, order_date, contract_no, material_name, spec_model, transport_no, + quantity, unit, unit_price, total_amount, delivery_date, shipment_date, + created_by, created_at, updated_at + FROM reconciliations + ORDER BY id ASC + ''') + rows = [dict(r) for r in c.fetchall()] + conn.close() + return jsonify({'list': rows}) + + +@app.post('/api/reconciliations') +@require_login +@require_any_role('admin', 'superadmin') +def create_reconciliation(): + """创建对账单""" + data = request.get_json() or {} + + order_date = format_date_to_slash(data.get('order_date')) + contract_no = (data.get('contract_no') or '').strip() + material_name = (data.get('material_name') or '').strip() + spec_model = (data.get('spec_model') or '').strip() + transport_no = (data.get('transport_no') or '').strip() + quantity = data.get('quantity') + unit = (data.get('unit') or 'pcs').strip() + unit_price = data.get('unit_price') + total_amount = data.get('total_amount') + delivery_date = format_date_to_slash(data.get('delivery_date')) + shipment_date = format_date_to_slash(data.get('shipment_date')) + + # 验证必填字段 + if not all([order_date, contract_no, material_name, spec_model, quantity, unit, unit_price is not None]): + return jsonify({'error': '请填写所有必填字段'}), 400 + + try: + quantity = int(quantity) + unit_price = float(unit_price) + total_amount = float(total_amount) if total_amount else quantity * unit_price + except (ValueError, TypeError): + return jsonify({'error': '数量、单价或金额格式不正确'}), 400 + + if quantity <= 0: + return jsonify({'error': '数量必须大于0'}), 400 + + if unit_price < 0: + return jsonify({'error': '单价不能为负数'}), 400 + + conn = get_db() + c = conn.cursor() + now = get_beijing_time() + username = session.get('username', '') + + try: + c.execute(''' + INSERT INTO reconciliations( + order_date, contract_no, material_name, spec_model, transport_no, + quantity, unit, unit_price, total_amount, delivery_date, shipment_date, + created_by, created_at, updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ''', ( + order_date, contract_no, material_name, spec_model, transport_no, + quantity, unit, unit_price, total_amount, delivery_date, shipment_date, + username, now, now + )) + conn.commit() + reconciliation_id = c.lastrowid + conn.close() + + log('create_reconciliation', f'合同号: {contract_no}, 物料: {material_name}, 数量: {quantity}') + notify_superadmin('新增对账单', f'合同号: {contract_no}, 物料: {material_name}') + + return jsonify({'ok': True, 'id': reconciliation_id, 'message': '对账单创建成功'}) + except Exception as e: + conn.close() + return jsonify({'error': f'创建失败:{str(e)}'}), 500 + + +@app.put('/api/reconciliations/') +@require_login +@require_any_role('admin', 'superadmin') +def update_reconciliation(reconciliation_id): + """更新对账单""" + data = request.get_json() or {} + + order_date = format_date_to_slash(data.get('order_date')) + contract_no = (data.get('contract_no') or '').strip() + material_name = (data.get('material_name') or '').strip() + spec_model = (data.get('spec_model') or '').strip() + transport_no = (data.get('transport_no') or '').strip() + quantity = data.get('quantity') + unit = (data.get('unit') or 'pcs').strip() + unit_price = data.get('unit_price') + total_amount = data.get('total_amount') + delivery_date = format_date_to_slash(data.get('delivery_date')) + shipment_date = format_date_to_slash(data.get('shipment_date')) + + # 验证必填字段 + if not all([order_date, contract_no, material_name, spec_model, quantity, unit, unit_price is not None]): + return jsonify({'error': '请填写所有必填字段'}), 400 + + try: + quantity = int(quantity) + unit_price = float(unit_price) + total_amount = float(total_amount) if total_amount else quantity * unit_price + except (ValueError, TypeError): + return jsonify({'error': '数量、单价或金额格式不正确'}), 400 + + if quantity <= 0: + return jsonify({'error': '数量必须大于0'}), 400 + + if unit_price < 0: + return jsonify({'error': '单价不能为负数'}), 400 + + conn = get_db() + c = conn.cursor() + + # 检查对账单是否存在 + c.execute('SELECT id FROM reconciliations WHERE id=?', (reconciliation_id,)) + if not c.fetchone(): + conn.close() + return jsonify({'error': '对账单不存在'}), 404 + + now = get_beijing_time() + + try: + c.execute(''' + UPDATE reconciliations SET + order_date=?, contract_no=?, material_name=?, spec_model=?, transport_no=?, + quantity=?, unit=?, unit_price=?, total_amount=?, delivery_date=?, shipment_date=?, + updated_at=? + WHERE id=? + ''', ( + order_date, contract_no, material_name, spec_model, transport_no, + quantity, unit, unit_price, total_amount, delivery_date, shipment_date, + now, reconciliation_id + )) + conn.commit() + conn.close() + + log('update_reconciliation', f'对账单ID: {reconciliation_id}, 合同号: {contract_no}, 物料: {material_name}') + notify_superadmin('更新对账单', f'对账单ID: {reconciliation_id}, 合同号: {contract_no}') + + return jsonify({'ok': True, 'message': '对账单更新成功'}) + except Exception as e: + conn.close() + return jsonify({'error': f'更新失败:{str(e)}'}), 500 + + +@app.delete('/api/reconciliations/') +@require_login +@require_any_role('admin', 'superadmin') +def delete_reconciliation(reconciliation_id): + """删除对账单""" + conn = get_db() + c = conn.cursor() + + # 获取对账单信息用于日志 + c.execute('SELECT contract_no, material_name FROM reconciliations WHERE id=?', (reconciliation_id,)) + row = c.fetchone() + + if not row: + conn.close() + return jsonify({'error': '对账单不存在'}), 404 + + contract_no = row['contract_no'] + material_name = row['material_name'] + + c.execute('DELETE FROM reconciliations WHERE id=?', (reconciliation_id,)) + conn.commit() + conn.close() + + log('delete_reconciliation', f'对账单ID: {reconciliation_id}, 合同号: {contract_no}') + + return jsonify({'ok': True, 'message': '对账单删除成功'}) + + +@app.post('/api/reconciliations/batch-delete') +@require_login +@require_any_role('admin', 'superadmin') +def batch_delete_reconciliations(): + """批量删除对账单""" + data = request.get_json() + ids = data.get('ids', []) + + if not ids or not isinstance(ids, list): + return jsonify({'error': '请提供要删除的对账单ID列表'}), 400 + + if len(ids) == 0: + return jsonify({'error': '请至少选择一条对账单'}), 400 + + conn = get_db() + c = conn.cursor() + + try: + # 获取对账单信息用于日志 + placeholders = ','.join('?' * len(ids)) + c.execute(f'SELECT id, contract_no, material_name FROM reconciliations WHERE id IN ({placeholders})', ids) + rows = c.fetchall() + + if len(rows) == 0: + conn.close() + return jsonify({'error': '未找到要删除的对账单'}), 404 + + # 批量删除 + c.execute(f'DELETE FROM reconciliations WHERE id IN ({placeholders})', ids) + conn.commit() + + # 记录日志 + deleted_info = ', '.join([f"ID:{row['id']}({row['contract_no']})" for row in rows]) + log('batch_delete_reconciliations', f'批量删除 {len(rows)} 条对账单: {deleted_info}') + + conn.close() + return jsonify({'ok': True, 'message': f'成功删除 {len(rows)} 条对账单'}) + + except Exception as e: + conn.close() + print(f'批量删除对账单失败: {e}') + return jsonify({'error': f'批量删除失败: {str(e)}'}), 500 + + +@app.get('/api/reconciliations/export') +@require_login +def export_reconciliations(): + """导出对账单为 xlsx 格式""" + try: + import pandas as pd + from io import BytesIO + from flask import send_file + + conn = get_db() + c = conn.cursor() + c.execute(''' + SELECT order_date, contract_no, material_name, spec_model, transport_no, + quantity, unit, unit_price, total_amount, delivery_date, shipment_date + FROM reconciliations + ORDER BY id ASC + ''') + rows = c.fetchall() + conn.close() + + if not rows: + return jsonify({'error': '暂无数据可导出'}), 400 + + # 转换为 DataFrame + data = [] + for idx, row in enumerate(rows, start=1): + data.append({ + '序号': idx, + '下单时间': row['order_date'] or '', + '合同编号': row['contract_no'] or '', + '物料名称': row['material_name'] or '', + '规格型号': row['spec_model'] or '', + '运输单号': row['transport_no'] or '', + '数量': row['quantity'] or 0, + '单位': row['unit'] or '', + '含税单价': row['unit_price'] or 0, + '含税金额': row['total_amount'] or 0, + '交货日期': row['delivery_date'] or '', + '出货日期': row['shipment_date'] or '' + }) + + df = pd.DataFrame(data) + + # 创建 Excel 文件 + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='对账单') + + # 获取工作表并设置列宽和行高 + worksheet = writer.sheets['对账单'] + + # 导入样式 + from openpyxl.styles import Alignment, Font + + # 设置列宽(按指定宽度,Excel列宽需要稍微增加以达到实际显示效果) + column_widths = { + '序号': 8.7, + '下单时间': 13.5, + '合同编号': 14.2, + '物料名称': 14, + '规格型号': 25, + '运输单号': 32, + '数量': 16.7, + '单位': 6.5, + '含税单价': 9.5, + '含税金额': 8.8, + '交货日期': 13.2, + '出货日期': 11.7 + } + + for idx, col in enumerate(df.columns): + col_letter = chr(65 + idx) # A, B, C, ... + if col in column_widths: + worksheet.column_dimensions[col_letter].width = column_widths[col] + else: + worksheet.column_dimensions[col_letter].width = 15 # 默认宽度 + + # 设置所有行的行高为39,并设置单元格居中对齐、宋体字体和自动换行 + for row_idx in range(1, len(df) + 2): # +2 因为包含表头行,且从1开始 + worksheet.row_dimensions[row_idx].height = 39 + # 设置该行所有单元格居中对齐、宋体字体和自动换行 + for col_idx in range(1, len(df.columns) + 1): + cell = worksheet.cell(row=row_idx, column=col_idx) + cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + cell.font = Font(name='宋体', size=11) + + output.seek(0) + + # 生成文件名(包含当前日期) + from datetime import datetime + filename = f'对账单_{datetime.now().strftime("%Y%m%d_%H%M%S")}.xlsx' + + log('export_reconciliations', f'导出对账单,共 {len(rows)} 条记录') + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + + except Exception as e: + log('export_reconciliations_error', str(e)) + return jsonify({'error': f'导出失败:{str(e)}'}), 500 + + +@app.post('/api/reconciliations/upload-shipment') +@require_login +@require_any_role('admin', 'superadmin') +def upload_shipment(): + """上传发货单并解析生成对账单""" + if 'file' not in request.files: + return jsonify({'error': '未选择文件'}), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({'error': '未选择文件'}), 400 + + # 验证文件类型 + allowed_extensions = {'xls', 'xlsx'} + if '.' not in file.filename: + return jsonify({'error': '无效的文件格式'}), 400 + + ext = file.filename.rsplit('.', 1)[1].lower() + if ext not in allowed_extensions: + return jsonify({'error': '不支持的文件格式,请上传 XLS 或 XLSX 格式'}), 400 + + try: + import pandas as pd + import numpy as np + from io import BytesIO + + # 读取Excel文件 + file_content = file.read() + df = pd.read_excel(BytesIO(file_content), header=None) + + # 日期格式化函数:统一转换为 YYYY/MM/DD 格式 + def format_date(date_val): + if pd.isna(date_val): + return '' + if isinstance(date_val, pd.Timestamp): + return date_val.strftime('%Y/%m/%d') + date_str = str(date_val).strip() + # 去掉时间部分 + if ' ' in date_str: + date_str = date_str.split()[0] + # 将 YYYY-MM-DD 转换为 YYYY/MM/DD + if '-' in date_str: + return date_str.replace('-', '/') + return date_str + + # 提取头部信息 + shipment_date = None + transport_method = None + + # 解析发货日期(第1行,索引2) + if len(df) > 1 and len(df.columns) > 2: + shipment_date_raw = df.iloc[1, 2] + if pd.notna(shipment_date_raw): + shipment_date = format_date(shipment_date_raw) + + # 解析供货方式(第2行,索引2) + if len(df) > 2 and len(df.columns) > 2: + transport_method_raw = df.iloc[2, 2] + if pd.notna(transport_method_raw): + transport_method = str(transport_method_raw) + + # 找到表格数据的起始行(序号、采购单号、物料编码...) + header_row = None + for i in range(len(df)): + if df.iloc[i, 0] == '序号': + header_row = i + break + + if header_row is None: + return jsonify({'error': '无法识别发货单格式,未找到表格头部'}), 400 + + # 从表格起始行读取数据 + data_df = pd.read_excel(BytesIO(file_content), header=header_row) + + # 过滤掉合计行和备注行(只保留序号为数字的行) + valid_data = data_df[data_df['序号'].apply(lambda x: isinstance(x, (int, float)) and not pd.isna(x))] + + if len(valid_data) == 0: + return jsonify({'error': '发货单中没有有效的数据行'}), 400 + + # 获取客户订单数据用于查找单价和下单时间 + conn = get_db() + c = conn.cursor() + c.execute('SELECT order_no, order_date, material, unit_price FROM customer_orders') + customer_orders_list = c.fetchall() + + # 构建字典(支持一个订单号对应多个物料) + customer_orders = {} + for row in customer_orders_list: + order_no = row['order_no'] + if order_no not in customer_orders: + customer_orders[order_no] = [] + customer_orders[order_no].append({ + 'order_date': row['order_date'], + 'material': row['material'], + 'unit_price': row['unit_price'] + }) + + # 解析每一行数据并插入对账单 + now = get_beijing_time() + username = session.get('username', '') + success_count = 0 + error_rows = [] + + for idx, row in valid_data.iterrows(): + try: + # 提取数据 + contract_no = row.get('采购单号') + if pd.isna(contract_no): + # 如果采购单号为空,尝试使用上一行的采购单号 + if success_count > 0: + contract_no = last_contract_no + else: + error_rows.append(f"第{int(row['序号'])}行:采购单号为空") + continue + else: + contract_no = str(contract_no).strip() + last_contract_no = contract_no + + material_code = row.get('物料编码') + if pd.isna(material_code): + error_rows.append(f"第{int(row['序号'])}行:物料编码为空") + continue + material_code = str(material_code).strip().replace('\n', ' ') + + spec_model = row.get('规格型号') + if pd.isna(spec_model): + spec_model = '' + else: + spec_model = str(spec_model).strip() + + quantity = row.get('实送数量') + if pd.isna(quantity): + error_rows.append(f"第{int(row['序号'])}行:实送数量为空") + continue + quantity = int(float(quantity)) + + # 单位统一设置为 pcs + unit = 'pcs' + + # 使用发货单头部的供货方式作为运输单号(统一) + transport_no = transport_method or '' + + # 从客户订单中查找单价和下单时间 + unit_price = 0 + order_date = shipment_date or '' + + # 提取物料编码的第一部分(去掉换行符后的内容) + material_code_key = material_code.split('\n')[0].split()[0].strip() if material_code else '' + + # 遍历客户订单查找匹配的物料 + if contract_no in customer_orders: + for order_info in customer_orders[contract_no]: + # 提取订单中的物料编码(第一部分) + order_material = order_info['material'].split('\n')[0].split()[0].strip() + + # 匹配物料编码 + if material_code_key and order_material and material_code_key in order_material: + unit_price = order_info['unit_price'] + # 格式化下单时间为 YYYY/MM/DD 格式 + order_date = format_date(order_info['order_date']) or shipment_date + break + + # 如果未找到匹配,检查是否是飞机盒,设置默认单价 + if unit_price == 0 and '飞机盒' in material_code: + unit_price = 2 + + # 计算含税金额 + total_amount = quantity * unit_price + + # 插入对账单 + c.execute(''' + INSERT INTO reconciliations( + order_date, contract_no, material_name, spec_model, transport_no, + quantity, unit, unit_price, total_amount, delivery_date, shipment_date, + created_by, created_at, updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ''', ( + order_date, contract_no, material_code, spec_model, transport_no, + quantity, unit, unit_price, total_amount, shipment_date, shipment_date, + username, now, now + )) + + success_count += 1 + + except Exception as e: + error_rows.append(f"第{int(row['序号'])}行:{str(e)}") + continue + + conn.commit() + conn.close() + + log('upload_shipment', f'上传发货单,成功导入 {success_count} 条记录') + notify_superadmin('上传发货单', f'成功导入 {success_count} 条对账单记录') + + result = { + 'ok': True, + 'success_count': success_count, + 'message': f'成功导入 {success_count} 条对账单记录' + } + + if error_rows: + result['errors'] = error_rows + result['message'] += f',{len(error_rows)} 条记录失败' + + return jsonify(result) + + except Exception as e: + log('upload_shipment_error', str(e)) + return jsonify({'error': f'解析发货单失败:{str(e)}'}), 500 + + @app.errorhandler(404) def not_found(e): # 如果请求的是 HTML 页面(通过 Accept header 判断),返回 index.html diff --git a/server/requirements.txt b/server/requirements.txt index aba4aad..a25e975 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,6 +1,7 @@ Flask>=2.3.0 Werkzeug>=2.3.0 redis>=4.5.0 +pandas>=2.0.0 openpyxl>=3.1.0 reportlab>=4.0.0 Pillow>=10.0.0 diff --git a/test_py/batch_import.py b/test_py/batch_import.py new file mode 100644 index 0000000..dbd540b --- /dev/null +++ b/test_py/batch_import.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +import pandas as pd +import redis +from tqdm import tqdm +import argparse +import os + +# 连接Redis +parser = argparse.ArgumentParser() +parser.add_argument("type", choices=["pdd", "yt", "tx"], help="目标: pdd/yt/tx") +args = parser.parse_args() + +r = redis.Redis(host='180.163.74.83', port=6379, password='Zzh08165511', decode_responses=True) + +# 读取Excel文件 +base_dir = '/home/hyx/work/batch_import_xlsx' +if args.type == "yt": + base_name = 'sn_test_yt' + pool = 'batch_sn_mapping_yt' + mac_col = 'MAC' +elif args.type == "pdd": + base_name = 'sn_test_pdd' + pool = 'batch_sn_mapping_pdd' + mac_col = 'MAC' +else: + base_name = 'sn_test_tx' + pool = 'batch_sn_mapping' + mac_col = 'SN_MAC' + +# 自动检测文件扩展名(优先.xlsx,其次.xls) +excel_path = None +for ext in ['.xlsx', '.xls']: + test_path = os.path.join(base_dir, f'{base_name}{ext}') + if os.path.exists(test_path): + excel_path = test_path + break + +if not excel_path: + print(f"错误: 找不到文件 {base_name}.xlsx 或 {base_name}.xls") + exit(1) + +# 根据文件扩展名选择合适的引擎 +if excel_path.endswith('.xls'): + df = pd.read_excel(excel_path, engine='xlrd') +else: + df = pd.read_excel(excel_path) +existing = r.hgetall(pool) +mac_to_batches = {} +for b, m in existing.items(): + mac_to_batches.setdefault(m, []).append(b) +s = df[mac_col].astype(str).str.strip() +dup_keys = set(s[s.duplicated(keep=False)].unique()) + +# 批量导入数据 +pipe = r.pipeline() +duplicates = [] +inserted_count = 0 +invalids = [] +duplicates_current = {} +dup_current_count = 0 +for index, row in tqdm(df.iterrows(), total=len(df)): + batch_no = str(row['批次号']).strip() + sn_mac = str(row[mac_col]).strip() + expected_len = 27 if args.type == 'tx' else 12 + + if len(sn_mac) != expected_len: + invalids.append((sn_mac, batch_no)) + continue + + if sn_mac in dup_keys: + s = duplicates_current.get(sn_mac, set()) + s.add(batch_no) + duplicates_current[sn_mac] = s + dup_current_count += 1 + continue + + if sn_mac in mac_to_batches: + for b in mac_to_batches[sn_mac]: + duplicates.append((sn_mac, b)) + continue + + pipe.hset(pool, batch_no, sn_mac) + inserted_count += 1 + + if (index + 1) % 100 == 0: + pipe.execute() + pipe = r.pipeline() + +pipe.execute() +print(f"成功导入 {inserted_count} 条数据,数据库重复跳过 {len(duplicates)} 条,当前批次重复跳过 {dup_current_count} 条,长度错误跳过 {len(invalids)} 条") + +# 输出成功导入的数据(JSON格式,方便前端解析) +if inserted_count > 0: + print("\n=== 成功导入的数据 ===") + import json + success_records = [] + for index, row in df.iterrows(): + batch_no = str(row['批次号']).strip() + sn_mac = str(row[mac_col]).strip() + expected_len = 27 if args.type == 'tx' else 12 + + # 只输出成功导入的记录 + if len(sn_mac) == expected_len and sn_mac not in dup_keys and sn_mac not in mac_to_batches: + success_records.append({ + 'mac': sn_mac, + 'batch': batch_no + }) + # 移除数量限制,输出所有成功导入的记录 + + print(json.dumps(success_records, ensure_ascii=False)) + print("=== 数据输出结束 ===") +if duplicates: + for mac, b in duplicates: + print(f"重复: {mac} 已存在于批次号 {b}") + dup_df = pd.DataFrame(duplicates, columns=[mac_col, '批次号']) + out_path = f"/home/hyx/work/batch_import_xlsx/duplicates_{args.type}.xlsx" + if os.path.exists(out_path): + old_df = pd.read_excel(out_path) + combined = pd.concat([old_df, dup_df], ignore_index=True) + combined.to_excel(out_path, index=False) + else: + dup_df.to_excel(out_path, index=False) + #print(f"重复数据已导出: {out_path}") +if duplicates_current: + for mac, bs in duplicates_current.items(): + for b in bs: + print(f"重复: {mac} 当前批次号 {b}") + cur_rows = [(mac, b) for mac, bs in duplicates_current.items() for b in bs] + cur_dup_df = pd.DataFrame(cur_rows, columns=[mac_col, '批次号']) + out_path_cur = f"/home/hyx/work/batch_import_xlsx/duplicates_current_{args.type}.xlsx" + if os.path.exists(out_path_cur): + old_cur_df = pd.read_excel(out_path_cur) + combined_cur = pd.concat([old_cur_df, cur_dup_df], ignore_index=True) + combined_cur.to_excel(out_path_cur, index=False) + else: + cur_dup_df.to_excel(out_path_cur, index=False) + #print(f"当前批次重复数据已导出: {out_path_cur}") +if invalids: + for mac, b in invalids: + print(f"长度错误: {mac} 批次号 {b}") + inv_df = pd.DataFrame(invalids, columns=[mac_col, '批次号']) + out_path_inv = f"/home/hyx/work/batch_import_xlsx/invalid_{args.type}.xlsx" + if os.path.exists(out_path_inv): + old_inv_df = pd.read_excel(out_path_inv) + combined_inv = pd.concat([old_inv_df, inv_df], ignore_index=True) + combined_inv.to_excel(out_path_inv, index=False) + else: + inv_df.to_excel(out_path_inv, index=False) + #print(f"长度错误数据已导出: {out_path_inv}") diff --git a/test_py/check_excel.py b/test_py/check_excel.py new file mode 100644 index 0000000..6b110a4 --- /dev/null +++ b/test_py/check_excel.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +import pandas as pd +import openpyxl +import warnings + +# 过滤openpyxl的跨平台兼容性警告 +warnings.filterwarnings('ignore', category=UserWarning, module='openpyxl') + +file_path = '/home/hyx/work/batch_import_xlsx/sn_test_tx.xlsx' + +print("检查Excel文件信息...") + +try: + # 使用openpyxl检查工作表(兼容Windows到Mac的Excel文件) + wb = openpyxl.load_workbook(file_path, data_only=True) + print(f"工作表数量: {len(wb.sheetnames)}") + print(f"工作表名称: {wb.sheetnames}") + + if wb.sheetnames: + ws = wb.active + print(f"活动工作表: {ws.title}") + print(f"最大行数: {ws.max_row}") + print(f"最大列数: {ws.max_column}") + + # 显示前几行数据 + print("\n前10行数据:") + for i, row in enumerate(ws.iter_rows(values_only=True), 1): + if i <= 10: + print(f"第{i}行: {row}") + else: + break + + wb.close() # 关闭工作簿释放资源 + +except Exception as e: + print(f"openpyxl错误: {e}") + print("提示: 这可能是Windows到Mac的Excel文件兼容性问题") + +try: + # 使用pandas检查 + print("\n使用pandas检查...") + xl_file = pd.ExcelFile(file_path) + print(f"pandas检测到的工作表: {xl_file.sheet_names}") + +except Exception as e: + print(f"pandas错误: {e}") diff --git a/test_py/check_login.py b/test_py/check_login.py new file mode 100755 index 0000000..4632d61 --- /dev/null +++ b/test_py/check_login.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +登录问题诊断脚本 +""" +import sqlite3 +import os + +DB_PATH = 'server/data.db' + +def check_database(): + """检查数据库和用户""" + print("=" * 60) + print("🔍 检查数据库...") + print("=" * 60) + + if not os.path.exists(DB_PATH): + print("❌ 数据库文件不存在:", DB_PATH) + return False + + print("✅ 数据库文件存在") + + try: + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # 检查用户表 + users = c.execute('SELECT username, role FROM users').fetchall() + + if not users: + print("❌ 没有找到任何用户") + return False + + print(f"\n✅ 找到 {len(users)} 个用户:") + print("-" * 60) + for username, role in users: + print(f" 👤 用户名: {username:15s} | 角色: {role}") + + conn.close() + return True + + except Exception as e: + print(f"❌ 数据库错误: {e}") + return False + +def check_server(): + """检查服务器状态""" + print("\n" + "=" * 60) + print("🔍 检查服务器...") + print("=" * 60) + + import subprocess + + # 检查进程 + try: + result = subprocess.run( + ['ps', 'aux'], + capture_output=True, + text=True + ) + + if 'python' in result.stdout and 'app.py' in result.stdout: + print("✅ 服务器正在运行") + + # 提取进程信息 + for line in result.stdout.split('\n'): + if 'app.py' in line: + print(f" 📋 进程: {' '.join(line.split()[10:])}") + return True + else: + print("❌ 服务器未运行") + print("\n💡 启动服务器:") + print(" cd server && python3 app.py") + return False + + except Exception as e: + print(f"⚠️ 无法检查进程: {e}") + return None + +def check_files(): + """检查关键文件""" + print("\n" + "=" * 60) + print("🔍 检查关键文件...") + print("=" * 60) + + files = { + 'frontend/login.html': '登录页面', + 'frontend/assets/login.css': '登录样式', + 'frontend/js/api.js': 'API 接口', + 'server/app.py': '后端服务', + } + + all_exist = True + for path, desc in files.items(): + if os.path.exists(path): + size = os.path.getsize(path) + print(f"✅ {desc:20s} - {path} ({size} bytes)") + else: + print(f"❌ {desc:20s} - {path} (不存在)") + all_exist = False + + return all_exist + +def show_instructions(): + """显示使用说明""" + print("\n" + "=" * 60) + print("📖 使用说明") + print("=" * 60) + + print("\n1️⃣ 启动后端服务:") + print(" cd server") + print(" python3 app.py") + + print("\n2️⃣ 访问登录页面:") + print(" http://localhost:5000/login.html") + print(" ⚠️ 注意:不是 login-preview.html") + + print("\n3️⃣ 使用以下账号登录:") + print(" - tz (超级管理员)") + print(" - 张正浩 (超级管理员)") + print(" - admin (管理员)") + print(" - 黄有想 (管理员)") + + print("\n4️⃣ 如果忘记密码,重置密码:") + print(" python3 reset_password.py <用户名> <新密码>") + + print("\n5️⃣ 清除浏览器缓存:") + print(" Chrome/Edge: Ctrl+Shift+Delete") + print(" 或者使用无痕模式: Ctrl+Shift+N") + +def create_reset_script(): + """创建密码重置脚本""" + script = '''#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +密码重置脚本 +用法: python3 reset_password.py <用户名> <新密码> +""" +import sys +import sqlite3 +from werkzeug.security import generate_password_hash + +if len(sys.argv) != 3: + print("用法: python3 reset_password.py <用户名> <新密码>") + sys.exit(1) + +username = sys.argv[1] +new_password = sys.argv[2] + +DB_PATH = 'server/data.db' + +try: + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # 检查用户是否存在 + user = c.execute('SELECT id FROM users WHERE username = ?', (username,)).fetchone() + + if not user: + print(f"❌ 用户 '{username}' 不存在") + print("\\n现有用户:") + users = c.execute('SELECT username FROM users').fetchall() + for u in users: + print(f" - {u[0]}") + sys.exit(1) + + # 更新密码 + password_hash = generate_password_hash(new_password) + c.execute('UPDATE users SET password_hash = ? WHERE username = ?', (password_hash, username)) + conn.commit() + + print(f"✅ 用户 '{username}' 的密码已重置") + print(f" 新密码: {new_password}") + + conn.close() + +except Exception as e: + print(f"❌ 错误: {e}") + sys.exit(1) +''' + + with open('reset_password.py', 'w', encoding='utf-8') as f: + f.write(script) + + os.chmod('reset_password.py', 0o755) + print("\n✅ 已创建密码重置脚本: reset_password.py") + +def main(): + print("\n" + "🔐 登录问题诊断工具".center(60, "=")) + print() + + db_ok = check_database() + files_ok = check_files() + server_ok = check_server() + + print("\n" + "=" * 60) + print("📊 诊断结果") + print("=" * 60) + + if db_ok and files_ok: + print("✅ 数据库和文件都正常") + + if server_ok: + print("✅ 服务器正在运行") + print("\n💡 如果仍然无法登录,请尝试:") + print(" 1. 清除浏览器缓存") + print(" 2. 使用无痕模式") + print(" 3. 检查浏览器控制台的错误信息") + print(" 4. 确认访问的是 login.html 而不是 login-preview.html") + else: + print("❌ 服务器未运行,请先启动服务器") + else: + print("❌ 发现问题,请检查上述错误信息") + + show_instructions() + create_reset_script() + + print("\n" + "=" * 60) + print() + +if __name__ == '__main__': + main() diff --git a/test_py/create_shipments_template.py b/test_py/create_shipments_template.py new file mode 100644 index 0000000..d152530 --- /dev/null +++ b/test_py/create_shipments_template.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +创建发货记录 Excel 模板文件 +""" +import pandas as pd +from datetime import datetime, timedelta + +def create_template(): + """创建发货记录模板文件(带合并单元格)""" + import openpyxl + from openpyxl.styles import Alignment, Font, Border, Side + + # 创建工作簿 + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "发货记录" + + # 创建表头 + headers = ['出货日期', '箱号'] + headers.extend([f'SN{i}' for i in range(1, 21)]) + ws.append(headers) + + # 设置表头样式 + for cell in ws[1]: + cell.font = Font(bold=True) + cell.alignment = Alignment(horizontal='center', vertical='center') + + # 创建示例数据 + base_date = datetime.now() + row_num = 2 + + # 每个日期3个箱子 + for day in range(3): + date = (base_date + timedelta(days=day)).strftime('%Y-%m-%d') + start_row = row_num + + # 每天3个箱子 + for box in range(3): + box_num = f"BOX{day*3+box+1:03d}" + + # 第一列:日期(只在第一行写入,后面会合并) + if box == 0: + ws.cell(row=row_num, column=1, value=date) + + # 第二列:箱号 + ws.cell(row=row_num, column=2, value=box_num) + + # SN1-SN20 + for sn_idx in range(1, 21): + sn_value = f"SN{(day*3+box)*20+sn_idx:04d}" if sn_idx <= 15 else '' + ws.cell(row=row_num, column=2+sn_idx, value=sn_value) + + row_num += 1 + + # 合并日期单元格 + if start_row < row_num - 1: + ws.merge_cells(f'A{start_row}:A{row_num-1}') + # 设置合并单元格的对齐方式 + ws.cell(row=start_row, column=1).alignment = Alignment(horizontal='center', vertical='center') + + # 调整列宽 + ws.column_dimensions['A'].width = 12 + ws.column_dimensions['B'].width = 10 + for i in range(3, 23): + ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = 10 + + # 保存文件 + output_file = 'shipments_template.xlsx' + wb.save(output_file) + + print(f"✓ 模板文件已创建:{output_file}") + print(f" - 包含 {row_num-2} 行示例数据") + print(f" - 列:出货日期(合并单元格)、箱号、SN1-SN20") + print(f" - 每个日期包含 3 个箱子") + + return output_file + +def create_empty_template(): + """创建空白模板文件""" + + # 创建列头 + columns = ['出货日期', '箱号'] + columns.extend([f'SN{i}' for i in range(1, 21)]) + + # 创建空 DataFrame + df = pd.DataFrame(columns=columns) + + # 保存为 Excel + output_file = 'shipments_template_empty.xlsx' + df.to_excel(output_file, index=False, engine='openpyxl') + + print(f"✓ 空白模板文件已创建:{output_file}") + + return output_file + +if __name__ == '__main__': + print("创建发货记录 Excel 模板...\n") + + # 创建带示例数据的模板 + create_template() + print() + + # 创建空白模板 + create_empty_template() + print() + + print("完成!您可以使用这些模板文件进行测试。") diff --git a/test_py/reset_all_passwords.py b/test_py/reset_all_passwords.py new file mode 100755 index 0000000..ed4a7c6 --- /dev/null +++ b/test_py/reset_all_passwords.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +批量重置所有用户密码 +""" +import sqlite3 +from werkzeug.security import generate_password_hash + +DB_PATH = 'server/data.db' + +# 默认密码设置 +DEFAULT_PASSWORDS = { + 'tz': 'tz123', + '张正浩': 'zzh123', + 'admin': 'admin123', + '黄有想': 'hyx123' +} + +def reset_all_passwords(): + """重置所有用户密码""" + try: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + + # 获取所有用户 + users = c.execute('SELECT username FROM users').fetchall() + + print("=" * 60) + print("🔐 批量重置用户密码") + print("=" * 60) + print() + + for user in users: + username = user['username'] + + # 使用默认密码或统一密码 + if username in DEFAULT_PASSWORDS: + new_password = DEFAULT_PASSWORDS[username] + else: + new_password = 'admin123' # 统一默认密码 + + # 生成新的密码哈希 + password_hash = generate_password_hash(new_password) + + # 更新密码 + c.execute('UPDATE users SET password_hash = ? WHERE username = ?', + (password_hash, username)) + + print(f"✅ {username:15s} | 新密码: {new_password}") + + conn.commit() + conn.close() + + print() + print("=" * 60) + print("✅ 所有密码已重置完成!") + print("=" * 60) + print() + print("📝 登录信息:") + print("-" * 60) + for username, password in DEFAULT_PASSWORDS.items(): + print(f" 用户名: {username:15s} | 密码: {password}") + print() + print("💡 请使用上述账号密码登录") + print(" 登录地址: http://localhost:5000/login.html") + print() + + except Exception as e: + print(f"❌ 错误: {e}") + return False + + return True + +if __name__ == '__main__': + reset_all_passwords() diff --git a/test_py/reset_password.py b/test_py/reset_password.py new file mode 100755 index 0000000..447afcd --- /dev/null +++ b/test_py/reset_password.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +密码重置脚本 +用法: python3 reset_password.py <用户名> <新密码> +""" +import sys +import sqlite3 +from werkzeug.security import generate_password_hash + +if len(sys.argv) != 3: + print("用法: python3 reset_password.py <用户名> <新密码>") + sys.exit(1) + +username = sys.argv[1] +new_password = sys.argv[2] + +DB_PATH = 'server/data.db' + +try: + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # 检查用户是否存在 + user = c.execute('SELECT id FROM users WHERE username = ?', (username,)).fetchone() + + if not user: + print(f"❌ 用户 '{username}' 不存在") + print("\n现有用户:") + users = c.execute('SELECT username FROM users').fetchall() + for u in users: + print(f" - {u[0]}") + sys.exit(1) + + # 更新密码 + password_hash = generate_password_hash(new_password) + c.execute('UPDATE users SET password_hash = ? WHERE username = ?', (password_hash, username)) + conn.commit() + + print(f"✅ 用户 '{username}' 的密码已重置") + print(f" 新密码: {new_password}") + + conn.close() + +except Exception as e: + print(f"❌ 错误: {e}") + sys.exit(1) diff --git a/test_py/test-theme.html b/test_py/test-theme.html new file mode 100644 index 0000000..a31c76b --- /dev/null +++ b/test_py/test-theme.html @@ -0,0 +1,71 @@ + + + + +主题测试 + + + + +
+ + +
+ +
+

上传日志测试

+
+
上传日志
+
+[2024-01-01 10:00:00] 开始上传文件...
+[2024-01-01 10:00:01] 验证文件格式...
+[2024-01-01 10:00:02] 处理数据中...
+[2024-01-01 10:00:03] 成功上传 100 条记录
+[2024-01-01 10:00:04] 完成!
+    
+
+
+ +
+

日期选择器测试

+ +
+ +
+

卡片测试

+
+
测试卡片
+

这是一个测试卡片,用于验证主题颜色是否正确。

+
    +
  • 项目1标签
  • +
  • 项目2标签
  • +
+
+
+ + + + diff --git a/test_py/test_captcha.png b/test_py/test_captcha.png new file mode 100644 index 0000000000000000000000000000000000000000..9dc10b0a6640241cbca003d3307f2beb6f8e711a GIT binary patch literal 2132 zcmV-a2&?yrP)cp=pLpS|CYbN)wupNizdY zN;3>~%0MU0qcd^q20{&W69x)IyquUCYz%%hvTC=}8sfgE@yxDxxAecw5U!{rog!%ABsGWw-!ja^Y{}~@Eb0QFlb?V_{8*aaeZ(G3O z0=Wb-lVO1hRnA5-iGNiQHuU~y2xVHtw4J-=dgri1Z)EKlgBcslR7W3m^eghw1)`ll z2{1D@PB(Pd9d8eH0OPG}bwKqAob5Vutf#_VX|#gT3RnB_q5t5GOVQFoKWiU>TgN)p zS)P(1@kXuIx$XTCA1gE1qPfI(Z~y>s$7FkR@9B`5_=Up-uYF2i(BWDyD|^FYhdsye zXIJp}KSjW!y?fnvlGYEw@Cd|-AUPiL6=-ojs8tB@-QS})-{#tT#DV<`7yzJJTN;wK zH};$~4~+modTK(JBB}Y>teD+Nz$#B)?cs+_PpvB8Xc2I=4EJ_9Hb(cJp*m6WLdJY8=Gyc z<}tc;7_D%)C9$lKLyS3mPFyD4HDVELS|R@E-_9?(7QXkg1p}nZ(Y##2n#H1`Y(a2s z6h*)KyW@6;3jk&pOn>30i^7?L-pnc%WwPk7KmkGmTw@0cMMK+7T%P9<3D?jF!Hg78 z=b)lYP$Z$0L=XwUa2L-07azHdN1PtXHobUY2&N}-C?qMUm~pYWdz6I}CFuET+qXj= z2fuL}-?&ZJHBFY1Hl3T_b-3BPn^Aso8 zw>tz93E)r95*sSeqFkg*1DOOQBFIjK^*=E?w2Bkg_G8HcgAmA!H3 z9J%{Yz-X(bUH`$~o0#X>USwJ>YR?1|}=3=-<$s zAt|3Ixj+FqQ&}~4ES!yC$i}@GZqzl9&E3@adfN#3+U^rHjR9cgj|!JRJkv+u_Xk?} z_rjncgXK2e2CyV8G28_^3D^2z?{VL2sLjbL`lpU4<#c>K+#j6db|^xyxhihOLXlJq zOfyS9Rg)&;ZZ$rnet53%*=1tpSutT#5J$^j;dY_Sb=(}ZJ^w@zQ z-EG=&lzFJ#x3)NcY7j{T@ZvJ!*9U3RPltZs6N`s?z1x0}-t|v>2$u zVF`rm(Pk@L>&IVq0RYE@A1OvD3ET?PlTeBbd6PqMNJo7=Mp^6G1p#zG3)$Hs|_~Lo2(Ol{Pws zNLiV4ril`GstoN}M|`rGIJ%Yi_-DlS6=;OSr#n2(@x3%qrm|Xvn~(naM|8i5L#8y_ z@p6?kQ2uZ{zUp+nbYR3?L8LH5OV!#^jA38BV_&^v zyrfKuo_vVk`>{xX#?YyUPCaJxWQ7lHmgV@#l=3ok5c-o#L7jutIZVk9IZWV{3PSM4 z(~{@@U<;n5BM8{}j6@zEVq!4LD9nIgK048SbnIW1DpH6Kw#FAL17=jw((sDDF@`f&U8h=HSHGc*!#dIDDdE`DpkXUzAVp(iUI ze5n*8#OSkKUGJdJ56_0t!{qsFH#l$b`9yj$(N3)YwZeUF@G;_KT8yZ8G-FN1qa%BZ z7_Th`ixH72+%xKkCGLlvF>2_D-(J6S*J1C)XuRA}Hoq{krvD$a-fWJtXYg|X0000< KMNUMnLSTXwT@;-F literal 0 HcmV?d00001 diff --git a/test_py/test_captcha.py b/test_py/test_captcha.py new file mode 100644 index 0000000..19efc98 --- /dev/null +++ b/test_py/test_captcha.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""测试验证码生成功能""" + +try: + from PIL import Image, ImageDraw, ImageFont + import random + import io + import base64 + + print("✓ Pillow 库已安装") + + # 生成4位随机数字 + code = ''.join([str(random.randint(0, 9)) for _ in range(4)]) + print(f"✓ 生成验证码: {code}") + + # 创建图片 + width, height = 120, 40 + image = Image.new('RGB', (width, height), color='#f0f4f8') + draw = ImageDraw.Draw(image) + print("✓ 创建图片成功") + + # 尝试使用系统字体 + font = None + font_paths = [ + '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', + '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf', + '/System/Library/Fonts/Helvetica.ttc', + 'C:\\Windows\\Fonts\\arial.ttf' + ] + + for font_path in font_paths: + try: + font = ImageFont.truetype(font_path, 28) + print(f"✓ 使用字体: {font_path}") + break + except: + continue + + if not font: + font = ImageFont.load_default() + print("⚠ 使用默认字体") + + # 绘制干扰线 + for _ in range(3): + x1 = random.randint(0, width) + y1 = random.randint(0, height) + x2 = random.randint(0, width) + y2 = random.randint(0, height) + draw.line([(x1, y1), (x2, y2)], fill='#cbd5e1', width=1) + + # 绘制验证码文字 + colors = ['#3b82f6', '#2563eb', '#1e40af', '#1e3a8a'] + for i, char in enumerate(code): + x = 20 + i * 25 + random.randint(-3, 3) + y = 5 + random.randint(-3, 3) + color = random.choice(colors) + draw.text((x, y), char, font=font, fill=color) + + # 绘制干扰点 + for _ in range(50): + x = random.randint(0, width) + y = random.randint(0, height) + draw.point((x, y), fill='#94a3b8') + + print("✓ 绘制验证码成功") + + # 转换为base64 + buffer = io.BytesIO() + image.save(buffer, format='PNG') + buffer.seek(0) + img_base64 = base64.b64encode(buffer.getvalue()).decode() + + print(f"✓ 转换为base64成功 (长度: {len(img_base64)})") + print("\n验证码功能测试通过!") + + # 保存测试图片 + image.save('test_captcha.png') + print("✓ 测试图片已保存为 test_captcha.png") + +except ImportError as e: + print(f"✗ 缺少依赖库: {e}") + print("\n请安装 Pillow 库:") + print(" pip install Pillow") +except Exception as e: + print(f"✗ 测试失败: {e}") + import traceback + traceback.print_exc() diff --git a/test_py/test_import.py b/test_py/test_import.py new file mode 100644 index 0000000..8cd0c8e --- /dev/null +++ b/test_py/test_import.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import sys +sys.path.insert(0, 'server') + +# 测试导入函数的逻辑 +def test_parse(): + # 模拟辅助函数 + def parse_percentage(value): + if value is None: + return 0 + if isinstance(value, (int, float)): + return float(value) + value_str = str(value).strip() + if value_str.endswith('%'): + value_str = value_str[:-1] + try: + return float(value_str) + except: + return 0 + + def safe_int(value, default=0): + if value is None: + return default + try: + return int(float(value)) + except: + return default + + # 测试数据 + test_cases = [ + ('62.28%', 62.28), + ('62.28', 62.28), + (62.28, 62.28), + ('', 0), + (None, 0), + ] + + print("测试 parse_percentage:") + for input_val, expected in test_cases: + result = parse_percentage(input_val) + status = "✓" if result == expected else "✗" + print(f" {status} parse_percentage({repr(input_val)}) = {result} (期望: {expected})") + + print("\n测试 safe_int:") + test_int_cases = [ + (100, 100), + (100.5, 100), + ('100', 100), + ('', 0), + (None, 0), + ] + for input_val, expected in test_int_cases: + result = safe_int(input_val) + status = "✓" if result == expected else "✗" + print(f" {status} safe_int({repr(input_val)}) = {result} (期望: {expected})") + +if __name__ == '__main__': + test_parse() diff --git a/test_py/test_login.html b/test_py/test_login.html new file mode 100644 index 0000000..e69de29 diff --git a/test_py/test_sop_feature.py b/test_py/test_sop_feature.py new file mode 100755 index 0000000..0e24d0d --- /dev/null +++ b/test_py/test_sop_feature.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +SOP 功能测试脚本 +用于验证 SOP 文件管理功能是否正常工作 +""" + +import os +import sys +import sqlite3 + +# 添加 server 目录到路径 +sys.path.insert(0, 'server') + +def test_database_table(): + """测试数据库表是否创建成功""" + print("测试 1: 检查数据库表...") + + db_path = 'server/data.db' + if not os.path.exists(db_path): + print(" ❌ 数据库文件不存在") + return False + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # 检查 sop_files 表是否存在 + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='sop_files'") + result = cursor.fetchone() + + if result: + print(" ✅ sop_files 表已创建") + + # 检查表结构 + cursor.execute("PRAGMA table_info(sop_files)") + columns = cursor.fetchall() + print(f" ✅ 表结构包含 {len(columns)} 列:") + for col in columns: + print(f" - {col[1]} ({col[2]})") + + conn.close() + return True + else: + print(" ❌ sop_files 表不存在") + conn.close() + return False + +def test_sop_directory(): + """测试 SOP 文件存储目录""" + print("\n测试 2: 检查 SOP 文件目录...") + + sop_dir = 'frontend/sop_files' + + if os.path.exists(sop_dir): + print(f" ✅ 目录已存在: {sop_dir}") + + # 检查目录权限 + if os.access(sop_dir, os.W_OK): + print(" ✅ 目录可写") + else: + print(" ⚠️ 目录不可写,可能需要调整权限") + + # 列出现有文件 + files = os.listdir(sop_dir) + if files: + print(f" 📁 目录中已有 {len(files)} 个文件:") + for f in files[:5]: # 只显示前5个 + print(f" - {f}") + else: + print(" 📁 目录为空") + + return True + else: + print(f" ⚠️ 目录不存在: {sop_dir}") + print(" 💡 系统会在首次上传时自动创建") + return True + +def test_api_routes(): + """测试 API 路由是否注册""" + print("\n测试 3: 检查 API 路由...") + + try: + from app import app + + # 获取所有路由 + routes = [] + for rule in app.url_map.iter_rules(): + if 'sop' in rule.rule: + routes.append(f"{rule.rule} [{', '.join(rule.methods - {'HEAD', 'OPTIONS'})}]") + + if routes: + print(" ✅ SOP API 路由已注册:") + for route in routes: + print(f" - {route}") + return True + else: + print(" ❌ 未找到 SOP API 路由") + return False + except Exception as e: + print(f" ❌ 导入失败: {e}") + return False + +def test_frontend_files(): + """测试前端文件是否更新""" + print("\n测试 4: 检查前端文件...") + + files_to_check = { + 'frontend/js/api.js': ['listSopFiles', 'uploadSopFile', 'deleteSopFile'], + 'frontend/js/components/upload.js': ['renderSop', 'bindSopEvents', 'loadSopList'], + 'frontend/js/router.js': ['sop'], + 'frontend/index.html': ['upload/sop'] + } + + all_ok = True + for filepath, keywords in files_to_check.items(): + if not os.path.exists(filepath): + print(f" ❌ 文件不存在: {filepath}") + all_ok = False + continue + + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + missing = [kw for kw in keywords if kw not in content] + if missing: + print(f" ⚠️ {filepath} 缺少关键字: {', '.join(missing)}") + all_ok = False + else: + print(f" ✅ {filepath} 已更新") + + return all_ok + +def main(): + """运行所有测试""" + print("=" * 60) + print("SOP 功能测试") + print("=" * 60) + + results = [] + + # 运行测试 + results.append(("数据库表", test_database_table())) + results.append(("文件目录", test_sop_directory())) + results.append(("API 路由", test_api_routes())) + results.append(("前端文件", test_frontend_files())) + + # 汇总结果 + print("\n" + "=" * 60) + print("测试结果汇总") + print("=" * 60) + + for name, result in results: + status = "✅ 通过" if result else "❌ 失败" + print(f"{name}: {status}") + + all_passed = all(r[1] for r in results) + + print("\n" + "=" * 60) + if all_passed: + print("🎉 所有测试通过!SOP 功能已准备就绪。") + print("\n下一步:") + print("1. 重启服务器") + print("2. 登录系统(使用管理员账号)") + print("3. 进入 '上传' → 'SOP' 菜单") + print("4. 尝试上传测试文件: sop_template_example.csv") + else: + print("⚠️ 部分测试未通过,请检查上述错误信息。") + print("=" * 60) + + return 0 if all_passed else 1 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/test_py/validate_excel.py b/test_py/validate_excel.py new file mode 100644 index 0000000..a9b3a02 --- /dev/null +++ b/test_py/validate_excel.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +验证Excel文件格式是否符合MAC与批次导入要求 +""" +import sys +import pandas as pd +import warnings + +warnings.filterwarnings('ignore', category=UserWarning, module='openpyxl') + +def validate_excel(file_path): + """ + 验证Excel文件格式 + 返回: (is_valid, error_message) + """ + try: + df = pd.read_excel(file_path) + + if df.empty: + return False, "文件为空,没有数据" + + columns = df.columns.tolist() + + # 检查是否有批次号列 + if '批次号' not in columns: + return False, "缺少必需的列:批次号" + + # 检查是否有MAC或SN_MAC列 + has_mac = 'MAC' in columns + has_sn_mac = 'SN_MAC' in columns + + if not has_mac and not has_sn_mac: + return False, "缺少必需的列:MAC 或 SN_MAC" + + # 检查列数(应该只有2列) + if len(columns) != 2: + return False, f"文件应该只包含2列数据,当前有{len(columns)}列:{', '.join(columns)}" + + # 验证通过 + mac_col = 'MAC' if has_mac else 'SN_MAC' + return True, f"文件格式正确,包含列:{mac_col} 和 批次号,共{len(df)}行数据" + + except Exception as e: + return False, f"读取文件失败:{str(e)}" + +if __name__ == '__main__': + if len(sys.argv) < 2: + print("用法: python validate_excel.py ") + sys.exit(1) + + file_path = sys.argv[1] + is_valid, message = validate_excel(file_path) + + if is_valid: + print(f"✓ {message}") + sys.exit(0) + else: + print(f"✗ {message}") + sys.exit(1) diff --git a/test_shipment_upload.py b/test_shipment_upload.py new file mode 100644 index 0000000..3e26f43 --- /dev/null +++ b/test_shipment_upload.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +测试发货单上传解析功能 +""" +import pandas as pd +import numpy as np + +def test_parse_shipment(): + """测试解析发货单""" + file_path = '/home/hyx/work/生产管理系统/发货单-20251121.xls' + + # 读取Excel文件 + df = pd.read_excel(file_path, header=None) + + print("=== 解析发货单头部信息 ===") + + # 提取头部信息 + shipment_date = None + transport_method = None + + # 解析发货日期(第1行,索引2) + if len(df) > 1 and len(df.columns) > 2: + shipment_date_raw = df.iloc[1, 2] + if pd.notna(shipment_date_raw): + if isinstance(shipment_date_raw, pd.Timestamp): + shipment_date = shipment_date_raw.strftime('%Y-%m-%d') + else: + shipment_date = str(shipment_date_raw) + + print(f"发货日期: {shipment_date}") + + # 解析供货方式(第2行,索引2) + if len(df) > 2 and len(df.columns) > 2: + transport_method_raw = df.iloc[2, 2] + if pd.notna(transport_method_raw): + transport_method = str(transport_method_raw) + + print(f"供货方式(运输单号): {transport_method}") + + # 找到表格数据的起始行(序号、采购单号、物料编码...) + header_row = None + for i in range(len(df)): + if df.iloc[i, 0] == '序号': + header_row = i + break + + if header_row is None: + print("错误:无法识别发货单格式,未找到表格头部") + return + + print(f"\n表格起始行: {header_row}") + + # 从表格起始行读取数据 + data_df = pd.read_excel(file_path, header=header_row) + + print(f"表格列名: {data_df.columns.tolist()}") + + # 过滤掉合计行和备注行(只保留序号为数字的行) + valid_data = data_df[data_df['序号'].apply(lambda x: isinstance(x, (int, float)) and not pd.isna(x))] + + print(f"\n有效数据行数: {len(valid_data)}") + + # 模拟客户订单数据 + customer_orders = { + 'CGDD002878': { + 'order_date': '2025-11-15', + 'material': 'AP-DZ006 灯条基站', + 'unit_price': 150.5 + }, + 'CGDD003082': { + 'order_date': '2025-11-18', + 'material': '飞机盒', + 'unit_price': 5.0 + } + } + + print("\n=== 解析数据行 ===") + last_contract_no = None + + for idx, row in valid_data.iterrows(): + print(f"\n序号: {int(row['序号'])}") + + # 提取数据 + contract_no = row.get('采购单号') + if pd.isna(contract_no): + if last_contract_no: + contract_no = last_contract_no + print(f" 采购单号: {contract_no} (继承上一行)") + else: + print(f" 采购单号: 空 (错误)") + continue + else: + contract_no = str(contract_no).strip() + last_contract_no = contract_no + print(f" 采购单号(合同编号): {contract_no}") + + material_code = row.get('物料编码') + if pd.isna(material_code): + print(f" 物料编码: 空 (错误)") + continue + material_code = str(material_code).strip().replace('\n', ' ') + print(f" 物料编码(物料名称): {material_code}") + + spec_model = row.get('规格型号') + if pd.isna(spec_model): + spec_model = '' + else: + spec_model = str(spec_model).strip() + print(f" 规格型号: {spec_model}") + + quantity = row.get('实送数量') + if pd.isna(quantity): + print(f" 实送数量: 空 (错误)") + continue + quantity = int(float(quantity)) + print(f" 实送数量(数量): {quantity}") + + unit = row.get('单位') + if pd.isna(unit): + unit = 'pcs' + else: + unit = str(unit).strip() + print(f" 单位: {unit}") + + # 从备注中提取运输单号(如果有) + remark = row.get('备注') + transport_no = transport_method or '' + if pd.notna(remark): + remark_str = str(remark).strip() + if remark_str: + transport_no = remark_str + print(f" 运输单号: {transport_no}") + + # 从客户订单中查找单价和下单时间 + unit_price = 0 + order_date = shipment_date or '' + + if contract_no in customer_orders: + order_info = customer_orders[contract_no] + # 匹配物料名称 + if material_code in order_info['material']: + unit_price = order_info['unit_price'] + order_date = order_info['order_date'] + print(f" 含税单价: {unit_price} (从客户订单查找)") + print(f" 下单时间: {order_date} (从客户订单查找)") + else: + print(f" 含税单价: {unit_price} (未找到匹配的客户订单)") + print(f" 下单时间: {order_date} (使用发货日期)") + else: + print(f" 含税单价: {unit_price} (未找到对应的采购单号)") + print(f" 下单时间: {order_date} (使用发货日期)") + + # 计算含税金额 + total_amount = quantity * unit_price + print(f" 含税金额: {total_amount}") + + print(f" 交货日期: {shipment_date}") + print(f" 出货日期: {shipment_date}") + +if __name__ == '__main__': + test_parse_shipment() diff --git a/发货单-20251121.xls b/发货单-20251121.xls new file mode 100644 index 0000000000000000000000000000000000000000..57011997b6b75140cc2decce6cdfa46e03c1e854 GIT binary patch literal 31744 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