项目文档
# Excel库存分析系统
一个用于分析Excel库存数据的系统,特别专注于查询零覆盖门店(门店库存、门店在途、店间在途均为零的门店)。
## 功能特点
- 直接上传自己在小米pos系统中导出的产品覆盖数据表
- 支持多产品同时查询零覆盖门店
- 自动统计各城市库存总量
- 可视化展示库存数据分布
- 支持多种Excel格式(.xlsx, .xls)
## 目录说明
- `inventory_files`目录是门店库存概览数据源(支持前端上传覆盖)
- `css`目录是all.min.css的存放目录(index.html中改用链接了)不知道啥原因本地的无法使用链接可以
## 系统要求
- Python 3.6+
- 相关依赖包(见requirements.txt)
## 安装步骤
1. 克隆或下载本项目到本地
2. 安装依赖包:
pip install -r requirements.txt
## 使用方法
1. 运行系统:
python app.py
2. 系统会自动创建一个名为`inventory_files`的文件夹
3. 将您的Excel库存文件放入`inventory_files`文件夹中,确保文件包含以下列:
- 市(城市)
- 门店代码
- 机型代号(产品型号)
- 门店库存
- 门店在途
- 店间在途
4. 打开浏览器,访问 http://127.0.0.1:5000
5. 点击"加载/刷新数据"按钮加载库存数据
6. 在产品列表中选择一个或多个产品,点击"查询零覆盖门店"查看结果
## 注意事项
- 如果Excel文件格式不正确,系统会显示相应的错误提示
- 系统会加载`inventory_files`文件夹中第一个找到的Excel文件
- 更换Excel文件后,需要点击"加载/刷新数据"按钮重新加载
## 常见问题
Q: 系统提示"未找到Excel文件"怎么办?
A: 请检查`inventory_files`文件夹中是否有Excel文件,且文件格式正确(.xlsx或.xls)
Q: 如何更新库存数据?
A: 程序运行后前端点击上传文件(要求是.xlsx,xls文件)
Q: 零覆盖门店的定义是什么?
A: 零覆盖门店指的是门店库存、门店在途、店间在途三均为零的门店
系统后端(基于py)
import glob
import os
from io import BytesIO
import pandas as pd
import xlsxwriter
from flask import Flask, jsonify, send_from_directory, request, make_response
from flask import send_file
from flask_cors import CORS
app = Flask(__name__, static_folder='.', static_url_path='')
CORS(app)
EXCEL_DIRECTORY = "inventory_files"
parsed_data = None
city_summary = None
product_summary = None
products = None # 存储机型列表
total_stores = 0
loaded_filename = None
if not os.path.exists(EXCEL_DIRECTORY):
os.makedirs(EXCEL_DIRECTORY)
print(f"已创建Excel文件目录: {EXCEL_DIRECTORY}")
print(f"请将您的Excel库存文件放入此目录")
def load_excel_files():
"""加载本地目录中的Excel文件,处理空格、异常值和空产品型号"""
global parsed_data, city_summary, product_summary, products, total_stores, loaded_filename
excel_files = glob.glob(os.path.join(EXCEL_DIRECTORY, "*.xlsx")) + \
glob.glob(os.path.join(EXCEL_DIRECTORY, "*.xls"))
if not excel_files:
return {
"success": False,
"error": f"在 {EXCEL_DIRECTORY} 目录中未找到Excel文件",
"city_summary": [],
"product_summary": [],
"products": [],
"total_stores": 0,
"total_products": 0
}
loaded_filename = excel_files[0]
try:
df = pd.read_excel(loaded_filename)
# 清理列名中的空格
df.columns = df.columns.str.strip()
# 检查必要列
required_columns = [
'时间', '市', '区', '门店名称', '门店代码',
'机型', '门店库存', '门店在途', '店间在途'
]
missing_columns = [col for col in required_columns if col not in df.columns]
if missing_columns:
return {
"success": False,
"error": f"Excel文件缺少必要的列: {', '.join(missing_columns)}",
"city_summary": [],
"product_summary": [],
"products": [],
"total_stores": 0,
"total_products": 0
}
# 清理文本列中的空格
text_columns = ['市', '区', '门店名称', '门店代码', '机型']
for col in text_columns:
df[col] = df[col].astype(str).str.strip()
df[col] = df[col].replace('', None)
# 关键修改:过滤掉空的或无效的产品型号
df = df[df['机型'].notna() & (df['机型'] != '') & (df['机型'] != 'nan')]
# 处理时间列
if '时间' in df.columns:
try:
df['时间'] = pd.to_datetime(df['时间'], errors='coerce')
df['时间'] = df['时间'].dt.strftime('%Y-%m-%d %H:%M:%S')
df['时间'] = df['时间'].fillna('未知时间')
except Exception as e:
print(f"时间格式处理警告: {e}")
df['时间'] = df['时间'].astype(str).str.strip().fillna('未知时间')
# 处理数值列
numeric_columns = ['门店库存', '门店在途', '店间在途']
for col in numeric_columns:
df[col] = df[col].astype(str).str.strip()
df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0)
df[col] = df[col].astype(int)
parsed_data = df.to_dict('records')
# 计算各城市库存总量
city_summary = df.groupby('市').agg({
'门店库存': 'sum',
'门店在途': 'sum',
'店间在途': 'sum'
}).reset_index().fillna(0).to_dict('records')
# 计算各产品库存总量
product_summary = df.groupby('机型').agg({
'门店库存': 'sum',
'门店在途': 'sum',
'店间在途': 'sum'
}).reset_index().fillna(0).to_dict('records')
# 获取所有产品列表(已过滤空值)
products = df['机型'].dropna().unique().tolist()
# 再次过滤可能的无效值
products = [p for p in products if p and p.strip() and p.lower() != 'nan']
# 获取总门店数
total_stores = df['门店代码'].dropna().nunique()
return {
"success": True,
"filename": os.path.basename(loaded_filename),
"city_summary": city_summary,
"product_summary": product_summary,
"products": products,
"total_stores": total_stores,
"total_products": len(products)
}
except Exception as e:
return {
"success": False,
"error": f'文件解析错误: {str(e).replace("'", "\"")}',
"city_summary": [],
"product_summary": [],
"products": [],
"total_stores": 0,
"total_products": 0
}
@app.route('/')
def index():
return send_from_directory(app.static_folder, 'index.html')
@app.route('/upload', methods=['POST'])
def upload_file():
"""处理文件上传并覆盖现有文件"""
if 'file' not in request.files:
return jsonify({"success": False, "error": "未找到文件"})
file = request.files['file']
if file.filename == '':
return jsonify({"success": False, "error": "未选择文件"})
# 验证文件类型
allowed_extensions = {'xlsx', 'xls'}
if '.' not in file.filename or \
file.filename.rsplit('.', 1)[1].lower() not in allowed_extensions:
return jsonify({"success": False, "error": "只允许上传.xlsx和.xls格式的文件"})
try:
# 清除现有文件
excel_files = glob.glob(os.path.join(EXCEL_DIRECTORY, "*.xlsx")) + \
glob.glob(os.path.join(EXCEL_DIRECTORY, "*.xls"))
for f in excel_files:
os.remove(f)
# 保存新文件
filename = file.filename
file_path = os.path.join(EXCEL_DIRECTORY, filename)
file.save(file_path)
# 重新加载数据
load_excel_files()
return jsonify({"success": True, "message": "文件上传成功"})
except Exception as e:
return jsonify({"success": False, "error": f"文件处理错误: {str(e)}"})
@app.route('/load', methods=['GET'])
def load_data():
return jsonify(load_excel_files())
@app.route('/download', methods=['GET'])
def download_results():
"""下载查询结果为Excel文件(支持判断逻辑)"""
global parsed_data
if parsed_data is None:
load_result = load_excel_files()
if not load_result["success"]:
return jsonify({"success": False, "error": "数据未加载,请检查Excel文件"})
# 获取查询的产品和判断逻辑
selected_products = request.args.getlist('products')
zero_logic = request.args.get('zero_logic', 'all')
if not selected_products:
return jsonify({"success": False, "error": "请选择至少一个产品"})
try:
df = pd.DataFrame(parsed_data)
# 过滤掉可能的空值或NaN产品
selected_products = [p for p in selected_products if p and p.strip() and p.lower() != 'nan']
if not selected_products:
return jsonify({"success": False, "error": "所选产品均无效,请重新选择"})
filtered_df = df[df['机型'].isin(selected_products)]
# 按门店+机型分组,合并多版本数据
grouped = filtered_df.groupby(['门店代码', '机型']).agg({
'时间': 'first',
'市': 'first',
'区': 'first',
'门店名称': 'first',
'门店库存': 'sum',
'门店在途': 'sum',
'店间在途': 'sum'
}).reset_index()
# 标记每个产品是否为零覆盖
grouped['is_zero_coverage'] = (
(grouped['门店库存'] == 0) &
(grouped['门店在途'] == 0) &
(grouped['店间在途'] == 0)
)
# 按门店分组,根据判断逻辑筛选
store_groups = grouped.groupby('门店代码')
if zero_logic == 'all':
# 全部产品均为零覆盖才算零覆盖门店
zero_coverage = pd.DataFrame()
for store_code, group in store_groups:
if len(group) == len(selected_products) and group['is_zero_coverage'].all():
zero_coverage = pd.concat([zero_coverage, group])
else:
# 任一产品为零覆盖就算零覆盖门店
zero_coverage = pd.DataFrame()
for store_code, group in store_groups:
if group['is_zero_coverage'].any():
zero_coverage_products = group[group['is_zero_coverage']]
zero_coverage = pd.concat([zero_coverage, zero_coverage_products])
# 清理不需要的列
if 'is_zero_coverage' in zero_coverage.columns:
zero_coverage = zero_coverage.drop(columns=['is_zero_coverage'])
# 清理空值
zero_coverage = zero_coverage.fillna({
'时间': '未知时间',
'市': '',
'区': '',
'门店名称': '',
'门店代码': '',
'机型': '',
'门店库存': 0,
'门店在途': 0,
'店间在途': 0
})
# 按产品在选择列表中的顺序排序
zero_coverage['机型'] = pd.Categorical(
zero_coverage['机型'],
categories=selected_products,
ordered=True
)
zero_coverage = zero_coverage.sort_values('机型')
# 准备下载的Excel文件
output = BytesIO()
with pd.ExcelWriter(output, engine='xlsxwriter') as writer:
zero_coverage.to_excel(writer, index=False, sheet_name='零覆盖门店')
# 添加标题行格式
worksheet = writer.sheets['零覆盖门店']
header_format = writer.book.add_format({
'bold': True,
'bg_color': '#3B82F6',
'font_color': 'white',
'border': 1
})
# 应用标题格式
for col_num, value in enumerate(zero_coverage.columns.values):
worksheet.write(0, col_num, value, header_format)
# 调整列宽
worksheet.set_column('A:I', 15)
output.seek(0)
# 生成文件名,包含判断逻辑
logic_suffix = "全部为零" if zero_logic == 'all' else "任一为零"
filename = f"零覆盖门店查询结果_{logic_suffix}_{'_'.join(selected_products[:3])}{'...' if len(selected_products) > 3 else ''}.xlsx"
return send_file(
output,
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
as_attachment=True,
download_name=filename
)
except Exception as e:
return jsonify({"success": False, "error": f"下载错误: {str(e).replace('"', "'")}"})
@app.route('/query', methods=['POST'])
def query_products():
"""查询零覆盖门店(增加判断逻辑参数)"""
global parsed_data
if parsed_data is None:
load_result = load_excel_files()
if not load_result["success"]:
return jsonify({"success": False, "error": "数据未加载,请检查Excel文件"})
try:
data = request.get_json()
selected_products = data.get('products', [])
zero_logic = data.get('zero_logic', 'all') # 获取判断逻辑,默认为全部为零
if not selected_products:
return jsonify({"success": False, "error": "请选择至少一个产品"})
# 过滤掉可能的空值或NaN产品
selected_products = [p for p in selected_products if p and p.strip() and p.lower() != 'nan']
if not selected_products:
return jsonify({"success": False, "error": "所选产品均无效,请重新选择"})
df = pd.DataFrame(parsed_data)
filtered_df = df[df['机型'].isin(selected_products)]
# 按门店+机型分组,合并多版本数据
grouped = filtered_df.groupby(['门店代码', '机型']).agg({
'时间': 'first',
'市': 'first',
'区': 'first',
'门店名称': 'first',
'门店库存': 'sum',
'门店在途': 'sum',
'店间在途': 'sum'
}).reset_index()
# 标记每个产品是否为零覆盖
grouped['is_zero_coverage'] = (
(grouped['门店库存'] == 0) &
(grouped['门店在途'] == 0) &
(grouped['店间在途'] == 0)
)
# 按门店分组,根据判断逻辑筛选
store_groups = grouped.groupby('门店代码')
if zero_logic == 'all':
# 全部产品均为零覆盖才算零覆盖门店
# 找到所有产品都为零覆盖的门店
zero_coverage_stores = []
for store_code, group in store_groups:
# 检查该门店的所有选中产品是否都为零覆盖
if len(group) == len(selected_products) and group['is_zero_coverage'].all():
zero_coverage_stores.extend(group.to_dict('records'))
else:
# 任一产品为零覆盖就算零覆盖门店
# 找到至少有一个产品为零覆盖的门店
zero_coverage_stores = []
for store_code, group in store_groups:
if group['is_zero_coverage'].any():
# 只添加那些为零覆盖的产品记录
zero_coverage_products = group[group['is_zero_coverage']]
zero_coverage_stores.extend(zero_coverage_products.to_dict('records'))
return jsonify({
"success": True,
"query_products": selected_products,
"zero_coverage_count": len(zero_coverage_stores),
"zero_coverage_stores": zero_coverage_stores
})
except Exception as e:
return jsonify({"success": False, "error": f"查询错误: {str(e).replace('"', "'")}"})
if __name__ == '__main__':
pd.set_option('display.unicode.east_asian_width', True)
load_excel_files()
app.run(host='0.0.0.0', port=5000, debug=True)
前端文件(基于HTML)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Excel库存分析系统</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<!-- 在 <head> 标签内添加 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Chart.js -->
<script src="chart.umd.min.js"></script>
<!-- 自定义配置 -->
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#3B82F6',
secondary: '#64748B',
success: '#10B981',
warning: '#F59E0B',
danger: '#EF4444',
info: '#3B82F6',
light: '#F8FAFC',
dark: '#1E293B'
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
}
}
}
</script>
<style type="text/tailwindcss">
@layer utilities {
.content-auto {
content-visibility: auto;
}
.transition-custom {
transition: all 0.3s ease;
}
.shadow-custom {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}
.hover-lift {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.hover-lift:hover {
transform: translateY(-2px);
box-shadow: 0 6px 25px rgba(0, 0, 0, 0.12);
}
}
.down {
background-color: #67e55a;
}
</style>
</head>
<body class="bg-gray-50 font-sans text-gray-800 min-h-screen flex flex-col">
<!-- 顶部导航栏 -->
<header class="bg-white shadow-sm sticky top-0 z-50">
<div class="container mx-auto px-4 py-4 flex justify-between items-center">
<div class="flex items-center space-x-3">
<div class="bg-primary text-white p-2 rounded-lg">
<i class="fas fa-chart-line text-xl"></i>
</div>
<h1 class="text-xl font-bold text-gray-800">Excel库存分析系统</h1>
</div>
<div class="flex items-center space-x-4">
<button id="helpBtn" class="text-gray-600 hover:text-primary transition-custom">
<i class="fas fa-question-circle text-lg"></i>
</button>
<button id="themeToggle" class="text-gray-600 hover:text-primary transition-custom">
<i class="fas fa-moon text-lg"></i>
</button>
</div>
</div>
</header>
<!-- 主要内容区域 -->
<main class="flex-grow container mx-auto px-4 py-8">
<!-- 文件加载区域 -->
<section id="loadSection" class="mb-8">
<div class="bg-white rounded-xl shadow-custom p-6 hover-lift">
<h2 class="text-xl font-semibold mb-4 flex items-center">
<i class="fas fa-folder-open text-primary mr-2"></i>
加载本地库存文件
</h2>
<div class="text-center">
<p class="text-gray-600 mb-4">系统将加载 <code
class="bg-gray-100 px-2 py-1 rounded">inventory_files</code> 目录中的Excel文件</p>
<div class="flex flex-col sm:flex-row justify-center gap-4">
<button id="loadBtn"
class="bg-primary text-white px-6 py-3 rounded-lg hover:bg-primary/90 transition-custom flex items-center justify-center">
<i class="fas fa-sync-alt mr-2"></i>加载/刷新数据
</button>
<button id="showDirBtn"
class="bg-gray-200 text-gray-700 px-6 py-3 rounded-lg hover:bg-gray-300 transition-custom flex items-center justify-center">
<i class="fas fa-folder mr-2"></i>上传新文件
</button>
<!-- 隐藏的文件上传控件 -->
<input type="file" id="fileUpload" accept=".xlsx,.xls" class="hidden"/>
</div>
<p id="loadedFileName" class="mt-4 text-sm text-gray-500 hidden"></p>
</div>
</div>
</section>
<!-- 数据概览区域 -->
<section id="overviewSection" class="mb-8 hidden">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="bg-white rounded-xl shadow-custom p-6 hover-lift">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600 text-sm">总门店数</p>
<p class="text-2xl font-bold text-gray-800" id="totalStores">0</p>
</div>
<div class="bg-blue-100 text-primary p-3 rounded-lg">
<i class="fas fa-store text-xl"></i>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-custom p-6 hover-lift">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600 text-sm">总产品数</p>
<p class="text-2xl font-bold text-gray-800" id="totalProducts">0</p>
</div>
<div class="bg-green-100 text-success p-3 rounded-lg">
<i class="fas fa-box text-xl"></i>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-custom p-6 hover-lift">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600 text-sm">总库存数量</p>
<p class="text-2xl font-bold text-gray-800" id="totalInventory">0</p>
</div>
<div class="bg-yellow-100 text-warning p-3 rounded-lg">
<i class="fas fa-warehouse text-xl"></i>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-custom p-6 hover-lift">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600 text-sm">总在途数量</p>
<p class="text-2xl font-bold text-gray-800" id="totalInTransit">0</p>
</div>
<div class="bg-purple-100 text-purple-600 p-3 rounded-lg">
<i class="fas fa-truck text-xl"></i>
</div>
</div>
</div>
</div>
</section>
<!-- 产品查询区域 -->
<section id="querySection" class="mb-8 hidden">
<div class="bg-white rounded-xl shadow-custom p-6 hover-lift">
<h2 class="text-xl font-semibold mb-4 flex items-center">
<i class="fas fa-search text-primary mr-2"></i>
产品零覆盖门店查询
</h2>
<!-- 在产品选择区域添加判断逻辑选项 -->
<div class="mb-4">
<label class="block text-gray-700 mb-2 font-medium">零覆盖判断逻辑选择</label>
<div class="flex items-center space-x-4">
<div class="flex items-center">
<input type="radio" id="allZero" name="zeroLogic" value="all" checked
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<label for="allZero" class="ml-2 text-gray-700 text-sm cursor-pointer">
选择产品均没有才算
</label>
</div>
<div class="flex items-center">
<input type="radio" id="anyZero" name="zeroLogic" value="any"
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<label for="anyZero" class="ml-2 text-gray-700 text-sm cursor-pointer">
任一产品为零覆盖就算
</label>
</div>
</div>
</div>
<div class="mb-4">
<label class="block text-gray-700 mb-2 font-medium">选择产品(可多选)</label>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3" id="productCheckboxes">
<!-- 产品复选框将通过JavaScript动态生成 -->
</div>
</div>
<button id="queryBtn"
class="bg-primary text-white px-6 py-3 rounded-lg hover:bg-primary/90 transition-custom flex items-center justify-center w-full sm:w-auto">
<i class="fas fa-search mr-2"></i>查询零覆盖门店
</button>
<br>
<button id="downloadBtn"
class="down text-white px-6 py-3 rounded-lg hover:bg-primary/90 transition-custom flex items-center justify-center w-full sm:w-auto">
<i class="fas fa-folder mr-2"></i>下载零覆盖门店
</button>
</div>
</section>
<!-- 查询结果区域 -->
<section id="resultSection" class="mb-8 hidden">
<div class="bg-white rounded-xl shadow-custom p-6 hover-lift">
<h2 class="text-xl font-semibold mb-4 flex items-center">
<i class="fas fa-list-alt text-primary mr-2"></i>
查询结果
</h2>
<div id="querySummary" class="mb-4 p-4 bg-blue-50 rounded-lg">
<p class="text-gray-700">
<span class="font-medium">查询产品:</span>
<span id="queryProductsText">-</span>
</p>
<p class="text-gray-700 mt-1">
<span class="font-medium">零覆盖门店数量:</span>
<span id="zeroCoverageCount" class="font-bold text-danger">0</span>
</p>
</div>
<!-- 修改查询结果表格结构,添加产品分组标题 -->
<div class="overflow-x-auto">
<table class="min-w-full table-auto">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
时间
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
城市
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">区
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
门店名称
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
门店代码
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
产品型号
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
门店库存
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
门店在途
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
店间在途
</th>
</tr>
</thead>
<tbody id="resultTableBody" class="bg-white divide-y divide-gray-200">
<!-- 查询结果将通过JavaScript动态生成 -->
</tbody>
</table>
</div>
<div id="noResults" class="text-center py-8 text-gray-500 hidden">
<i class="fas fa-check-circle text-4xl text-success mb-2"></i>
<p>没有找到零覆盖门店,所有门店都有库存或在途</p>
</div>
</div>
</section>
<!-- 数据可视化区域 -->
<section id="visualizationSection" class="mb-8 hidden">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-white rounded-xl shadow-custom p-6 hover-lift">
<h3 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-chart-bar text-primary mr-2"></i>
各城市库存总量
</h3>
<div class="h-80">
<canvas id="cityInventoryChart"></canvas>
</div>
</div>
<div class="bg-white rounded-xl shadow-custom p-6 hover-lift">
<h3 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-chart-pie text-primary mr-2"></i>
各产品库存分布
</h3>
<div class="h-80">
<canvas id="productInventoryChart"></canvas>
</div>
</div>
</div>
</section>
</main>
<!-- 页脚 -->
<footer class="bg-white border-t border-gray-200 py-6">
<div class="container mx-auto px-4 text-center text-gray-600">
<p>© 2024 Excel库存分析系统 - 高效管理您的库存数据</p>
</div>
</footer>
<!-- 帮助模态框 -->
<div id="helpModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-xl font-bold text-gray-800">使用帮助</h3>
<button id="closeHelpModal" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div class="p-6">
<div class="mb-6">
<h4 class="text-lg font-semibold mb-2 text-gray-800">1. 准备工作</h4>
<p class="text-gray-600">将您的Excel库存文件放入系统自动创建的"inventory_files"文件夹中。</p>
</div>
<div class="mb-6">
<h4 class="text-lg font-semibold mb-2 text-gray-800">2. 加载数据</h4>
<p class="text-gray-600">点击"加载/刷新数据"按钮,系统会读取inventory_files文件夹中的Excel文件。</p>
</div>
<div class="mb-6">
<h4 class="text-lg font-semibold mb-2 text-gray-800">3. 产品查询</h4>
<p class="text-gray-600">从产品列表中选择一个或多个产品,点击"查询零覆盖门店"按钮。</p>
</div>
<div class="mb-6">
<h4 class="text-lg font-semibold mb-2 text-gray-800">4. 零覆盖门店定义</h4>
<p class="text-gray-600">零覆盖门店指的是门店库存、门店在途、店间在途三者都为零的门店。</p>
</div>
<div class="mb-6">
<h4 class="text-lg font-semibold mb-2 text-gray-800">5. 数据可视化</h4>
<p class="text-gray-600">系统会自动生成各城市库存总量和各产品库存分布的图表。</p>
</div>
</div>
<div class="p-6 border-t border-gray-200 flex justify-end">
<button id="closeHelpModalBtn"
class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-custom">
了解了
</button>
</div>
</div>
</div>
<!-- JavaScript -->
<script>
// 全局变量
let cityInventoryChart = null;
let productInventoryChart = null;
let uploadedData = null;
// DOM元素
const loadBtn = document.getElementById('loadBtn');
const showDirBtn = document.getElementById('showDirBtn');
const loadedFileName = document.getElementById('loadedFileName');
const overviewSection = document.getElementById('overviewSection');
const querySection = document.getElementById('querySection');
const resultSection = document.getElementById('resultSection');
const visualizationSection = document.getElementById('visualizationSection');
const productCheckboxes = document.getElementById('productCheckboxes');
const queryBtn = document.getElementById('queryBtn');
const resultTableBody = document.getElementById('resultTableBody');
const noResults = document.getElementById('noResults');
const queryProductsText = document.getElementById('queryProductsText');
const zeroCoverageCount = document.getElementById('zeroCoverageCount');
const totalStores = document.getElementById('totalStores');
const totalProducts = document.getElementById('totalProducts');
const totalInventory = document.getElementById('totalInventory');
const totalInTransit = document.getElementById('totalInTransit');
const helpBtn = document.getElementById('helpBtn');
const helpModal = document.getElementById('helpModal');
const closeHelpModal = document.getElementById('closeHelpModal');
const closeHelpModalBtn = document.getElementById('closeHelpModalBtn');
const themeToggle = document.getElementById('themeToggle');
// 页面加载时尝试自动加载数据
document.addEventListener('DOMContentLoaded', () => {
loadData();
});
// 加载数据按钮点击事件
loadBtn.addEventListener('click', () => {
loadData();
});
// 显示文件目录按钮点击事件
// 替换原有的showDirBtn点击事件
showDirBtn.addEventListener('click', () => {
// 触发文件选择对话框
document.getElementById('fileUpload').click();
});
// 添加文件上传处理
document.getElementById('fileUpload').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
// 验证文件类型
const fileExtension = file.name.split('.').pop().toLowerCase();
if (!['xlsx', 'xls'].includes(fileExtension)) {
alert('请上传Excel文件(.xlsx 或 .xls格式)');
return;
}
// 显示上传状态
showDirBtn.disabled = true;
showDirBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>上传中...';
// 创建FormData对象
const formData = new FormData();
formData.append('file', file);
// 发送文件到服务器
fetch('/upload', {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('文件上传失败');
}
return response.json();
})
.then(data => {
if (data.success) {
alert('文件上传成功,已覆盖原有文件');
// 自动加载新数据
loadData();
} else {
alert(`上传失败: ${data.error}`);
}
})
.catch(error => {
alert(`上传出错: ${error.message}`);
})
.finally(() => {
// 恢复按钮状态
showDirBtn.disabled = false;
showDirBtn.innerHTML = '<i class="fas fa-folder mr-2"></i>上传新文件';
// 重置文件输入,允许重复选择同一文件
document.getElementById('fileUpload').value = '';
});
});
// 加载数据
function loadData() {
// 显示加载状态
loadBtn.disabled = true;
loadBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>加载中...';
loadedFileName.classList.add('hidden');
fetch('/load')
.then(response => {
if (!response.ok) {
throw new Error('数据加载失败');
}
return response.json();
})
.then(data => {
if (data.success) {
// 存储加载的数据
uploadedData = data;
// 更新UI
updateUIAfterLoad(data);
// 显示加载的文件名
loadedFileName.textContent = `已加载文件: ${data.filename}`;
loadedFileName.classList.remove('hidden');
// 显示其他区域
overviewSection.classList.remove('hidden');
querySection.classList.remove('hidden');
visualizationSection.classList.remove('hidden');
// 生成图表
generateCharts(data.city_summary, data.product_summary);
} else {
alert(data.error);
// 隐藏数据区域
overviewSection.classList.add('hidden');
querySection.classList.add('hidden');
resultSection.classList.add('hidden');
visualizationSection.classList.add('hidden');
}
})
.catch(error => {
alert(`加载失败: ${error.message}`);
})
.finally(() => {
// 恢复按钮状态
loadBtn.disabled = false;
loadBtn.innerHTML = '<i class="fas fa-sync-alt mr-2"></i>加载/刷新数据';
});
}
// 加载后更新UI
function updateUIAfterLoad(data) {
// 更新统计信息
totalStores.textContent = data.total_stores;
totalProducts.textContent = data.total_products;
// 计算总库存和总在途
const totalInv = data.city_summary.reduce((sum, city) => sum + city.门店库存, 0);
const totalTransit = data.city_summary.reduce((sum, city) => sum + city.门店在途 + city.店间在途, 0);
totalInventory.textContent = totalInv;
totalInTransit.textContent = totalTransit;
// 生成产品复选框
productCheckboxes.innerHTML = '';
data.products.forEach(product => {
const div = document.createElement('div');
div.className = 'flex items-center';
div.innerHTML = `
<input type="checkbox" id="product_${product}" value="${product}" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<label for="product_${product}" class="ml-2 text-gray-700 text-sm cursor-pointer">${product}</label>
`;
productCheckboxes.appendChild(div);
});
}
// 查询按钮点击事件
queryBtn.addEventListener('click', () => {
// 获取选中的产品
const selectedProducts = Array.from(productCheckboxes.querySelectorAll('input:checked'))
.map(checkbox => checkbox.value);
if (selectedProducts.length === 0) {
alert('请至少选择一个产品');
return;
}
// 获取选中的零覆盖判断逻辑
const zeroLogic = document.querySelector('input[name="zeroLogic"]:checked').value;
// 显示加载状态
queryBtn.disabled = true;
queryBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>查询中...';
resultTableBody.innerHTML = `
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500">
<i class="fas fa-spinner fa-spin text-xl mr-2"></i>正在查询...
</td>
</tr>
`;
resultSection.classList.remove('hidden');
noResults.classList.add('hidden');
// 发送查询请求
fetch('/query', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
products: selectedProducts,
zero_logic: zeroLogic // 添加判断逻辑参数
})
})
.then(response => {
if (!response.ok) {
throw new Error('查询失败');
}
return response.json();
})
// 修改查询成功后的结果处理逻辑
.then(data => {
if (data.success) {
// 更新查询摘要
queryProductsText.textContent = data.query_products.join(', ');
zeroCoverageCount.textContent = data.zero_coverage_count;
// 更新结果表格 - 按产品分组显示
if (data.zero_coverage_count > 0) {
resultTableBody.innerHTML = '';
// 按产品对结果进行分组
const productsGroup = {};
data.zero_coverage_stores.forEach(store => {
const product = store.机型 || '未知产品';
if (!productsGroup[product]) {
productsGroup[product] = [];
}
productsGroup[product].push(store);
});
// 遍历每个产品组,生成表格内容
Object.keys(productsGroup).forEach(product => {
// 添加产品分组标题行
const groupHeader = document.createElement('tr');
groupHeader.className = 'bg-gray-100';
groupHeader.innerHTML = `
<td colspan="9" class="px-4 py-3 text-left text-base font-bold text-gray-800">
产品: ${product} (共 ${productsGroup[product].length} 条记录)
</td>
`;
resultTableBody.appendChild(groupHeader);
// 添加该产品的所有门店记录
productsGroup[product].forEach(store => {
const row = document.createElement('tr');
row.className = 'hover:bg-gray-50 transition-custom';
row.innerHTML = `
<td class="px-4 py-3 text-sm text-gray-900">${store.时间 || '未知时间'}</td>
<td class="px-4 py-3 text-sm text-gray-900">${store.市 || '-'}</td>
<td class="px-4 py-3 text-sm text-gray-900">${store.区 || '-'}</td>
<td class="px-4 py-3 text-sm font-medium text-gray-900">${store.门店名称 || '-'}</td>
<td class="px-4 py-3 text-sm text-gray-900">${store.门店代码 || '-'}</td>
<td class="px-4 py-3 text-sm font-medium text-gray-900">${store.机型 || '-'}</td>
<td class="px-4 py-3 text-sm text-danger font-medium">${store.门店库存}</td>
<td class="px-4 py-3 text-sm text-danger font-medium">${store.门店在途}</td>
<td class="px-4 py-3 text-sm text-danger font-medium">${store.店间在途}</td>
`;
resultTableBody.appendChild(row);
});
});
noResults.classList.add('hidden');
} else {
resultTableBody.innerHTML = '';
noResults.classList.remove('hidden');
}
} else {
throw new Error(data.error || '查询处理失败');
}
})
.catch(error => {
resultTableBody.innerHTML = `
<tr>
<td colspan="6" class="px-4 py-8 text-center text-danger">
<i class="fas fa-exclamation-triangle mr-2"></i>${error.message}
</td>
</tr>
`;
})
.finally(() => {
// 恢复按钮状态
queryBtn.disabled = false;
queryBtn.innerHTML = '<i class="fas fa-search mr-2"></i>查询零覆盖门店';
});
});
document.addEventListener('DOMContentLoaded', function () {
// 缓存元素引用
const downloadBtn = document.getElementById('downloadBtn');
const productCheckboxes = document.getElementById('productCheckboxes');
// 检查元素是否存在,避免错误
if (!downloadBtn || !productCheckboxes) {
console.error('下载功能所需元素未找到');
return;
}
// 添加下载按钮点击事件
// 在下载按钮点击事件中添加判断逻辑参数
downloadBtn.addEventListener('click', function () {
try {
const selectedProducts = Array.from(productCheckboxes.querySelectorAll('input:checked'))
.map(checkbox => checkbox.value)
.filter(Boolean);
if (selectedProducts.length === 0) {
alert('请至少选择一个产品');
return;
}
// 获取选中的零覆盖判断逻辑
const zeroLogic = document.querySelector('input[name="zeroLogic"]:checked').value;
// 构建下载URL,包含判断逻辑参数
const downloadUrl = new URL('/download', window.location.href);
selectedProducts.forEach(product => {
downloadUrl.searchParams.append('products', product);
});
downloadUrl.searchParams.append('zero_logic', zeroLogic); // 添加判断逻辑参数
// 触发下载
const link = document.createElement('a');
link.href = downloadUrl.toString();
link.download = `零覆盖门店查询结果.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error('下载功能出错:', error);
alert('下载失败,请稍后重试');
}
});
// 修改查询成功后的回调,确保兼容现有代码
// 找到原查询成功的代码,替换为:
function handleQuerySuccess(data) {
if (data.success) {
// 更新查询摘要
const queryProductsText = document.getElementById('queryProductsText');
const zeroCoverageCount = document.getElementById('zeroCoverageCount');
if (queryProductsText && zeroCoverageCount) {
queryProductsText.textContent = data.query_products?.join(', ') || '无';
zeroCoverageCount.textContent = data.zero_coverage_count || '0';
// 显示下载按钮
downloadBtn.classList.remove('hidden');
}
// 保留原有的表格更新逻辑
// ... 原有代码 ...
}
}
// 如果原代码使用的是Promise.then()形式,确保添加错误处理
// 例如:
// fetch(...)
// .then(response => response.json())
// .then(data => {
// handleQuerySuccess(data);
// })
// .catch(error => {
// console.error('查询出错:', error);
// alert('查询失败,请重试');
// });
});
// 生成图表
function generateCharts(cityData, productData) {
// 城市库存柱状图
const cityCtx = document.getElementById('cityInventoryChart').getContext('2d');
if (cityInventoryChart) {
cityInventoryChart.destroy();
}
cityInventoryChart = new Chart(cityCtx, {
type: 'bar',
data: {
labels: cityData.map(item => item.市),
datasets: [
{
label: '门店库存',
data: cityData.map(item => item.门店库存),
backgroundColor: 'rgba(59, 130, 246, 0.8)',
borderColor: 'rgba(59, 130, 246, 1)',
borderWidth: 1
},
{
label: '门店在途',
data: cityData.map(item => item.门店在途),
backgroundColor: 'rgba(16, 185, 129, 0.8)',
borderColor: 'rgba(16, 185, 129, 1)',
borderWidth: 1
},
{
label: '店间在途',
data: cityData.map(item => item.店间在途),
backgroundColor: 'rgba(245, 158, 11, 0.8)',
borderColor: 'rgba(245, 158, 11, 1)',
borderWidth: 1
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
tooltip: {
mode: 'index',
intersect: false,
}
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
x: {
grid: {
display: false
}
}
},
animation: {
duration: 1000,
easing: 'easeOutQuart'
}
}
});
// 产品库存饼图
const productCtx = document.getElementById('productInventoryChart').getContext('2d');
if (productInventoryChart) {
productInventoryChart.destroy();
}
const colors = [
'rgba(59, 130, 246, 0.8)',
'rgba(16, 185, 129, 0.8)',
'rgba(245, 158, 11, 0.8)',
'rgba(239, 68, 68, 0.8)',
'rgba(139, 92, 246, 0.8)',
'rgba(236, 72, 153, 0.8)',
'rgba(236, 72, 458, 0.8)',
'rgba(26, 72, 153, 0.8)',
'rgba(36, 82, 153, 0.8)',
'rgba(346, 92, 13, 0.8)',
];
productInventoryChart = new Chart(productCtx, {
type: 'doughnut',
data: {
labels: productData.map(item => item.机型),
datasets: [{
data: productData.map(item => item.门店库存),
backgroundColor: colors.slice(0, productData.length),
borderColor: colors.slice(0, productData.length).map(color => color.replace('0.8', '1')),
borderWidth: 2,
hoverOffset: 10
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20,
usePointStyle: true
}
},
tooltip: {
callbacks: {
label: function (context) {
const label = context.label || '';
const value = context.parsed;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value} (${percentage}%)`;
}
}
}
},
animation: {
animateRotate: true,
duration: 1500,
easing: 'easeOutQuart'
}
}
});
}
// 帮助模态框
helpBtn.addEventListener('click', () => {
helpModal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
});
function closeModal() {
helpModal.classList.add('hidden');
document.body.style.overflow = 'auto';
}
closeHelpModal.addEventListener('click', closeModal);
closeHelpModalBtn.addEventListener('click', closeModal);
helpModal.addEventListener('click', (e) => {
if (e.target === helpModal) {
closeModal();
}
});
// 主题切换
themeToggle.addEventListener('click', () => {
document.body.classList.toggle('dark-mode');
const icon = themeToggle.querySelector('i');
if (document.body.classList.contains('dark-mode')) {
icon.classList.remove('fa-moon');
icon.classList.add('fa-sun');
} else {
icon.classList.remove('fa-sun');
icon.classList.add('fa-moon');
}
});
</script>
</body>
</html>
配置文件
# requirements.txt
Flask==2.3.3
Flask-CORS==4.0.1
pandas==2.0.3
openpyxl==3.1.2 # 新增:解析.xlsx格式必需
xlrd==2.0.1 # 新增:解析.xls格式必需(兼容旧版Excel)
matplotlib==3.7.2 # 可选:确保数据可视化兼容性
xlsxwriter==3.2.0
下载链接
那么好看到这里懒得复制的话直接下载