bug fix 对账单

This commit is contained in:
zzh 2026-02-03 13:27:32 +08:00
parent 6b2e2d6635
commit 0db85e75a0
14 changed files with 1258 additions and 12 deletions

98
check_total_amount.py Normal file
View File

@ -0,0 +1,98 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
检查对账单含税金额是否正确更新
"""
import sqlite3
import sys
import os
def check_total_amount():
"""检查含税金额更新情况"""
# 连接数据库
conn = sqlite3.connect('server/data.db')
c = conn.cursor()
print("=== 检查对账单含税金额更新情况 ===\n")
# 查询所有对账单记录
c.execute('SELECT id, contract_no, material_name, quantity, unit_price, total_amount FROM reconciliations ORDER BY id DESC LIMIT 20')
records = c.fetchall()
print("最近20条对账单记录")
print("-" * 80)
print(f"{'ID':<5} {'合同号':<15} {'物料名称':<30} {'数量':<8} {'单价':<8} {'含税金额':<10} {'应计算金额':<10}")
print("-" * 80)
incorrect_count = 0
for record in records:
recon_id, contract_no, material_name, quantity, unit_price, total_amount = record
# 计算应有的含税金额
calculated_amount = quantity * unit_price
# 检查是否一致(考虑浮点数精度)
if abs(total_amount - calculated_amount) > 0.01:
incorrect_count += 1
status = ""
else:
status = ""
print(f"{recon_id:<5} {contract_no:<15} {material_name[:28]:<30} {quantity:<8} {unit_price:<8} {total_amount:<10} {calculated_amount:<10} {status}")
print("-" * 80)
print(f"\n发现 {incorrect_count} 条记录的含税金额不正确")
# 修复不正确的记录
if incorrect_count > 0:
print("\n正在修复不正确的记录...")
c.execute('SELECT id, quantity, unit_price FROM reconciliations')
all_records = c.fetchall()
fixed_count = 0
for record in all_records:
recon_id, quantity, unit_price = record
correct_amount = quantity * unit_price
# 更新含税金额
c.execute('UPDATE reconciliations SET total_amount=? WHERE id=?', (correct_amount, recon_id))
fixed_count += 1
conn.commit()
print(f"已修复 {fixed_count} 条记录的含税金额")
# 测试刷新单价功能
print("\n\n=== 测试刷新单价功能 ===")
# 找一个有客户订单的合同号进行测试
c.execute("SELECT DISTINCT contract_no FROM reconciliations LIMIT 1")
test_contract = c.fetchone()
if test_contract:
test_contract_no = test_contract[0]
print(f"测试合同号: {test_contract_no}")
# 查看该合同号的对账单记录
c.execute('SELECT id, material_name, unit_price, total_amount, quantity FROM reconciliations WHERE contract_no=?', (test_contract_no,))
test_records = c.fetchall()
print("\n刷新前的记录:")
for record in test_records:
recon_id, material_name, unit_price, total_amount, quantity = record
print(f" ID={recon_id}, 物料={material_name[:30]}..., 单价={unit_price}, 金额={total_amount}, 数量={quantity}")
# 模拟刷新单价(这里只是检查逻辑)
print("\n注意实际的刷新单价功能需要通过API调用")
conn.close()
print("\n=== 建议检查事项 ===")
print("1. 确认后端刷新单价的SQL语句是否正确")
print("2. 检查前端是否正确调用了calculateTotal函数")
print("3. 验证数据库中的total_amount字段是否被正确更新")
print("4. 查看浏览器控制台是否有JavaScript错误")
if __name__ == '__main__':
check_total_amount()

View File

@ -857,7 +857,7 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover{
color: var(--primary);
font-weight: 500;
}
.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--surface-2);color:var(--text);padding:10px 14px;border-radius:10px;border:1px solid var(--border);opacity:0;pointer-events:none;transition:opacity .2s ease}
.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--surface-2);color:var(--text);padding:10px 14px;border-radius:10px;border:1px solid var(--border);opacity:0;pointer-events:none;transition:opacity .2s ease;z-index:3000}
.toast.show{opacity:1}
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center}
.overlay.hidden{display:none}
@ -1821,10 +1821,45 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover{
}
/* 工单页面样式 */
.page-container{max-width:100%;margin:0 auto}
.page-container{max-width:100%;margin:0 auto;padding-bottom:40px}
.page-header{margin-bottom:24px}
.page-header h1{font-size:24px;font-weight:700;margin:0;color:var(--text)}
/* 设置页面专用样式 - 添加滚动 */
.view .page-container {
max-height: calc(100vh - 150px);
overflow-y: auto;
overflow-x: hidden;
padding-right: 8px;
padding-bottom: 60px;
}
/* 设置页面滚动条样式 */
.view .page-container::-webkit-scrollbar {
width: 8px;
}
.view .page-container::-webkit-scrollbar-track {
background: var(--bg);
border-radius: 4px;
}
.view .page-container::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
.view .page-container::-webkit-scrollbar-thumb:hover {
background: var(--text-2);
}
/* 火狐浏览器滚动条样式 */
.view .page-container {
scrollbar-width: thin;
scrollbar-color: var(--border) var(--bg);
}
.filter-section{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:20px;margin-bottom:20px}
.filter-row{display:flex;gap:16px;align-items:flex-end;flex-wrap:wrap}
.filter-item{flex:1;min-width:200px;display:flex;flex-direction:column;gap:6px}
@ -2627,7 +2662,7 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover{
#app.trackit-layout{margin:0;height:100vh;border-radius:0;overflow:hidden;background:#ffffff;border:none;box-shadow:none;display:flex;flex-direction:row}
#app.trackit-layout .content{background:rgba(248,250,252,.6);height:100%}
#app.trackit-layout .content.topbar-layout{height:100%;display:flex;flex-direction:column}
#app.trackit-layout .content.topbar-layout .view{padding:24px;height:100%;overflow:hidden}
#app.trackit-layout .content.topbar-layout .view{padding:24px;height:100%;overflow:auto}
#app.trackit-layout > #sidebar.sidebar{border-right:1px solid rgba(226,232,240,.9);background:rgba(255,255,255,.75);backdrop-filter:blur(10px);height:100%}
[data-theme="dark"] #app.trackit-layout > #sidebar.sidebar{background:linear-gradient(180deg,var(--surface),var(--surface-2));border-right:1px solid var(--border);backdrop-filter:none}

View File

@ -107,11 +107,53 @@ const API = (() => {
xhr.send(formData);
});
}
function toast(msg) {
// 存储当前的toast定时器
let toastTimer = null;
function toast(msg, type = 'info', duration = 2000) {
const t = document.getElementById('toast');
// 清除之前的定时器
if (toastTimer) {
clearTimeout(toastTimer);
toastTimer = null;
}
t.textContent = msg;
// 添加类型样式
if (type === 'success') {
t.style.backgroundColor = '#10b981';
t.style.color = 'white';
} else if (type === 'error') {
t.style.backgroundColor = '#ef4444';
t.style.color = 'white';
} else if (type === 'warning') {
t.style.backgroundColor = '#f59e0b';
t.style.color = 'white';
} else {
t.style.backgroundColor = '#3b82f6';
t.style.color = 'white';
}
// 支持多行文本
t.style.whiteSpace = 'pre-line';
// 添加show类来显示toast
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2000);
// 调试信息
console.log(`Toast显示: 类型=${type}, 持续时间=${duration}ms, 消息长度=${msg.length}`);
toastTimer = setTimeout(() => {
t.classList.remove('show');
// 重置样式
t.style.backgroundColor = '';
t.style.color = '';
t.style.whiteSpace = '';
toastTimer = null;
console.log('Toast已隐藏');
}, duration);
}
return {
// 通用HTTP方法

View File

@ -108,6 +108,8 @@
<button id="batch-delete-btn" class="btn btn-danger" style="margin-right: 10px; display: none;">批量删除</button>
<button id="export-reconciliation-btn" class="btn btn-secondary" style="margin-right: 10px;">导出对账单</button>
<button id="upload-shipment-btn" class="btn btn-secondary" style="margin-right: 10px;">上传发货单</button>
<button id="refresh-prices-btn" class="btn btn-info" style="margin-right: 10px;" title="从客户订单同步最新单价">刷新单价</button>
<button id="fix-total-amount-btn" class="btn btn-warning" style="margin-right: 10px;" title="修复所有含税金额计算错误的记录">修复含税金额</button>
<button id="add-reconciliation-btn" class="btn btn-primary">新增对账单</button>
<input type="file" id="shipment-file-input" accept=".xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" style="display: none;" />
</div>
@ -216,7 +218,10 @@
</div>
<div class="field">
<label>含税单价 <span style="color: var(--danger);">*</span></label>
<input type="number" id="unit-price" class="input" placeholder="单价" step="0.1" min="0" required />
<div style="display: flex; gap: 10px;">
<input type="number" id="unit-price" class="input" placeholder="单价" step="0.1" min="0" required />
<button type="button" id="fetch-price-btn" class="btn btn-secondary" style="white-space: nowrap;" title="根据合同号和物料名称从客户订单获取单价">获取单价</button>
</div>
</div>
<div class="field">
<label>含税金额</label>
@ -324,13 +329,13 @@
let message = data.message;
if (data.errors && data.errors.length > 0) {
message += '\n\n错误详情\n' + data.errors.join('\n');
API.toast(message, 'warning');
API.toast(message, 'warning', 10000);
} else {
API.toast(message, 'success');
API.toast(message, 'success', 5000);
}
await loadReconciliations();
} else {
API.toast(data.error || '上传失败', 'error');
API.toast(data.error || '上传失败', 'error', 5000);
}
} catch (err) {
console.error('上传发货单失败:', err);
@ -341,6 +346,69 @@
});
}
// 刷新单价按钮
const refreshPricesBtn = document.getElementById('refresh-prices-btn');
if (refreshPricesBtn) {
refreshPricesBtn.addEventListener('click', async () => {
if (!confirm('确定要从客户订单刷新所有对账单的含税单价吗?\n此操作将根据客户订单中的单价更新对账单中的单价。')) {
return;
}
API.toast('正在刷新单价...', 'info');
try {
const res = await fetch('/api/reconciliations/refresh-prices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
if (res.ok && data.ok) {
// 延长显示时间到15秒支持多行显示
API.toast(data.message, 'success', 15000);
await loadReconciliations();
} else {
API.toast(data.error || '刷新单价失败', 'error', 5000);
}
} catch (err) {
console.error('刷新单价失败:', err);
API.toast('刷新单价失败', 'error');
}
});
}
// 修复含税金额按钮
const fixTotalAmountBtn = document.getElementById('fix-total-amount-btn');
if (fixTotalAmountBtn) {
fixTotalAmountBtn.addEventListener('click', async () => {
if (!confirm('确定要修复所有含税金额计算错误的记录吗?\n此操作将根据数量和单价重新计算含税金额。')) {
return;
}
API.toast('正在修复含税金额...', 'info');
try {
const res = await fetch('/api/reconciliations/fix-total-amount', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
if (res.ok && data.ok) {
API.toast(data.message, 'success', 5000);
await loadReconciliations();
} else {
API.toast(data.error || '修复含税金额失败', 'error', 5000);
}
} catch (err) {
console.error('修复含税金额失败:', err);
API.toast('修复含税金额失败', 'error');
}
});
}
const quantityInput = document.getElementById('quantity');
const unitPriceInput = document.getElementById('unit-price');
const totalAmountInput = document.getElementById('total-amount');
@ -348,13 +416,56 @@
const calculateTotal = () => {
const qty = parseFloat(quantityInput.value) || 0;
const price = parseFloat(unitPriceInput.value) || 0;
const total = qty * price;
totalAmountInput.value = total.toFixed(1);
totalAmountInput.value = (qty * price).toFixed(2);
};
if (quantityInput) quantityInput.addEventListener('input', calculateTotal);
if (unitPriceInput) unitPriceInput.addEventListener('input', calculateTotal);
// 获取单价按钮
const fetchPriceBtn = document.getElementById('fetch-price-btn');
if (fetchPriceBtn) {
fetchPriceBtn.addEventListener('click', async () => {
const contractNo = document.getElementById('contract-no').value.trim();
const materialName = document.getElementById('material-name').value.trim();
if (!contractNo || !materialName) {
API.toast('请先填写合同号和物料名称', 'warning');
return;
}
fetchPriceBtn.disabled = true;
fetchPriceBtn.textContent = '获取中...';
try {
const res = await fetch('/api/reconciliations/fetch-price', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contract_no: contractNo,
material_name: materialName
})
});
const data = await res.json();
if (res.ok && data.ok) {
document.getElementById('unit-price').value = data.unit_price;
calculateTotal();
API.toast(data.message, 'success');
} else {
API.toast(data.error || '获取单价失败', 'error');
}
} catch (err) {
console.error('获取单价失败:', err);
API.toast('获取单价失败', 'error');
} finally {
fetchPriceBtn.disabled = false;
fetchPriceBtn.textContent = '获取单价';
}
});
}
loadReconciliations();
}, 100);

View File

@ -44,6 +44,35 @@ Router.register('/settings', async () => {
</div>
</div>
<!-- 界面设置区域 -->
<div class="card" style="margin-bottom:24px">
<h2 style="font-size:18px;font-weight:600;margin-bottom:20px;padding-bottom:12px;border-bottom:2px solid var(--border)">🎨 界面设置</h2>
<div style="padding:20px;background:var(--surface);border-radius:8px;border:1px solid var(--border)">
<div style="font-weight:600;margin-bottom:16px;font-size:15px">📐 侧边栏设置</div>
<div style="margin-bottom:16px">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="auto-collapse-sidebar" style="width:16px;height:16px" />
<span style="font-size:14px">点击二级菜单时自动缩进侧边栏</span>
</label>
<div style="font-size:12px;color:var(--text-2);margin-top:4px;margin-left:24px">
启用后点击二级菜单项时会自动收起侧边栏节省屏幕空间
</div>
</div>
<div style="margin-bottom:16px">
<div style="font-size:13px;color:var(--text-2);margin-bottom:8px">
快捷键<kbd style="padding:2px 6px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:12px">Ctrl + B</kbd>
</div>
</div>
<div class="actions">
<button id="save-sidebar-settings" class="btn btn-primary">保存设置</button>
</div>
</div>
</div>
${(me && me.role === 'superadmin') ? `
<!-- 用户管理区域 -->
<div class="card" style="margin-bottom:24px">
@ -422,6 +451,22 @@ Router.register('/settings', async () => {
}
});
// 侧边栏设置
const autoCollapseCheckbox = document.getElementById('auto-collapse-sidebar');
const saveSidebarBtn = document.getElementById('save-sidebar-settings');
// 恢复设置状态
if (autoCollapseCheckbox) {
autoCollapseCheckbox.checked = localStorage.getItem('sidebar-auto-collapse') !== 'false';
}
// 保存设置
saveSidebarBtn?.addEventListener('click', () => {
const enabled = autoCollapseCheckbox?.checked;
localStorage.setItem('sidebar-auto-collapse', enabled ? 'true' : 'false');
API.toast('设置已保存,刷新页面后生效');
});
// 水印开关
const watermarkToggle = document.getElementById('watermark-toggle');
watermarkToggle?.addEventListener('change', (e) => {

View File

@ -36,4 +36,42 @@
// 设置初始 title
toggleBtn.title = sidebar.classList.contains('collapsed') ? '展开菜单' : '收起菜单';
}
// 二级菜单点击时自动缩进侧边栏功能
// 检查是否启用了此功能(默认启用)
const autoCollapseOnSubmenu = localStorage.getItem('sidebar-auto-collapse') !== 'false';
if (autoCollapseOnSubmenu) {
const dropdownItems = document.querySelectorAll('.dropdown-item');
dropdownItems.forEach(item => {
item.addEventListener('click', () => {
// 延迟执行,确保路由跳转完成后再缩进
setTimeout(() => {
if (sidebar && !sidebar.classList.contains('collapsed')) {
sidebar.classList.add('collapsed');
localStorage.setItem('sidebar-collapsed', 'true');
if (toggleBtn) {
toggleBtn.title = '展开菜单';
}
// 触发窗口 resize 事件,让图表立即重绘
setTimeout(() => {
window.dispatchEvent(new Event('resize'));
}, 310);
}
}, 100);
});
});
}
// 添加键盘快捷键切换功能
document.addEventListener('keydown', (e) => {
// Ctrl + B 切换侧边栏
if (e.ctrlKey && e.key === 'b') {
e.preventDefault();
if (sidebar && toggleBtn) {
toggleBtn.click();
}
}
});
})();

View File

@ -75,7 +75,7 @@ const Upload = (() => {
<div> Excel文件必须包含以下列头按顺序</div>
<div style="margin-top:4px;padding:8px;background:var(--bg);border-radius:4px;font-family:monospace;font-size:12px">
拼多多/圆通<br>
第1列: MAC地址格式90A9F7300000<br>
第1列: MAC格式90A9F7300000<br>
第2列: 批次号<br><br>
兔喜<br>
第1列: SN_MAC格式TJ251639510533:90A9F73007D0<br>

View File

@ -348,6 +348,8 @@ def log(action, detail=''):
def notify_superadmin(action, detail=''):
"""为超级管理员创建通知"""
try:
user_role = session.get('role')
user_id = session.get('user_id')
if not user_id:
return
@ -5375,6 +5377,354 @@ def upload_shipment():
return jsonify({'error': f'解析发货单失败:{str(e)}'}), 500
@app.post('/api/reconciliations/refresh-prices')
@require_login
@require_any_role('admin', 'superadmin')
def refresh_reconciliation_prices():
"""从客户订单刷新对账单含税单价"""
try:
conn = get_db()
c = conn.cursor()
# 获取所有客户订单的单价信息
c.execute('SELECT order_no, material, unit_price FROM customer_orders')
customer_orders = c.fetchall()
# 构建订单单价字典
order_prices = {}
for order in customer_orders:
order_no = order['order_no']
if order_no not in order_prices:
order_prices[order_no] = {}
# 处理物料名称(支持换行符分割的多个物料)
materials = str(order['material']).split('\n')
for material in materials:
material = material.strip()
if material:
order_prices[order_no][material] = order['unit_price']
# 获取所有对账单记录
c.execute('SELECT id, contract_no, material_name, unit_price FROM reconciliations ORDER BY id ASC')
reconciliations = c.fetchall()
updated_count = 0
not_found_count = 0
changed_details = [] # 记录详细的变更信息
# 计算序号从1开始
for index, recon in enumerate(reconciliations, 1):
contract_no = recon['contract_no']
material_name = recon['material_name']
# 尝试从客户订单中查找匹配的单价
new_price = None
match_info = ""
# 策略1精确匹配合同号
if contract_no in order_prices:
# 策略1.1:精确匹配物料名
if material_name in order_prices[contract_no]:
new_price = order_prices[contract_no][material_name]
match_info = f"精确匹配(合同号:{contract_no}, 物料:{material_name})"
else:
# 策略1.2:部分匹配物料名(对账单物料名包含订单物料名)
for order_material, price in order_prices[contract_no].items():
if material_name in order_material:
new_price = price
match_info = f"部分匹配(合同号:{contract_no}, 订单物料:{order_material[:30]}...)"
break
# 策略1.3:反向部分匹配(订单物料名包含对账单物料名)
if new_price is None:
for order_material, price in order_prices[contract_no].items():
if order_material in material_name:
new_price = price
match_info = f"反向匹配(合同号:{contract_no}, 订单物料:{order_material[:30]}...)"
break
# 策略2如果精确匹配没找到尝试提取物料编码进行匹配
if new_price is None:
# 提取对账单物料的第一部分作为关键词
material_keywords = []
# 按空格、换行、横线分割
for part in material_name.replace('\n', ' ').replace('-', ' ').split():
if part.strip() and len(part.strip()) > 3: # 只取长度大于3的部分
material_keywords.append(part.strip())
# 尝试使用关键词匹配
if contract_no in order_prices:
for keyword in material_keywords:
for order_material, price in order_prices[contract_no].items():
if keyword in order_material:
new_price = price
match_info = f"关键词匹配(合同号:{contract_no}, 关键词:{keyword})"
break
if new_price is not None:
break
# 策略3模糊匹配合同号处理合同号略有差异的情况
if new_price is None:
for order_no, materials in order_prices.items():
# 策略3.1:合同号互相包含
if contract_no in order_no or order_no in contract_no:
# 先尝试精确匹配物料
if material_name in materials:
new_price = materials[material_name]
match_info = f"模糊合同号+精确物料(订单:{order_no})"
break
else:
# 尝试部分匹配物料
for order_material, price in materials.items():
if material_name in order_material or order_material in material_name:
new_price = price
match_info = f"模糊合同号+部分物料(订单:{order_no})"
break
if new_price is not None:
break
# 策略4特殊处理常见物料
if new_price is None:
# 处理飞机盒等特殊物料
if '飞机盒' in material_name:
new_price = 2 # 默认飞机盒单价
match_info = "默认单价(飞机盒)"
# 处理蓝牙模块
elif '蓝牙' in material_name or 'WD1MK0SMD' in material_name:
# 查找所有蓝牙模块的单价,取平均值
bluetooth_prices = []
for materials in order_prices.values():
for mat, price in materials.items():
if '蓝牙' in mat or 'WD1MK0SMD' in mat:
bluetooth_prices.append(price)
if bluetooth_prices:
new_price = sum(bluetooth_prices) / len(bluetooth_prices)
match_info = f"蓝牙模块平均单价({new_price:.2f})"
# 如果找到了新单价且与当前单价不同,则更新
if new_price is not None and abs(new_price - recon['unit_price']) > 0.01: # 避免浮点数精度问题
c.execute('UPDATE reconciliations SET unit_price=?, total_amount=quantity*unit_price, updated_at=? WHERE id=?',
(new_price, get_beijing_time(), recon['id']))
updated_count += 1
# 记录变更详情
change_detail = {
'index': index, # 使用表格序号
'id': recon['id'], # 保留ID供调试使用
'contract_no': contract_no,
'material_name': material_name[:30] + '...' if len(material_name) > 30 else material_name,
'old_price': recon['unit_price'],
'new_price': new_price,
'match_type': match_info
}
changed_details.append(change_detail)
print(f"更新: 序号={index}, ID={recon['id']}, 合同={contract_no}, 旧单价={recon['unit_price']}, 新单价={new_price}, 匹配方式: {match_info}")
elif new_price is not None and abs(new_price - recon['unit_price']) <= 0.01:
print(f"跳过: ID={recon['id']}, 单价相同({new_price})")
elif new_price is None:
not_found_count += 1
print(f"未匹配: ID={recon['id']}, 合同={contract_no}, 物料={material_name[:30]}...")
conn.commit()
conn.close()
log('refresh_reconciliation_prices', f'刷新单价成功,更新了 {updated_count} 条记录')
# 构建详细的变更信息
result = {
'ok': True,
'updated_count': updated_count,
'not_found_count': not_found_count,
'changed_details': changed_details, # 添加详细的变更列表
'message': f'成功更新 {updated_count} 条对账单的单价'
}
if not_found_count > 0:
result['message'] += f'{not_found_count} 条记录未找到对应的客户订单单价'
# 如果有更新的记录,添加详细信息到消息中
if changed_details:
result['message'] += '\n\n详细信息(序号为表格中的行号):'
for i, detail in enumerate(changed_details[:10]): # 只显示前10条
result['message'] += f'\n{detail["index"]}行: 合同={detail["contract_no"]}, 物料={detail["material_name"]}, 单价: {detail["old_price"]}{detail["new_price"]}'
if len(changed_details) > 10:
result['message'] += f'\n... 还有 {len(changed_details) - 10} 条记录已更新'
return jsonify(result)
except Exception as e:
log('refresh_reconciliation_prices_error', str(e))
return jsonify({'error': f'刷新单价失败:{str(e)}'}), 500
@app.post('/api/reconciliations/fetch-price')
@require_login
@require_any_role('admin', 'superadmin')
def fetch_price_from_orders():
"""根据合同号和物料名称从客户订单获取单价"""
try:
contract_no = request.json.get('contract_no', '').strip()
material_name = request.json.get('material_name', '').strip()
if not contract_no or not material_name:
return jsonify({'error': '请提供合同号和物料名称'}), 400
conn = get_db()
c = conn.cursor()
# 查找匹配的客户订单单价
c.execute('SELECT order_no, material, unit_price FROM customer_orders')
customer_orders = c.fetchall()
# 构建订单单价字典
order_prices = {}
for order in customer_orders:
order_no = order['order_no']
if order_no not in order_prices:
order_prices[order_no] = {}
# 处理物料名称(支持换行符分割的多个物料)
materials = str(order['material']).split('\n')
for material in materials:
material = material.strip()
if material:
order_prices[order_no][material] = order['unit_price']
# 查找匹配的单价
found_price = None
match_info = ""
# 策略1精确匹配合同号
if contract_no in order_prices:
# 策略1.1:精确匹配物料名
if material_name in order_prices[contract_no]:
found_price = order_prices[contract_no][material_name]
match_info = f"精确匹配(合同号:{contract_no}, 物料:{material_name})"
else:
# 策略1.2:部分匹配物料名
for order_material, price in order_prices[contract_no].items():
if material_name in order_material:
found_price = price
match_info = f"部分匹配(订单物料:{order_material[:30]}...)"
break
# 策略1.3:反向部分匹配
if found_price is None:
for order_material, price in order_prices[contract_no].items():
if order_material in material_name:
found_price = price
match_info = f"反向匹配(订单物料:{order_material[:30]}...)"
break
# 策略2关键词匹配
if found_price is None:
material_keywords = []
for part in material_name.replace('\n', ' ').replace('-', ' ').split():
if part.strip() and len(part.strip()) > 3:
material_keywords.append(part.strip())
if contract_no in order_prices:
for keyword in material_keywords:
for order_material, price in order_prices[contract_no].items():
if keyword in order_material:
found_price = price
match_info = f"关键词匹配({keyword})"
break
if found_price is not None:
break
# 策略3模糊匹配合同号
if found_price is None:
for order_no, materials in order_prices.items():
if contract_no in order_no or order_no in contract_no:
if material_name in materials:
found_price = materials[material_name]
match_info = f"模糊合同号匹配({order_no})"
break
else:
for order_material, price in materials.items():
if material_name in order_material or order_material in material_name:
found_price = price
match_info = f"模糊匹配(订单:{order_no})"
break
if found_price is not None:
break
# 策略4特殊物料处理
if found_price is None:
if '飞机盒' in material_name:
found_price = 2
match_info = "默认单价(飞机盒)"
elif '蓝牙' in material_name or 'WD1MK0SMD' in material_name:
bluetooth_prices = []
for materials in order_prices.values():
for mat, price in materials.items():
if '蓝牙' in mat or 'WD1MK0SMD' in mat:
bluetooth_prices.append(price)
if bluetooth_prices:
found_price = sum(bluetooth_prices) / len(bluetooth_prices)
match_info = f"蓝牙模块平均单价({found_price:.2f})"
conn.close()
if found_price is not None:
return jsonify({
'ok': True,
'unit_price': found_price,
'message': f'已从客户订单获取单价:{found_price}'
})
else:
return jsonify({
'ok': False,
'error': '未找到匹配的客户订单单价'
}), 404
except Exception as e:
log('fetch_price_error', str(e))
return jsonify({'error': f'获取单价失败:{str(e)}'}), 500
@app.post('/api/reconciliations/fix-total-amount')
@require_login
@require_any_role('admin', 'superadmin')
def fix_total_amount():
"""修复所有对账单的含税金额(根据数量和单价重新计算)"""
try:
conn = get_db()
c = conn.cursor()
# 获取所有记录
c.execute('SELECT id, quantity, unit_price, total_amount FROM reconciliations')
records = c.fetchall()
fixed_count = 0
for record in records:
recon_id, quantity, unit_price, current_total = record
correct_total = quantity * unit_price
# 如果含税金额不正确(考虑浮点数精度)
if abs(current_total - correct_total) > 0.01:
c.execute('UPDATE reconciliations SET total_amount=?, updated_at=? WHERE id=?',
(correct_total, get_beijing_time(), recon_id))
fixed_count += 1
print(f"修复: ID={recon_id}, 数量={quantity}, 单价={unit_price}, 旧金额={current_total}, 新金额={correct_total}")
conn.commit()
conn.close()
log('fix_total_amount', f'修复含税金额成功,修复了 {fixed_count} 条记录')
return jsonify({
'ok': True,
'fixed_count': fixed_count,
'message': f'成功修复 {fixed_count} 条记录的含税金额'
})
except Exception as e:
log('fix_total_amount_error', str(e))
return jsonify({'error': f'修复含税金额失败:{str(e)}'}), 500
# ==================== BOM物料清单 API ====================
@app.get('/api/bom')

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 KiB

60
test_fix_total_amount.py Normal file
View File

@ -0,0 +1,60 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
测试修复含税金额功能
"""
import requests
import json
def test_fix_total_amount():
"""测试修复含税金额API"""
# API地址
url = "http://localhost:5000/api/reconciliations/fix-total-amount"
# 模拟登录需要先获取session
login_url = "http://localhost:5000/api/login"
try:
# 创建会话
session = requests.Session()
# 登录
login_data = {
"username": "admin",
"password": "admin"
}
print("正在登录...")
login_response = session.post(login_url, json=login_data)
if login_response.status_code == 200:
print("登录成功!")
# 调用修复含税金额API
print("\n正在调用修复含税金额API...")
response = session.post(url)
if response.status_code == 200:
result = response.json()
print(f"\n修复结果:")
print(f"- 状态: {'成功' if result.get('ok') else '失败'}")
print(f"- 修复记录数: {result.get('fixed_count', 0)}")
print(f"- 消息: {result.get('message', '')}")
else:
print(f"API调用失败状态码: {response.status_code}")
print(f"错误信息: {response.text}")
else:
print(f"登录失败,状态码: {login_response.status_code}")
print(f"错误信息: {login_response.text}")
except requests.exceptions.ConnectionError:
print("\n错误:无法连接到服务器")
print("请确保生产管理系统正在运行systemctl start prod-mgmt")
except Exception as e:
print(f"\n发生错误: {str(e)}")
if __name__ == '__main__':
print("=== 测试修复含税金额功能 ===\n")
test_fix_total_amount()

148
test_price_matching.py Normal file
View File

@ -0,0 +1,148 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
测试对账单单价匹配功能 - 详细版本
"""
import sqlite3
import sys
import os
def test_price_matching():
"""测试价格匹配逻辑"""
# 连接数据库
conn = sqlite3.connect('server/data.db')
c = conn.cursor()
print("=== 对账单单价匹配测试 ===\n")
# 获取所有客户订单
print("1. 获取所有客户订单数据...")
c.execute('SELECT order_no, material, unit_price FROM customer_orders')
customer_orders = c.fetchall()
# 构建订单单价字典
order_prices = {}
for order in customer_orders:
order_no = order[0]
if order_no not in order_prices:
order_prices[order_no] = {}
# 处理物料名称(支持换行符分割的多个物料)
materials = str(order[1]).split('\n')
for material in materials:
material = material.strip()
if material:
order_prices[order_no][material] = order[2]
print(f" 共找到 {len(order_prices)} 个不同的合同号")
# 显示每个合同的物料列表
print("\n2. 客户订单中的合同号及物料:")
for order_no, materials in sorted(order_prices.items()):
print(f"\n 合同号: {order_no}")
for material, price in sorted(materials.items()):
print(f" - 物料: {material[:50]}... , 单价: {price}")
# 获取所有对账单
print("\n\n3. 检查对账单匹配情况...")
c.execute('SELECT id, contract_no, material_name, unit_price FROM reconciliations ORDER BY contract_no')
reconciliations = c.fetchall()
# 统计匹配情况
matched = 0
not_matched = []
for recon in reconciliations:
recon_id, contract_no, material_name, current_price = recon
new_price = None
match_type = ""
# 精确匹配合同号
if contract_no in order_prices:
# 尝试精确匹配物料名
if material_name in order_prices[contract_no]:
new_price = order_prices[contract_no][material_name]
match_type = "精确匹配"
else:
# 尝试部分匹配(物料名包含关系)
for order_material, price in order_prices[contract_no].items():
if material_name in order_material or order_material in material_name:
new_price = price
match_type = f"部分匹配(订单物料: {order_material[:30]}...)"
break
# 如果精确匹配没找到,尝试模糊匹配合同号
if new_price is None:
for order_no, materials in order_prices.items():
if contract_no in order_no or order_no in contract_no:
if material_name in materials:
new_price = materials[material_name]
match_type = f"模糊匹配合同号({order_no})"
break
else:
# 尝试部分匹配物料名
for order_material, price in materials.items():
if material_name in order_material or order_material in material_name:
new_price = price
match_type = f"模糊匹配(订单: {order_no}, 物料: {order_material[:30]}...)"
break
if new_price is not None:
break
if new_price is not None:
matched += 1
print(f" ✓ ID={recon_id}, 合同={contract_no}, 匹配类型: {match_type}")
else:
not_matched.append((recon_id, contract_no, material_name))
print(f"\n\n4. 匹配结果统计:")
print(f" 总对账单记录数: {len(reconciliations)}")
print(f" 成功匹配: {matched}")
print(f" 未匹配: {len(not_matched)}")
if not_matched:
print(f"\n5. 未匹配的记录前10条")
for recon_id, contract_no, material_name in not_matched[:10]:
print(f" ✗ ID={recon_id}, 合同号={contract_no}, 物料={material_name[:50]}...")
# 检查是否有相似的合同号
similar_orders = [o for o in order_prices.keys() if contract_no[:10] in o or o[:10] in contract_no]
if similar_orders:
print(f" → 相似合同号: {', '.join(similar_orders)}")
# 测试特定匹配案例
print(f"\n6. 测试特定匹配案例:")
test_cases = [
("CGDD002878", "AP-DZ006灯条基站"),
("CGDD002878", "WD1MK0SMD0551"),
("CGDD001850", "AP-DZ009灯条基站"),
]
for contract_no, material_name in test_cases:
print(f"\n 测试: 合同号={contract_no}, 物料={material_name}")
if contract_no in order_prices:
print(f" 找到合同号,包含 {len(order_prices[contract_no])} 个物料")
for mat, price in order_prices[contract_no].items():
if material_name in mat or mat in material_name:
print(f" ✓ 匹配成功: {mat[:50]}... -> {price}")
break
else:
print(f" ✗ 未找到匹配的物料")
else:
print(f" ✗ 未找到合同号")
# 查找相似合同号
similar = [o for o in order_prices.keys() if contract_no in o or o in contract_no]
if similar:
print(f" → 相似合同号: {similar}")
conn.close()
print("\n=== 优化建议 ===")
print("1. 确保对账单的合同号与客户订单的订单号完全一致")
print("2. 物料名称应该标准化,避免使用简写或别名")
print("3. 可以在上传发货单时自动匹配客户订单单价")
print("4. 对于无法匹配的记录,可以手动设置默认单价")
if __name__ == '__main__':
test_price_matching()

116
test_price_refresh.py Normal file
View File

@ -0,0 +1,116 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
测试对账单单价刷新功能
"""
import sqlite3
import sys
import os
# 添加项目路径
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
def test_price_refresh():
"""测试价格刷新功能"""
# 连接数据库
conn = sqlite3.connect('server/data.db')
c = conn.cursor()
print("=== 测试对账单单价刷新功能 ===\n")
# 1. 检查数据库结构
print("1. 检查数据库表结构...")
c.execute("PRAGMA table_info(customer_orders)")
customer_orders_columns = c.fetchall()
print(f" customer_orders 表字段: {[col[1] for col in customer_orders_columns]}")
c.execute("PRAGMA table_info(reconciliations)")
reconciliations_columns = c.fetchall()
print(f" reconciliations 表字段: {[col[1] for col in reconciliations_columns]}")
# 2. 查看现有数据
print("\n2. 查看现有数据...")
c.execute("SELECT COUNT(*) FROM customer_orders")
customer_orders_count = c.fetchone()[0]
print(f" 客户订单记录数: {customer_orders_count}")
c.execute("SELECT COUNT(*) FROM reconciliations")
reconciliations_count = c.fetchone()[0]
print(f" 对账单记录数: {reconciliations_count}")
# 3. 显示部分示例数据
print("\n3. 客户订单示例数据:")
c.execute("SELECT order_no, material, unit_price FROM customer_orders LIMIT 5")
for row in c.fetchall():
print(f" 订单号: {row[0]}, 物料: {row[1][:30]}..., 单价: {row[2]}")
print("\n4. 对账单示例数据:")
c.execute("SELECT contract_no, material_name, unit_price FROM reconciliations LIMIT 5")
for row in c.fetchall():
print(f" 合同号: {row[0]}, 物料: {row[1][:30]}..., 单价: {row[2]}")
# 4. 测试价格匹配逻辑
print("\n5. 测试价格匹配逻辑...")
# 获取所有客户订单
c.execute('SELECT order_no, material, unit_price FROM customer_orders')
customer_orders = c.fetchall()
# 构建订单单价字典
order_prices = {}
for order in customer_orders:
order_no = order[0]
if order_no not in order_prices:
order_prices[order_no] = {}
# 处理物料名称(支持换行符分割的多个物料)
materials = str(order[1]).split('\n')
for material in materials:
material = material.strip()
if material:
order_prices[order_no][material] = order[2]
# 测试对账单匹配
c.execute('SELECT id, contract_no, material_name, unit_price FROM reconciliations LIMIT 10')
test_reconciliations = c.fetchall()
matched_count = 0
for recon in test_reconciliations:
recon_id, contract_no, material_name, current_price = recon
new_price = None
# 精确匹配合同号
if contract_no in order_prices:
# 尝试精确匹配物料名
if material_name in order_prices[contract_no]:
new_price = order_prices[contract_no][material_name]
else:
# 尝试部分匹配
for order_material, price in order_prices[contract_no].items():
if material_name in order_material or order_material in material_name:
new_price = price
break
if new_price is not None:
print(f" ✓ 匹配成功: 对账单ID={recon_id}, 合同号={contract_no}, 当前单价={current_price}, 匹配单价={new_price}")
matched_count += 1
else:
print(f" ✗ 未匹配: 对账单ID={recon_id}, 合同号={contract_no}, 物料={material_name[:30]}...")
print(f"\n 测试结果: {matched_count}/{len(test_reconciliations)} 条记录成功匹配")
conn.close()
print("\n=== 测试完成 ===")
print("\n功能说明:")
print("1. 已添加 /api/reconciliations/refresh-prices 接口,用于批量刷新所有对账单单价")
print("2. 已添加 /api/reconciliations/fetch-price 接口,用于根据合同号和物料名获取单个单价")
print("3. 前端已添加'刷新单价'按钮,支持手动批量刷新")
print("4. 新增/编辑对账单时,可通过'获取单价'按钮从客户订单获取最新单价")
print("\n使用方法:")
print("- 批量刷新: 在对账单管理页面点击'刷新单价'按钮")
print("- 单个获取: 在新增/编辑对账单时,填写合同号和物料名称后点击'获取单价'")
if __name__ == '__main__':
test_price_refresh()

