新增物料管理界面功能
This commit is contained in:
parent
c4455def3f
commit
61721d7102
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(() => {});
|
||||
});
|
||||
|
||||
@ -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: '设置'
|
||||
};
|
||||
|
||||
535
server/app.py
535
server/app.py
@ -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()
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user