新增物料管理界面功能

This commit is contained in:
zzh 2025-11-24 09:30:08 +08:00
parent c4455def3f
commit 61721d7102
6 changed files with 958 additions and 20 deletions

View File

@ -684,3 +684,397 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover{
/* 确认按钮样式 */
.btn-icon.btn-confirm{color:#fb923c}
.btn-icon.btn-confirm:hover{background:rgba(251,146,60,.15);color:#fb923c}
/* 物料清单-采购页面样式 */
.page-container {
max-width: 100%;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.page-header h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: var(--text);
}
.page-actions {
display: flex;
gap: 12px;
}
.filter-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
margin-bottom: 20px;
}
.filter-row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.input-field {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
min-width: 200px;
}
.input-field:focus {
outline: 2px solid var(--primary);
outline-offset: 0;
}
[data-theme="light"] .input-field {
background: #ffffff;
}
.table-container {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow-x: auto;
margin-bottom: 20px;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.data-table thead {
background: var(--surface-2);
position: sticky;
top: 0;
z-index: 10;
}
.data-table th {
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: var(--text);
border-bottom: 2px solid var(--border);
white-space: nowrap;
}
.data-table td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
color: var(--text);
white-space: nowrap;
}
.data-table tbody tr:hover {
background: rgba(79, 140, 255, 0.08);
}
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.badge-success {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.badge-warning {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
}
.badge-danger {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.badge-info {
background: rgba(79, 140, 255, 0.15);
color: #4f8cff;
}
.badge-default {
background: rgba(107, 114, 128, 0.15);
color: var(--text-2);
}
.btn-icon {
background: transparent;
border: none;
cursor: pointer;
font-size: 16px;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s;
}
.btn-icon:hover {
background: rgba(79, 140, 255, 0.12);
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
padding: 16px;
}
#page-info {
color: var(--text-2);
font-size: 14px;
}
/* 模态框样式 */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1000;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal-content {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
width: 100%;
max-width: 600px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text);
}
.modal-close {
background: transparent;
border: none;
font-size: 28px;
color: var(--text-2);
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s;
}
.modal-close:hover {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.modal-body {
padding: 24px;
max-height: 60vh;
overflow-y: auto;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid var(--border);
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 13px;
font-weight: 500;
color: var(--text-2);
}
.required {
color: #ef4444;
}
.btn-text {
background: transparent;
border: none;
color: var(--primary);
cursor: pointer;
padding: 6px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
transition: background 0.2s;
}
.btn-text:hover {
background: rgba(79, 140, 255, 0.12);
}
/* 响应式调整 */
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
.filter-row {
flex-direction: column;
align-items: stretch;
}
.input-field {
min-width: 100%;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
/* 导出字段选择样式 */
.export-fields-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
max-height: 50vh;
overflow-y: auto;
padding: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
}
.export-field-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.export-field-item:hover {
background: var(--surface-2);
border-color: var(--primary);
}
.export-field-item input[type="checkbox"] {
cursor: pointer;
width: 16px;
height: 16px;
accent-color: var(--primary);
}
.export-field-item span {
font-size: 13px;
color: var(--text);
flex: 1;
}
.export-field-item:has(input:checked) {
background: rgba(79, 140, 255, 0.1);
border-color: var(--primary);
}
@media (max-width: 768px) {
.export-fields-grid {
grid-template-columns: 1fr;
}
}
/* 选择信息样式 */
.selection-info {
display: inline-block;
margin-left: 16px;
padding: 4px 12px;
background: rgba(79, 140, 255, 0.15);
color: var(--primary);
border-radius: 12px;
font-size: 13px;
font-weight: 500;
}
/* 表格复选框样式 */
.data-table input[type="checkbox"] {
cursor: pointer;
width: 16px;
height: 16px;
accent-color: var(--primary);
}
/* 全选复选框的半选状态 */
#select-all-checkbox:indeterminate {
opacity: 0.6;
}
/* 批量删除按钮样式(仅用于实心按钮) */
.btn.btn-danger {
background: #ef4444;
color: #ffffff;
}
.btn.btn-danger:hover {
background: #dc2626;
}
/* 页面头部调整 */
.page-header > div:first-child {
display: flex;
align-items: center;
gap: 12px;
}

View File

@ -90,6 +90,22 @@
</div>
</div>
</div>
<div class="nav-group">
<div class="nav-group-title">计划管理</div>
<div class="nav-item has-children" data-expand="plan-mgmt">
<button class="nav-item-btn">
<span class="icon">📅</span>
<span class="text">计划管理</span>
<span class="caret"></span>
</button>
<div class="nav-children" data-parent="plan-mgmt">
<a href="#/plan-mgmt/material-purchase" class="nav-child" data-route="plan-mgmt-material-purchase">
<span class="child-icon">🛒</span>
<span>物料清单-采购</span>
</a>
</div>
</div>
</div>
<div class="nav-group">
<div class="nav-group-title">扩展采集</div>
<div class="nav-item has-children" data-expand="collect">
@ -193,7 +209,6 @@
<script src="./js/utils/memory-monitor.js"></script>
<script src="./js/components/sidebar.js"></script>
<script src="./js/components/dashboard.js"></script>
<script src="./js/components/login.js"></script>
<script src="./js/components/upload.js"></script>
<script src="./js/components/shipments.js"></script>
<script src="./js/components/shipment-query.js"></script>
@ -205,6 +220,7 @@
<script src="./js/components/qa.js"></script>
<script src="./js/components/production.js"></script>
<script src="./js/components/work-order.js"></script>
<script src="./js/components/material-purchase.js"></script>
<script src="./js/components/export.js"></script>
<script src="./js/components/settings.js"></script>
<script src="./js/components/notifications.js"></script>

View File

@ -79,9 +79,10 @@
document.getElementById('overlay').classList.remove('hidden');
try {
currentUser = await API.me().catch(() => null);
const publicRoutes = ['/login'];
if ((!currentUser || !currentUser.username) && !publicRoutes.includes(path)) {
location.hash = '#/login';
if (!currentUser || !currentUser.username) {
// 未登录,重定向到独立登录页面
window.location.href = './login.html';
return;
}
} catch(e) {}
});
@ -106,7 +107,10 @@
if (user && (user.role === 'superadmin' || user.role === 'admin') && window.NotificationSystem) {
window.NotificationSystem.init();
}
}).catch(()=>{});
}).catch(() => {
// 未登录,重定向到独立登录页面
window.location.href = './login.html';
});
// 用户头像按钮点击事件 - 显示/隐藏下拉菜单
const userAvatarBtn = document.getElementById('user-avatar-btn');
@ -139,7 +143,8 @@
removeWatermark();
updateUserDisplay(null);
userDropdown.style.display = 'none';
location.hash = '#/login';
// 重定向到独立登录页面
window.location.href = './login.html';
})
.catch(() => {});
});

