优化前端
This commit is contained in:
@@ -19,7 +19,17 @@ SAVE_PATH = OUTPUT_DIR / "receipt_details_full_clean.json"
|
|||||||
def fetch_receipt_details_full():
|
def fetch_receipt_details_full():
|
||||||
log("INFO", "=== 🚚 启动收货明细报表全量抓取 (精简字段模式) ===")
|
log("INFO", "=== 🚚 启动收货明细报表全量抓取 (精简字段模式) ===")
|
||||||
page = get_page(port=9222)
|
page = get_page(port=9222)
|
||||||
|
|
||||||
|
# 尝试加载已有的存档,实现真正的断点累加
|
||||||
all_clean_items = []
|
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:
|
try:
|
||||||
log("INFO", f"正在回到主页起点: {HOME_URL}")
|
log("INFO", f"正在回到主页起点: {HOME_URL}")
|
||||||
@@ -75,43 +85,107 @@ def fetch_receipt_details_full():
|
|||||||
return
|
return
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# 第一页数据处理
|
# 第一页数据处理 (如果触发断点,则忽略第一页数据)
|
||||||
# =========================================================
|
# =========================================================
|
||||||
log("OK", f"🎉 成功拦截到第一页数据!HTTP: {packet.response.status}")
|
log("OK", f"🎉 成功拦截到第一页数据!HTTP: {packet.response.status}")
|
||||||
body = packet.response.body
|
body = packet.response.body
|
||||||
data = body if isinstance(body, (dict, list)) else json.loads(body)
|
data = body if isinstance(body, (dict, list)) else json.loads(body)
|
||||||
|
|
||||||
|
# 设定开始抓取的页码,1表示从头开始抓全量数据
|
||||||
|
target_resume_page = 690
|
||||||
|
|
||||||
total_count = 0
|
total_count = 0
|
||||||
if isinstance(data, dict) and "result" in data:
|
if isinstance(data, dict) and "result" in data:
|
||||||
total_count = data["result"].get("totalCount", 0)
|
total_count = data["result"].get("totalCount", 0)
|
||||||
items = data["result"].get("items", [])
|
items = data["result"].get("items", [])
|
||||||
for item in items:
|
|
||||||
all_clean_items.append({
|
# 只有当不是断点续传(即从第1页开始)时,才把第一页的数据加入列表
|
||||||
"采购订单号": item.get("purchaseOrderCode"),
|
if target_resume_page <= 1:
|
||||||
"行号": item.get("rowsNum"),
|
for item in items:
|
||||||
"物料代码": item.get("materialCode"),
|
all_clean_items.append({
|
||||||
"物料名称": item.get("materialName"),
|
"采购订单号": item.get("purchaseOrderCode"),
|
||||||
"物料规格": item.get("materialSpecification"),
|
"行号": item.get("rowsNum"),
|
||||||
"仓库代码": item.get("warehouseCode"),
|
"物料代码": item.get("materialCode"),
|
||||||
"仓库名称": item.get("warehouseName"),
|
"物料名称": item.get("materialName"),
|
||||||
"供应商代码": item.get("supplierCode"),
|
"物料规格": item.get("materialSpecification"),
|
||||||
"供应商名称": item.get("supplierName"),
|
"仓库代码": item.get("warehouseCode"),
|
||||||
"单位名称": item.get("unitName"),
|
"仓库名称": item.get("warehouseName"),
|
||||||
"转换单位": item.get("convertUnitName"),
|
"供应商代码": item.get("supplierCode"),
|
||||||
"收货单价": item.get("receivePrice"),
|
"供应商名称": item.get("supplierName"),
|
||||||
"收货时间": item.get("receiptTime"),
|
"单位名称": item.get("unitName"),
|
||||||
"进货数量": item.get("convertPlannedPurchaseQuantity") if item.get("convertPlannedPurchaseQuantity") is not None else item.get("plannedPurchaseQuantity"),
|
"转换单位": item.get("convertUnitName"),
|
||||||
"收货数量": item.get("convertGoodsQuantity") if item.get("convertGoodsQuantity") is not None else item.get("goodsQuantity"),
|
"收货单价": item.get("receivePrice"),
|
||||||
"收货总金额": item.get("receiveAmount")
|
"收货时间": item.get("receiptTime"),
|
||||||
})
|
"进货数量": item.get("plannedPurchaseQuantity"),
|
||||||
log("OK", f"第一页清洗完成,提取了 {len(items)} 条数据。后端报告总条数: {total_count}")
|
"收货数量": 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
|
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"),
|
||||||
|
"行号": 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"第 {page_num} 页清洗完成,累计提取 {len(all_clean_items)} 条数据。")
|
||||||
|
else:
|
||||||
|
log("ERR", "找不到页码输入框,断点跳转失败,将从第 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:
|
while True:
|
||||||
# 引入“类人”随机延迟(2.5 秒到 5.5 秒之间随机)
|
# 引入“类人”随机延迟(2.5 秒到 5.5 秒之间随机)
|
||||||
@@ -125,10 +199,31 @@ def fetch_receipt_details_full():
|
|||||||
log("INFO", f"☕️ 已经连续高强度翻了 {page_num} 页,触发风控规避机制,假装喝水休息 {long_delay:.2f} 秒...")
|
log("INFO", f"☕️ 已经连续高强度翻了 {page_num} 页,触发风控规避机制,假装喝水休息 {long_delay:.2f} 秒...")
|
||||||
time.sleep(long_delay)
|
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:
|
if not next_btn:
|
||||||
log("ERR", "找不到下一页按钮,翻页中止。")
|
# 尝试备用定位方式:直接找右箭头图标所在的按钮
|
||||||
break
|
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
|
||||||
|
|
||||||
# 检查按钮是否被禁用
|
# 检查按钮是否被禁用
|
||||||
class_str = str(next_btn.attr("class"))
|
class_str = str(next_btn.attr("class"))
|
||||||
@@ -212,6 +307,13 @@ def fetch_receipt_details_full():
|
|||||||
with open(rescue_path, "w", encoding="utf-8") as f:
|
with open(rescue_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(all_clean_items, f, ensure_ascii=False, indent=2)
|
json.dump(all_clean_items, f, ensure_ascii=False, indent=2)
|
||||||
log("INFO", f"🆘 触发异常保存,抢救了 {len(all_clean_items)} 条数据。")
|
log("INFO", f"🆘 触发异常保存,抢救了 {len(all_clean_items)} 条数据。")
|
||||||
|
finally:
|
||||||
|
# 无论脚本正常结束还是异常退出,都强制停止监听,防止成为僵尸爬虫
|
||||||
|
try:
|
||||||
|
page.listen.stop()
|
||||||
|
log("INFO", "🛑 已释放浏览器监听资源。")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
fetch_receipt_details_full()
|
fetch_receipt_details_full()
|
||||||
|
|||||||
@@ -224,8 +224,17 @@ def fetch_receipt_details_incremental():
|
|||||||
log("INFO", f"⏳ 停顿 {delay:.2f} 秒后点击下一页...")
|
log("INFO", f"⏳ 停顿 {delay:.2f} 秒后点击下一页...")
|
||||||
time.sleep(delay)
|
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:
|
if next_btn:
|
||||||
try: next_btn.click()
|
try: next_btn.click()
|
||||||
@@ -236,7 +245,7 @@ def fetch_receipt_details_incremental():
|
|||||||
log("ERR", f"第 {current_page + 1} 页请求超时!")
|
log("ERR", f"第 {current_page + 1} 页请求超时!")
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
log("ERR", "找不到下一页按钮!")
|
log("ERR", "重试 3 次后仍然找不到下一页按钮!")
|
||||||
break
|
break
|
||||||
|
|
||||||
current_page += 1
|
current_page += 1
|
||||||
@@ -246,8 +255,13 @@ def fetch_receipt_details_incremental():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log("ERR", f"发生全局异常: {e}")
|
log("ERR", f"发生全局异常: {e}")
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
if 'conn' in locals() and conn:
|
||||||
page.listen.stop()
|
conn.close()
|
||||||
|
if 'page' in locals() and page:
|
||||||
|
try:
|
||||||
|
page.listen.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
fetch_receipt_details_incremental()
|
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():
|
def sync_receipts():
|
||||||
"""触发后台运行收货明细增量抓取脚本(修复跨机器路径问题)"""
|
"""触发后台运行收货明细增量抓取脚本(修复跨机器路径问题)"""
|
||||||
import sys
|
import sys
|
||||||
import io
|
|
||||||
import contextlib
|
|
||||||
|
|
||||||
if str(BROWSER_LOGIN_DIR) not in sys.path:
|
if str(BROWSER_LOGIN_DIR) not in sys.path:
|
||||||
sys.path.insert(0, str(BROWSER_LOGIN_DIR))
|
sys.path.insert(0, str(BROWSER_LOGIN_DIR))
|
||||||
@@ -158,16 +156,13 @@ def sync_receipts():
|
|||||||
try:
|
try:
|
||||||
from fetch_receipt_details_incremental import fetch_receipt_details_incremental
|
from fetch_receipt_details_incremental import fetch_receipt_details_incremental
|
||||||
|
|
||||||
# 捕获函数内部的 print/log 输出
|
# 将长时间运行的抓取任务放入后台线程,防止前端请求超时
|
||||||
f = io.StringIO()
|
threading.Thread(target=fetch_receipt_details_incremental, daemon=True).start()
|
||||||
with contextlib.redirect_stdout(f), contextlib.redirect_stderr(f):
|
|
||||||
fetch_receipt_details_incremental()
|
|
||||||
|
|
||||||
logs = f.getvalue()
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "增量同步执行完成",
|
"message": "增量同步任务已在后台启动!请观察黑框控制台的运行日志。",
|
||||||
"logs": logs
|
"logs": "任务已在后台运行..."
|
||||||
})
|
})
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return jsonify({"success": False, "message": "找不到增量抓取脚本或导入失败"}), 404
|
return jsonify({"success": False, "message": "找不到增量抓取脚本或导入失败"}), 404
|
||||||
@@ -178,8 +173,6 @@ def sync_receipts():
|
|||||||
def sync_bom():
|
def sync_bom():
|
||||||
"""触发后台运行 BOM 成本全量抓取脚本(修复跨机器路径问题)"""
|
"""触发后台运行 BOM 成本全量抓取脚本(修复跨机器路径问题)"""
|
||||||
import sys
|
import sys
|
||||||
import io
|
|
||||||
import contextlib
|
|
||||||
|
|
||||||
if str(BROWSER_LOGIN_DIR) not in sys.path:
|
if str(BROWSER_LOGIN_DIR) not in sys.path:
|
||||||
sys.path.insert(0, str(BROWSER_LOGIN_DIR))
|
sys.path.insert(0, str(BROWSER_LOGIN_DIR))
|
||||||
@@ -187,16 +180,13 @@ def sync_bom():
|
|||||||
try:
|
try:
|
||||||
from fetch_bom_cost_full_tree import fetch_bom_cost_tree
|
from fetch_bom_cost_full_tree import fetch_bom_cost_tree
|
||||||
|
|
||||||
# 捕获函数内部的 print/log 输出
|
# 将长时间运行的抓取任务放入后台线程,防止前端请求超时
|
||||||
f = io.StringIO()
|
threading.Thread(target=fetch_bom_cost_tree, daemon=True).start()
|
||||||
with contextlib.redirect_stdout(f), contextlib.redirect_stderr(f):
|
|
||||||
fetch_bom_cost_tree()
|
|
||||||
|
|
||||||
logs = f.getvalue()
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "BOM 同步执行完成",
|
"message": "BOM 树抓取任务已在后台启动!预计耗时 10-20 分钟,请观察黑框控制台的运行日志。",
|
||||||
"logs": logs
|
"logs": "任务已在后台运行..."
|
||||||
})
|
})
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return jsonify({"success": False, "message": "找不到 BOM 抓取脚本或导入失败"}), 404
|
return jsonify({"success": False, "message": "找不到 BOM 抓取脚本或导入失败"}), 404
|
||||||
@@ -447,19 +437,8 @@ def compare_page():
|
|||||||
"""渲染 BOM 成本期间对比分析页面"""
|
"""渲染 BOM 成本期间对比分析页面"""
|
||||||
return render_template('bom_compare.html')
|
return render_template('bom_compare.html')
|
||||||
|
|
||||||
@app.route('/api/bom_tree_compare/<parent_code>')
|
def build_bom_compare_tree(parent_code, start_a, end_a, start_b, end_b):
|
||||||
def get_bom_tree_compare(parent_code):
|
"""构建用于成本对比分析的 BOM 树数据"""
|
||||||
"""
|
|
||||||
为成本对比页面提供带时间段价格分析的 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')
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
|
|
||||||
# 1. 查出基本结构
|
# 1. 查出基本结构
|
||||||
@@ -470,7 +449,7 @@ def get_bom_tree_compare(parent_code):
|
|||||||
|
|
||||||
if not parent_info:
|
if not parent_info:
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"error": "找不到该父件"}), 404
|
return None, "找不到该父件"
|
||||||
|
|
||||||
children_records = conn.execute(
|
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 = ?',
|
'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' # 标记为使用了历史最近价
|
prices_dict[code][status_key] = 'fallback' # 标记为使用了历史最近价
|
||||||
fallback_found.add(code)
|
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:
|
for code in missing_codes:
|
||||||
if code not in fallback_found:
|
if code not in fallback_found:
|
||||||
prices_dict[code][period_key] = 0.0
|
prices_dict[code][period_key] = 0.0
|
||||||
if prices_dict[code].get('latest_price', 0) > 0:
|
if prices_dict[code].get('latest_price', 0) > 0:
|
||||||
# 有基准价,但在该期间和该期间之前都没买过,这不算“从未采购过”
|
|
||||||
prices_dict[code][status_key] = 'fallback'
|
prices_dict[code][status_key] = 'fallback'
|
||||||
else:
|
else:
|
||||||
# 彻底查不到任何记录,才是真正的 missing
|
|
||||||
prices_dict[code][status_key] = 'missing'
|
prices_dict[code][status_key] = 'missing'
|
||||||
|
|
||||||
# --- B. 处理期间 A ---
|
# --- B. 处理期间 A ---
|
||||||
@@ -655,19 +627,11 @@ def get_bom_tree_compare(parent_code):
|
|||||||
|
|
||||||
has_children = bool(node.get('children'))
|
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:
|
if not has_children:
|
||||||
# 叶子节点,核心逻辑修改:区分是否是 1PZJ 铸件
|
|
||||||
if code and code.startswith('1PZJ') and node.get('castingWeight'):
|
if code and code.startswith('1PZJ') and node.get('castingWeight'):
|
||||||
# 铸件:总成本 = BOM 用量(件) * 单件重量(KG/件) * 采购单价(元/KG)
|
|
||||||
multiplier = usage_qty * float(node['castingWeight'])
|
multiplier = usage_qty * float(node['castingWeight'])
|
||||||
node['calcType'] = 'casting'
|
node['calcType'] = 'casting'
|
||||||
else:
|
else:
|
||||||
# 普通件:总成本 = BOM 用量 * 采购单价
|
|
||||||
multiplier = usage_qty
|
multiplier = usage_qty
|
||||||
node['calcType'] = 'normal'
|
node['calcType'] = 'normal'
|
||||||
|
|
||||||
@@ -675,9 +639,6 @@ def get_bom_tree_compare(parent_code):
|
|||||||
node['totalPeriodA'] = node['periodAUnitPrice'] * multiplier
|
node['totalPeriodA'] = node['periodAUnitPrice'] * multiplier
|
||||||
node['totalPeriodB'] = node['periodBUnitPrice'] * multiplier
|
node['totalPeriodB'] = node['periodBUnitPrice'] * multiplier
|
||||||
|
|
||||||
# 只要它是叶子节点,我们就给它标颜色。
|
|
||||||
# 如果它真的是虚拟件(基准价是 0,且 A B 都是 missing),那就大大方方给它标红点
|
|
||||||
# 用户刚才反馈说有的子件有基准价但是连点都没标,就是因为之前那个 is_real_purchased_leaf 过滤得太严格或者有 Bug。
|
|
||||||
node['showAStatus'] = node['periodAStatus']
|
node['showAStatus'] = node['periodAStatus']
|
||||||
node['showBStatus'] = node['periodBStatus']
|
node['showBStatus'] = node['periodBStatus']
|
||||||
else:
|
else:
|
||||||
@@ -691,33 +652,186 @@ def get_bom_tree_compare(parent_code):
|
|||||||
total_a += child['totalPeriodA']
|
total_a += child['totalPeriodA']
|
||||||
total_b += child['totalPeriodB']
|
total_b += child['totalPeriodB']
|
||||||
|
|
||||||
# 父件总成本 = (子件成本之和) * 父件自身的耗用量
|
|
||||||
# 如果累加为0但自身有价格,使用 (自身单价 * 自身耗用量) 作为兜底
|
|
||||||
node['totalLatest'] = (total_latest if total_latest > 0 else node['latestUnitPrice']) * usage_qty
|
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['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['totalPeriodB'] = (total_b if total_b > 0 else node['periodBUnitPrice']) * usage_qty
|
||||||
|
|
||||||
# 父件的累加成本是由众多子件构成的,所以不显示单一的黄/红状态灯
|
|
||||||
node['showAStatus'] = 'normal'
|
node['showAStatus'] = 'normal'
|
||||||
node['showBStatus'] = 'normal'
|
node['showBStatus'] = 'normal'
|
||||||
|
|
||||||
calc_compare_costs(root_tree)
|
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 的树形表格,根节点必须包装在数组里
|
# 为了配合 ElementUI 的树形表格,根节点必须包装在数组里
|
||||||
return jsonify([root_tree])
|
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 秒后自动打开系统默认浏览器"""
|
"""延迟 1.5 秒后自动打开系统默认浏览器"""
|
||||||
time.sleep(1.5)
|
time.sleep(1.5)
|
||||||
url = "http://127.0.0.1:5050"
|
url = f"http://127.0.0.1:{port}"
|
||||||
print(f"🚀 正在自动打开浏览器: {url}")
|
print(f"🚀 正在自动打开浏览器: {url}")
|
||||||
webbrowser.open(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__':
|
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")
|
print(f"🚀 前端展示后端服务已启动!请在浏览器访问: http://127.0.0.1:{port}")
|
||||||
# 更改默认端口为 5050,避开 macOS 控制中心的 5000 端口占用
|
|
||||||
app.run(debug=False, host='0.0.0.0', port=5050)
|
# 智能判断:如果是通过 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">
|
<div class="toolbar" v-if="currentParentCode">
|
||||||
<!-- 第一行:期间选择 -->
|
<!-- 第一行:期间选择 -->
|
||||||
<el-row :gutter="20" type="flex" align="middle" style="flex-wrap: wrap; margin-bottom: 10px;">
|
<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>
|
<span style="font-size: 14px; font-weight: bold; margin-right: 10px;">期间 A (基准期)</span>
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="periodA_start"
|
v-model="periodA_start"
|
||||||
@@ -232,7 +232,7 @@
|
|||||||
</el-date-picker>
|
</el-date-picker>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|
||||||
<el-col :span="11">
|
<el-col :span="10">
|
||||||
<span style="font-size: 14px; font-weight: bold; margin-right: 10px;">期间 B (对比期)</span>
|
<span style="font-size: 14px; font-weight: bold; margin-right: 10px;">期间 B (对比期)</span>
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="periodB_start"
|
v-model="periodB_start"
|
||||||
@@ -252,8 +252,9 @@
|
|||||||
style="width: 140px;">
|
style="width: 140px;">
|
||||||
</el-date-picker>
|
</el-date-picker>
|
||||||
</el-col>
|
</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="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-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
@@ -418,6 +419,7 @@
|
|||||||
parents: [],
|
parents: [],
|
||||||
loadingParents: false,
|
loadingParents: false,
|
||||||
loadingData: false,
|
loadingData: false,
|
||||||
|
exporting: false,
|
||||||
currentParentCode: null,
|
currentParentCode: null,
|
||||||
tableData: [],
|
tableData: [],
|
||||||
originalTableData: [],
|
originalTableData: [],
|
||||||
@@ -562,6 +564,59 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.tableData = filterInvisibleNodes(newData);
|
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