Compare commits

...

21 Commits

Author SHA1 Message Date
abae0a5fdb fix: 'common' version 2026-06-03 17:23:18 +08:00
a3e3c2a8fa fix: input cursor pointer 2026-05-27 17:48:54 +08:00
7cb59b5122 update: bump 'common' version to 0.1.15 2026-05-26 11:59:46 +08:00
b3fadc99f3 minor fix 2026-05-08 15:58:15 +08:00
c693d624fc minor fix 2026-05-08 15:28:12 +08:00
760b88faee minor fix 2026-05-08 15:10:16 +08:00
acb5607e2c minor fix 2026-05-08 14:26:17 +08:00
3007993ad2 minor fix 2026-05-08 10:35:47 +08:00
e578c31c28 minor fix 2026-05-08 10:18:12 +08:00
28a4252852 minor fix 2026-05-08 10:11:45 +08:00
eb6df01c06 fix: handling invoice pagination 2026-05-05 19:16:41 +08:00
4ca78fda50 update: added 'common' dependency 2026-04-30 16:07:09 +08:00
cbe68b8f0a minor fix 2025-08-01 14:47:13 +08:00
bedbc9ec42 update: added override mode 2025-08-01 14:21:45 +08:00
0289f722de update: avoir support 2025-07-19 11:42:57 +08:00
0e8503a0d2 update: import results check 2025-07-04 14:48:00 +08:00
45fcf74fd9 minor fixes 2025-07-04 11:29:22 +08:00
9e505f0a9a update: click feedback detection 2025-07-02 20:33:40 +08:00
db217ee457 update: relation lookup 2025-06-26 18:10:58 +08:00
e56a9ae4b0 minor fixes 2025-06-06 15:31:22 +08:00
8c649a8a97 minor fixes 2025-06-06 15:13:53 +08:00
7 changed files with 1194 additions and 551 deletions

7
.gitignore vendored
View File

@@ -1,5 +1,2 @@
.venv/ /.vscode
.vscode/ /venv
.workbooks/
*.xlsx
*.xml

358
index.html Normal file
View File