View File

@ -33,18 +33,7 @@ const Router = (() => {
updateBreadcrumb(path);
});
// 检查用户登录状态,决定默认路径
let defaultPath = '/dashboard';
try {
const user = await API.me().catch(() => null);
if (!user || !user.username) {
defaultPath = '/login';
}
} catch(e) {
defaultPath = '/login';
}
const path = location.hash.replace('#', '') || defaultPath;
const path = location.hash.replace('#', '') || '/dashboard';
navigate(path);
highlightActive(path);
updateBreadcrumb(path);
@ -94,6 +83,8 @@ const Router = (() => {
production: '时间记录',
'production-mgmt': '生产管理',
'work-order': '生产工单下发中心',
'plan-mgmt': '计划管理',
'material-purchase': '物料清单-采购',
export: '导出',
settings: '设置'
};

View File

@ -157,6 +157,33 @@ def init_db():
created_at TEXT,
updated_at TEXT
)''')
c.execute('''CREATE TABLE IF NOT EXISTS material_purchase(
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
list_no TEXT NOT NULL,
plan_no TEXT NOT NULL,
bom_result TEXT,
status TEXT NOT NULL,
demand_status TEXT NOT NULL,
complete_rate REAL,
material_code TEXT NOT NULL,
material_name TEXT NOT NULL,
batch_no TEXT,
level INTEGER,
required_qty INTEGER NOT NULL,
stock_qty INTEGER,
shortage INTEGER,
acquire_method TEXT,
realtime_stock INTEGER,
pending_qty INTEGER,
dispatched_qty INTEGER,
received_qty INTEGER,
submitter TEXT,
submit_time TEXT,
update_time TEXT,
deleted INTEGER DEFAULT 0,
deleted_at TEXT
)''')
# 为已存在的表添加列(如果不存在)
try:
c.execute('ALTER TABLE work_orders ADD COLUMN product_model TEXT')
@ -412,19 +439,102 @@ def index():
return send_from_directory(FRONTEND_DIR, 'index.html')
@app.route('/index.html')
def index_html():
return send_from_directory(FRONTEND_DIR, 'index.html')
# auth
@app.get('/api/auth/captcha')
def captcha():
"""生成验证码图片"""
try:
from PIL import Image, ImageDraw, ImageFont
import random
import io
import base64
# 生成4位随机数字
code = ''.join([str(random.randint(0, 9)) for _ in range(4)])
# 将验证码存储到session
session['captcha'] = code
# 创建图片
width, height = 120, 40
image = Image.new('RGB', (width, height), color='#f0f4f8')
draw = ImageDraw.Draw(image)
# 尝试使用系统字体,如果失败则使用默认字体
try:
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 28)
except:
try:
font = ImageFont.truetype('/System/Library/Fonts/Helvetica.ttc', 28)
except:
font = ImageFont.load_default()
# 绘制干扰线
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')
# 转换为base64
buffer = io.BytesIO()
image.save(buffer, format='PNG')
buffer.seek(0)
img_base64 = base64.b64encode(buffer.getvalue()).decode()
return jsonify({'image': f'data:image/png;base64,{img_base64}'})
except Exception as e:
log('captcha_error', str(e))
# 如果生成失败,返回简单的验证码
code = ''.join([str(random.randint(0, 9)) for _ in range(4)])
session['captcha'] = code
return jsonify({'image': '', 'code': code})
@app.post('/api/auth/login')
def login():
data = request.get_json() or {}
username = data.get('username')
password = data.get('password')
captcha = data.get('captcha')
# 验证验证码
session_captcha = session.get('captcha', '').lower()
if not captcha or captcha.lower() != session_captcha:
# 清除验证码,防止重复使用
session.pop('captcha', None)
return jsonify({'error': '验证码错误'}), 400
# 清除验证码,防止重复使用
session.pop('captcha', None)
conn = get_db()
c = conn.cursor()
c.execute('SELECT id, password_hash, role FROM users WHERE username=?', (username,))
row = c.fetchone()
conn.close()
if not row or not check_password_hash(row['password_hash'], password or ''):
return jsonify({'error': 'invalid credentials'}), 400
return jsonify({'error': '用户名或密码错误'}), 400
session['user_id'] = row['id']
session['role'] = row['role']
session['username'] = username
@ -2822,6 +2932,429 @@ def confirm_work_order(order_id):
return jsonify({'ok': True, 'message': '工单确认成功'})
# ==================== 物料清单-采购 API ====================
def convert_material_purchase_to_camel(row):
"""将数据库行转换为驼峰命名的字典"""
return {
'id': row['id'],
'title': row['title'],
'listNo': row['list_no'],
'planNo': row['plan_no'],
'bomResult': row['bom_result'],
'status': row['status'],
'demandStatus': row['demand_status'],
'completeRate': row['complete_rate'],
'materialCode': row['material_code'],
'materialName': row['material_name'],
'batchNo': row['batch_no'],
'level': row['level'],
'requiredQty': row['required_qty'],
'stockQty': row['stock_qty'],
'shortage': row['shortage'],
'acquireMethod': row['acquire_method'],
'realtimeStock': row['realtime_stock'],
'pendingQty': row['pending_qty'],
'dispatchedQty': row['dispatched_qty'],
'receivedQty': row['received_qty'],
'submitter': row['submitter'],
'submitTime': row['submit_time'],
'updateTime': row['update_time'],
'deleted': row['deleted'],
'deletedAt': row['deleted_at']
}
@app.get('/api/material-purchase/list')
@require_login
def list_material_purchase():
"""获取物料清单列表(不包括已删除的)"""
conn = get_db()
c = conn.cursor()
c.execute('''SELECT * FROM material_purchase
WHERE deleted=0
ORDER BY submit_time DESC''')
rows = c.fetchall()
conn.close()
return jsonify({'list': [convert_material_purchase_to_camel(r) for r in rows]})
@app.get('/api/material-purchase/recycle-bin')
@require_login
def list_material_purchase_recycle_bin():
"""获取回收站列表"""
conn = get_db()
c = conn.cursor()
c.execute('''SELECT * FROM material_purchase
WHERE deleted=1
ORDER BY deleted_at DESC''')
rows = c.fetchall()
conn.close()
return jsonify({'list': [convert_material_purchase_to_camel(r) for r in rows]})
@app.post('/api/material-purchase/add')
@require_login
@require_any_role('admin', 'superadmin')
def add_material_purchase():
"""新增物料需求"""
data = request.get_json() or {}
required_fields = ['title', 'list_no', 'plan_no', 'status', 'demand_status',
'material_code', 'material_name', 'required_qty']
for field in required_fields:
if not data.get(field):
return jsonify({'error': f'缺少必填字段: {field}'}), 400
conn = get_db()
c = conn.cursor()
now = get_beijing_time()
username = session.get('username', '')
c.execute('''INSERT INTO material_purchase(
title, list_no, plan_no, bom_result, status, demand_status,
complete_rate, material_code, material_name, batch_no, level,
required_qty, stock_qty, shortage, acquire_method, realtime_stock,
pending_qty, dispatched_qty, received_qty, submitter, submit_time, update_time
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', (
data.get('title'), data.get('list_no'), data.get('plan_no'),
data.get('bom_result'), data.get('status'), data.get('demand_status'),
data.get('complete_rate'), data.get('material_code'), data.get('material_name'),
data.get('batch_no'), data.get('level'), data.get('required_qty'),
data.get('stock_qty'), data.get('shortage'), data.get('acquire_method'),
data.get('realtime_stock'), data.get('pending_qty'), data.get('dispatched_qty'),
data.get('received_qty'), username, now, now
))
conn.commit()
item_id = c.lastrowid
conn.close()
log('add_material_purchase', f'物料编码: {data.get("material_code")}')
return jsonify({'ok': True, 'id': item_id})
@app.post('/api/material-purchase/update')
@require_login
@require_any_role('admin', 'superadmin')
def update_material_purchase():
"""更新物料需求"""
data = request.get_json() or {}
item_id = data.get('id')
if not item_id:
return jsonify({'error': '缺少ID'}), 400
conn = get_db()
c = conn.cursor()
now = get_beijing_time()
c.execute('''UPDATE material_purchase SET
title=?, list_no=?, plan_no=?, bom_result=?, status=?, demand_status=?,
complete_rate=?, material_code=?, material_name=?, batch_no=?, level=?,
required_qty=?, stock_qty=?, shortage=?, acquire_method=?, realtime_stock=?,
pending_qty=?, dispatched_qty=?, received_qty=?, update_time=?
WHERE id=?''', (
data.get('title'), data.get('list_no'), data.get('plan_no'),
data.get('bom_result'), data.get('status'), data.get('demand_status'),
data.get('complete_rate'), data.get('material_code'), data.get('material_name'),
data.get('batch_no'), data.get('level'), data.get('required_qty'),
data.get('stock_qty'), data.get('shortage'), data.get('acquire_method'),
data.get('realtime_stock'), data.get('pending_qty'), data.get('dispatched_qty'),
data.get('received_qty'), now, item_id
))
conn.commit()
conn.close()
log('update_material_purchase', f'ID: {item_id}')
return jsonify({'ok': True})
@app.post('/api/material-purchase/delete')
@require_login
@require_any_role('admin', 'superadmin')
def delete_material_purchase():
"""删除物料需求(移到回收站)"""
data = request.get_json() or {}
item_ids = data.get('ids', [])
if not item_ids:
return jsonify({'error': '缺少ID'}), 400
conn = get_db()
c = conn.cursor()
now = get_beijing_time()
placeholders = ','.join('?' * len(item_ids))
c.execute(f'''UPDATE material_purchase SET
deleted=1, deleted_at=?
WHERE id IN ({placeholders})''', [now] + item_ids)
conn.commit()
conn.close()
log('delete_material_purchase', f'删除数量: {len(item_ids)}')
return jsonify({'ok': True, 'count': len(item_ids)})
@app.post('/api/material-purchase/restore')
@require_login
@require_any_role('admin', 'superadmin')
def restore_material_purchase():
"""从回收站恢复"""
data = request.get_json() or {}
item_id = data.get('id')
if not item_id:
return jsonify({'error': '缺少ID'}), 400
conn = get_db()
c = conn.cursor()
c.execute('''UPDATE material_purchase SET
deleted=0, deleted_at=NULL
WHERE id=?''', (item_id,))
conn.commit()
conn.close()
log('restore_material_purchase', f'ID: {item_id}')
return jsonify({'ok': True})
@app.post('/api/material-purchase/permanent-delete')
@require_login
@require_any_role('admin', 'superadmin')
def permanent_delete_material_purchase():
"""永久删除"""
data = request.get_json() or {}
item_id = data.get('id')
if not item_id:
return jsonify({'error': '缺少ID'}), 400
conn = get_db()
c = conn.cursor()
c.execute('DELETE FROM material_purchase WHERE id=?', (item_id,))
conn.commit()
conn.close()
log('permanent_delete_material_purchase', f'ID: {item_id}')
return jsonify({'ok': True})
@app.post('/api/material-purchase/empty-recycle-bin')
@require_login
@require_any_role('admin', 'superadmin')
def empty_recycle_bin():
"""清空回收站"""
conn = get_db()
c = conn.cursor()
c.execute('DELETE FROM material_purchase WHERE deleted=1')
count = c.rowcount
conn.commit()
conn.close()
log('empty_recycle_bin', f'清空数量: {count}')
return jsonify({'ok': True, 'count': count})
@app.route('/api/validate/material-purchase-file', methods=['POST'])
@require_login
@require_any_role('admin', 'superadmin')
def validate_material_purchase_file():
"""验证物料清单Excel文件格式"""
f = request.files.get('file')
if not f:
return jsonify({'error': 'no file'}), 400
name = secure_filename(f.filename or '')
ext = (name.split('.')[-1] or '').lower()
if ext not in ['xlsx', 'xls']:
return jsonify({'valid': False, 'message': '文件格式不支持请上传Excel文件.xlsx或.xls'}), 200
try:
import openpyxl
wb = openpyxl.load_workbook(f)
ws = wb.active
if ws.max_row < 2:
wb.close()
return jsonify({'valid': False, 'message': '文件为空,没有数据'}), 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]
# 必需的列
required_columns = ['标题', '生产计划明细物料需求清单编号', '生产计划编号', '状态',
'需求状态', '物料编码', '物料名称', '所需物料数']
missing_columns = []
for col in required_columns:
if col not in header:
missing_columns.append(col)
if missing_columns:
wb.close()
return jsonify({
'valid': False,
'message': f'缺少必需的列:{", ".join(missing_columns)}'
}), 200
data_rows = ws.max_row - 1
wb.close()
return jsonify({
'valid': True,
'message': f'文件格式正确,共{data_rows}行数据'
}), 200
except Exception as e:
return jsonify({'valid': False, 'message': f'读取文件失败:{str(e)}'}), 200
@app.post('/api/upload/material-purchase-file')
@require_login
@require_any_role('admin', 'superadmin')
def upload_material_purchase_file():
"""上传物料清单Excel文件"""
f = request.files.get('file')
if not f:
return jsonify({'error': 'no file'}), 400
try:
import openpyxl
wb = openpyxl.load_workbook(f)
ws = wb.active
# 获取表头
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]
# 创建列索引映射
col_map = {h: i for i, h in enumerate(header)}
# 辅助函数:解析百分比字符串
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
# 读取数据
rows = []
for row in ws.iter_rows(min_row=2, values_only=True):
if not any(row): # 跳过空行
continue
# 辅助函数:安全获取单元格值
def get_cell_value(col_name, default=''):
if col_name not in col_map:
return default
idx = col_map[col_name]
if idx >= len(row):
return default
value = row[idx]
return value if value is not None else default
# 提取数据
item = {
'title': str(get_cell_value('标题') or ''),
'list_no': str(get_cell_value('生产计划明细物料需求清单编号') or ''),
'plan_no': str(get_cell_value('生产计划编号') or ''),
'bom_result': str(get_cell_value('产品BOM分析结果') or ''),
'status': str(get_cell_value('状态') or 'pending'),
'demand_status': str(get_cell_value('需求状态') or 'normal'),
'complete_rate': parse_percentage(get_cell_value('物料齐套率', 0)),
'material_code': str(get_cell_value('物料编码') or ''),
'material_name': str(get_cell_value('物料名称') or ''),
'batch_no': str(get_cell_value('物料批次号') or ''),
'level': safe_int(get_cell_value('物料层级', 1), 1),
'required_qty': safe_int(get_cell_value('所需物料数', 0), 0),
'stock_qty': safe_int(get_cell_value('库存现有物料数', 0), 0),
'shortage': safe_int(get_cell_value('欠缺值', 0), 0),
'acquire_method': str(get_cell_value('物料获取方式') or ''),
'realtime_stock': safe_int(get_cell_value('实时库存值', 0), 0),
'pending_qty': safe_int(get_cell_value('待入库数量', 0), 0),
'dispatched_qty': safe_int(get_cell_value('派发数量', 0), 0),
'received_qty': safe_int(get_cell_value('入库数量', 0), 0),
}
# 验证必填字段
if not item['title'] or not item['list_no'] or not item['material_code']:
continue
rows.append(item)
wb.close()
if not rows:
return jsonify({'error': '文件中没有有效数据'}), 400
# 批量插入数据库
conn = get_db()
c = conn.cursor()
now = get_beijing_time()
username = session.get('username', '')
for item in rows:
c.execute('''INSERT INTO material_purchase(
title, list_no, plan_no, bom_result, status, demand_status,
complete_rate, material_code, material_name, batch_no, level,
required_qty, stock_qty, shortage, acquire_method, realtime_stock,
pending_qty, dispatched_qty, received_qty, submitter, submit_time, update_time
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', (
item['title'], item['list_no'], item['plan_no'], item['bom_result'],
item['status'], item['demand_status'], item['complete_rate'],
item['material_code'], item['material_name'], item['batch_no'],
item['level'], item['required_qty'], item['stock_qty'], item['shortage'],
item['acquire_method'], item['realtime_stock'], item['pending_qty'],
item['dispatched_qty'], item['received_qty'], username, now, now
))
conn.commit()
conn.close()
log('upload_material_purchase_file', f'导入数量: {len(rows)}')
return jsonify({'ok': True, 'count': len(rows), 'message': f'成功导入{len(rows)}条数据'})
except Exception as e:
return jsonify({'error': f'导入失败:{str(e)}'}), 500
@app.errorhandler(404)
def not_found(e):
# 如果请求的是 HTML 页面(通过 Accept header 判断),返回 index.html
# 这样可以支持前端路由的直接访问
if request.path and not request.path.startswith('/api/'):
accept = request.headers.get('Accept', '')
if 'text/html' in accept:
return send_from_directory(FRONTEND_DIR, 'index.html')
return jsonify({'error': 'not found'}), 404
init_db()

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB