update: relation lookup

This commit is contained in:
2025-06-26 18:10:58 +08:00
parent e56a9ae4b0
commit db217ee457

View File

@@ -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}')
# 订单导入字段
# 详情见 <https://crm.xiaoman.cn/order/importOrder>
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)