@@ -0,0 +1,358 @@
<link rel="icon" href="https://s3.dualstack.us-east-2.amazonaws.com/pythondotorg-assets/media/files/python-logo-only.svg" type="image/svg+xml">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/pure-min.css" integrity="sha384-X38yfunGUhNzHpBaEBsWLO+A0HDYOQi8ufWDkZ0k9e0eXz/tH3II7uKZ9msv++Ls" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/grids-responsive-min.css">
<title>Order Import</title>
<form class="pure-form pure-form-stacked pure-u-11-12 pure-u-lg-3-4 pure-u-xl-2-3">
<fieldset>
<legend>Basic Information</legend>
<div class="gaps pure-g">
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Number</span>
<span class="less ellipsis pure-u-3-4" id="numberLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Progress</span>
<span class="less ellipsis pure-u-3-4" id="progressLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Uptime</span>
<span class="less ellipsis pure-u-3-4" id="uptimeLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Remaining</span>
<span class="less ellipsis pure-u-3-4" id="remainingLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Status</span>
<span class="less ellipsis pure-u-3-4" id="statusLabel"></span>
</div>
</div>
<br>
<div id="actions" class="inline-flex">
<button type="button" class="inline-flex pure-button pure-button-primary" id="begin" disabled>
<span class="text"></span>
<span class="icon spin">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-loader">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 6l0 -3" />
<path d="M16.25 7.75l2.15 -2.15" />
<path d="M18 12l3 0" />
<path d="M16.25 16.25l2.15 2.15" />
<path d="M12 18l0 3" />
<path d="M7.75 16.25l-2.15 2.15" />
<path d="M6 12l-3 0" />
<path d="M7.75 7.75l-2.15 -2.15" />
</svg>
</span>
</button>
<button type="button" class="pure-button" id="skip" disabled>
Skip
</button>
<button type="button" class="pure-button" id="cancel" disabled>
Cancel
</button>
</div>
</fieldset>
<br>
<fieldset>
<legend>Profile Relevant</legend>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-3">
<label for="prefix">Prefix</label>
<input id="prefix" class="pure-u-23-24" type="text" readonly/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="suffix">Suffix</label>
<input id="suffix" class="pure-u-23-24" type="text" readonly/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="subdomain">Subdomain</label>
<input id="subdomain" class="pure-u-23-24" type="text" readonly/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="remise">Remise Code</label>
<input id="remise" class="pure-u-23-24" type="text" readonly/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="person">Individual Code</label>
<input id="person" class="pure-u-23-24" type="text" readonly/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="name">Name</label>
<select id="name" class="pure-input-1-2" required>
</select>
</div>
</div>
</fieldset>
<br>
<fieldset>
<legend>Parameters</legend>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-3">
<label for="timeout">Timeout</label>
<input id="timeout" class="pure-u-23-24" type="number" min="0"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="interval">Interval</label>
<input id="interval" class="pure-u-23-24" type="number" min="0"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="attempts">Attempts</label>
<input id="attempts" class="pure-u-23-24" type="number" min="1"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="datefrom">Date From</label>
<input id="datefrom" class="pure-u-23-24" type="date"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="dateto">Date To</label>
<input id="dateto" class="pure-u-23-24" type="date"/>
</div>
</div>
<br>
<label for="avoir" class="pure-checkbox">
<input id="avoir" type="checkbox" checked/> Include Facture d'Avoirs
</label>
<label for="all" class="pure-checkbox">
<input id="all" type="checkbox"/> Once for all profiles
</label>
<label for="draft" class="pure-checkbox">
<input id="draft" type="checkbox"/> Save as drafts
</label>
<label for="logs" class="pure-checkbox local-only">
<input id="logs" type="checkbox"/> Show logs
</label>
<br>
<textarea id="messages" name="messages" rows="30" hidden readonly></textarea>
</fieldset>
</form>
<script type="module">
import { default as $, Rpc2, LogRecord } from '/';
if (performance.getEntriesByType('navigation')[0].type === 'reload') await Rpc2.invoke('exit');
let error = null;
let status = null;
let { profiles, parameters } = await Rpc2.invoke('context');
let date = new Date();
let day = date.getDay() || 7;
$('#datefrom').valueAsNumber = date.setHours(-24 * (day - 1)) - date.getTimezoneOffset() * 60 * 1000;
$('#dateto').valueAsNumber = date.setHours(24 * 7) + date.getTimezoneOffset() * 60 * 1000;
$('#all').dispatchEvent(new Event('change'));
$.set('#begin', 'click', async () => {
switch (status) {
case 'READY':
let name = $('#name').value;
let options = { profile: name };
for (let element of $.all("input[type='checkbox']:not(.local-only)")) {
options[element.id] = element.checked;
}
for (let element of $.all("input[type='date']")) {
options[element.id] = element.value;
}
for (let element of $.all("input[type='number']")) {
parameters[element.id] = element.valueAsNumber;
}
await Rpc2.invoke('begin', options, parameters);
break;
case 'RUNNING':
await Rpc2.invoke('pause');
break;
case 'STANDBY':
await Rpc2.invoke('resume');
break;
}
});
$.set('#begin', 'click', () => {
$('#begin > span.icon').removeAttribute('hidden');
$('#begin > span.text').innerText = '';
$('#begin').classList.remove('pulse');
$('#begin').disabled = true;
});
$.set('#cancel', 'click', async () => {
$('#cancel').disabled = true;
await Rpc2.invoke('cancel');
});
$.set('#skip', 'click', async () => {
$('#skip').disabled = true;
await Rpcs.invoke('skip');
});
$.set('#logs', 'change', (e) => {
if (e.target.checked) $('#messages').removeAttribute('hidden');
else $('#messages').setAttribute('hidden', '');
});
$.set('#name', 'change', (e) => {
let p = profiles.find(o => o.name === e.target.value);
for (let k of Object.keys(p)) $(`#${k}`)?.setAttribute('value', p[k] ?? '');
});
$.set('#all', 'change', (e) => {
$('#name').disabled = e.target.checked;
});
if (parameters['account'] && parameters['password']) {
let account = new String(parameters['account']);
let name = account.split('@', 1).pop();
name = name.charAt(0).toLocaleUpperCase() + name.slice(1);
document.title += ` (${name})`;
}
for (let item of profiles) {
$('#name').add(new Option(item.name, item.name));
$('#name').dispatchEvent(new Event('change'));
}
for (let item of $.all("input[type='number']")) {
item.value = parameters[item.id];
}
while (await new Promise(o => setTimeout(o, 1000, true))) {
let history = await Rpc2.invoke('history').catch(() => []);
let logs = Array.from(history);
for (let record of logs) {
if ($('#messages').childNodes.length >= 500) $('#messages').childNodes.item(0)?.remove();
if (record.levelno >= 40) error = record;
let message = LogRecord.format(record);
let node = document.createTextNode(new String(message).concat('\n'));
$('#messages').appendChild(node);
$('#messages').scrollTop = $('#messages').scrollHeight;
}
status = await Rpc2.invoke('status').catch(() => null);
$('#statusLabel').innerText = status ? status.charAt(0).toUpperCase() + status.slice(1).toLowerCase() : '';
switch (status) {
case 'IDLE':
continue;
case 'READY':
$('#begin > span.text').innerText = 'Begin';
$('#begin').classList.remove('pulse');
$('#progressLabel').innerText = '';
$('#remainingLabel').innerText = '';
break;
case 'RUNNING':
$('#begin > span.text').innerText = 'Pause';
$('#begin').classList.add('pulse');
let progress = await Rpc2.invoke('progress').catch(() => new Object());
let { task, number, index, limit } = progress;
$('#numberLabel').innerText = number ?? '';
$('#progressLabel').innerText = limit ? `${task}, ${parseFloat((index / limit * 100).toFixed(2))}% (${index}/${limit})` : task;
let [t1, t2] = await Rpc2.invoke('uptime').catch(() => []);
$('#uptimeLabel').innerText = Temporal.Duration.from({ seconds: t1 ?? 0 }).round({ largestUnit: 'hours' }).toLocaleString('en', { style: 'digital' });
let remaining = index && limit && t2 ? Math.floor((limit - index) / (index / t2)) : 0;
$('#remainingLabel').innerHTML = remaining ? Temporal.Duration.from({ seconds: remaining }).round({ largestUnit: 'hours' }).toLocaleString('en') : '';
break;
case 'STANDBY':
if (error !== null) {
alert(`(${error.levelname}) ${error.msg}\n${error.exc_text ?? ''}`);
error = null;
}
$('#begin > span.text').innerText = 'Resume';
$('#begin').classList.remove('pulse');
break;
}
$('#begin > span.icon').setAttribute('hidden', '');
$('#begin').disabled = false;
let actions = await Rpc2.invoke('actions').catch(() => new Object());
$('#cancel').disabled = !actions['Cancel'];
$('#skip').disabled = !actions['Skip'];
}
</script>
<style type="text/css">
body {
width: 100%;
margin: 2em 0 2em 0;
display: inline-flex;
justify-content: center;
}
select, option, input[type='checkbox'], input[type='date'] {
cursor: pointer;
}
label {
user-select: none;
width: fit-content;
}
#actions {
max-height: 2.5em;
column-gap: 0.4em;
}
#actions > button {
height: stretch;
}
#messages {
resize: none;
white-space: pre-wrap;
width: 100%;
}
#mesages:focus {
outline: none;
}
.inline-flex {
display: inline-flex;
align-items: center;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gaps {
row-gap: 0.4em;
}
.less {
color: #666;
max-width: 72%;
}
.icon {
height: 1em;
}
.spin {
animation: spin 1s linear infinite;
}
.pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
50% {
opacity: 0.6;
}
}
</style>

