diff --git a/.gitignore b/.gitignore index 72266b0..8f47547 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,2 @@ -/.venv /.vscode -/.workbooks -*.bat -*.xlsx -*.xml \ No newline at end of file +/venv \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..d55145d --- /dev/null +++ b/index.html @@ -0,0 +1,369 @@ + + + +Order Import + +
+
+ Basic Information +
+
+ Number + +
+
+ Progress + +
+
+ Uptime + +
+
+ Remaining + +
+
+ Status + +
+
+
+
+ + + +
+
+
+
+ Profile Relevant +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ Parameters +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + +
+ +
+
+ + + + \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..d3d8f09 --- /dev/null +++ b/main.py @@ -0,0 +1,784 @@ +import unicodedata +import argparse +import openpyxl +import logging +import json +import re + +from selenium.webdriver import Chrome, ChromeOptions +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.common.exceptions import TimeoutException, NoSuchElementException +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys + +from common import jsonrpc2 +from common.utils import * +from common.actionflow import Action, ActionFlow + +from enum import Enum +from wakepy import keep +from pathlib import Path +from datetime import datetime +from itertools import repeat, count +from urllib3 import PoolManager + +parser = argparse.ArgumentParser(description="Order Import") +parser.add_argument('account', type=str, nargs='?') +parser.add_argument('password', type=str, nargs='?') +parser.add_argument('-d', '--directory', type=str, default=str(Path.home().joinpath('Downloads'))) +parser.add_argument('-o', '--profile', nargs='+', action='append', required=True) +parser.add_argument('-t', '--timeout', type=int, default=60) +parser.add_argument('-r', '--attempts', type=int, default=3) +parser.add_argument('-i', '--interval', type=int, default=3) +parser.add_argument('-l', '--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']) +args = parser.parse_args() + +WEBURL = "https://crm.xiaoman.cn/%s" +APIURL = "https://%s.vosfactures.fr" + +def main(driver: WebDriver, logger = logging.getLogger('main')): + http = PoolManager() + parameters = vars(args) + + class Status(Enum): + IDLE = 0 + READY = 1 + RUNNING = 3 + STANDBY = 4 + + class Profile: + def __init__(self, name, subdomain, token, remise, person=None, prefix=None, suffix=None): + self.name = name + self.subdomain = subdomain + self.token = token + self.remise = remise + self.person = person + self.prefix = prefix + self.suffix = suffix + + def format(self, number: str): + result = ''.join(filter(bool, [self.prefix, number, self.suffix])) + return result + + timer = Timer() + options = dict() + profiles: list[Profile] = list() + status = Status.IDLE + + def begin(opts: dict, args: dict): + nonlocal options, status + options.update(opts) + status = Status.RUNNING + parameters.update(args) + timer.clear() + timer.start() + + def pause(): + nonlocal status + status = Status.STANDBY + timer.pause() + + def resume(): + nonlocal status + status = Status.RUNNING + driver.switch_to.window(driver.current_window_handle) + timer.start() + + jsonrpc2.define('begin', begin) + jsonrpc2.define('pause', pause) + jsonrpc2.define('resume', resume) + jsonrpc2.define('status', lambda: status.name) + jsonrpc2.define('uptime', lambda: timer.delta()) + + try: + for source, profile in zip(parameters.get('profile'), repeat(dict())): + for key, value in map(lambda o: str.split(o, '=', 2), source): + profile[key.lower().strip()] = value.strip() + + item = Profile(**profile) + profiles.append(item) + except Exception as e: + logger.critical('Unable to load profiles', exc_info=e) + return 2 + + try: + manifest = json.dumps(profiles, default=lambda o: vars(o)) + driver.get(str(Path('index.html').resolve())) + driver.execute_script(jsonrpc2.prelude(), manifest, parameters) + except Exception as e: + logger.critical('Unable to load starup page', exc_info=e) + return 3 + + try: + driver.switch_to.new_window('tab') + driver.set_page_load_timeout(parameters.get('timeout')) + driver.get(WEBURL % 'product') + except TimeoutException: + logger.warning('Timeout') + driver.execute_script("window.stop();") + + setup(driver, parameters.get('attempts'), parameters.get('timeout'), parameters.get('interval')) + until(lambda x: 'loginProgress' in x.find_element(By.TAG_NAME, "body").get_attribute('class'), watch=False) + logger.info('Waiting for authentication to complete...') + + if (account := parameters.get('account')) and (password := parameters.get('password')): + try: + logger.info('Logging in as %s (%s)', str.split(account, '@', 1).pop(0).capitalize(), account) + locate("input.account").send_keys(account) + locate("input#password").send_keys(password) + click("input.agree-checkbox") + click("button.login-btn") + except Exception as e: + logger.critical('Unable to login to %s', account, exc_info=e) + return 3 + + while True: + try: + locate("#container", wait=False) + logger.info('Done') + break + except: + sleep(parameters.get('interval')) + + class ProductInfo: + def __init__(self, file): + self.wb = openpyxl.load_workbook(file, read_only=True) + self.headers = list() + + for col in range(1, self.wb.active.max_column + 1): + value = str(self.wb.active.cell(1, col).value) + self.headers.append(value) + + def search(self, by: str, needle) -> dict: + a = self.headers.index(by) + b = None + + for row in range(1, self.wb.active.max_row + 1): + if self.wb.active.cell(row, a+1).value == needle: + b = row + break + + if b is None: return dict() + return { k: self.wb.active.cell(b, c+1).value for c, k in enumerate(self.headers) } + + try: + logger.info('Downloading product list...') + click("header .okki-space .okki-space-item:nth-child(1) button") + click(".okki-dropdown button") + sleep(parameters.get('interval')) + click(".okki-modal.product-export-wrap .mm-selector-rendered") + click(".mm-outside.ui-field-selector-popper .selector-area:nth-child(1) button") + click(".okki-modal.product-export-wrap .okki-modal-footer button.okki-btn-primary") + + while True: + try: + click(".okki-modal.product-export-wrap .okki-modal-footer button.okki-btn-primary") + sleep(parameters.get('interval')) + click(".okki-modal.product-export-wrap .okki-modal-body .virtual-list-wrap .vue-recycle-scroller__item-wrapper > div:nth-child(1) button.okki-btn-link", wait=False) + filename = locate(".okki-modal.product-export-wrap .okki-modal-body .virtual-list-wrap .vue-recycle-scroller__item-wrapper > div:nth-child(1) > div > div:nth-child(1) span").get_attribute('title') + break + except: + sleep(parameters.get('interval')) + + file = Path(parameters.get('directory')).joinpath(filename) + until(lambda _: file.exists(), watch=False) + p = ProductInfo(file) + driver.close() + driver.switch_to.window(driver.window_handles[0]) + status = Status.READY + logger.info('Done') + except Exception as e: + logger.critical('Unable to load products', exc_info=e) + return 4 + + def wait(seconds: float): + while not sleep(seconds): + if status == Status.RUNNING: + break + + def ready(driver: WebDriver): + html = driver.find_element(By.TAG_NAME, 'html') + return 'nprogress-busy' in html.get_attribute('class') + + def fetch(url: str, method = 'GET', retry = parameters.get('attempts')): + for attempt in range(1, retry + 1): + try: + response = http.request(method, url) + result = response.json() + return result + except Exception as e: + logger.error('Error while fetching data from %s, retrying... (%d)', url, attempt, exc_info=e) + assert attempt < retry + + flow = ActionFlow() + profile = None + progress = { 'task': '' } + selection = 0 + + class Wait(Action): + @classmethod + def prepare(cls): + return True + + @classmethod + def perform(cls): + flow.do(cls) + wait(1) + + class Cancel(Action): + @classmethod + def prepare(cls): + nonlocal status + status = Status.RUNNING + return True + + class Skip(Action): + @classmethod + def prepare(cls): + return True + + flow.append(Wait) + flow.allow(Wait) + flow.do(Wait) + + flow.append(Cancel) + flow.append(Skip) + + jsonrpc2.define('actions', lambda: flow.capabilities()) + jsonrpc2.define('cancel', lambda: flow.do(Cancel)) + jsonrpc2.define('skip', lambda: flow.do(Skip)) + jsonrpc2.define('progress', lambda: progress) + + while not wait(1): + try: + progress.clear() + progress['task'] = 'Task 1 of 4' + flow.allow(Cancel, False) + flow.allow(Skip, False) + + if options.get('all'): + profile = profiles[selection] + else: + name = options.pop('profile') + profile = next(filter(lambda o: o.name == name, profiles)) + + for i in range(len(driver.window_handles), 0): + driver.close() + driver.switch_to.window(driver.window_handles[i]) + except (IndexError, KeyError): + logger.info('Done') + status = Status.READY + continue + except StopIteration: + logger.error("Invalid profile '%s'", name) + status = Status.STANDBY + continue + + base = APIURL % profile.subdomain + data = list() + df = options.get('datefrom') + dt = options.get('dateto') + types = ['vat'] + if options.get('avoir'): types.append('correction') + + logger.info('Profile selected: %s', profile.name) + logger.info('Date from %s to %s', df, dt) + flow.allow(Cancel) + + for page in count(1): + try: + result = fetch(f'{base}/invoices.json?{'&'.join([f'kinds%5B%5D={k}' for k in types])}&api_token={profile.token}&include_positions=true&per_page=25&page={page}&period=more&date_from={df}&date_to={dt}') + if 'message' in result: raise Exception(result['message']) + if not isinstance(result, list): raise TypeError() + if len(result) == 0: break + + logger.info('Downloading invoices (%d)', page) + data.extend(result) + flow.react() + wait(parameters.get('interval')) + except Skip: + pass + except Cancel: + status = Status.READY + break + except Exception as e: + logger.error('Error while fetching data from %s', base, exc_info=e) + break + + if len(data) == 0: + logger.warning('Server returned empty response') + selection += 1 + continue + + logger.info('Initializing Workbook...') + progress['task'] = 'Task 2 of 4' + progress['limit'] = len(data) + workbook = openpyxl.Workbook() + sheet = workbook.active + + class Record: + def __init__(self, fields: dict[int, str]): + self.headers = fields + self.data = dict() + + def clear(self): + self.data.clear() + + def __setitem__(self, key, value): + if key not in self.headers: raise KeyError(key) + self.data[key] = value + + def __getitem__(self, key): + if key not in self.headers: raise KeyError(key) + return self.data[key] + + # Required fields + # See + record = Record({ + 1: '订单号', + 2: '订单名称', + 3: '订单日期', + 4: '当前处理人', + 5: '业绩归属部门', + 6: '客户编号', + 7: '币种', + 8: '产品名称', + 9: '产品编号', + 10: '产品型号', + 11: '原价', + 12: '折扣率', + 13: '单价', + 14: '数量', + 15: '产品描述', + 16: 'AVOIR', + }) + sheet.append(record.headers) + categories = dict() + clients = dict() + + try: + for i, item in enumerate(data, 1): + number: str = item['number'] + logger.info('[%d/%d] Preprocessing data for %s', i, len(data), number) + progress['number'] = number + progress['index'] = i-1 + + if (id := item['category_id']) not in categories: + flow.react() + categories[id] = fetch(f'{base}/categories/{id}.json?api_token={profile.token}') + + if (id := item['client_id']) not in clients: + flow.react() + clients[id] = fetch(f'{base}/clients/{id}.json?api_token={profile.token}') + + category = categories.get(item['category_id']) + client = clients.get(item['client_id']) + + identity = client['external_id'] if client['company'] else profile.person + date: str = item['issue_date'] + kind: str = item['kind'] + total = float(item['price_net']) + positions: list = item['positions'] + + for position in positions: + code: str = position['code'] + product: str = position['name'] + description: str = position['description'] + price = float(position['price_net'] or '0') + discount = float(position['discount_percent'] or '0') + quantity = float(position['quantity'] or '0') + + record.clear() + record[1] = profile.format(number) + record[2] = number.replace('/', '-') + record[3] = date + record[4] = category['name'] + record[5] = profile.name + record[6] = identity + record[7] = 'USD' + + match kind: + case 'vat': + record[8] = product + record[9] = (p.search('产品型号', code) or p.search('产品名称', product)).get('产品编号') + record[10] = code + record[11] = '%.2f' % price + record[12] = '%g%%' % discount + record[13] = '%.2f' % (price * (1 - discount / 100)) + record[14] = '%g' % quantity + record[15] = description + case 'correction': + record[8] = p.search('产品编号', profile.remise).get('产品名称') + record[9] = profile.remise + record[13] = '0' + record[14] = '0' + record[16] = '%.2f' % total + positions.clear() + + if record[9] is None: logger.warning("Could not identify product '%s'", product) + sheet.append(record.data) + flow.react() + except Skip: + pass + except Cancel: + status = Status.READY + continue + except Exception as e: + logger.error('Error while processing data', exc_info=e) + status = Status.STANDBY + continue + + try: + filename = f'Order-Import-{profile.name}-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.xlsx' + file = Path(parameters.get('directory')).joinpath(filename) + logger.info('Saving excel file to %s', str(file)) + flow.react() + workbook.save(file) + except Skip: + pass + except Cancel: + status = Status.READY + continue + except Exception as e: + logger.error('Error while saving excel file', exc_info=e) + status = Status.STANDBY + continue + + try: + logger.info('Uploading...') + progress.clear() + progress['task'] = 'Task 3 of 4' + driver.switch_to.new_window('tab') + driver.get(WEBURL % 'order/importOrder') + + click(".product-import-img-box .import-img-radio:nth-child(2) .mm-radio-group > label:nth-child(2) .mm-radio-input", condition=None) + click(".product-import-img-box .mm-selector-rendered") + click(".mm-outside.mm-select-dropdown ul li:nth-child(%d) span" % (1 if options.get('draft') else 6)) + + flow.react() + locate(".big-file-upload input", wait=False).send_keys(str(file)) + click(".product-import-img-footer button") + click(".product-import-img-footer button.mm-button__primary") + + while True: + try: + innerText = driver.find_element(By.CSS_SELECTOR, ".img-box-result-title").get_attribute('innerText') + if innerText == '导入完成': break + except: + wait(parameters.get('interval')) + + click(".mm-notification-container .mm-icon-close") + click(".product-import-img-footer button.mm-button__primary") + err = locate(".mm-tbody table tbody tr:nth-child(1) td:nth-child(5) .okki-space-item:nth-child(1) button") + + if err.get_attribute('disabled') is None: + logger.warning('Incomplete import detected; downloaded 1 related document') + err.click() + sleep(1) + + click(".mm-tbody table tbody tr:nth-child(1) td:nth-child(3) a") + driver.switch_to.window(driver.window_handles[2]) + until(ready) + logger.info('Done') + except Skip: + pass + except Cancel: + status = Status.READY + continue + except Exception as e: + logger.error('Error while uploading excel file', exc_info=e) + status = Status.STANDBY + continue + + class Parse: + def __init__(self, url: str): + from urllib.parse import urlsplit, parse_qs + parts = list(urlsplit(url)) + query = parse_qs(parts[3]) + self.parts = parts + self.query = json.loads(query['query'][0]) if 'query' in query else dict() + + def encode(self, extra=None): + from urllib.parse import urlencode, urlunsplit + query = { 'query': json.dumps(self.query, separators=(',', ':')) } + if extra is not None: query.update(extra) + self.parts[3] = urlencode(query) + return urlunsplit(self.parts) + + def get(self, key: str): + return self.query[key] + + def set(self, key: str, value): + self.query[key] = value + + progress['task'] = 'Task 4 of 4' + progress['limit'] = len(data) + index = 0 + attempts = 0 + + while index < len(data): + try: + item = data[index] + title = re.search(r'O\d+', item['title']) + number = profile.format(item['number']) + positions = item['positions'] + opportunity = None + attempts += 1 + + if attempts > parameters.get('attempts'): + logger.warning('Exhausted all allowed attempts; skipping %s', number) + index += 1 + attempts = 0 + continue + + progress['number'] = number + progress['index'] = index + flow.allow(Cancel) + flow.allow(Skip) + flow.react() + + try: + for page in count(1): + url = Parse(driver.current_url) + url.set('page_size', 1) + url.set('page', page) + url.set('query_filters', [{ + 'field': 'order_no', + 'name': '订单号', + 'operator': 'match', + 'value': number, + 'object_name': 'objOrder', + 'field_type': 35, + 'unit': '', + 'array_flag': 0 + }]) + driver.get(url.encode()) + wait(parameters.get('interval')) + flow.react() + url = Parse(driver.current_url) + if url.get('page') != page: raise Exception(number) + + wait(parameters.get('interval')) + link = locate(".virtual-list-wrap .vue-recycle-scroller .vue-recycle-scroller__item-wrapper > div:nth-child(1) .cell[data-cci='1'] a", wait=False) + if link.text != number: continue + logger.info('[%d/%d] Processing %s...', index+1, len(data), number) + link.click() + driver.switch_to.window(driver.window_handles[3]) + flow.react() + click(".sticky .okki-space-item:nth-child(1) button") + break + except Skip: + index += 1 + attempts = 0 + continue + except Cancel: + status = Status.READY + break + except NoSuchElementException: + logger.warning("Could not find invoice '%s'; skipping", number) + index += 1 + attempts = 0 + continue + except Exception as e: + logger.error("Error while looking up invoice '%s'", number, exc_info=e) + status = Status.STANDBY + continue + + if title is not None and (match := title[0]): + try: + driver.switch_to.new_window('tab') + url = Parse(WEBURL % 'crm/business/list') + driver.get(url.encode({ 'mode': 'list' })) + wait(parameters.get('interval')) + flow.react() + + try: click(".new-wrapper .paas-next-invoice-list-filter-line-wrapper .okki-btn-background-ghost", wait=False) + except: pass + + for page in count(1): + url = Parse(WEBURL % 'crm/business/list') + url.set('keyword', match) + url.set('search_field', 'serial_keyword') + url.set('curPage', page) + url.set('pageSize', 1) + driver.get(url.encode({ 'keyword': match, 'search_field': 'serial_keyword' })) + wait(parameters.get('interval')) + flow.react() + url = Parse(driver.current_url) + if url.get('curPage') != page: raise Exception(match) + + wait(parameters.get('interval')) + cell = locate(".virtual-list-wrap .vue-recycle-scroller .row-item > .cell:nth-child(3) .ow-serial-read-pretty_ellipsis", wait=False) + if cell.text != match: continue + link = locate(".virtual-list-wrap .vue-recycle-scroller .row-item > .cell:nth-child(6) a", wait=False) + opportunity = unicodedata.normalize('NFKD', link.get_attribute('title')) + break + except Skip: + index += 1 + attempts = 0 + continue + except Cancel: + status = Status.READY + break + except NoSuchElementException: + logger.warning("Could not find opportunity '%s'; skipping", match) + except Exception as e: + logger.error("Error while looking up opportunity '%s'", match, exc_info=e) + status = Status.STANDBY + continue + finally: + driver.close() + driver.switch_to.window(driver.window_handles[3]) + + if opportunity is not None: + try: + flow.react() + dropdown = locate("#rc_select_1") + dropdown.clear() + dropdown.send_keys(opportunity) + + menu = locate(".okki-select-dropdown") + menuitems = menu.find_elements(By.CSS_SELECTOR, ".rc-virtual-list-holder-inner > div") + + for menuitem in menuitems: + if menuitem.get_attribute('label').strip().startswith(opportunity): + click(menuitem) + except Skip: + index += 1 + attempts = 0 + continue + except Cancel: + status = Status.READY + break + except Exception as e: + logger.warning('Error while selecting opportunity; skipping', exc_info=e) + + try: + click(".okki-pagination-options-size-changer") + click(".okki-select-dropdown .rc-virtual-list-holder-inner > div:nth-child(1)") + except: + logger.warning('Unable to setup pagination; this may cause issues') + + pids = list() + wrapper = locate(".paas-order-product-list .row-items", condition=None) + class Eureka(Exception): pass + + try: + for page in count(1): + flow.react() + tail = next(reversed(pids), None) + driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", wrapper) + driver.execute_script("arguments[0].scrollTo(arguments[1], arguments[2]);", wrapper, 0, 0) + + for item in wrapper.find_elements(By.CSS_SELECTOR, ".row-item"): + if len(pids) >= len(positions): raise Eureka() + div = item.find_element(By.CSS_SELECTOR, ".cell[data-cci='2'] .cell-inner div") + + if (serial := int(div.text)) and serial not in pids: + flow.react() + base = (serial - tail) if tail is not None else index + height = int(item.get_attribute('clientHeight')) + offset = (base - 1) * height + driver.execute_script("arguments[0].scrollTo(arguments[1], arguments[2]);", wrapper, 0, offset) + #span = item.find_element(By.CSS_SELECTOR, ".cell[data-cci='3'] .product-info-group-product-info:nth-child(3) span") + #code = span.text[6:] + + value = item.find_element(By.CSS_SELECTOR, ".cell[data-cci='4'] input").get_attribute('value') + driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", wrapper) + driver.execute_script("arguments[0].scroll(arguments[1], arguments[2]);", wrapper, 400, offset) + wait(parameters.get('interval')) + target = item.find_element(By.CSS_SELECTOR, ".cell[data-cci='6'] input") + + if (target.get_attribute('value') == '0'): + target.send_keys(Keys.BACKSPACE) + target.send_keys(value) + + pids.append(serial) + + button = locate(".text-right li.okki-pagination-next button", condition=None) + if button.get_attribute('disabled') is not None and len(pids) < len(positions): + raise Exception('Product list imcomplete; expected %d, got %d', len(pids), len(positions)) + flow.react() + click(button) + except Eureka: + pass + except Skip: + index += 1 + attempts = 0 + continue + except Cancel: + status = Status.READY + break + except Exception as e: + logger.error('Error while modifying invoice', exc_info=e) + status = Status.STANDBY + continue + + try: click(".ow-box button.okki-btn-round", wait=False) + except: pass + + try: + wait(parameters.get('interval')) + flow.react() + flow.allow(Cancel, False) + flow.allow(Skip, False) + click(".sticky.bottom-0 button.okki-btn-primary", condition=None) + wait(parameters.get('interval')) + except Skip: + pass + except Cancel: + status = Status.READY + break + except Exception as e: + logger.error('Error while saving document', exc_info=e) + status = Status.STANDBY + continue + finally: + driver.close() + driver.switch_to.window(driver.window_handles[2]) + + index += 1 + attempts = 0 + except Exception as e: + logger.error('Unexpected error', exc_info=e) + status = Status.STANDBY + + try: + driver.close() + driver.switch_to.window(driver.window_handles[1]) + driver.close() + driver.switch_to.window(driver.window_handles[0]) + selection += 1 + except Exception as e: + logger.error('Unexpected error', exc_info=e) + status = Status.STANDBY + +if __name__ == '__main__': + try: + logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s", datefmt="%Y-%m-%d %H:%M") + logger = logging.getLogger() + level = logging.getLevelNamesMapping().get(args.log_level, 'INFO') + logger.setLevel(level) + + logger.info('Initializing...') + history = jsonrpc2.History() + logger.addHandler(history) + opts = jsonrpc2.Options() + jsonrpc2.define('history', lambda: history.truncate()) + jsonrpc2.run(opts) + + logger.info('Creating automation instance') + opts = ChromeOptions() + opts.enable_downloads = True + opts.add_argument('--deny-permission-prompts') + opts.add_experimental_option('prefs', { 'download.default_directory': args.directory }) + + with keep.presenting(): + driver = Chrome(options=opts) + status = main(driver) + except Exception as e: + logger.critical('Fatal error', exc_info=e) + status = 1 + finally: + driver.quit() + exit(status) diff --git a/profiles/profile-example.bat b/profiles/profile-example.bat new file mode 100644 index 0000000..06f3635 --- /dev/null +++ b/profiles/profile-example.bat @@ -0,0 +1,4 @@ +cd /D .\..\ +set SE_PROXY=http://127.0.0.1:10809 +.\venv\Scripts\pythonw.exe .\main.py "user@example.com" "example" --profile "Example-1" --token "TOKEN" --subdomain "eg" --remise "100" +@pause \ No newline at end of file diff --git a/profiles/setup-virtualenv.bat b/profiles/setup-virtualenv.bat new file mode 100644 index 0000000..f2160c3 --- /dev/null +++ b/profiles/setup-virtualenv.bat @@ -0,0 +1,4 @@ +cd /D .\..\ +python -m venv venv +.\venv\Scripts\pip.exe install -r ./requirements.txt +@pause \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1f4f3dc..8244844 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/销售订单自动导入.py b/销售订单自动导入.py deleted file mode 100644 index 8b391d5..0000000 --- a/销售订单自动导入.py +++ /dev/null @@ -1,666 +0,0 @@ -import argparse -import requests -import pandas -import os -import re - -from urllib import parse -from datetime import datetime, timedelta -from xml.etree import ElementTree as ET - -from selenium import webdriver -from selenium.common.exceptions import StaleElementReferenceException, TimeoutException -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.common.action_chains import ActionChains -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait -from selenium.webdriver.remote.webelement import WebElement - -parser = argparse.ArgumentParser(description="销售订单自动导入") -parser.add_argument('invoices', nargs='?', default='') -parser.add_argument('--xm-username', type=str, nargs='?', default='') -parser.add_argument('--xm-password', type=str, nargs='?', default='') -parser.add_argument('--vf-token', type=str, nargs='?', default='') -parser.add_argument('--xm-web-url', type=str, nargs='?', default='https://login.xiaoman.cn/login/') -parser.add_argument('--vf-api-url', type=str, nargs='?', default='') -parser.add_argument('-C', '--currency', type=str, nargs='?', default='USD') -parser.add_argument('-D', '--department', type=str, nargs='?', default='') -parser.add_argument('-T', '--duration', type=str, nargs='*', default=[]) -parser.add_argument('-P', '--prefix', type=str, nargs='?', default='') -parser.add_argument('-a', '--automation', type=str, choices=['none', 'draft', 'final', 'override'], nargs='?', default='none') -parser.add_argument('-e', '--encoding', type=str, nargs='?', default='utf-8') -parser.add_argument('-d', '--outdir', type=str, nargs='?', default='.') -parser.add_argument('-o', '--output', type=str, nargs='?', default='') -parser.add_argument('-t', '--timeout', type=int, nargs='?', default=60) -parser.add_argument('-i', '--interval', type=int, nargs='?', default=5) -parser.add_argument('-k', '--kinds', type=str, nargs='*', default=['vat', 'correction']) -parser.add_argument('-r', '--retry', type=int, nargs='?', default=3) - -args = parser.parse_args() -date = datetime.now() - -success = 0 -warning = 0 - -def main(workbook=None): - if args.invoices.endswith('.xml'): - # 读取发票数据 - print(f'[信息] 正在读取文件:{args.invoices}') - try: - with open(args.invoices, 'r', encoding=args.encoding) as file: - document = ET.parse(file) - root = document.getroot() - except Exception as e: - print(f'[!!!!] 读取文件时发生了错误:{e}') - return 1 - elif args.invoices.endswith('.xlsx'): - # 读取订单数据 - print(f'[信息] 正在读取文件:{args.invoices}') - try: - workbook = pandas.read_excel(args.invoices) - except Exception as e: - print(f'[!!!!] 读取文件时发生了错误:{e}') - return 1 - elif bool(args.invoices): - print(f'[!!!!] 无效参数:{args.invoices}') - return 1 - else: - root = None - index = 1 - format = "%Y-%m-%d" - - match args.duration: - case it if len(it) == 0: - yesterday = (datetime.today() - timedelta(days=1)) - datefrom = yesterday - dateto = yesterday - print(f'[信息] 正在尝试获取 {yesterday.strftime(format)} 的发票数据') - case it if len(it) == 1 and (duration := str(it[0])) and duration.isnumeric(): - datefrom = (datetime.today() - timedelta(days=int(duration))) - dateto = datetime.today() - print(f'[信息] 正在尝试获取自 {datefrom.strftime(format)} 到 {dateto.strftime(format)} 的发票数据') - case it if len(it) == 2: - datefrom = datetime.strptime(it[0], format) - dateto = datetime.strptime(it[1], format) - print(f'[信息] 正在尝试获取自 {datefrom.strftime(format)} 到 {dateto.strftime(format)} 的发票数据') - case _: - print(f'[!!!!] 无效参数:{args.duration}') - return 11 - - format = "%d/%m/%Y" - datefrom = parse.quote(datefrom.strftime(format)) - dateto = parse.quote(dateto.strftime(format)) - kinds = '&'.join([f'kinds%5B%5D={k}' for k in args.kinds]) - - if not args.vf_token or not args.vf_api_url: - print('[!!!!] 缺少参数:vf_token 或 vf_api_url') - return 12 - - while True: - try: - response = requests.get(f'{args.vf_api_url}/invoices.xml?{kinds}&include_positions=true&per_page=25&page={index}&period=more&date_from={datefrom}&date_to={dateto}&search_date_type=issue_date&api_token={args.vf_token}', timeout=args.timeout) - string = response.content.decode(args.encoding) - data = ET.fromstring(string) - except Exception as e: - print(f'[警告] 向服务器请求数据时发生了错误:{e}') - continue - - if data.tag in ['nil-classes', 'hash']: break - else: index += 1 - - if root is None: root = data - else: root.extend(data.findall('invoice')) - - if root is None: - print('[!!!!] 服务器返回了无效数据') - return 21 - - def lookup(value, fieldname, df: pandas.DataFrame) -> Result: - df = df.astype(object).where(df.notna(), None) - rows = df[df.isin([value]).any(axis=1)] - data = rows.to_dict(orient='list') - return Result(data.get(fieldname)) - - def fetch(url, error=0) -> Result[requests.Response, Exception]: - while True: - try: - response = requests.get(url, timeout=args.timeout) - return Result(response) - except Exception as e: - if error < args.retry: error += 1 - else: return Result(None, e) - - def text(element, fieldname) -> str: - result = Result(element).map(lambda x: x.find(fieldname).text.strip()) - text = result.ornone() - if bool(text): return text - else: return None - - if workbook is None: - # 导出发票数据 - invoices = root.findall('invoice') - limit = len(invoices) - print(f'[信息] 已读取发票数据 {limit} 条') - - # 订单导入字段 - # 详情见 - data = FieldArray( - '订单号', - '商机号', - '订单日期', - '当前处理人', - '业绩归属部门', - '客户编号', - '币种', - '产品名称', - '产品编号', - '产品型号', - '原价', - '折扣率', - '单价', - '数量', - '产品描述', - 'AVOIR' - ) - - for index, invoice in enumerate(invoices): - rate = index / limit - kind = text(invoice, 'kind') - number = text(invoice, 'number') - issue_date = text(invoice, 'issue-date') - total = float(text(invoice, 'price-net') or '0') - - print(f'[信息] 正在载入 {number} ... {str(round(rate * 100)).rjust(3)} %') - - relation = fetch(f'{args.vf_api_url}/invoices/{text(invoice, 'from-invoice-id')}.json?api_token={args.vf_token}').map(lambda res: res.json()['number']).orelse(Result(text(invoice, 'title')).map(lambda x: x.split(maxsplit=1)[0])).ornone() - category = fetch(f'{args.vf_api_url}/categories/{text(invoice, 'category-id')}.json?api_token={args.vf_token}').map(lambda res: res.json()['name']).ornone() - client = fetch(f'{args.vf_api_url}/clients/{text(invoice, 'client-id')}.json?api_token={args.vf_token}').map(lambda res: res.json()['external_id']).ornone() - - for position in invoice.find('positions'): - code = text(position, 'code') - product = text(position, 'name') - description = text(position, 'description') - price = float(text(position, 'price-net') or '0') - discount = float(text(position, 'discount-percent') or '0') - quantity = float(text(position, 'quantity') or '0') - - data.append('订单号', args.prefix + number) - data.append('商机号', relation) - data.append('订单日期', issue_date) - data.append('当前处理人', category) - data.append('业绩归属部门', args.department) - data.append('客户编号', client) - data.append('币种', args.currency) - match kind: - case 'vat': - data.append('产品名称', product) - data.append('产品型号', code) - data.append('原价', '%.2f' % price) - data.append('折扣率', '%g%%' % discount) - data.append('单价', '%.2f' % (price * (1 - discount / 100))) - data.append('数量', '%g' % quantity) - data.append('产品描述', description) - case 'correction': - data.append('产品名称', 'REMISE SPECIAL') - data.append('产品编号', '186') - data.append('单价', '0') - data.append('数量', '0') - data.append('AVOIR', '%.2f' % total) - data.newrow() - - # 新建导入数据表 - if not bool(args.output): args.output = f'{args.outdir}/ultimatron-orders-import-{date.strftime("%Y%m%d-%H%M%S-%f")}.xlsx' - print(f'[信息] 正在写入文件:{args.output}') - - try: - workbook = pandas.DataFrame(data.map) - workbook.to_excel(args.output, index=False, sheet_name='Sheet1') - except Exception as e: - print(f'[!!!!] 写入文件时发生了错误:{e}') - return 3 - - if args.automation == 'none': return 0 - print('[信息] 正在启动自动化程序') - - try: - opts = webdriver.ChromeOptions() - opts.add_experimental_option("excludeSwitches", ["enable-logging"]) - - driver = webdriver.Chrome(opts) - driver.set_page_load_timeout(args.timeout) - except Exception as e: - print(f'[!!!!] 初始化时发生了错误:{e}') - return 6 - - try: - driver.get(args.xm_web_url) - except TimeoutException: - # 停止加载 - print(f'[警告] 操作超时') - driver.execute_script("window.stop();") - except Exception as e: - print(f'[警告] 载入网页时发生了错误:{e}') - return 7 - - def locate(selector, wait=True, parent=driver, condition=EC.visibility_of_element_located) -> WebElement: - while True: - try: - locator = (By.CSS_SELECTOR, selector) - if not wait: return parent.find_element(*locator) - - wait = WebDriverWait(parent, timeout=args.timeout) - element = wait.until(EC.presence_of_element_located(locator)) - # 查看元素 - driver.execute_script("arguments[0].scrollIntoView({ block: 'center', inline: 'nearest' });", element) - - if condition is not None: - wait = WebDriverWait(parent, timeout=args.timeout) - element = wait.until(condition(locator)) - return element - except StaleElementReferenceException: - # 如果遇到过期元素,重新尝试查找 - continue - except TimeoutException: - # 超时错误 - raise Exception('操作超时') - except Exception as e: - # 其他错误 - raise e - - def click(selector, wait=True, parent=driver, condition=EC.element_to_be_clickable): - element = locate(selector, wait, parent, condition) if isinstance(selector, str) else selector - counter = lambda: int(element.get_attribute('taximeter') or 0) - error = False - - value = counter() - driver.execute_script("arguments[0].addEventListener('click', () => arguments[0].setAttribute('taximeter', arguments[1] + 1));", element, value) - - for attempt in range(args.retry): - try: - if not error: element.click() - else: driver.execute_script("arguments[0].click();", element) - except StaleElementReferenceException: - break - except: - error = True - continue - # 检测点击事件 - try: - WebDriverWait(driver, args.interval).until(lambda _: counter() > value) - break - except TimeoutException: continue - except: break - - def ready(driver): - try: - condition = lambda x: 'nprogress-busy' in x.find_element(By.TAG_NAME, 'html').get_attribute('class') - wait = WebDriverWait(driver, timeout=args.interval) - wait.until(condition) - except (TimeoutException, StaleElementReferenceException): - pass - wait = WebDriverWait(driver, timeout=args.timeout) - wait.until_not(condition) - return True - - def keyin(element: WebElement, value): - try: element.send_keys(value) - except: driver.execute_script("arguments[0].value = arguments[1]", element, value) - - if bool(args.xm_username) and bool(args.xm_password): - try: - locate("input.account").send_keys(args.xm_username) - locate("input#password").send_keys(args.xm_password) - click("input.agree-checkbox") - click("button.login-btn") - except Exception as e: - print(f'[!!!!] 登录网页时发生了错误:{e}') - return 81 - - print("[信息] 正在检测登录状态...") - while True: - try: - driver.find_element(By.ID, 'container') - print("[信息] 已登录") - break - except: - ready(driver) #1 - - try: - click(".layout-sidebar ul li.list-none.cpq div div") - ready(driver) - click(".layout-sidebar .layout-second-menu li.order a") - except Exception as e: - print(f'[!!!!] 尝试检索订单时发生了错误:{e}') - return 82 - - # 状态菜单映射 - if index := { 'draft': 1, 'final': 6 }.get(args.automation): - try: - click(".list-header-top button.okki-dropdown-trigger") - click(".okki-dropdown-content button") - driver.switch_to.window(driver.window_handles[1]) - except Exception as e: - print(f'[!!!!] 尝试进入录入订单页面时发生了错误:{e}') - return 83 - - try: - # 选择不导入无对应产品的订单 - click(".product-import-img-box .import-img-radio:nth-child(2) .mm-radio-group > label:nth-child(2) .mm-radio-input", condition=None) - # 变更状态 - click(".product-import-img-box .mm-selector-rendered") - click(f".mm-outside.mm-select-dropdown ul li:nth-child({index}) span") - - path = os.path.abspath(args.output or args.invoices) - # 上传文件 - locate(".big-file-upload input", wait=False).send_keys(path) - click(".product-import-img-footer button") - # 开始导入 - click(".product-import-img-footer button.mm-button__primary") - except Exception as e: - print(f'[!!!!] 上传文件时发生了错误:{e}') - return 83 - - # 等待订单录入 - while True: - try: - innerText = driver.find_element(By.CSS_SELECTOR, ".img-box-result-title").get_attribute('innerText') - if innerText == '导入完成': break - except: - ready(driver) #1 - - try: - # 查看导入结果 - click(".mm-notification-container .mm-icon-close") - click(".product-import-img-footer button.mm-button__primary") - - cell = locate(".mm-tbody table tbody tr:nth-child(1) td:nth-child(3)", condition=None) - indicator = locate("div > div > span", parent=cell, condition=None) - error = int(indicator.text[3:]) - - if error != 0: - print(f'[警告] {error} 个订单导入失败') - print(f'[信息] 提示:可下载失败记录') - except Exception as e: - print(f'[!!!!] 等待订单录入时发生了错误:{e}') - return 84 - - try: - click("a", wait=False, parent=cell) - driver.switch_to.window(driver.window_handles[2]) - except: - print('[!!!!] 查看导入结果时发生了错误') - return 85 - - ready(driver) #2 - modified = [] - - def modify(link: WebElement, handle=3): - click(link) - driver.switch_to.window(driver.window_handles[handle]) - click(".sticky .okki-space-item:nth-child(1) button") - - warn = False - associative = False - relation = None - number = Result(None).map(lambda _: locate("h1.serial-input-title span").get_attribute('innerText')).ornone() - correction = lookup(number, 'AVOIR', workbook).map(lambda x: x[0]).ornone() - - if number is not None and number not in modified and correction is None: - try: - # 选择商机 - for attempt in range(args.retry): - try: - relation = lookup(number, '商机号', workbook).map(lambda x: x[0]).unwrap() - needle = str(relation).strip() - - if match := Result(re.search(r'O\d+', needle)).map(lambda x: x[0]).ornone(): - try: - href = locate(".layout-sidebar ul li.list-none.opportunity a").get_attribute('href') - driver.switch_to.new_window('tab') - driver.get(href) - - # 检索商机 - locate(".new-header input").send_keys(match) - click(".new-header .search-btn span") - ready(driver) - - name = locate("#business-board-drag-wrapper .business-card-wrapper > div .name-wrapper a", wait=False) - needle = name.text.strip() - finally: - driver.close() - driver.switch_to.window(driver.window_handles[handle]) - - dropdown = locate("#rc_select_1") - dropdown.clear() - dropdown.send_keys(needle) - - wait = WebDriverWait(driver, timeout=args.timeout) - menu = wait.until(lambda x: x.find_element(By.CSS_SELECTOR, ".okki-select-dropdown")) - - for item in menu.find_elements(By.CSS_SELECTOR, ".rc-virtual-list-holder-inner > div"): - if item.get_attribute('label').strip().startswith(needle): - click(item) - associative = True - raise KeyboardInterrupt() - # 关闭菜单栏 - click(dropdown) - except KeyboardInterrupt: - break - except Exception as e: - print(f"[警告] {number}: 关联商机时发生错误:{e}") - warn = True - break - - if not associative: - print(f'[警告] {number}: 无法找到对应商机 "{relation}"') - warn = True - - # 设定分页选项 10 条/页 - try: - click(".okki-pagination-options-size-changer") - click(".okki-select-dropdown .rc-virtual-list-holder-inner > div:nth-child(1)") - except: - print(f'[警告] {number}: 分页选项设置失败') - pass - - # 编辑产品 - try: - wrapper = locate(".paas-order-product-list .row-items", condition=None) - positions = lookup(number, '产品型号', workbook).unwrap() - ids = [] - - while (index := 1): - for attempts in range(args.retry): - if index >= 10: break - tail = Result(ids).map(lambda x: x[-1:][0]).ornone() - driver.execute_script("arguments[0].scrollIntoView({ block: 'end' });", wrapper) - driver.execute_script("arguments[0].scrollTo(arguments[1], arguments[2]);", wrapper, 0, 0) - - for item in wrapper.find_elements(By.CSS_SELECTOR, ".row-item"): - if len(ids) >= len(positions): raise KeyboardInterrupt() - serial = Result(item.find_elements(By.CSS_SELECTOR, ".cell[data-cci='2'] .cell-inner div")).map(lambda x: int(x[0].text)).ornone() - - if serial is not None and serial not in ids: - base = (serial - tail) if tail is not None else index - height = int(item.get_attribute('clientHeight')) - offset = (base - 1) * height - driver.execute_script("arguments[0].scrollTo(arguments[1], arguments[2]);", wrapper, 0, offset) - - click(".product-info-group-product-info a.jump-link", parent=item) - price = locate(".okki-drawer-body div[title='含税成本价'] + div div.mm-formily-preview-text", condition=None).text - click(".okki-drawer-body .space-header-after button") - - wait = WebDriverWait(driver, timeout=args.timeout) - wait.until_not(lambda x: x.find_element(By.CSS_SELECTOR, ".okki-drawer-body").is_displayed()) - - original = locate(".cell[data-cci='4'] input", parent=item, condition=None) - if price == '--': price = original.get_attribute('value') - - driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", wrapper) - driver.execute_script("arguments[0].scroll(arguments[1], arguments[2]);", wrapper, 400, offset) - - # 填入含税成本价 - target = locate(".cell[data-cci='6'] input", parent=item, condition=None) - length = len(target.get_attribute('value')) - target.send_keys(Keys.BACKSPACE * length) - target.send_keys(price) - - ids.append(serial) - index += 1 - - # 下一页 - button = locate(".text-right li.okki-pagination-next button", condition=None) - if bool(button.get_attribute('disabled')) and len(ids) < len(positions): raise Exception(f'产品信息不完整。缺少:{', '.join(positions[len(ids):])}') - click(button) - except KeyboardInterrupt: - pass - - # 删除 Avoir 费用 - try: click(".ow-box button.okki-btn-round", wait=False) - except: pass - - # 保存订单 - ready(driver) - click(".sticky.bottom-0 button.okki-btn-primary", condition=None) - ready(driver) - - print(f"[信息] {number}: 修改完成") - modified.append(number) - - global success - success += 1 - except Exception as e: - print(f"[警告] {number}: 编辑订单时发生了错误:{e}") - warn = True - - global warning - if warn: warning += 1 - - # 关闭标签页 - driver.close() - driver.switch_to.window(driver.window_handles[handle-1]) - - # 设置分页选项 10 条/页 - try: - click(".paas-invoice-list-frame .okki-pagination-options-size-changer") - click(".okki-select-dropdown .rc-virtual-list-holder-inner > div:nth-child(1)") - ready(driver) - except: - print(f'[警告] 分页选项设置失败') - pass - - if args.automation == 'override': - for number in workbook['订单号'].unique(): - try: - box = locate(".list-header-wrap div[data-item-name='订单号']", condition=None) - search = locate("input", parent=box, condition=None) - length = len(search.get_attribute('value')) - click(box) - ActionChains(driver).send_keys(Keys.BACKSPACE * length).send_keys(number).send_keys(Keys.ENTER).perform() - except Exception as e: - print(f'[警告] {number}: 检索订单时发生了错误:{e}') - continue - - entry = None - department: str = lookup(number, '业绩归属部门', workbook).map(lambda x: x[0]).ornone() - - for attempt in range(args.retry): - try: - while ready(driver): - for link in driver.find_elements(By.CSS_SELECTOR, ".list-frame-table .vue-recycle-scroller__item-wrapper .cell[data-cci='1'] a"): - name = link.text.strip() - cell = link.find_element(By.XPATH, '../../../../div[@data-cci="6"]/div/span') - title = cell.get_attribute('title').strip() - - if link.is_displayed() and name == number and title == department: - entry = link - raise KeyboardInterrupt() - - # 下一页 - button = locate(".okki-pagination-next button", condition=None) - if bool(button.get_attribute('disabled')): break - click(button) - except KeyboardInterrupt: - break - except StaleElementReferenceException: - continue - except Exception as e: - print(f'[警告] {number}: 发生错误:{e}') - continue - - if entry is None: - print(f'[警告] {number}: 无法找到对应发票') - continue - - try: - modify(entry, handle=1) - except Exception as e: - print(f'[!!!!] {number}: 编辑订单时发生了错误:{e}') - continue - - return 0 - - while ready(driver): - for link in driver.find_elements(By.CSS_SELECTOR, ".list-frame-table .vue-recycle-scroller__item-wrapper .cell[data-cci='1'] a"): - try: - modify(link) - except Exception as e: - print(f'[!!!!] 编辑订单时发生了错误:{e}') - return 85 - - try: - button = locate(".paas-invoice-list-frame .list-okki-footer-wrap li.okki-pagination-next button", wait=False) - if bool(button.get_attribute('disabled')): raise KeyboardInterrupt() - click(button) - except: - print('[信息] 已经是最后一页') - break - - return 0 - -class FieldArray[K, V]: - def __init__(self, *args: K): - self.map: dict[K, list[V]] = { name: [] for name in args } - self.index = 0 - pass - - def append(self, key: K, value: V): - array = self.map.get(key, []) - array.insert(self.index, value) - - def newrow(self, padding=None): - for array in self.map.values(): - limit = len(array) - 1 - delta = self.index - limit - array.extend([ padding for _ in range(delta) ]) - self.index += 1 - -class Result[T, E]: - def __init__(self, value: T, error: E | None = None): - self.value = value - self.error = error - - def orelse(self, other): - if self.error is not None: return other - else: return self - - def ornone(self): - if self.error is not None: return None - else: return self.value - - def unwrap(self): - if self.error is not None: raise self.error - else: return self.value - - def map(self, f): - if self.error is not None: return self - try: return Result(f(self.value)) - except Exception as e: return Result(None, e) - -try: status = main() -except KeyboardInterrupt: status = 145 - -print(f'[信息] 已修改 {success} 个订单,其中包含 {warning} 条警告信息') -print(f'[信息] 总耗时 {datetime.now() - date}') -exit(status) \ No newline at end of file