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
+
+
+
+
+
+
\ 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