826
main.py Normal file
View File

@@ -0,0 +1,826 @@
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.utils import *
from common.timer import Timer
from common.jsonrpc2 import ServiceProvider
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')):
parameters = vars(args)
http = PoolManager()
sp = ServiceProvider.default()
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
t1 = Timer()
t2 = Timer()
options = dict()
status = Status.IDLE
def begin(opts: dict, args: dict):
nonlocal status
options.update(opts)
status = Status.RUNNING
parameters.update(args)
t1.clear()
t1.start()
def pause():
nonlocal status
status = Status.STANDBY
t1.pause()
t2.pause()
def resume():
nonlocal status
status = Status.RUNNING
driver.switch_to.window(driver.current_window_handle)
t1.start()
t2.start()
sp.set('begin', begin)
sp.set('pause', pause)
sp.set('resume', resume)
sp.set('status', lambda: status.name)
sp.set('uptime', lambda: [t1.delta()])
try:
profiles = [
Profile(**{ k.lower().strip(): v.strip() for k, v in map(lambda o: str.split(o, '=', 2), p) })
for p in parameters['profile']
]
except Exception as e:
logger.critical('Unable to load profiles', exc_info=e)
return 2
try:
sp.set('context', lambda: { 'profiles': list(map(vars, profiles)), 'parameters': parameters })
driver.get(sp.run())
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['timeout'])
driver.get(WEBURL % 'product')
except TimeoutException:
logger.warning('Timeout')
driver.execute_script("window.stop();")
setup(driver, parameters)
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['account']) and (password := parameters['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['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['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['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['interval'])
file = Path(parameters['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 fetch(url: str, method = 'GET', retry = parameters['attempts']):
for attempt in range(1, retry + 1):
try:
response = http.request(method, url)
result = response.json()
return result
except Exception as e:
logger.warning('Error while fetching data from %s, retrying... (%d)', url, attempt, exc_info=e)
assert attempt < retry
class Wait(Action):
@classmethod
def prepare(cls):
return True
@classmethod
def perform(cls):
if status == Status.RUNNING: return False
sleep(0.2); return True
class Sleep(Action):
@classmethod
def prepare(cls):
return True
@classmethod
def perform(cls):
sleep(parameters['interval'])
return False
class Cancel(Action):
@classmethod
def prepare(cls):
nonlocal status
status = Status.RUNNING
return True
@classmethod
def perform(cls):
nonlocal status
status = Status.READY
driver.switch_to.window(driver.window_handles[0])
raise cls
class Skip(Action):
@classmethod
def prepare(cls):
return True
@classmethod
def perform(cls):
driver.switch_to.window(driver.current_window_handle)
raise cls
flow = ActionFlow()
flow.append(Wait)
flow.append(Sleep)
flow.append(Cancel)
flow.append(Skip)
profile = None
progress = { 'task': '' }
selection = 0
sp.set('actions', lambda: flow.capabilities())
sp.set('cancel', lambda: flow.do(Cancel, force=True))
sp.set('skip', lambda: flow.do(Skip, force=True))
sp.set('progress', lambda: progress)
while True:
try:
flow.allow(Wait)
flow.allow(Sleep)
flow.do(Wait)
flow.react()
for i in range(len(driver.window_handles), 1, -1):
driver.switch_to.window(driver.window_handles[i-1])
driver.close()
driver.switch_to.window(driver.window_handles[i-2])
if options.get('all'):
profile = profiles[selection]
selection += 1
else:
name = options.pop('profile')
profile = next(filter(lambda o: o.name == name, profiles))
except (IndexError, KeyError):
logger.info('Done')
status = Status.READY
continue
except StopIteration:
logger.error("Invalid profile '%s'", name)
status = Status.STANDBY
continue
except Exception as e:
logger.error('Unexpected error', exc_info=e)
status = Status.STANDBY
continue
progress.clear()
progress['task'] = 'Task 1 of 4'
t2.clear()
t2.start()
sp.pop('uptime')
sp.set('uptime', lambda: [t1.delta(), t2.delta()])
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)
flow.allow(Skip, False)
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.do(Wait)
flow.do(Sleep)
flow.react()
except Skip:
pass
except Cancel:
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 an empty response')
continue
logger.info('Initializing Workbook...')
progress['task'] = 'Task 2 of 4'
progress['limit'] = len(data)
progress['index'] = 0
t2.clear()
t2.start()
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 <https://crm.xiaoman.cn/order/importOrder>
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):
flow.do(Wait)
flow.react()
number: str = item['number']
logger.info('[%d/%d] Preprocessing data for %s', i, len(data), number)
progress['number'] = number
progress['index'] = i-1
if (category := categories.get(o := item['category_id'])) is None:
if 'error' in (category := fetch(f'{base}/categories/{o}.json?api_token={profile.token}')):
error = category['error']
code = category['code']
logger.warning("Error while fetching 'category' (code: %s, message: %s); skipping", code, error)
continue
if (client := clients.get(o := item['client_id'])) is None:
if 'error' in (client := fetch(f'{base}/clients/{o}.json?api_token={profile.token}')):
error = client['error']
code = client['code']
logger.warning("Error while fetching 'client' (code: %s, message: %s); skipping", code, error)
continue
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)
except Skip:
pass
except Cancel:
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['directory']).joinpath(filename)
logger.info('Saving excel file to %s', str(file))
flow.do(Wait)
flow.react()
workbook.save(file)
except Skip:
pass
except Cancel:
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'
t2.clear()
t2.start()
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.do(Wait)
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:
flow.do(Sleep)
flow.react()
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()
flow.do(Sleep)
flow.react()
click(".mm-tbody table tbody tr:nth-child(1) td:nth-child(3) a")
flow.do(Sleep)
flow.react()
logger.info('Done')
except Skip:
pass
except Cancel:
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)
t2.clear()
t2.start()
index = 0
attempts = 0
while index < len(data):
try:
attempts += 1
driver.switch_to.window(driver.window_handles[2])
item = data[index]
kind = item['kind']
title = re.search(r'O\d+', item['title'])
number = profile.format(item['number'])
positions = item['positions']
opportunity = None
if kind != 'vat':
logger.info('[%d/%d] Skipping %s', index+1, len(data), number)
index += 1
attempts = 0
continue
if attempts > parameters['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.do(Wait)
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())
flow.do(Wait)
flow.do(Sleep)
flow.react()
url = Parse(driver.current_url)
if url.get('page') != page: raise Exception(number)
flow.do(Sleep)
flow.react()
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.do(Wait)
flow.react()
click(".sticky .okki-space-item:nth-child(1) button")
break
except Skip:
index += 1
attempts = 0
continue
except Cancel:
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' }))
flow.do(Wait)
flow.do(Sleep)
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' }))
flow.do(Wait)
flow.do(Sleep)
flow.react()
url = Parse(driver.current_url)
if url.get('curPage') != page: raise Exception(match)
flow.do(Sleep)
flow.react()
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:
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.do(Wait)
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:
break
except Exception as e:
logger.warning('Could not select opportunity; skipping', exc_info=e)
try:
pagination = 10
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')
try:
ids = list()
wrapper = locate(".paas-order-product-list .row-items", condition=None)
for page in count(1):
hits = 0
iteration = 0
flow.do(Wait)
flow.react()
while hits < pagination and iteration < parameters['attempts']:
iteration += 1
height = int(wrapper.get_attribute('clientHeight')) if iteration > 1 else 0
driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", wrapper)
driver.execute_script("arguments[0].scrollTo(0, arguments[1]);", wrapper, height)
rows = wrapper.find_elements(By.CSS_SELECTOR, ".row-item")
for row in reversed(rows) if iteration > 1 else rows:
flow.do(Wait)
flow.react()
driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", wrapper)
driver.execute_script("arguments[0].scrollTo(0, arguments[1]);", wrapper, height)
serial = row.text.split('\n', 1)[0].strip()
if not serial or serial in ids: continue
driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", row)
driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", wrapper)
value = row.find_element(By.CSS_SELECTOR, ".cell[data-cci='4'] input").get_attribute('value')
driver.execute_script("arguments[0].scroll(400, 0);", wrapper)
driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", row)
driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", wrapper)
flow.do(Sleep)
flow.react()
target = row.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)
ids.append(serial)
hits += 1
if len(ids) >= len(positions): break
button = locate(".text-right li.okki-pagination-next button", condition=None)
if button.get_attribute('disabled') is not None and len(ids) < len(positions):
raise Exception('Product list imcomplete; expected %d, got %d' % (len(positions), len(ids)))
flow.do(Wait)
flow.react()
click(button)
except Skip:
index += 1
attempts = 0
continue
except Cancel:
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)
flow.do(Sleep)
flow.react()
except Exception as e:
logger.warning('Unable to unset additional fees; skipping', exc_info=e)
try:
flow.do(Wait)
flow.react()
flow.allow(Cancel, False)
flow.allow(Skip, False)
click(".sticky.bottom-0 button.okki-btn-primary", condition=None)
flow.do(Sleep)
flow.react()
except Skip:
pass
except Cancel:
break
except Exception as e:
logger.error('Error while saving document', exc_info=e)
status = Status.STANDBY
continue
finally:
driver.close()
index += 1
attempts = 0
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)
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 KeyboardInterrupt:
status = 0
except Exception as e:
logger.critical('Fatal error', exc_info=e)
status = 1
finally:
driver.quit()
exit(status)

