diff --git a/销售订单自动导入.py b/销售订单自动导入.py index d707dc7..77a57da 100644 --- a/销售订单自动导入.py +++ b/销售订单自动导入.py @@ -1,8 +1,8 @@ import argparse import requests import pandas -import time import os +import re from urllib import parse from datetime import datetime, timedelta @@ -21,17 +21,16 @@ 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='https://ultimatron-france.vosfactures.fr/') -parser.add_argument('-C', '--currency', type=str, nargs='?', default='') -parser.add_argument('-D', '--department', type=str, nargs='?', default='') +parser.add_argument('-C', '--currency', type=str, nargs='?', default='USD') +parser.add_argument('-D', '--department', type=str, nargs='?', default='ULT事业部') parser.add_argument('-T', '--days-delta', type=int, nargs='?', default=None) parser.add_argument('-a', '--automation', type=str, choices=['none', 'draft', 'final'], nargs='?', default='none') parser.add_argument('-e', '--encoding', type=str, nargs='?', default='utf-8') -parser.add_argument('-m', '--mappings', type=str, nargs='?', default='') parser.add_argument('-d', '--outdir', type=str, nargs='?', default='.') parser.add_argument('-o', '--output', type=str, nargs='?', default='') -parser.add_argument('-p', '--per-page', type=int, nargs='?', default=5) parser.add_argument('-t', '--timeout', type=int, nargs='?', default=60) parser.add_argument('-i', '--interval', type=int, nargs='?', default=5) +parser.add_argument('-r', '--retry', type=int, nargs='?', default=3) args = parser.parse_args() date = datetime.now() @@ -51,18 +50,21 @@ def main(workbook=None): 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 = "%d/%m/%Y" - + if args.days_delta is not None: datefrom = (datetime.today() - timedelta(days=args.days_delta)) dateto = datetime.today() @@ -83,9 +85,7 @@ def main(workbook=None): data = ET.fromstring(string) except Exception as e: print(f'[警告] 向服务器请求数据时发生了错误:{e}') - return 21 - - time.sleep(args.interval) #1 + continue if data.tag in ['nil-classes', 'hash']: break else: index += 1 @@ -95,108 +95,40 @@ def main(workbook=None): if root is None: print('[!!!!] 服务器返回了无效数据') - return 22 + return 21 def lookup(value, fieldname, excel) -> Result: rows = excel[excel.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: - child = element.find(fieldname) - if bool(child.text): return child.text.strip() + 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: - # 读取产品信息 - print(f'[信息] 正在读取文件:{args.mappings}') - try: - products = pandas.read_excel(args.mappings) - except Exception as e: - print(f'[!!!!] 读取文件时发生了错误:{e}') - return 1 + # 导出发票数据 + invoices = root.findall('invoice') + limit = len(invoices) - print(f'[信息] 已读取产品数据 {len(products)} 条') - - clients = RelationMap(lambda x: text(x, 'client-id')) - invoices = RelationMap(lambda x: text(x, 'number')) - proformas = RelationMap(lambda x: text(x, 'from-invoice-id')) - categories = RelationMap(lambda x: text(x, 'category-id')) - - for invoice in root.findall('invoice'): - number = text(invoice, 'number') - kind = invoice.find('kind').text - - if kind not in ['vat']: - print(f"[警告] {number}: 类型错误 ({kind})") - continue - - if invoice.find('positions') is None: - print(f"[警告] {number}: 缺少产品信息") - continue - - try: - clients.setValueOf(invoice, None) - except Exception as e: - print(f"[警告] {number}: Client 数据错误 ({e})") - continue - - try: - proformas.setValueOf(invoice, None) - except Exception as e: - print(f"[警告] {number}: Proforma 数据错误 ({e})") - continue - - try: - categories.setValueOf(invoice, None) - except Exception as e: - print(f"[警告] {number}: Category 数据错误 ({e})") - continue - - # 有效发票(包含完整的客户、PI和归属数据) - invoices.setValueOf(invoice, invoice) - - print(f'[信息] 已读取有效发票数据 {len(invoices.map)} 条') - if len(invoices.map) == 0: return 0 - - print('[信息] 正在向服务器请求数据') - - for id in clients.map.keys(): - try: - response = requests.get(f'{args.vf_api_url}/clients/{id}.json?api_token={args.vf_token}', timeout=args.timeout) - data = response.json() - clients.map[id] = data - except Exception as e: - print(f'[警告] 向服务器请求数据时发生了错误:{e}') - continue - - time.sleep(args.interval) #2 - - for id in proformas.map.keys(): - try: - response = requests.get(f'{args.vf_api_url}/invoices/{id}.json?api_token={args.vf_token}', timeout=args.timeout) - data = response.json() - proformas.map[id] = data - except Exception as e: - print(f'[警告] 向服务器请求数据时发生了错误:{e}') - continue - - time.sleep(args.interval) #3 - - for id in categories.map.keys(): - try: - response = requests.get(f'{args.vf_api_url}/categories/{id}.json?api_token={args.vf_token}', timeout=args.timeout) - data = response.json() - categories.map[id] = data - except Exception as e: - print(f'[警告] 向服务器请求数据时发生了错误:{e}') - continue + print(f'[信息] 已读取发票数据 {limit} 条') # 订单导入字段 # 详情见 data = FieldArray( '订单号', - '形式发票', + '商机号', '订单日期', '当前处理人', '业绩归属部门', @@ -212,31 +144,34 @@ def main(workbook=None): '产品描述', ) - for invoice in invoices.map.values(): - for position in invoice.find('positions'): - number = text(invoice, 'number') - issue_date = text(invoice, 'issue-date') - category = categories.getValueOf(invoice)['name'] - proforma = proformas.getValueOf(invoice)['number'] - client = clients.getValueOf(invoice)['external_id'] + for index, invoice in enumerate(invoices): + rate = index / limit + number = text(invoice, 'number') + issue_date = text(invoice, 'issue-date') + 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') - id = lookup(code, '产品编号', products).map(lambda x: x[0]).ornone() - product = lookup(code, '产品名称', products).map(lambda x: x[0]).ornone() - description = lookup(code, '产品描述', products).map(lambda x: x[0]).ornone() + 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('形式发票', proforma) + data.append('商机号', relation) data.append('订单日期', issue_date) data.append('当前处理人', category) - data.append('业绩归属部门', args.department or None) + data.append('业绩归属部门', args.department) data.append('客户编号', client) - data.append('币种', args.currency or None) + data.append('币种', args.currency) data.append('订单号', number) data.append('产品名称', product) - data.append('产品编号', id) + data.append('产品编号', None) data.append('产品型号', code) data.append('原价', '%.2f' % price) data.append('折扣率', '%g%%' % discount) @@ -270,7 +205,6 @@ def main(workbook=None): return 6 try: - print(f'[信息] 正在载入 {args.xm_web_url}') driver.get(args.xm_web_url) except TimeoutException: # 停止加载 @@ -294,7 +228,6 @@ def main(workbook=None): if condition is not None: wait = WebDriverWait(parent, timeout=args.timeout) element = wait.until(condition(locator)) - return element except StaleElementReferenceException: # 如果遇到过期元素,重新尝试查找 @@ -306,12 +239,27 @@ def main(workbook=None): # 其他错误 raise e + def click(selector, parent=driver, condition=EC.element_to_be_clickable): + element = locate(selector, True, parent, condition) + try: element.click() + except: driver.execute_script("arguments[0].click();", element) + + def ready(driver): + try: + wait = WebDriverWait(driver, timeout=args.interval) + wait.until(lambda x: 'nprogress-busy' in x.find_element(By.TAG_NAME, 'html').get_attribute('class')) + except TimeoutException: + pass + wait = WebDriverWait(driver, timeout=args.timeout) + wait.until(lambda x: 'nprogress-busy' not in x.find_element(By.TAG_NAME, 'html').get_attribute('class')) + return True + 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) - locate("input.agree-checkbox").click() - locate("button.login-btn").click() + click("input.agree-checkbox") + click("button.login-btn") except Exception as e: print(f'[!!!!] 登录网页时发生了错误:{e}') return 81 @@ -323,13 +271,14 @@ def main(workbook=None): print("[信息] 已登录") break except: - time.sleep(args.interval) #4 + ready(driver) #1 try: - locate(".layout-sidebar ul li.list-none.cpq div div").click() - locate(".layout-sidebar .layout-second-menu li.order a").click() - locate(".list-header-top button.okki-dropdown-trigger").click() - locate(".okki-dropdown-content button").click() + click(".layout-sidebar ul li.list-none.cpq div div") + ready(driver) + click(".layout-sidebar .layout-second-menu li.order a") + 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}') @@ -337,43 +286,46 @@ def main(workbook=None): # 状态菜单映射 status = { 'draft': 1, 'final': 6 } - time.sleep(args.interval) #5 try: # 变更状态 - locate(".product-import-img-box .mm-selector-rendered").click() - locate(f".mm-outside.mm-select-dropdown ul li:nth-child({status.get(args.automation)}) span").click() + click(".product-import-img-box .mm-selector-rendered") + click(f".mm-outside.mm-select-dropdown ul li:nth-child({status.get(args.automation)}) span") path = os.path.abspath(args.output or args.invoices) # 上传文件 locate(".big-file-upload input", wait=False).send_keys(path) - locate(".product-import-img-footer button", condition=EC.element_to_be_clickable).click() + click(".product-import-img-footer button") # 开始导入 - locate(".product-import-img-footer button.mm-button__primary", condition=EC.element_to_be_clickable).click() + click(".product-import-img-footer button.mm-button__primary") except Exception as e: print(f'[!!!!] 上传文件时发生了错误:{e}') return 83 - time.sleep(args.interval) #6 + # 等待订单录入 + 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: - # 等待订单录入 - wait = WebDriverWait(driver, timeout=600) - wait.until(lambda x: x.find_element(By.CSS_SELECTOR, ".img-box-result-title").get_attribute('innerText') == '导入完成') # 查看导入结果 - locate(".mm-notification-container .mm-icon-close").click() - locate(".product-import-img-footer button.mm-button__primary").click() - locate(".mm-tbody table tbody tr:nth-child(1) td:nth-child(3) a").click() + click(".mm-notification-container .mm-icon-close") + click(".product-import-img-footer button.mm-button__primary") + click(".mm-tbody table tbody tr:nth-child(1) td:nth-child(3) a") driver.switch_to.window(driver.window_handles[2]) except Exception as e: print(f'[!!!!] 等待订单录入时发生了错误:{e}') return 84 # 设置每页显示记录数 - time.sleep(args.interval) #7 + ready(driver) #2 url = driver.current_url - param = f'%22page_size%22%3A{args.per_page}' + per_page = 5 + param = f'%22page_size%22%3A{per_page}' if 'page_size' in url: url = url.replace('%22page_size%22%3A20', param) else: url += param @@ -381,25 +333,19 @@ def main(workbook=None): driver.get(url) modified = [] - while True: - # 等待页面加载 - wait = WebDriverWait(driver, timeout=args.timeout) - wait.until(lambda x: 'nprogress-busy' not in x.find_element(By.TAG_NAME, 'html').get_attribute('class')) - time.sleep(args.interval) #8 - - for idx in range(args.per_page): + while ready(driver): + for idx in range(per_page): try: - links = driver.find_elements(By.CSS_SELECTOR, ".list-frame-table .row-items .cell[data-cci='1'] a") - element = links[idx] - except Exception as e: - print(f'[警告] 未找到记录: {e}') + links = driver.find_elements(By.CSS_SELECTOR, ".list-frame-table .vue-recycle-scroller__item-wrapper .cell[data-cci='1'] a") + link = links[idx] + except: break try: - driver.execute_script("arguments[0].click();", element) + driver.execute_script("arguments[0].click();", link) driver.switch_to.window(driver.window_handles[3]) # 编辑订单 - locate(".component-detail-frame-header .okki-space-item:nth-child(1) button").click() + click(".component-detail-frame-header .okki-space-item:nth-child(1) button") warn = False header = locate(".order-edit-header .edit-order-no span span:nth-child(1)") @@ -409,66 +355,100 @@ def main(workbook=None): # 选择商机 try: selected = False - proforma = lookup(number, '形式发票', workbook).map(lambda x: x[0]).unwrap() - index = 1 - dropdown = locate(".component-business-select .mm-selector-rendered") - dropdown.click() + relation = lookup(number, '商机号', workbook).map(lambda x: x[0]).unwrap() - while True: - element = locate(f".mm-outside.mm-select-dropdown ul li:nth-child({index}) span", condition=None) - index += 1 - if element.text.startswith(proforma): - driver.execute_script("arguments[0].scrollIntoView({ block: 'center', inline: 'nearest' });", element) - element.click() + if re.match(r'O\d+', str(relation)) is not None: + 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(relation) + click(".new-header .search-btn span") + ready(driver) + + name = locate("#business-board-drag-wrapper .business-card-wrapper > div .name-wrapper a", wait=False) + relation = name.text + finally: + driver.close() + driver.switch_to.window(driver.window_handles[3]) + + click(".component-business-select .mm-selector-rendered") + wait = WebDriverWait(driver, timeout=args.timeout) + menu = wait.until(lambda x: x.find_element(By.CSS_SELECTOR, ".mm-outside.mm-select-dropdown")) + + for item in menu.find_elements(By.CSS_SELECTOR, "ul li span"): + driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", item) + if item.text.startswith(relation): + item.click() selected = True break - if not selected: - raise Exception('无法找到对应商机') + raise Exception(f'无法找到对应商机 "{relation}"') except Exception as e: print(f"[警告] {number}: 关联商机时发生错误:{e}") warn = True - # 编辑运费 + # 编辑产品 try: + # 设定分页选项 10 条/页 + click(".okki-pagination-options-size-changer") + click(".okki-select-dropdown .rc-virtual-list-holder-inner div:nth-child(1)") + wrapper = locate(".order-edit-product-wrapper .row-items", condition=None) positions = lookup(number, '产品型号', workbook).unwrap() - index = 0 - count = positions.count('port') - limit = len(positions) - ports = [] + ids = [] while True: - driver.execute_script("arguments[0].scrollTo(0, arguments[0].scrollHeight);", wrapper) - items = wrapper.find_elements(By.CSS_SELECTOR, "div.vue-recycle-scroller__item-wrapper div.vue-recycle-scroller__item-view") + driver.execute_script("arguments[0].scrollTo(0, 0);", wrapper) + index = 0 - for item in items: - driver.execute_script("arguments[0].scrollIntoView({ block: 'start', inline: 'end' });", item) - product = locate(f".product-info-group-product-name input", parent=item, condition=None) - # 定位元素 - driver.execute_script("arguments[0].scrollTo(0, arguments[0].scrollHeight);", wrapper) - serial = locate(".cell[data-cci='2'] div div", parent=item, condition=None).text - source = locate(".cell[data-cci='4'] input", parent=item, condition=None) - target = locate(".cell[data-cci='6'] input", parent=item, condition=None) + while index < 10: + if len(ids) >= len(positions): raise KeyboardInterrupt() + # 获取项 + if index >= 5: driver.execute_script("arguments[0].scrollTo(0, arguments[0].scrollHeight);", wrapper) + items = wrapper.find_elements(By.CSS_SELECTOR, ".row-item") - if product.get_attribute('value') == 'port' and serial not in ports: - # 填入含税成本价 - price = source.get_attribute('value') - target.send_keys(price) - index += 1 - ports.append(serial) + for item in items: + # 订单序号 + try: serial = item.find_element(By.CSS_SELECTOR, ".cell[data-cci='2'] .cell-inner div").text + except: continue + if not bool(serial): continue - if index >= count: break - if index >= limit: raise Exception('无法定位指定产品') + # 转到元素 + driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", item) + driver.execute_script("arguments[0].scrollIntoView({ block: 'start' });", wrapper) + + if serial not in ids: + 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') + + # 填入含税成本价 + target = locate(".cell[data-cci='6'] input", parent=item, condition=None) + target.send_keys(price) + ids.append(serial) + index += 1 + + # 下一页 + click(".summary-pagination-wrapper li.okki-pagination-next button", condition=None) + except KeyboardInterrupt: + pass except Exception as e: - print(f"[警告] {number}: 编辑运费时发生错误:{e}") + print(f"[警告] {number}: 编辑产品时发生错误:{e}") warn = True # 保存订单 - button = locate(".order-edit-footer button.okki-btn-primary", condition=None) - driver.execute_script("arguments[0].click();", button) + click(".order-edit-footer button.okki-btn-primary", condition=None) + ready(driver) - time.sleep(args.interval) #9 print(f"[信息] {number}: 修改完成") modified.append(number) @@ -495,21 +475,6 @@ def main(workbook=None): return 0 -class RelationMap: - def __init__(self, predicate): - self.map = {} - self.predicate = predicate - - def getValueOf(self, object): - id = self.predicate(object) - if bool(id): return self.map.get(id) - else: raise Exception(f"Invalid ID: '{id}'") - - def setValueOf(self, object, value): - id = self.predicate(object) - if bool(id): self.map[id] = value - else: raise Exception(f"Invalid ID: '{id}'") - class FieldArray[K, V]: def __init__(self, *args: K): self.map: dict[K, list[V]] = { name: [] for name in args } @@ -527,8 +492,8 @@ class FieldArray[K, V]: array.extend([ padding for _ in range(delta) ]) self.index += 1 -class Result: - def __init__(self, value, error=None): +class Result[T, E]: + def __init__(self, value: T, error: E | None = None): self.value = value self.error = error @@ -554,3 +519,4 @@ except KeyboardInterrupt: status = 145 print(f'[信息] 已修改 {success} 个订单,其中包含 {warning} 条警告信息') print(f'[信息] 总耗时 {datetime.now() - date}') +exit(status) \ No newline at end of file