# -*- 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)}"