View File

@@ -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 "name=example" "token=TOKEN" "subdomain=eg"
@pause

View File

@@ -0,0 +1,4 @@
cd /D .\..\
python -m venv venv
.\venv\Scripts\pip.exe install -r ./requirements.txt
@pause

Binary file not shown.

View File

@@ -1,546 +0,0 @@
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='?', 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='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('-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)
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
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()
print(f'[信息] 正在尝试获取自 {datefrom:%Y-%m-%d}{dateto:%Y-%m-%d} 的 Facture 数据')
else:
yesterday = (datetime.today() - timedelta(days=1))
datefrom = yesterday
dateto = yesterday
print(f'[信息] 正在尝试获取 {yesterday:%Y-%m-%d} 的 Facture 数据')
datefrom = parse.quote(datefrom.strftime(format))
dateto = parse.quote(dateto.strftime(format))
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={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}')
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, excel) -> Result:
rows = excel[excel.isin([value]).any(axis=1)]
data = rows.to_dict(orient='list')
return Result(data.get(fieldname))
def text(element, fieldname) -> str:
child = element.find(fieldname)
if bool(child.text): return child.text.strip()
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
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
# 订单导入字段
# 详情见 <https://crm.xiaoman.cn/order/importOrder>
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']
proforma = proformas.getValueOf(invoice)['number']
client = clients.getValueOf(invoice)['external_id']
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()
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('订单日期', issue_date)
data.append('当前处理人', category)
data.append('业绩归属部门', args.department or None)
data.append('客户编号', client)
data.append('币种', args.currency or None)
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('[信息] 正在启动自动化程序')
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)
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
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()
except Exception as e:
print(f'[!!!!] 登录网页时发生了错误:{e}')
return 81
else:
print("[警告] 未提供有效登录凭证")
key = input('[????] 请确认登录状态:已登录 (Y) / 取消操作 (N): ')
if key in ['Y', 'y']:
print('[信息] 已确认操作')
else:
print('[信息] 已取消操作')
return 0
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, 'final': 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()
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()
# 开始导入
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=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()
driver.switch_to.window(driver.window_handles[2])
except Exception as e:
print(f'[!!!!] 等待订单录入时发生了错误:{e}')
return 84
# 设置每页显示记录数
time.sleep(args.interval) #6
url = driver.current_url
param = f'%22page_size%22%3A{args.per_page}'
if 'page_size' in url: url = url.replace('%22page_size%22%3A20', param)
else: url += param
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) #7
for idx in range(args.per_page):
try:
links = driver.find_elements(By.CSS_SELECTOR, ".list-frame-table .virtual-list-os-target .row-items .cell[data-cci='1'] a")
element = links[idx]
except Exception as e:
print(f'[警告] 未找到记录: {e}')
break
try:
driver.execute_script("arguments[0].click();", element)
driver.switch_to.window(driver.window_handles[3])
# 编辑订单
locate(".component-detail-frame-header .okki-space-item:nth-child(1) button").click()
warn = False
header = locate(".order-edit-header .edit-order-no span span:nth-child(1)")
number = header.text
if number not in modified:
# 选择商机
try:
proforma = lookup(number, '形式发票', workbook).map(lambda x: x[0]).unwrap()
index = 1
dropdown = locate(".component-business-select .mm-selector-rendered")
dropdown.click()
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()
break
except Exception as e:
print(f"[警告] {number}: 录入商机时发生错误:{e}")
warn = True
# 编辑运费
try:
wrapper = locate(".order-edit-product-wrapper .virtual-list-os-target .row-items", condition=None)
positions = lookup(number, '产品型号', workbook).unwrap()
index = 0
count = positions.count('port')
limit = len(positions)
ports = []
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")
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)
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)
if index >= count: break
if index >= limit: raise Exception('无法定位指定产品')
except Exception as 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)
time.sleep(args.interval) #8
print(f"[信息] {number}: 修改完成")
modified.append(number)
global success
success += 1
global warning
if warn: warning += 1
# 关闭标签页
driver.close()
driver.switch_to.window(driver.window_handles[2])
except Exception as e:
print(f'[!!!!] 编辑订单时发生了错误:{e}')
return 85
try:
button = locate(".okki-pagination-next button", wait=False)
if bool(button.get_attribute('disabled')): raise Exception()
driver.execute_script("arguments[0].click()", button)
except:
print('[信息] 已经是最后一页')
break
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=None):
for array in self.map.values():
if len(array) <= self.index:
array.insert(self.index, padding)
self.index += 1
class Result:
def __init__(self, value, error=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}')