优化前端
This commit is contained in:
@@ -19,6 +19,16 @@ SAVE_PATH = OUTPUT_DIR / "receipt_details_full_clean.json"
|
||||
def fetch_receipt_details_full():
|
||||
log("INFO", "=== 🚚 启动收货明细报表全量抓取 (精简字段模式) ===")
|
||||
page = get_page(port=9222)
|
||||
|
||||
# 尝试加载已有的存档,实现真正的断点累加
|
||||
all_clean_items = []
|
||||
if SAVE_PATH.exists():
|
||||
try:
|
||||
with open(SAVE_PATH, "r", encoding="utf-8") as f:
|
||||
all_clean_items = json.load(f)
|
||||
log("INFO", f"📦 已加载本地历史存档,包含 {len(all_clean_items)} 条数据。")
|
||||
except Exception as e:
|
||||
log("WARN", f"加载本地存档失败: {e},将从空列表开始。")
|
||||
all_clean_items = []
|
||||
|
||||
try:
|
||||
@@ -75,16 +85,80 @@ def fetch_receipt_details_full():
|
||||
return
|
||||
|
||||
# =========================================================
|
||||
# 第一页数据处理
|
||||
# 第一页数据处理 (如果触发断点,则忽略第一页数据)
|
||||
# =========================================================
|
||||
log("OK", f"🎉 成功拦截到第一页数据!HTTP: {packet.response.status}")
|
||||
body = packet.response.body
|
||||
data = body if isinstance(body, (dict, list)) else json.loads(body)
|
||||
|
||||
# 设定开始抓取的页码,1表示从头开始抓全量数据
|
||||
target_resume_page = 690
|
||||
|
||||
total_count = 0
|
||||
if isinstance(data, dict) and "result" in data:
|
||||
total_count = data["result"].get("totalCount", 0)
|
||||
items = data["result"].get("items", [])
|
||||
|
||||
# 只有当不是断点续传(即从第1页开始)时,才把第一页的数据加入列表
|
||||
if target_resume_page <= 1:
|
||||
for item in items:
|
||||
all_clean_items.append({
|
||||
"采购订单号": item.get("purchaseOrderCode"),
|
||||
"行号": item.get("rowsNum"),
|
||||
"物料代码": item.get("materialCode"),
|
||||
"物料名称": item.get("materialName"),
|
||||
"物料规格": item.get("materialSpecification"),
|
||||
"仓库代码": item.get("warehouseCode"),
|
||||
"仓库名称": item.get("warehouseName"),
|
||||
"供应商代码": item.get("supplierCode"),
|
||||
"供应商名称": item.get("supplierName"),
|
||||
"单位名称": item.get("unitName"),
|
||||
"转换单位": item.get("convertUnitName"),
|
||||
"收货单价": item.get("receivePrice"),
|
||||
"收货时间": item.get("receiptTime"),
|
||||
"进货数量": item.get("plannedPurchaseQuantity"),
|
||||
"收货数量": item.get("convertGoodsQuantity") if item.get("convertGoodsQuantity") is not None else item.get("goodsQuantity"),
|
||||
"收货总金额": item.get("receiveAmount")
|
||||
})
|
||||
log("OK", f"第一页清洗完成,提取了 {len(items)} 条数据。后端报告总条数: {total_count}")
|
||||
else:
|
||||
log("INFO", f"触发断点续传,跳过第一页的数据保存。后端报告总条数: {total_count}")
|
||||
|
||||
page_num = 1
|
||||
|
||||
# =========================================================
|
||||
# 断点续传逻辑 (由于刚才中断在 711 页,我们需要跳到 712 页继续)
|
||||
# =========================================================
|
||||
|
||||
if target_resume_page > 1:
|
||||
log("INFO", f"🚀 触发断点续传机制!准备直接跳转到第 {target_resume_page} 页...")
|
||||
# 尝试找页码输入框
|
||||
jumper_input_xpath = 'xpath://*[@id="app"]/div/div[1]/div[2]/div[2]/div[1]/div[2]/div/div[2]/div[1]/span[3]/div/div//input'
|
||||
input_ele = page.ele(jumper_input_xpath, timeout=5)
|
||||
|
||||
if not input_ele:
|
||||
jumper_input_xpath = 'xpath://input[@type="number" and @aria-label="页"]'
|
||||
input_ele = page.ele(jumper_input_xpath, timeout=5)
|
||||
|
||||
if input_ele:
|
||||
input_ele.clear()
|
||||
input_ele.input(str(target_resume_page))
|
||||
time.sleep(0.5)
|
||||
input_ele.input('\n')
|
||||
|
||||
packet = page.listen.wait(timeout=15)
|
||||
if not packet:
|
||||
log("ERR", "断点跳转失败,未拦截到目标页的数据请求。")
|
||||
return
|
||||
|
||||
log("OK", f"✅ 成功跳转至第 {target_resume_page} 页并截获数据!")
|
||||
page_num = target_resume_page
|
||||
|
||||
# 读取并解析第 191 页的数据
|
||||
body = packet.response.body
|
||||
data = body if isinstance(body, (dict, list)) else json.loads(body)
|
||||
if isinstance(data, dict) and "result" in data:
|
||||
items = data["result"].get("items", [])
|
||||
for item in items:
|
||||
all_clean_items.append({
|
||||
"采购订单号": item.get("purchaseOrderCode"),
|
||||
@@ -100,18 +174,18 @@ def fetch_receipt_details_full():
|
||||
"转换单位": item.get("convertUnitName"),
|
||||
"收货单价": item.get("receivePrice"),
|
||||
"收货时间": item.get("receiptTime"),
|
||||
"进货数量": item.get("convertPlannedPurchaseQuantity") if item.get("convertPlannedPurchaseQuantity") is not None else item.get("plannedPurchaseQuantity"),
|
||||
"进货数量": item.get("plannedPurchaseQuantity"),
|
||||
"收货数量": item.get("convertGoodsQuantity") if item.get("convertGoodsQuantity") is not None else item.get("goodsQuantity"),
|
||||
"收货总金额": item.get("receiveAmount")
|
||||
})
|
||||
log("OK", f"第一页清洗完成,提取了 {len(items)} 条数据。后端报告总条数: {total_count}")
|
||||
log("OK", f"第 {page_num} 页清洗完成,累计提取 {len(all_clean_items)} 条数据。")
|
||||
else:
|
||||
log("ERR", "找不到页码输入框,断点跳转失败,将从第 1 页继续!")
|
||||
|
||||
page_num = 1
|
||||
|
||||
# =========================================================
|
||||
# 循环翻页抓取
|
||||
# =========================================================
|
||||
next_btn_xpath = 'xpath://*[@id="app"]/div/div[1]/div[2]/div[2]/div[1]/div[2]/div/div[2]/div[1]/button[2]'
|
||||
|
||||
while True:
|
||||
# 引入“类人”随机延迟(2.5 秒到 5.5 秒之间随机)
|
||||
@@ -125,9 +199,30 @@ def fetch_receipt_details_full():
|
||||
log("INFO", f"☕️ 已经连续高强度翻了 {page_num} 页,触发风控规避机制,假装喝水休息 {long_delay:.2f} 秒...")
|
||||
time.sleep(long_delay)
|
||||
|
||||
next_btn = page.ele(next_btn_xpath, timeout=5)
|
||||
# 兼容多种 ElementUI 翻页按钮的特征
|
||||
# 为了防止由于网络延迟导致的 DOM 元素短暂消失,我们加入重试机制
|
||||
next_btn = None
|
||||
for _ in range(3):
|
||||
next_btn = page.ele('xpath://button[contains(@class, "btn-next")]', timeout=3)
|
||||
if next_btn:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
# 【修复】当跳页页数大于 400 页时,某些页面的 ElementUI 分页组件会为了节省 DOM 而卸载 next_btn
|
||||
# 或者被包裹在隐藏容器里。如果在页面底部直接寻找带有 "btn-next" 且不包含 disabled 的按钮
|
||||
if not next_btn:
|
||||
log("ERR", "找不到下一页按钮,翻页中止。")
|
||||
# 尝试备用定位方式:直接找右箭头图标所在的按钮
|
||||
next_btn = page.ele('xpath://i[contains(@class, "el-icon-arrow-right")]/parent::button', timeout=3)
|
||||
|
||||
if not next_btn:
|
||||
log("ERR", "重试 3 次后仍然找不到下一页按钮,可能是页面崩溃或会话超时,尝试强制刷新页面...")
|
||||
page.refresh()
|
||||
page.wait.load_start()
|
||||
time.sleep(5)
|
||||
# 刷新后尝试重新找一次
|
||||
next_btn = page.ele('xpath://button[contains(@class, "btn-next")]', timeout=5)
|
||||
if not next_btn:
|
||||
log("ERR", "刷新后依然找不到下一页按钮,彻底中止。")
|
||||
break
|
||||
|
||||
# 检查按钮是否被禁用
|
||||
@@ -212,6 +307,13 @@ def fetch_receipt_details_full():
|
||||
with open(rescue_path, "w", encoding="utf-8") as f:
|
||||
json.dump(all_clean_items, f, ensure_ascii=False, indent=2)
|
||||
log("INFO", f"🆘 触发异常保存,抢救了 {len(all_clean_items)} 条数据。")
|
||||
finally:
|
||||
# 无论脚本正常结束还是异常退出,都强制停止监听,防止成为僵尸爬虫
|
||||
try:
|
||||
page.listen.stop()
|
||||
log("INFO", "🛑 已释放浏览器监听资源。")
|
||||
except:
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
fetch_receipt_details_full()
|
||||
|
||||
@@ -224,8 +224,17 @@ def fetch_receipt_details_incremental():
|
||||
log("INFO", f"⏳ 停顿 {delay:.2f} 秒后点击下一页...")
|
||||
time.sleep(delay)
|
||||
|
||||
next_btn_xpath = 'xpath://*[@id="app"]/div/div[1]/div[2]/div[2]/div[1]/div[2]/div/div[2]/div[1]/button[2]'
|
||||
next_btn = page.ele(next_btn_xpath, timeout=5)
|
||||
# 同步全量脚本的优化:重试机制与兼容的类名匹配
|
||||
next_btn = None
|
||||
for _ in range(3):
|
||||
next_btn = page.ele('xpath://button[contains(@class, "btn-next")]', timeout=3)
|
||||
if next_btn:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
# 备用定位方式:直接找右箭头图标所在的按钮
|
||||
if not next_btn:
|
||||
next_btn = page.ele('xpath://i[contains(@class, "el-icon-arrow-right")]/parent::button', timeout=3)
|
||||
|
||||
if next_btn:
|
||||
try: next_btn.click()
|
||||
@@ -236,7 +245,7 @@ def fetch_receipt_details_incremental():
|
||||
log("ERR", f"第 {current_page + 1} 页请求超时!")
|
||||
break
|
||||
else:
|
||||
log("ERR", "找不到下一页按钮!")
|
||||
log("ERR", "重试 3 次后仍然找不到下一页按钮!")
|
||||
break
|
||||
|
||||
current_page += 1
|
||||
@@ -246,8 +255,13 @@ def fetch_receipt_details_incremental():
|
||||
except Exception as e:
|
||||
log("ERR", f"发生全局异常: {e}")
|
||||
finally:
|
||||
if 'conn' in locals() and conn:
|
||||
conn.close()
|
||||
if 'page' in locals() and page:
|
||||
try:
|
||||
page.listen.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
fetch_receipt_details_incremental()
|
||||
BIN
web_ui/__pycache__/app.cpython-313.pyc
Normal file
BIN
web_ui/__pycache__/app.cpython-313.pyc
Normal file
Binary file not shown.
232
web_ui/app.py
232
web_ui/app.py
@@ -149,8 +149,6 @@ def get_receipts():
|
||||
def sync_receipts():
|
||||
"""触发后台运行收货明细增量抓取脚本(修复跨机器路径问题)"""
|
||||
import sys
|
||||
import io
|
||||
import contextlib
|
||||
|
||||
if str(BROWSER_LOGIN_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BROWSER_LOGIN_DIR))
|
||||
@@ -158,16 +156,13 @@ def sync_receipts():
|
||||
try:
|
||||
from fetch_receipt_details_incremental import fetch_receipt_details_incremental
|
||||
|
||||
# 捕获函数内部的 print/log 输出
|
||||
f = io.StringIO()
|
||||
with contextlib.redirect_stdout(f), contextlib.redirect_stderr(f):
|
||||
fetch_receipt_details_incremental()
|
||||
# 将长时间运行的抓取任务放入后台线程,防止前端请求超时
|
||||
threading.Thread(target=fetch_receipt_details_incremental, daemon=True).start()
|
||||
|
||||
logs = f.getvalue()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "增量同步执行完成",
|
||||
"logs": logs
|
||||
"message": "增量同步任务已在后台启动!请观察黑框控制台的运行日志。",
|
||||
"logs": "任务已在后台运行..."
|
||||
})
|
||||
except ImportError:
|
||||
return jsonify({"success": False, "message": "找不到增量抓取脚本或导入失败"}), 404
|
||||
@@ -178,8 +173,6 @@ def sync_receipts():
|
||||
def sync_bom():
|
||||
"""触发后台运行 BOM 成本全量抓取脚本(修复跨机器路径问题)"""
|
||||
import sys
|
||||
import io
|
||||
import contextlib
|
||||
|
||||
if str(BROWSER_LOGIN_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BROWSER_LOGIN_DIR))
|
||||
@@ -187,16 +180,13 @@ def sync_bom():
|
||||
try:
|
||||
from fetch_bom_cost_full_tree import fetch_bom_cost_tree
|
||||
|
||||
# 捕获函数内部的 print/log 输出
|
||||
f = io.StringIO()
|
||||
with contextlib.redirect_stdout(f), contextlib.redirect_stderr(f):
|
||||
fetch_bom_cost_tree()
|
||||
# 将长时间运行的抓取任务放入后台线程,防止前端请求超时
|
||||
threading.Thread(target=fetch_bom_cost_tree, daemon=True).start()
|
||||
|
||||
logs = f.getvalue()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "BOM 同步执行完成",
|
||||
"logs": logs
|
||||
"message": "BOM 树抓取任务已在后台启动!预计耗时 10-20 分钟,请观察黑框控制台的运行日志。",
|
||||
"logs": "任务已在后台运行..."
|
||||
})
|
||||
except ImportError:
|
||||
return jsonify({"success": False, "message": "找不到 BOM 抓取脚本或导入失败"}), 404
|
||||
@@ -447,19 +437,8 @@ def compare_page():
|
||||
"""渲染 BOM 成本期间对比分析页面"""
|
||||
return render_template('bom_compare.html')
|
||||
|
||||
@app.route('/api/bom_tree_compare/<parent_code>')
|
||||
def get_bom_tree_compare(parent_code):
|
||||
"""
|
||||
为成本对比页面提供带时间段价格分析的 BOM 树数据。
|
||||
前端需要传入两个时间段参数:
|
||||
start_a, end_a (期间 A)
|
||||
start_b, end_b (期间 B)
|
||||
"""
|
||||
start_a = request.args.get('start_a')
|
||||
end_a = request.args.get('end_a')
|
||||
start_b = request.args.get('start_b')
|
||||
end_b = request.args.get('end_b')
|
||||
|
||||
def build_bom_compare_tree(parent_code, start_a, end_a, start_b, end_b):
|
||||
"""构建用于成本对比分析的 BOM 树数据"""
|
||||
conn = get_db_connection()
|
||||
|
||||
# 1. 查出基本结构
|
||||
@@ -470,7 +449,7 @@ def get_bom_tree_compare(parent_code):
|
||||
|
||||
if not parent_info:
|
||||
conn.close()
|
||||
return jsonify({"error": "找不到该父件"}), 404
|
||||
return None, "找不到该父件"
|
||||
|
||||
children_records = conn.execute(
|
||||
'SELECT id, node_material_code, node_material_name, parent_node_id, bom_level, usage_qty FROM bom_child WHERE parent_material_code = ?',
|
||||
@@ -578,19 +557,12 @@ def get_bom_tree_compare(parent_code):
|
||||
prices_dict[code][status_key] = 'fallback' # 标记为使用了历史最近价
|
||||
fallback_found.add(code)
|
||||
|
||||
# 3. 如果连期间之前的历史记录都没有,但它有基准价(说明是在期间之后才采购的),也算是 fallback 还是 missing?
|
||||
# 我们之前定义的是“无任何历史记录”才标红。所以,如果它有最新基准价(latest_price > 0),
|
||||
# 说明这东西买过,只是在 start_date 之前没买过。我们不应该把它标红(missing),而应该标为普通的没价格或者某种特殊颜色。
|
||||
# 但为了严谨:既然我们在 start_date 之前没买过,意味着期初无库存/无价格,期间也没买,那这期间的价格只能是 0。
|
||||
# 并且如果 latest_price > 0,说明它并非“历史从未采购”,我们把它标为 fallback 但价格是 0。
|
||||
for code in missing_codes:
|
||||
if code not in fallback_found:
|
||||
prices_dict[code][period_key] = 0.0
|
||||
if prices_dict[code].get('latest_price', 0) > 0:
|
||||
# 有基准价,但在该期间和该期间之前都没买过,这不算“从未采购过”
|
||||
prices_dict[code][status_key] = 'fallback'
|
||||
else:
|
||||
# 彻底查不到任何记录,才是真正的 missing
|
||||
prices_dict[code][status_key] = 'missing'
|
||||
|
||||
# --- B. 处理期间 A ---
|
||||
@@ -655,19 +627,11 @@ def get_bom_tree_compare(parent_code):
|
||||
|
||||
has_children = bool(node.get('children'))
|
||||
|
||||
# 判断一个节点在业务上是不是真的“组件”还是“采购件”
|
||||
# 即使它没有子节点(抓取时下面没数据),但如果它自身没有采购价(latestUnitPrice == 0)
|
||||
# 且在实际业务中它是挂在 BOM 上的(说明是个虚拟组件或大总成),我们也不该给它标颜色
|
||||
is_real_purchased_leaf = (not has_children) and (node['latestUnitPrice'] > 0 or node['periodAStatus'] != 'missing' or node['periodBStatus'] != 'missing')
|
||||
|
||||
if not has_children:
|
||||
# 叶子节点,核心逻辑修改:区分是否是 1PZJ 铸件
|
||||
if code and code.startswith('1PZJ') and node.get('castingWeight'):
|
||||
# 铸件:总成本 = BOM 用量(件) * 单件重量(KG/件) * 采购单价(元/KG)
|
||||
multiplier = usage_qty * float(node['castingWeight'])
|
||||
node['calcType'] = 'casting'
|
||||
else:
|
||||
# 普通件:总成本 = BOM 用量 * 采购单价
|
||||
multiplier = usage_qty
|
||||
node['calcType'] = 'normal'
|
||||
|
||||
@@ -675,9 +639,6 @@ def get_bom_tree_compare(parent_code):
|
||||
node['totalPeriodA'] = node['periodAUnitPrice'] * multiplier
|
||||
node['totalPeriodB'] = node['periodBUnitPrice'] * multiplier
|
||||
|
||||
# 只要它是叶子节点,我们就给它标颜色。
|
||||
# 如果它真的是虚拟件(基准价是 0,且 A B 都是 missing),那就大大方方给它标红点
|
||||
# 用户刚才反馈说有的子件有基准价但是连点都没标,就是因为之前那个 is_real_purchased_leaf 过滤得太严格或者有 Bug。
|
||||
node['showAStatus'] = node['periodAStatus']
|
||||
node['showBStatus'] = node['periodBStatus']
|
||||
else:
|
||||
@@ -691,33 +652,186 @@ def get_bom_tree_compare(parent_code):
|
||||
total_a += child['totalPeriodA']
|
||||
total_b += child['totalPeriodB']
|
||||
|
||||
# 父件总成本 = (子件成本之和) * 父件自身的耗用量
|
||||
# 如果累加为0但自身有价格,使用 (自身单价 * 自身耗用量) 作为兜底
|
||||
node['totalLatest'] = (total_latest if total_latest > 0 else node['latestUnitPrice']) * usage_qty
|
||||
node['totalPeriodA'] = (total_a if total_a > 0 else node['periodAUnitPrice']) * usage_qty
|
||||
node['totalPeriodB'] = (total_b if total_b > 0 else node['periodBUnitPrice']) * usage_qty
|
||||
|
||||
# 父件的累加成本是由众多子件构成的,所以不显示单一的黄/红状态灯
|
||||
node['showAStatus'] = 'normal'
|
||||
node['showBStatus'] = 'normal'
|
||||
|
||||
calc_compare_costs(root_tree)
|
||||
return root_tree, None
|
||||
|
||||
@app.route('/api/bom_tree_compare/<parent_code>')
|
||||
def get_bom_tree_compare(parent_code):
|
||||
"""
|
||||
为成本对比页面提供带时间段价格分析的 BOM 树数据。
|
||||
"""
|
||||
start_a = request.args.get('start_a')
|
||||
end_a = request.args.get('end_a')
|
||||
start_b = request.args.get('start_b')
|
||||
end_b = request.args.get('end_b')
|
||||
|
||||
root_tree, error = build_bom_compare_tree(parent_code, start_a, end_a, start_b, end_b)
|
||||
if error:
|
||||
return jsonify({"error": error}), 404
|
||||
|
||||
# 为了配合 ElementUI 的树形表格,根节点必须包装在数组里
|
||||
return jsonify([root_tree])
|
||||
|
||||
def open_browser():
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from flask import send_file
|
||||
|
||||
@app.route('/api/export_compare/<parent_code>')
|
||||
def export_compare(parent_code):
|
||||
start_a = request.args.get('start_a')
|
||||
end_a = request.args.get('end_a')
|
||||
start_b = request.args.get('start_b')
|
||||
end_b = request.args.get('end_b')
|
||||
|
||||
root_tree, error = build_bom_compare_tree(parent_code, start_a, end_a, start_b, end_b)
|
||||
if error:
|
||||
return jsonify({"error": error}), 404
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "BOM成本对比"
|
||||
|
||||
# 定义样式
|
||||
header_fill = PatternFill(start_color="409EFF", end_color="409EFF", fill_type="solid")
|
||||
header_font = Font(color="FFFFFF", bold=True)
|
||||
center_align = Alignment(horizontal="center", vertical="center")
|
||||
border = Border(left=Side(style='thin'), right=Side(style='thin'),
|
||||
top=Side(style='thin'), bottom=Side(style='thin'))
|
||||
|
||||
headers = [
|
||||
"物料代码", "物料名称", "BOM层级", "用量", "单位",
|
||||
"最新单价", "最新总成本",
|
||||
f"期间A单价 ({start_a or '无'}至{end_a or '无'})", f"期间A总成本",
|
||||
f"期间B单价 ({start_b or '无'}至{end_b or '无'})", f"期间B总成本",
|
||||
"单价差异 (B-A)", "总成本差异 (B-A)"
|
||||
]
|
||||
|
||||
ws.append(headers)
|
||||
for col_idx, cell in enumerate(ws[1], 1):
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = center_align
|
||||
cell.border = border
|
||||
ws.column_dimensions[get_column_letter(col_idx)].width = 15
|
||||
|
||||
ws.column_dimensions['A'].width = 18
|
||||
ws.column_dimensions['B'].width = 30
|
||||
ws.column_dimensions['H'].width = 25
|
||||
ws.column_dimensions['J'].width = 25
|
||||
|
||||
def write_node_to_excel(node, level=0):
|
||||
# 差异计算逻辑(同前端)
|
||||
period_a_unit = node.get('periodAUnitPrice', 0) or 0
|
||||
period_b_unit = node.get('periodBUnitPrice', 0) or 0
|
||||
diff_unit = period_b_unit - period_a_unit
|
||||
|
||||
period_a_total = node.get('totalPeriodA', 0) or 0
|
||||
period_b_total = node.get('totalPeriodB', 0) or 0
|
||||
diff_total = period_b_total - period_a_total
|
||||
|
||||
prefix = " " * level
|
||||
|
||||
row_data = [
|
||||
node.get('materialCode', ''),
|
||||
prefix + node.get('materialName', ''),
|
||||
node.get('bomLevel', ''),
|
||||
node.get('usageQty', 1),
|
||||
node.get('unitName', ''),
|
||||
node.get('latestUnitPrice', 0),
|
||||
node.get('totalLatest', 0),
|
||||
period_a_unit,
|
||||
period_a_total,
|
||||
period_b_unit,
|
||||
period_b_total,
|
||||
diff_unit,
|
||||
diff_total
|
||||
]
|
||||
|
||||
ws.append(row_data)
|
||||
current_row = ws.max_row
|
||||
|
||||
# 简单格式化
|
||||
for col_idx, cell in enumerate(ws[current_row], 1):
|
||||
cell.border = border
|
||||
if col_idx in [6, 7, 8, 9, 10, 11, 12, 13]:
|
||||
cell.number_format = '0.00'
|
||||
|
||||
# 递归子节点
|
||||
if node.get('children'):
|
||||
for child in node['children']:
|
||||
write_node_to_excel(child, level + 1)
|
||||
|
||||
write_node_to_excel(root_tree)
|
||||
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
|
||||
filename = f"BOM成本对比_{parent_code}.xlsx"
|
||||
return send_file(
|
||||
output,
|
||||
as_attachment=True,
|
||||
download_name=filename,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
|
||||
def open_browser(port):
|
||||
"""延迟 1.5 秒后自动打开系统默认浏览器"""
|
||||
time.sleep(1.5)
|
||||
url = "http://127.0.0.1:5050"
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
print(f"🚀 正在自动打开浏览器: {url}")
|
||||
webbrowser.open(url)
|
||||
|
||||
def find_free_port(start_port=5050, max_port=5100):
|
||||
"""自动寻找未被占用的端口,避免 Windows 10013 端口被拒错误"""
|
||||
import socket
|
||||
for port in range(start_port, max_port):
|
||||
# 尝试通过创建 socket 并监听来确认端口是否真的可用
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
# 开启端口复用,避免 TIME_WAIT 状态导致误判
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
# 绑定到 127.0.0.1 而不是 0.0.0.0
|
||||
s.bind(('127.0.0.1', port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# 如果 5050-5100 都被占用或被拦截,尝试让系统随机分配一个端口
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('127.0.0.1', 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 动态获取一个可用端口
|
||||
try:
|
||||
port = find_free_port()
|
||||
except Exception as e:
|
||||
print(f"⚠️ 无法自动获取端口,回退到默认端口: {e}")
|
||||
port = 5050
|
||||
|
||||
# 启动前开启一个线程去拉起浏览器
|
||||
threading.Thread(target=open_browser, daemon=True).start()
|
||||
threading.Thread(target=open_browser, args=(port,), daemon=True).start()
|
||||
|
||||
# 启动后端服务
|
||||
print("🚀 前端展示后端服务已启动!请在浏览器访问: http://127.0.0.1:5050")
|
||||
# 更改默认端口为 5050,避开 macOS 控制中心的 5000 端口占用
|
||||
app.run(debug=False, host='0.0.0.0', port=5050)
|
||||
print(f"🚀 前端展示后端服务已启动!请在浏览器访问: http://127.0.0.1:{port}")
|
||||
|
||||
# 智能判断:如果是通过 PyInstaller 打包运行的,则关闭热加载以避免多进程冲突和双开浏览器
|
||||
is_frozen = getattr(sys, 'frozen', False)
|
||||
|
||||
# 更改为动态端口,避开被占用的端口。修改 host 为 127.0.0.1 避免 Windows 权限拦截
|
||||
app.run(
|
||||
debug=not is_frozen,
|
||||
host='127.0.0.1',
|
||||
port=port,
|
||||
threaded=True,
|
||||
use_reloader=not is_frozen
|
||||
)
|
||||
@@ -211,7 +211,7 @@
|
||||
<div class="toolbar" v-if="currentParentCode">
|
||||
<!-- 第一行:期间选择 -->
|
||||
<el-row :gutter="20" type="flex" align="middle" style="flex-wrap: wrap; margin-bottom: 10px;">
|
||||
<el-col :span="11">
|
||||
<el-col :span="10">
|
||||
<span style="font-size: 14px; font-weight: bold; margin-right: 10px;">期间 A (基准期)</span>
|
||||
<el-date-picker
|
||||
v-model="periodA_start"
|
||||
@@ -232,7 +232,7 @@
|
||||
</el-date-picker>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="11">
|
||||
<el-col :span="10">
|
||||
<span style="font-size: 14px; font-weight: bold; margin-right: 10px;">期间 B (对比期)</span>
|
||||
<el-date-picker
|
||||
v-model="periodB_start"
|
||||
@@ -252,8 +252,9 @@
|
||||
style="width: 140px;">
|
||||
</el-date-picker>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<el-col :span="4" style="display: flex; gap: 10px;">
|
||||
<el-button type="primary" size="small" icon="el-icon-search" @click="fetchTreeData" :loading="loadingData">执行对比</el-button>
|
||||
<el-button type="success" size="small" icon="el-icon-download" @click="exportExcel" :loading="exporting">导出 Excel</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
@@ -418,6 +419,7 @@
|
||||
parents: [],
|
||||
loadingParents: false,
|
||||
loadingData: false,
|
||||
exporting: false,
|
||||
currentParentCode: null,
|
||||
tableData: [],
|
||||
originalTableData: [],
|
||||
@@ -562,6 +564,59 @@
|
||||
};
|
||||
|
||||
this.tableData = filterInvisibleNodes(newData);
|
||||
},
|
||||
|
||||
exportExcel() {
|
||||
if (!this.currentParentCode) {
|
||||
this.$message.warning('请先选择一个成品父件并执行对比');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.periodA_start || !this.periodA_end || !this.periodB_start || !this.periodB_end) {
|
||||
this.$message.warning('请完整选择期间A和期间B的时间范围');
|
||||
return;
|
||||
}
|
||||
|
||||
this.exporting = true;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
start_a: this.periodA_start,
|
||||
end_a: this.periodA_end,
|
||||
start_b: this.periodB_start,
|
||||
end_b: this.periodB_end
|
||||
});
|
||||
|
||||
axios({
|
||||
url: `/api/export_compare/${this.currentParentCode}?${params.toString()}`,
|
||||
method: 'GET',
|
||||
responseType: 'blob' // 重要:设置响应类型为 blob
|
||||
})
|
||||
.then(response => {
|
||||
// 创建下载链接
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
// 从响应头中获取文件名,如果没有则使用默认文件名
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
let fileName = `BOM成本对比_${this.currentParentCode}.xlsx`;
|
||||
if (contentDisposition) {
|
||||
const fileNameMatch = contentDisposition.match(/filename="?(.+)"?/);
|
||||
if (fileNameMatch && fileNameMatch.length === 2) {
|
||||
fileName = decodeURIComponent(fileNameMatch[1]);
|
||||
}
|
||||
}
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
this.exporting = false;
|
||||
this.$message.success('导出成功');
|
||||
})
|
||||
.catch(error => {
|
||||
this.exporting = false;
|
||||
this.$message.error('导出失败,请重试');
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user