From 529e7e15498c026885a977d750cc6074859bdcc4 Mon Sep 17 00:00:00 2001 From: break27 Date: Thu, 24 Apr 2025 10:52:33 +0200 Subject: [PATCH] initial commit --- .gitignore | 4 + requirements.txt | Bin 0 -> 988 bytes 销售订单自动导入.py | 450 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 454 insertions(+) create mode 100644 .gitignore create mode 100644 requirements.txt create mode 100644 销售订单自动导入.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4812a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +.vscode/ +.workbooks/ +*.xlsx \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..1f4f3dcd852c1730fd8bfc0582f1c38395e0646f GIT binary patch literal 988 zcmZXT;ZDLp5QO))iH`!I1)~1&9ZY-xQ?W=RmP$)N9$x)s_pTUCQ-s^unc2DZ`>V2n zXK%gT^OUxOtoPdU-)b>|3I#ttG%*;H8aRbOP>_< zH&0vm&gQOjx`Wuc5+Cv36!ilRw>%9F=FSj%m(%+1ZNlurOFNt@$J;UABThP4cT_Ja IiZwF%0WFY^o&W#< literal 0 HcmV?d00001 diff --git a/销售订单自动导入.py b/销售订单自动导入.py new file mode 100644 index 0000000..111cbe6 --- /dev/null +++ b/销售订单自动导入.py @@ -0,0 +1,450 @@ +import argparse +import requests +import pandas +import time +import os + +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.support import expected_conditions as EC +from selenium.webdriver.support.wait import WebDriverWait + +parser = argparse.ArgumentParser(description="销售订单自动导入脚本") +parser.add_argument('invoices', nargs='?') +parser.add_argument('products') +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, required=True) +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('--outdir', type=str, nargs='?', default='.') +parser.add_argument('-a', '--automation', type=str, choices=['none', 'draft', 'done'], nargs='?', default='none') +parser.add_argument('-e', '--encoding', type=str, nargs='?', default='utf-8') +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=3) + +args = parser.parse_args() +date = datetime.now() + +success = 0 +warning = 0 + +def main(): + # 读取产品信息 + print(f'[信息] 正在读取文件:{args.products}') + try: + products = pandas.read_excel(args.products) + except Exception as e: + print(f'[!!!!] 读取文件时发生了错误:{e}') + return 1 + + print(f'[信息] 已读取产品数据 {len(products)} 条') + + if args.invoices: + # 读取发票数据 + 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 2 + else: + root = None + index = 1 + yesterday = (datetime.today() - timedelta(days=1)).strftime("%d/%m/%Y") + quote = parse.quote(yesterday) + + print(f'[信息] 正在尝试获取 {yesterday} 的 Facture 数据') + while True: + try: + response = requests.get(f'{args.vf_api_url}/invoices.xml?kind=vat&include_positions=true&per_page=25&page={index}&period=more&date_from={quote}&date_to={quote}&search_date_type=issue_date&status=paid&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}') + return 21 + + time.sleep(args.interval) #1 + + if data.tag == 'nil-classes': break + else: index += 1 + + if root is None: root = data + else: root.extend(data.findall('invoice')) + + if root is None: + print('[!!!!] 服务器返回了无效数据') + return 22 + + def lookup(value, fieldname) -> str: + rows = products[products.isin([value]).any(axis=1)] + data = rows.to_dict(orient='list') + try: return data.get(fieldname)[0] + except: return '' + + def text(element, fieldname) -> str: + child = element.find(fieldname) + if bool(child.text): return child.text.strip() + else: return '' + + 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 + + # 订单导入字段 + # 详情见 + data = FieldArray( + '订单号', + '订单日期', + '当前处理人', + '客户编号', + '币种', + '产品名称', + '产品编号', + '产品型号', + '原价', + '折扣率', + '单价', + '数量', + '产品描述', + '订单金额', + ) + + 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'] + client = clients.getValueOf(invoice)['external_id'] + + code = text(position, 'code') + id = lookup(code, '产品编号') + product = lookup(code, '产品名称') + description = lookup(code, '产品描述') + price = float(text(position, 'price-net')) + discount = float(text(position, 'discount-percent') or '0') + quantity = float(text(position, 'quantity')) + + data.append('订单日期', issue_date) + data.append('当前处理人', category) + data.append('客户编号', client) + data.append('币种', 'USD') + data.append('订单号', number) + data.append('产品名称', product) + data.append('产品编号', id) + data.append('产品型号', code) + data.append('原价', '%.2f' % price) + data.append('折扣率', '%g%%' % discount) + data.append('产品描述', description) + data.append('单价', '%.2f' % (price * (1 - discount / 100))) + data.append('数量', '%g' % quantity) + 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('[信息] 正在启动自动化程序') + + if not bool(args.xm_username): + print(f"[!!!!] 错误:缺少参数 'xm-username'(小满账号邮箱)") + return 4 + + if not bool(args.xm_password): + print(f"[!!!!] 错误:缺少参数 'xm-password'(小满账号密码)") + return 5 + + try: + driver = webdriver.Chrome() + driver.set_page_load_timeout(args.timeout) + except Exception as e: + print(f'[!!!!] 初始化时发生了错误:{e}') + return 6 + + try: + print(f'[信息] 正在载入 {args.xm_web_url}') + 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): + 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) + element = wait.until(condition(locator)) + + return element + except StaleElementReferenceException: + # 如果遇到过期元素,重新尝试查找 + continue + except TimeoutException: + # 超时错误 + raise Exception('操作超时') + except Exception as e: + # 其他错误 + raise e + + 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() + except Exception as e: + print(f'[!!!!] 登录网页时发生了错误:{e}') + return 81 + + try: + locate(".layout-sidebar ul li:nth-child(10) div div").click() + locate(".layout-sidebar .layout-second-menu li:nth-child(3) a").click() + locate(".list-header-top button.okki-dropdown-trigger").click() + locate(".okki-dropdown-content button").click() + driver.switch_to.window(driver.window_handles[1]) + except Exception as e: + print(f'[!!!!] 尝试进入录入订单页面时发生了错误:{e}') + return 82 + + # 状态菜单映射 + status = { 'draft': 1, 'done': 6 } + time.sleep(args.interval) #4 + + 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() + + # 上传文件 + locate(".big-file-upload input", wait=False).send_keys(os.path.abspath(args.output)) + locate(".product-import-img-footer button", condition=EC.element_to_be_clickable).click() + + # 开始导入 + locate(".product-import-img-footer button.mm-button__primary", condition=EC.element_to_be_clickable).click() + except Exception as e: + print(f'[!!!!] 上传文件时发生了错误:{e}') + return 83 + + time.sleep(args.interval) #5 + + try: + # 等待订单录入 + wait = WebDriverWait(driver, timeout=(args.timeout * 4)) + wait.until(lambda x: x.find_element(By.CSS_SELECTOR, ".img-box-result-title").get_attribute('innerText') == '导入完成') + # 查看导入结果 + locate(".mm-notification-container .mm-icon-close", wait=False).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() + driver.switch_to.window(driver.window_handles[2]) + except Exception as e: + print(f'[!!!!] 等待订单录入时发生了错误:{e}') + return 84 + + time.sleep(args.interval) #6 + + while True: + links = driver.find_elements(By.CSS_SELECTOR, ".list-frame-table .virtual-list-os-target .order-name-wrap a.jump-link") + try: + for link in links: + link.click() + driver.switch_to.window(driver.window_handles[3]) + # 编辑订单 + locate(".component-detail-frame-header .okki-space-item:nth-child(1) button").click() + + warn = False + number = locate(".order-edit-header .edit-order-no span span:nth-child(1)").text + invoice = invoices.map.get(number) + + # 选择商机 + try: + dropdown = locate(".component-business-select .mm-selector-rendered") + dropdown.click() + for item in driver.find_elements(By.CSS_SELECTOR, ".mm-outside.mm-select-dropdown ul li span"): + if item.text.startswith(proformas.getValueOf(invoice)['number']): + driver.execute_script("arguments[0].scrollIntoView({ block: 'center', inline: 'nearest' });", item) + item.click() + break + except Exception as e: + print(f"[警告] 发票 '{number}' 录入商机时发生错误:{e}") + warn = True + + # 编辑运费 + try: + wrapper = driver.find_element(By.CSS_SELECTOR, ".order-edit-product-wrapper .virtual-list-os-target") + driver.execute_script("arguments[0].scrollIntoView({ block: 'center', inline: 'end' });", wrapper) + + elements = wrapper.find_elements(By.CSS_SELECTOR, ".row-item") + elements.reverse() + + for item in elements: + if item.find_element(By.CSS_SELECTOR, ".product-info-group-product-name input").get_attribute('value') == 'port': + target = locate("input[colindex='6']", parent=item) + source = locate("input[colindex='7']", parent=item) + # 填入含税成本价 + price = source.get_attribute('value') + target.send_keys(price) + break + except Exception as e: + print(f"[警告] 发票 '{number}' 编辑运费时发生错误:{e}") + warn = True + + # 保存订单 + locate(".order-edit-footer button.okki-btn-primary").click() + # 关闭标签页 + driver.close() + driver.switch_to.window(driver.window_handles[2]) + + global success + success += 1 + + global warning + if warn: warning += 1 + except Exception as e: + print(f'[!!!!] 编辑订单时发生了错误:{e}') + return 85 + + try: + button = locate(".okki-pagination-next button", wait=False) + driver.execute_script("arguments[0].click()", button) + time.sleep(args.interval) #7 + if bool(button.get_attribute('disabled')): raise Exception() + except: + print('[信息] 已经是最后一页') + break + + # 等待页面加载 + 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 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 } + 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=''): + for array in self.map.values(): + if len(array) <= self.index: + array.insert(self.index, padding) + self.index += 1 + +try: status = main() +except KeyboardInterrupt: status = 145 + +print(f'[信息] 已录入 {success} 个订单;收到 {warning} 条警告信息') +print(f'[信息] 总耗时 {datetime.now() - date}')