120
test_toast.html Normal file
View File

@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Toast 测试</title>
<style>
body {
padding: 20px;
font-family: Arial, sans-serif;
}
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
background: var(--surface-2);
color: var(--text);
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border);
opacity: 0;
pointer-events: none;
transition: opacity .2s ease;
}
.toast.show {
opacity: 1;
}
button {
margin: 10px;
padding: 10px 20px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>Toast 测试页面</h1>
<button onclick="testShort()">短提示 (2秒)</button>
<button onclick="testMedium()">中等提示 (5秒)</button>
<button onclick="testLong()">长提示 (10秒)</button>
<button onclick="testExtraLong()">超长提示 (15秒)</button>
<button onclick="testMultiLine()">多行提示</button>
<div id="toast" class="toast"></div>
<script>
// 存储当前的toast定时器
let toastTimer = null;
function toast(msg, type = 'info', duration = 2000) {
const t = document.getElementById('toast');
// 清除之前的定时器
if (toastTimer) {
clearTimeout(toastTimer);
toastTimer = null;
}
t.textContent = msg;
// 添加类型样式
if (type === 'success') {
t.style.backgroundColor = '#10b981';
t.style.color = 'white';
} else if (type === 'error') {
t.style.backgroundColor = '#ef4444';
t.style.color = 'white';
} else if (type === 'warning') {
t.style.backgroundColor = '#f59e0b';
t.style.color = 'white';
} else {
t.style.backgroundColor = '#3b82f6';
t.style.color = 'white';
}
// 支持多行文本
t.style.whiteSpace = 'pre-line';
// 添加show类来显示toast
t.classList.add('show');
// 调试信息
console.log(`Toast显示: 类型=${type}, 持续时间=${duration}ms, 消息长度=${msg.length}`);
console.log('当前时间:', new Date().toLocaleTimeString());
toastTimer = setTimeout(() => {
t.classList.remove('show');
// 重置样式
t.style.backgroundColor = '';
t.style.color = '';
t.style.whiteSpace = '';
toastTimer = null;
console.log('Toast已隐藏');
console.log('隐藏时间:', new Date().toLocaleTimeString());
}, duration);
}
function testShort() {
toast('这是一个2秒的短提示', 'info', 2000);
}
function testMedium() {
toast('这是一个5秒的中等提示', 'success', 5000);
}
function testLong() {
toast('这是一个10秒的长提示\n可以显示多行文本', 'warning', 10000);
}
function testExtraLong() {
toast('这是一个15秒的超长提示\n用于测试刷新单价功能\n包含详细信息\n1. 第一条信息\n2. 第二条信息\n3. 第三条信息', 'success', 15000);
}
function testMultiLine() {
toast('成功更新 5 条对账单的单价\n\n详细信息\n1. ID=123, 合同=CGDD002878, 物料=AP-DZ006灯条基站, 单价: 200 → 239.2\n2. ID=124, 合同=CGDD002878, 物料=WD1MK0SMD0551蓝牙模块, 单价: 1.0 → 1.1\n3. ID=125, 合同=CGDD002879, 物料=AP-DZ009灯条基站, 单价: 220 → 229.61', 'success', 15000);
}
</script>
</body>
</html>

View File

@ -0,0 +1,83 @@
# 对账单单价刷新功能说明
## 功能概述
对账单管理系统的单价刷新功能可以自动从客户订单中获取最新的含税单价,支持批量刷新和单个获取。系统会根据合同编号和物料名称智能匹配客户订单中的单价。
## 使用方法
### 1. 批量刷新所有对账单单价
- 在对账单管理页面,点击"刷新单价"按钮
- 系统会自动匹配所有对账单记录并更新单价
- 更新完成后会显示成功更新的记录数量
### 2. 新增/编辑时获取单个单价
- 在新增或编辑对账单时,填写合同编号和物料名称
- 点击"获取单价"按钮,系统会自动匹配并填充单价
- 如果找到匹配的单价,会自动计算含税金额
## 匹配策略
系统采用多级匹配策略,按优先级顺序进行匹配:
### 策略1精确匹配合同号
1. **精确匹配物料名**:合同号和物料名称完全一致
2. **部分匹配物料名**:对账单物料名包含客户订单物料名
3. **反向部分匹配**:客户订单物料名包含对账单物料名
### 策略2关键词匹配
- 提取物料名称中的关键词长度大于3的字符
- 使用关键词在相同合同号下进行匹配
### 策略3模糊匹配合同号
- 当合同号不完全一致时,检查是否包含关系
- 适用于合同号略有差异的情况
### 策略4特殊物料处理
- **飞机盒**:默认单价 2 元
- **蓝牙模块**:取所有蓝牙模块的平均单价
## 注意事项
1. **数据一致性**:确保客户订单和对账单的合同编号使用统一的格式
2. **物料命名规范**:建议使用标准化的物料名称,避免使用简写或别名
3. **匹配精度**:系统会跳过单价相同的记录,只更新有变化的记录
4. **日志记录**:所有单价更新操作都会记录在系统日志中
## 常见问题
### Q: 为什么某些记录无法匹配到单价?
A: 可能的原因:
- 客户订单中没有对应的合同编号
- 物料名称差异较大
- 合同编号格式不一致
### Q: 如何提高匹配成功率?
A: 建议:
- 统一合同编号格式
- 标准化物料名称
- 定期更新客户订单数据
### Q: 匹配错误怎么办?
A: 可以手动编辑对账单记录,修正单价。系统不会覆盖已经手动修改的单价(除非再次刷新)。
## API接口
### 批量刷新单价
```
POST /api/reconciliations/refresh-prices
```
### 获取单个单价
```
POST /api/reconciliations/fetch-price
参数:
- contract_no: 合同编号
- material_name: 物料名称
```
## 更新日志
- 2025-01-27实现基础价格刷新功能
- 2025-01-27优化匹配策略提高匹配成功率
- 2025-01-27添加特殊物料处理逻辑