# -*- coding: utf-8 -*-
import os
import html
import time
import re
import logging
from playwright.sync_api import sync_playwright
from ghn_scraper import chrome_profile_lock, BOT_USER_DATA
# Set up logging matching main bot
logger = logging.getLogger("GHN_BOT.Revert")
def escape_html(val):
if val is None:
return ""
return html.escape(str(val))
def clean_exception_message(ex):
if not ex:
return ""
s = str(ex)
if "====" in s:
s = s.split("====")[0].strip()
lines = [l.strip() for l in s.split("\n") if l.strip()]
if lines:
s = lines[0]
if len(s) > 200:
s = s[:200] + "..."
return s
def parse_revert_message(text, is_revert_chat=False):
"""
Parses the Telegram message for revert commands.
Format:
rv (optional if is_revert_chat is True)
Returns:
dict: {
"type": "Kho hiện tại" | "Kho lấy" | "Kho giao" | "Kho trả",
"wh_orders": { wh_code: [order_codes] }
} or None if not a valid revert message.
"""
lines = [line.strip() for line in text.split("\n") if line.strip()]
if not lines:
return None
first_line = lines[0].lower().lstrip("/")
has_prefix = first_line.startswith("rv") or first_line.startswith("revert")
if not has_prefix and not is_revert_chat:
return None
# Determine warehouse type
warehouse_type = "Kho giao" # Default fallback
start_idx = 0
if has_prefix:
start_idx = 1
fl = first_line # shorthand, already lowercased
# Normalize: strip diacritics keywords for matching
if any(k in fl for k in ["giao lai", "giao lại"]):
warehouse_type = "giao lại"
elif any(k in fl for k in ["hien tai", "hiện tại", "kho hien", "kho hiện"]):
warehouse_type = "Kho hiện tại"
elif any(k in fl for k in ["kho lay", "kho lấy", " lay", " lấy", "lay\n", "lấy\n"]):
warehouse_type = "Kho lấy"
elif any(k in fl for k in ["kho tra", "kho trả", " tra", " trả"]) and "giao" not in fl:
warehouse_type = "Kho trả"
elif "giao" in fl:
warehouse_type = "Kho giao"
elif fl.strip() in ("rv", "revert", "/rv", "/revert"):
warehouse_type = "Kho giao" # bare command → default giao
# Group remaining lines
is_status_revert = warehouse_type == "giao lại"
current_wh = "DEFAULT" if is_status_revert else None
wh_orders = {}
if current_wh:
wh_orders[current_wh] = []
for line in lines[start_idx:]:
# If it's a numeric string (warehouse code)
if line.isdigit():
current_wh = line
wh_orders[current_wh] = []
else:
# Clean and sanitize order code
order_code = re.sub(r'[^A-Z0-9_\-\.]', '', line.upper())
if order_code and current_wh:
wh_orders[current_wh].append(order_code)
# Filter out empty entries
wh_orders = {k: v for k, v in wh_orders.items() if v}
if not wh_orders:
return None
return {
"type": warehouse_type,
"wh_orders": wh_orders
}
def is_valid_approver(appr):
if not appr or appr == "N/A":
return False
lower_appr = appr.lower()
# Exclude technical strings / web design junk
bad_keywords = ["theme", "dynamic", "css", "js", "style", "class", "react", "div", "script", "link", "object"]
if any(bad in lower_appr for bad in bad_keywords):
return False
# Must contain at least one digit and one letter
has_digit = any(c.isdigit() for c in appr)
has_letter = any(c.isalpha() for c in appr)
return has_digit and has_letter
def _extract_approver(page):
"""
Multi-strategy approver extraction from eform detail page.
Tries CSS selectors, then HTML regex, then all link texts.
Returns the approver name string or 'N/A'.
"""
# Strategy 1: Try common CSS selectors for active/current step
step_selectors = [
".action-item.created .text-blue",
".action-item.active .text-blue",
".action-item:has(.fa-hourglass-half) .text-blue",
".action-item.created a",
".action-item.active a",
".step-item.active .text-blue",
".step-item.active a",
".process-step.active a",
".card-right a.text-blue",
".sidebar a.text-blue",
"[class*='process'] a.text-blue",
"[class*='workflow'] a.text-blue",
]
for sel in step_selectors:
try:
el = page.locator(sel)
if el.count() > 0:
t = el.first.text_content().strip()
if t and is_valid_approver(t):
return t
except Exception:
continue
# Strategy 2: Regex in raw HTML for employee ID pattern (e.g. "3086200 - Nguyễn...")
try:
html_content = page.content()
matches = re.findall(r'(\d{5,8}\s*[-–]\s*[^\<\n\r"\']{3,40})', html_content)
if matches:
seen = set()
for m in matches:
m = m.strip()
if m not in seen and is_valid_approver(m):
seen.add(m)
return m
except Exception:
pass
# Strategy 3: Scan all link text_content for employee ID pattern
try:
links = page.locator("a").all()
for link in links:
try:
t = link.text_content().strip()
if is_valid_approver(t):
return t
except Exception:
continue
except Exception:
pass
return "N/A"
def run_revert_automation(warehouse_type, wh_orders):
"""
Runs the Playwright automation to revert orders on tracuunoibo.ghn.vn/internal/revert.
Returns:
dict: {
"success": [order_codes],
"waiting_eform": [order_codes],
"failed": [{"order_code": "code", "reason": "reason"}]
}
"""
results = {
"success": [],
"waiting_eform": [],
"failed": []
}
# Collect all input order codes for tracking
all_order_codes = []
for codes in wh_orders.values():
all_order_codes.extend(codes)
headless = os.getenv("HEADLESS_MODE", "True").lower() == "true"
logger.info(f"Starting revert automation for type: {warehouse_type}")
try:
with chrome_profile_lock(timeout=120):
with sync_playwright() as p:
browser_args = [
"--start-maximized",
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
"--disable-gpu",
"--disable-software-rasterizer",
"--disable-dev-shm-usage",
]
context = p.chromium.launch_persistent_context(
user_data_dir=BOT_USER_DATA,
headless=headless,
args=browser_args,
ignore_default_args=["--enable-automation"],
viewport={"width": 1280, "height": 1000}
)
page = context.new_page()
page.goto("https://tracuunoibo.ghn.vn/internal/revert", wait_until="domcontentloaded", timeout=30000)
# Check for SSO redirect or wait for element
is_expired = False
body_text = page.locator("body").inner_text()
if "Mã nhân viên" in body_text and "Mật khẩu" in body_text:
is_expired = True
else:
try:
# Wait up to 15s for the main page content to load
page.wait_for_selector("text='Cập nhật kho'", timeout=15000)
except Exception:
is_expired = True
curr_url = page.url.lower()
if "sso-v2.ghn.vn" in curr_url or "sso.ghn.vn" in curr_url or "dang-nhap" in curr_url or "login" in curr_url:
is_expired = True
# Verify if we are logged in
if is_expired:
logger.error("Session expired or login required for Tra Cứu Nội Bộ!")
for code in all_order_codes:
results["failed"].append({
"order_code": code,
"reason": "Phiên đăng nhập hết hạn. Vui lòng chạy python3 login_all.py để đăng nhập lại."
})
context.close()
return results
# Loop through each warehouse batch
for batch_idx, (wh_code, order_codes) in enumerate(wh_orders.items()):
logger.info(f"Processing batch {batch_idx + 1} for warehouse {wh_code}: {order_codes}")
is_status_revert = warehouse_type == "giao lại"
try:
if batch_idx > 0:
page.goto("https://tracuunoibo.ghn.vn/internal/revert", wait_until="domcontentloaded", timeout=30000)
time.sleep(1)
if is_status_revert:
# Switch to "Chuyển ngược trạng thái đơn hàng" tab
status_tab = page.locator("text='Chuyển ngược trạng thái đơn hàng'")
if status_tab.count() > 0:
status_tab.first.click()
page.wait_for_selector(".switch-status-revert", timeout=5000)
else:
logger.error("Could not find 'Chuyển ngược trạng thái đơn hàng' tab!")
for code in order_codes:
results["failed"].append({
"order_code": code,
"reason": "Không tìm thấy tab Chuyển ngược trạng thái đơn hàng trên trang."
})
continue
# Select status option: Trả hàng -> Lưu kho (tương ứng với Giao lại)
opt_btn = page.locator(".switch-status-revert:has-text('Trả hàng')")
if opt_btn.count() > 0:
opt_btn.first.click()
time.sleep(0.5)
else:
# Switch to "Cập nhật kho" tab
cap_nhat_kho_tab = page.locator("text='Cập nhật kho'")
if cap_nhat_kho_tab.count() > 0:
cap_nhat_kho_tab.first.click()
page.wait_for_selector(".switch-warehouse-type", timeout=5000)
else:
logger.error("Could not find 'Cập nhật kho' tab!")
for code in order_codes:
results["failed"].append({
"order_code": code,
"reason": "Không tìm thấy tab Cập nhật kho trên trang."
})
continue
# 1. Select the warehouse type radio button
wh_type_btn = page.locator(f".switch-warehouse-type:has-text('{warehouse_type}')")
if wh_type_btn.count() > 0:
wh_type_btn.first.click()
time.sleep(0.5)
# 2. Click "Xóa tất cả" to clear inputs
clear_btn = page.locator("button:has-text('Xóa tất cả') >> visible=true")
if clear_btn.count() > 0:
clear_btn.first.click()
time.sleep(0.5)
# 3. Enter order codes into the tag input
order_input = page.locator("input[placeholder='Nhập đơn hàng']")
visible_input = None
for i in range(order_input.count()):
if order_input.nth(i).is_visible():
visible_input = order_input.nth(i)
break
if not visible_input:
logger.error("Could not find visible tag input box")
for code in order_codes:
results["failed"].append({
"order_code": code,
"reason": "Không tìm thấy ô nhập mã đơn hàng."
})
continue
for code in order_codes:
visible_input.fill(code)
visible_input.press("Enter")
time.sleep(0.15) # Chi du de UI cap nhat tag
if not is_status_revert:
# 4. Open "Chon kho" dropdown
dropdown_container = page.locator("xpath=//div[contains(., 'Chọn kho')]/following-sibling::div").first
dropdown_control = page.locator("xpath=//div[contains(., 'Chọn kho')]/following-sibling::div/div").first
dropdown_control.click()
# Cho o tim kiem dropdown hien ra
search_input = dropdown_container.locator("input[id*='react-select-']").first
search_input.wait_for(state="visible", timeout=5000)
search_input.fill("")
search_input.fill(wh_code)
# Chờ option hiển thị hoặc thông báo không tìm thấy
try:
page.wait_for_selector("xpath=//*[contains(@id, '-option-')] | //*[text()='No options'] | //*[text()='Không có dữ liệu'] | //*[contains(@class, 'react-select__no-options-message')]", timeout=6000)
except Exception:
pass
option_locator = page.locator("[id*='-option-']")
if option_locator.count() == 0:
raise Exception(f"Không tìm thấy kho '{wh_code}' trong danh sách của GHN.")
option = option_locator.first
option_text = option.text_content()
logger.info(f"Selecting warehouse option: {option_text}")
option.click()
# 5. Open "Ly do doi kho" dropdown (only if not already selected)
reason_control = page.locator("xpath=//div[contains(., 'Lý do đổi kho')]/following-sibling::div[contains(@class, 'select-container')]").first
reason_text = reason_control.text_content() if reason_control.count() > 0 else ""
if "Lý do khác" not in reason_text:
try:
reason_control.click()
page.wait_for_selector("text='Lý do khác'", timeout=5000)
page.locator("text='Lý do khác'").click()
except Exception as reason_err:
logger.warning(f"Could not select Lý do khác dropdown: {reason_err}")
# Fallback
try:
page.locator("text='Chọn giá trị'").first.click()
page.wait_for_selector("text='Lý do khác'", timeout=5000)
page.locator("text='Lý do khác'").click()
except Exception:
pass
# Cho textarea hien ra
textarea = page.locator("textarea[placeholder*='lý do khác'], input[placeholder*='lý do khác']")
if textarea.count() > 0:
try:
if not textarea.first.is_visible():
textarea.first.wait_for(state="visible", timeout=3000)
textarea.first.fill(" ")
except Exception:
pass
# 6. Click Cap nhat button
update_btn = page.locator("button:has-text('Cập nhật') >> visible=true")
is_disabled = update_btn.evaluate("el => el.disabled")
if is_disabled:
logger.error("Update button is disabled. Missing inputs?")
for code in order_codes:
results["failed"].append({
"order_code": code,
"reason": "Nút Cập nhật bị khóa (có thể thiếu thông tin kho hoặc lý do)."
})
continue
update_btn.click()
# Cho dialog xac nhan hien ra thay vi sleep(2)
try:
page.wait_for_selector("button:has-text('Đồng ý')", timeout=4000)
except Exception:
pass # Co the khong co dialog
# 7. Handle confirmation dialog
confirm_btn = page.locator("button:has-text('Đồng ý')")
if confirm_btn.count() > 0 and confirm_btn.first.is_visible():
logger.info("Clicking confirmation dialog...")
confirm_btn.first.click()
# 8. Wait for results processing (max 15 seconds)
logger.info("Waiting for processing results...")
success_card = page.locator(".card-container >> visible=true", has=page.locator("text='Danh sách đơn hàng cập nhật thành công'")).first
eform_card = page.locator(".card-container >> visible=true", has=page.locator("text='Danh sách đơn hàng chờ duyệt eform'")).first
failure_card = page.locator(".card-container >> visible=true", has=page.locator("text='Danh sách đơn hàng cập nhật thất bại'")).first
def get_card_text(locator):
try:
if locator.count() > 0:
return locator.text_content() or ""
except Exception:
pass
return ""
# Wait loop checking if all batch orders have landed in one of the cards
start_time = time.time()
while time.time() - start_time < 15:
success_text = get_card_text(success_card)
eform_text = get_card_text(eform_card)
failure_text = get_card_text(failure_card)
# Check if all batch orders are found in the cards text
found_all = True
for code in order_codes:
if (code not in success_text) and (code not in eform_text) and (code not in failure_text):
found_all = False
break
if found_all:
break
time.sleep(1)
# 9. Extract results for this batch
success_text = get_card_text(success_card)
eform_text = get_card_text(eform_card)
failure_text = get_card_text(failure_card)
for code in order_codes:
if code in success_text:
logger.info(f"Order {code} update SUCCESS")
results["success"].append(code)
elif code in eform_text:
logger.info(f"Order {code} update PENDING EFORM")
results["waiting_eform"].append(code)
elif code in failure_text:
# Hover over tag to retrieve dynamic tooltip reason
tag_el = failure_card.locator(f".react-tagsinput-tag:has-text('{code}')").first
reason = "Thất bại không rõ nguyên nhân"
if tag_el.count() > 0:
try:
tag_el.hover()
time.sleep(0.5)
tooltip = page.locator("div.ant-tooltip, div.tooltip, [role='tooltip'], div[class*='tooltip'] >> visible=true").first
if tooltip.count() > 0:
reason = tooltip.text_content().strip()
except Exception as hover_err:
logger.error(f"Hover failed for {code}: {hover_err}")
logger.info(f"Order {code} update FAILED: {reason}")
results["failed"].append({
"order_code": code,
"reason": reason
})
else:
logger.warning(f"Order {code} did not appear in any result card (Timeout)")
results["failed"].append({
"order_code": code,
"reason": "Hệ thống không phản hồi kịp (Quá thời gian chờ)."
})
except Exception as batch_ex:
logger.error(f"Lỗi khi xử lý kho {wh_code}: {batch_ex}", exc_info=True)
for code in order_codes:
results["failed"].append({
"order_code": code,
"reason": f"Lỗi xử lý kho {wh_code}: {clean_exception_message(batch_ex)}"
})
# 10. Retrieve eform details if any order is pending eform approval
if results["waiting_eform"]:
logger.info(f"Starting eform lookup for pending orders: {results['waiting_eform']}")
try:
time.sleep(6) # Chờ 6 giây để hệ thống eForm kịp tạo và đồng bộ phiếu mới
eform_list_url = "https://noibo.ghn.vn/eform/form?sort_by=-1&tab=my_forms"
page.goto(eform_list_url, wait_until="domcontentloaded", timeout=30000)
time.sleep(3.5) # Chờ React SPA tải dữ liệu thực tế tránh trống tạm thời
try:
page.wait_for_selector("tr[data-row-key], .ant-empty, .ant-table-empty", timeout=8000)
except Exception:
pass
rows = page.locator("tr").all()
rows_to_check = []
for row in rows:
try:
row_key = row.get_attribute("data-row-key")
if not row_key:
continue
row_text = row.text_content()
if "Cập nhật kho" in row_text:
tds = row.locator("td")
ticket_id = tds.first.text_content().strip() if tds.count() > 0 else f"Row_{len(rows_to_check)}"
detail_url = f"https://noibo.ghn.vn/eform/form/{row_key}"
rows_to_check.append((ticket_id, detail_url))
if len(rows_to_check) >= 8:
break
except Exception:
continue
found_details = {}
for ticket_id, detail_url in rows_to_check:
try:
page.goto(detail_url, wait_until="domcontentloaded", timeout=20000)
try:
page.wait_for_selector("text='Thông tin cập nhật'", timeout=5000)
except Exception:
try:
page.wait_for_selector("text='Loại cập nhật'", timeout=2000)
except Exception:
time.sleep(2)
# Extract order codes from inputs AND table cells
eform_order_codes = []
# Check input[type='text'] with value attribute
inputs = page.locator("input[type='text']")
for j in range(inputs.count()):
val = inputs.nth(j).get_attribute("value")
if val:
val_clean = val.strip()
if re.match(r'^[A-Z0-9_\-\.]{8,18}$', val_clean):
eform_order_codes.append(val_clean)
# Also check table cells (order list shown as table on eform detail)
table_rows = page.locator("table tr")
for j in range(table_rows.count()):
tds = table_rows.nth(j).locator("td")
for k in range(tds.count()):
try:
cell = tds.nth(k).text_content().strip()
if re.match(r'^[A-Z0-9_\-\.]{8,18}$', cell) and cell not in eform_order_codes:
eform_order_codes.append(cell)
except Exception:
continue
logger.info(f"Eform {ticket_id}: found order codes {eform_order_codes}")
# Extract active approver – multi-strategy
approver = _extract_approver(page)
for o_code in eform_order_codes:
if o_code in results["waiting_eform"]:
found_details[o_code] = {
"approver": approver,
"url": detail_url,
"ticket_code": ticket_id
}
if all(code in found_details for code in results["waiting_eform"]):
break
except Exception as detail_err:
logger.error(f"Lỗi đọc chi tiết eform {ticket_id}: {detail_err}")
results["eform_details"] = found_details
logger.info(f"Eform details lookup complete. Results: {found_details}")
except Exception as lookup_err:
logger.error(f"Error during eform details lookup: {lookup_err}", exc_info=True)
context.close()
except Exception as ex:
logger.error(f"Error in revert automation execution: {ex}", exc_info=True)
# Mark all orders as failed
for code in all_order_codes:
# Avoid duplicating if already processed
processed = (code in results["success"]) or (code in results["waiting_eform"]) or any(f["order_code"] == code for f in results["failed"])
if not processed:
results["failed"].append({
"order_code": code,
"reason": f"Lỗi hệ thống trong tiến trình bot: {clean_exception_message(ex)}"
})
return results
def format_revert_response(warehouse_type, results):
"""
Formats the revert results dict into the Telegram message string.
"""
response_lines = [
f"📋 KẾT QUẢ REVERT ĐƠN HÀNG ({escape_html(warehouse_type.upper())}) 📋\n"
]
# 1. Success Card
response_lines.append("✅ Danh sách đơn hàng cập nhật thành công:")
if results["success"]:
for code in results["success"]:
response_lines.append(f"• {escape_html(code)}")
else:
response_lines.append("Không có")
response_lines.append("")
# 2. Eform Card
response_lines.append("⏳ Danh sách đơn hàng chờ duyệt eform:")
if results["waiting_eform"]:
for code in results["waiting_eform"]:
response_lines.append(f"• {escape_html(code)}")
eform_details = results.get("eform_details", {})
if eform_details:
response_lines.append("")
response_lines.append("📋 DANH SÁCH PHIẾU EFORM CHỜ DUYỆT:")
# Group by approver
approver_map = {} # approver -> list of (ticket_code, url)
for code, detail in eform_details.items():
appr = detail.get("approver", "N/A")
url = detail.get("url", "")
tkt = detail.get("ticket_code", "")
if appr not in approver_map:
approver_map[appr] = []
approver_map[appr].append((tkt, url, code))
for appr, items in approver_map.items():
response_lines.append(f"{escape_html(appr)}")
for tkt, url, order_code in items:
response_lines.append(escape_html(url))
response_lines.append("")
else:
response_lines.append("Không có")
response_lines.append("")
# 3. Failure Card
response_lines.append("❌ Danh sách đơn hàng cập nhật thất bại:")
if results["failed"]:
for entry in results["failed"]:
response_lines.append(f"• {escape_html(entry['order_code'])} (Lý do: {escape_html(entry['reason'])})")
else:
response_lines.append("Không có")
response_lines.append("")
response_lines.append("😊 Em đã làm xong hết! Đại ca cần em xử lí gì thêm không ạ 🫡")
return "\n".join(response_lines)
def get_waiting_eforms_report():
"""
Scrapes the eform portal for pending eforms and returns a formatted report string.
"""
logger.info("Starting manual eform waiting list lookup...")
headless = os.getenv("HEADLESS_MODE", "True").lower() == "true"
try:
with chrome_profile_lock(timeout=120):
with sync_playwright() as p:
browser_args = [
"--start-maximized",
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
]
context = p.chromium.launch_persistent_context(
user_data_dir=BOT_USER_DATA,
headless=headless,
args=browser_args,
ignore_default_args=["--enable-automation"],
viewport={"width": 1280, "height": 1000}
)
to_time = int(time.time() + 86400)
from_time = int(time.time() - 15 * 86400)
url = f"https://noibo.ghn.vn/eform/form?from_time={from_time}&to_time={to_time}&sort_by=-1&tab=my_forms"
page.goto(url, wait_until="domcontentloaded", timeout=30000)
try:
page.wait_for_selector("tr[data-row-key], .ant-empty, .ant-table-empty, input[name='username'], input[type='password'], text='Đăng nhập', text='SSO'", timeout=8000)
except Exception:
pass
curr_url = page.url.lower()
if "sso" in curr_url or "dang-nhap" in curr_url or "login" in curr_url:
raise Exception("Phiên đăng nhập eForm đã hết hạn! Vui lòng gửi lệnh /login vào nhóm để đăng nhập lại.")
rows = page.locator("tr").all()
rows_to_check = []
for row in rows:
try:
row_key = row.get_attribute("data-row-key")
if not row_key:
continue
tds = row.locator("td")
if tds.count() >= 5:
ticket_code = tds.nth(0).text_content().strip()
step_name = tds.nth(4).text_content().strip()
# Skip completed eforms
if any(k in step_name for k in ["Hoàn tất", "Hoàn thành", "Từ chối", "Hủy"]):
continue
detail_url = f"https://noibo.ghn.vn/eform/form/{row_key}"
rows_to_check.append((ticket_code, detail_url))
if len(rows_to_check) >= 15: # limit to avoid excessive pages
break
except Exception:
continue
if not rows_to_check:
context.close()
return "🎉 Không có phiếu eform nào đang chờ duyệt!"
eforms = []
for ticket_code, detail_url in rows_to_check:
try:
page.goto(detail_url, wait_until="domcontentloaded", timeout=20000)
try:
page.wait_for_selector("text='Thông tin cập nhật'", timeout=5000)
except Exception:
try:
page.wait_for_selector("text='Loại cập nhật'", timeout=2000)
except Exception:
time.sleep(2)
# Extract active approver – multi-strategy
approver = _extract_approver(page)
eforms.append({
"ticket_code": ticket_code,
"approver": approver,
"url": detail_url
})
except Exception as e:
logger.error(f"Error checking eform {ticket_code}: {e}")
context.close()
# Format results grouped by approver
approver_map = {} # approver -> list of (ticket_code, url)
for item in eforms:
appr = item["approver"]
tkt = item.get("ticket_code", "")
u = item["url"]
if appr not in approver_map:
approver_map[appr] = []
approver_map[appr].append((tkt, u))
response_lines = [
"🔔 DANH SÁCH PHIẾU EFORM CHỜ DUYỆT 🔔\n"
]
for appr, items in approver_map.items():
response_lines.append(f"{escape_html(appr)}")
for tkt, u in items:
response_lines.append(escape_html(u))
response_lines.append("")
return "\n".join(response_lines).strip()
except Exception as ex:
logger.error(f"Error during manual eform check: {ex}", exc_info=True)
return f"❌ Lỗi hệ thống: Không thể lấy danh sách phiếu eform: {str(ex)}"
def clean_vietnamese_text(text):
if not text:
return ""
text = text.lower()
replacements = {
'á': 'a', 'à': 'a', 'ả': 'a', 'ã': 'a', 'ạ': 'a',
'ă': 'a', 'ắ': 'a', 'ằ': 'a', 'ẳ': 'a', 'ẵ': 'a', 'ặ': 'a',
'â': 'a', 'ấ': 'a', 'ầ': 'a', 'ẩ': 'a', 'ẫ': 'a', 'ậ': 'a',
'é': 'e', 'è': 'e', 'ẻ': 'e', 'ẽ': 'e', 'ẹ': 'e',
'ê': 'e', 'ế': 'e', 'ề': 'e', 'ể': 'e', 'ễ': 'e', 'ệ': 'e',
'í': 'i', 'ì': 'i', 'ỉ': 'i', 'ĩ': 'i', 'ị': 'i',
'ó': 'o', 'ò': 'o', 'ỏ': 'o', 'õ': 'o', 'ọ': 'o',
'ô': 'o', 'ố': 'o', 'ồ': 'o', 'ổ': 'o', 'ỗ': 'o', 'ộ': 'o',
'ơ': 'o', 'ớ': 'o', 'ờ': 'o', 'ở': 'o', 'ỡ': 'o', 'ợ': 'o',
'ú': 'u', 'ù': 'u', 'ủ': 'u', 'ũ': 'u', 'ụ': 'u',
'ư': 'u', 'ứ': 'u', 'ừ': 'u', 'ử': 'u', 'ữ': 'u', 'ự': 'u',
'ý': 'y', 'ỳ': 'y', 'ỷ': 'y', 'ỹ': 'y', 'ỵ': 'y',
'đ': 'd'
}
for k, v in replacements.items():
text = text.replace(k, v)
return text
def scan_ghn_forms_sync():
"""
Quét danh sách phiếu chờ duyệt trên portal eform.
Chỉ lấy các phiếu chứa từ khóa: 'kho giao', 'kho trả', 'kho hiện tại', 'kho lấy'.
Trả về dictionary: { "Tên Nhân Viên": [danh_sách_link_phiếu] }
"""
logger.info("Quét danh sách eform chờ duyệt...")
data_result = {}
headless = os.getenv("HEADLESS_MODE", "True").lower() == "true"
try:
with chrome_profile_lock(timeout=120):
with sync_playwright() as p:
browser_args = [
"--start-maximized",
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
]
context = p.chromium.launch_persistent_context(
user_data_dir=BOT_USER_DATA,
headless=headless,
args=browser_args,
ignore_default_args=["--enable-automation"],
viewport={"width": 1280, "height": 1000}
)
page = context.new_page()
to_time = int(time.time() + 86400)
from_time = int(time.time() - 15 * 86400)
url = f"https://noibo.ghn.vn/eform/form?from_time={from_time}&to_time={to_time}&sort_by=-1&tab=pending_approval"
page.goto(url, wait_until="domcontentloaded", timeout=30000)
# Chờ cho đến khi có ít nhất một dòng dữ liệu, hiển thị trống hoặc bị chuyển hướng đăng nhập
try:
page.wait_for_selector("text='Tạo phiếu', input[name='username'], input[type='password'], text='Đăng nhập', text='SSO'", timeout=8000)
except Exception:
pass
curr_url = page.url.lower()
if "sso" in curr_url or "dang-nhap" in curr_url or "login" in curr_url:
raise Exception("Phiên đăng nhập eForm đã hết hạn! Vui lòng gửi lệnh /login vào nhóm để đăng nhập lại.")
# Chờ thêm 2 giây và đợi skeleton-box tải xong để tránh việc lấy nhầm timestamp làm tên người yêu cầu
try:
time.sleep(2)
page.locator(".skeleton-box").first.wait_for(state="detached", timeout=5000)
except Exception:
pass
rows = page.locator("tr").all()
rows_to_check = []
for row in rows:
try:
row_key = row.get_attribute("data-row-key")
if not row_key:
continue
tds = row.locator("td")
if tds.count() >= 5:
ticket_code = tds.nth(0).text_content().strip()
creator_text = tds.nth(3).text_content().strip()
# Loại bỏ các mốc thời gian khỏi tên người yêu cầu
creator_clean = re.sub(r'\d{2}:\d{2}\s*[-–]\s*\d{2}/\d{2}/\d{4}', '', creator_text)
creator_clean = re.sub(r'\d{2}/\d{2}/\d{4}', '', creator_clean)
creator_clean = re.sub(r'\d{2}:\d{2}', '', creator_clean).strip()
# Trích xuất "1765374 - Huỳnh Thị Mai Trinh" nếu có
creator_match = re.match(r'(\d+\s*[-–]\s*.+)', creator_clean)
if creator_match:
creator = creator_match.group(1).strip()
elif creator_clean:
creator = creator_clean
else:
creator = creator_text
detail_url = f"https://noibo.ghn.vn/eform/form/{row_key}"
rows_to_check.append((ticket_code, creator, detail_url))
except Exception as e:
logger.error(f"Lỗi đọc dòng: {e}")
continue
for ticket_code, creator, detail_url in rows_to_check:
try:
page.goto(detail_url, wait_until="domcontentloaded", timeout=20000)
# Chờ cho đến khi thông tin chi tiết eform được tải và hiển thị
try:
# Chờ text "Thông tin cập nhật" xuất hiện (dấu hiệu dữ liệu eform đã tải từ API xong)
page.wait_for_selector("text='Thông tin cập nhật'", timeout=6000)
except Exception:
try:
page.wait_for_selector("text='Loại cập nhật'", timeout=2000)
except Exception:
time.sleep(2) # Fallback an toàn nếu không tìm thấy text selector
# Lấy nội dung trang chi tiết để lọc từ khóa
body_text = page.locator("body").text_content()
# Cập nhật thông tin người yêu cầu thực tế từ trang chi tiết nếu có
try:
detail_body_text = page.locator("body").inner_text()
creator_match_detail = re.search(r'Tạo bởi:\s*(\d+\s*[-–]\s*[^\n\r]+)', detail_body_text)
if creator_match_detail:
creator = creator_match_detail.group(1).strip()
except Exception as detail_creator_err:
logger.error(f"Lỗi trích xuất người tạo từ chi tiết: {detail_creator_err}")
cleaned_body = clean_vietnamese_text(body_text)
# Bộ lọc 4 loại eform
target_keywords = {
"kho giao": "Kho giao",
"kho tra": "Kho trả",
"kho hien tai": "Kho hiện tại",
"kho lay": "Kho lấy"
}
matched_type = None
for kw, display_name in target_keywords.items():
if kw in cleaned_body:
matched_type = display_name
break
if matched_type:
if creator not in data_result:
data_result[creator] = []
data_result[creator].append({
"url": detail_url,
"type": matched_type
})
logger.info(f"Tìm thấy phiếu hợp lệ của {creator} ({matched_type}): {detail_url}")
else:
logger.info(f"Bỏ qua phiếu {detail_url} vì không khớp từ khóa loại kho.")
except Exception as e:
logger.error(f"Lỗi kiểm tra chi tiết {detail_url}: {e}")
continue
context.close()
except Exception as e:
logger.error(f"Lỗi khi quét dữ liệu GHN: {e}", exc_info=True)
return data_result
def approve_form_on_web_sync(form_url):
"""Tự động duyệt phiếu dựa trên link"""
logger.info(f"Đang duyệt tự động phiếu: {form_url}")
headless = os.getenv("HEADLESS_MODE", "True").lower() == "true"
try:
with chrome_profile_lock(timeout=120):
with sync_playwright() as p:
browser_args = [
"--start-maximized",
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
]
context = p.chromium.launch_persistent_context(
user_data_dir=BOT_USER_DATA,
headless=headless,
args=browser_args,
ignore_default_args=["--enable-automation"],
viewport={"width": 1280, "height": 1000}
)
page = context.new_page()
page.goto(form_url, wait_until="domcontentloaded", timeout=30000)
curr_url = page.url.lower()
if "sso" in curr_url or "dang-nhap" in curr_url or "login" in curr_url:
raise Exception("Phiên đăng nhập đã hết hạn! Vui lòng gửi lệnh /login vào nhóm để đăng nhập lại.")
# Chờ nút duyệt hiển thị
approve_btn = page.locator("button:has-text('Duyệt'), .btn-approve, button:has-text('Phê duyệt')").first
approve_btn.wait_for(state="visible", timeout=10000)
approve_btn.click()
# Chờ xem có hộp thoại xác nhận xuất hiện không (trong vòng 3 giây)
try:
confirm_btn = page.locator("button:has-text('Đồng ý'), button:has-text('Xác nhận'), button:has-text('Đồng ý duyệt'), .ant-modal-confirm-btns button").first
confirm_btn.wait_for(state="visible", timeout=3000)
confirm_btn.click()
logger.info("Đã click nút xác nhận phê duyệt!")
except Exception:
logger.info("Không có hộp thoại xác nhận hoặc không click được nút xác nhận.")
# Chờ xác nhận hoặc xử lý xong
time.sleep(4)
context.close()
return True
except Exception as e:
logger.error(f"Không thể duyệt phiếu {form_url}: {e}", exc_info=True)
return False
def get_choduyet_report():
"""
Scrapes the eform portal for pending eforms and returns a formatted report string
grouped/formatted by active approver as requested by the user.
"""
logger.info("Starting manual eform waiting list lookup for /choduyet...")
headless = os.getenv("HEADLESS_MODE", "True").lower() == "true"
try:
with chrome_profile_lock(timeout=120):
with sync_playwright() as p:
browser_args = [
"--start-maximized",
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
]
context = p.chromium.launch_persistent_context(
user_data_dir=BOT_USER_DATA,
headless=headless,
args=browser_args,
ignore_default_args=["--enable-automation"],
viewport={"width": 1280, "height": 1000}
)
page = context.new_page()
to_time = int(time.time() + 86400)
from_time = int(time.time() - 15 * 86400)
url = f"https://noibo.ghn.vn/eform/form?from_time={from_time}&to_time={to_time}&sort_by=-1&tab=my_forms&status=created"
page.goto(url, wait_until="domcontentloaded", timeout=30000)
try:
page.wait_for_selector("text='Tạo phiếu', input[name='username'], input[type='password'], text='Đăng nhập', text='SSO'", timeout=8000)
except Exception:
pass
curr_url = page.url.lower()
if "sso" in curr_url or "dang-nhap" in curr_url or "login" in curr_url:
raise Exception("Phiên đăng nhập eForm đã hết hạn! Vui lòng gửi lệnh /login vào nhóm để đăng nhập lại.")
rows = page.locator("tr").all()
rows_to_check = []
for row in rows:
try:
row_key = row.get_attribute("data-row-key")
if not row_key:
continue
tds = row.locator("td")
if tds.count() >= 5:
ticket_code = tds.nth(0).text_content().strip()
step_name = tds.nth(4).text_content().strip()
# Skip completed or already approved eforms on the list page
lower_step = step_name.lower()
completed_keywords = ["hoàn tất", "hoàn thành", "từ chối", "hủy", "đã duyệt", "đã phê duyệt", "đã xử lý", "không duyệt", "approved", "completed", "done", "success", "rejected", "cancelled"]
if any(k in lower_step for k in completed_keywords):
continue
detail_url = f"https://noibo.ghn.vn/eform/form/{row_key}"
rows_to_check.append((ticket_code, detail_url))
if len(rows_to_check) >= 25: # limit to avoid excessive pages
break
except Exception:
continue
if not rows_to_check:
context.close()
return "🎉 Không có phiếu eform nào đang chờ duyệt!"
eforms = []
for ticket_code, detail_url in rows_to_check:
try:
page.goto(detail_url, wait_until="domcontentloaded", timeout=20000)
try:
page.wait_for_selector("text='Thông tin cập nhật'", timeout=5000)
except Exception:
try:
page.wait_for_selector("text='Loại cập nhật'", timeout=2000)
except Exception:
time.sleep(2)
# Extract active approver using multi-strategy helper
approver = _extract_approver(page)
# Only include if a valid approver is found (if it returns 'N/A' or invalid, it means it is already approved)
if approver and approver != "N/A" and is_valid_approver(approver):
eforms.append({
"approver": approver,
"url": detail_url
})
except Exception as e:
logger.error(f"Error checking eform {ticket_code} detail: {e}")
context.close()
if not eforms:
return "🎉 Không có phiếu eform nào đang chờ duyệt!"
response_lines = []
for item in eforms:
response_lines.append(f"{item['approver']}\n{item['url']}")
return "\n\n".join(response_lines).strip()
except Exception as ex:
logger.error(f"Error during choduyet check: {ex}", exc_info=True)
return f"❌ Lỗi hệ thống: Không thể lấy danh sách phiếu eform: {str(ex)}"