update: added 'common' dependency
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,6 +1,2 @@
|
|||||||
/.venv
|
|
||||||
/.vscode
|
/.vscode
|
||||||
/.workbooks
|
/venv
|
||||||
*.bat
|
|
||||||
*.xlsx
|
|
||||||
*.xml
|
|
||||||
369
index.html
Normal file
369
index.html
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
<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="text/javascript">
|
||||||
|
const $ = (selectors, fn) => {
|
||||||
|
let e = document.querySelector(selectors);
|
||||||
|
return fn && e ? fn(e) : e;
|
||||||
|
};
|
||||||
|
|
||||||
|
const $$ = (selectors, fn) => {
|
||||||
|
let e = document.querySelectorAll(selectors);
|
||||||
|
return fn && e ? fn(e) : e;
|
||||||
|
};
|
||||||
|
|
||||||
|
const $$$ = (selectors, event, listener) => {
|
||||||
|
$(selectors).addEventListener(event, listener);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Profiles = new Array();
|
||||||
|
let Latest = null;
|
||||||
|
let Status = null;
|
||||||
|
|
||||||
|
function main(profiles, args) {
|
||||||
|
$$$('#begin', 'click', async () => {
|
||||||
|
switch (Status) {
|
||||||
|
case 'READY':
|
||||||
|
let name = $('#name').value;
|
||||||
|
let options = new Map();
|
||||||
|
|
||||||
|
for (let element of $$("input[type='checkbox']:not(.local-only)")) {
|
||||||
|
options[element.id] = element.checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let element of $$("input[type='date']")) {
|
||||||
|
options[element.id] = element.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let element of $$("input[type='number']")) {
|
||||||
|
args[element.id] = element.valueAsNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
options['profile'] = name;
|
||||||
|
await Rpc2.invoke('begin', options, args);
|
||||||
|
break;
|
||||||
|
case 'RUNNING':
|
||||||
|
await Rpc2.invoke('pause');
|
||||||
|
break;
|
||||||
|
case 'STANDBY':
|
||||||
|
await Rpc2.invoke('resume');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$$$('#begin', 'click', (e) => {
|
||||||
|
$('#begin > span.icon').removeAttribute('hidden');
|
||||||
|
$('#begin > span.text').innerText = '';
|
||||||
|
$('#begin').classList.remove('pulse');
|
||||||
|
$('#begin').disabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$$$('#cancel', 'click', async (e) => {
|
||||||
|
$('#cancel').disabled = true;
|
||||||
|
await Rpc2.invoke('cancel');
|
||||||
|
});
|
||||||
|
|
||||||
|
$$$('#skip', 'click', async (e) => {
|
||||||
|
$('#skip').disabled = true;
|
||||||
|
await Rpcs.invoke('skip');
|
||||||
|
});
|
||||||
|
|
||||||
|
$$$('#logs', 'change', (e) => {
|
||||||
|
if (e.target.checked) $('#messages').removeAttribute('hidden');
|
||||||
|
else $('#messages').setAttribute('hidden', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
$$$('#name', 'change', (e) => {
|
||||||
|
let p = Profiles.find(o => o.name === e.target.value);
|
||||||
|
for (let k of Object.keys(p)) $(`#${k}`, e => e.value = p[k] ?? '');
|
||||||
|
});
|
||||||
|
|
||||||
|
$$$('#all', 'change', (e) => {
|
||||||
|
$('#name').disabled = e.target.checked;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let item of JSON.parse(profiles)) {
|
||||||
|
Profiles.push(item);
|
||||||
|
$('#name').add(new Option(item.name, item.name));
|
||||||
|
$('#name').dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let item of $$("input[type='number']")) {
|
||||||
|
item.value = args[item.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
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'));
|
||||||
|
|
||||||
|
let account = new String(args['account']);
|
||||||
|
let name = account.split('@', 1).pop() ?? 'unknown';
|
||||||
|
name = name.charAt(0).toLocaleUpperCase() + name.slice(1);
|
||||||
|
document.title += ` (${name})`;
|
||||||
|
|
||||||
|
setInterval(async () => {
|
||||||
|
let history = await Rpc2.invoke('history');
|
||||||
|
let logs = Array.from(history);
|
||||||
|
|
||||||
|
for (let record of logs) {
|
||||||
|
if (record.levelno >= 40) Latest = 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');
|
||||||
|
$('#statusLabel').innerText = Status.charAt(0).toUpperCase() + Status.slice(1).toLowerCase();
|
||||||
|
|
||||||
|
switch (Status) {
|
||||||
|
case 'IDLE':
|
||||||
|
return;
|
||||||
|
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');
|
||||||
|
let { task, number, index, limit } = progress;
|
||||||
|
$('#numberLabel').innerText = number ?? '';
|
||||||
|
$('#progressLabel').innerText = limit ? `${task}, ${parseFloat((index / limit * 100).toFixed(2))}% (${index}/${limit})` : task;
|
||||||
|
|
||||||
|
let uptime = await Rpc2.invoke('uptime');
|
||||||
|
$('#uptimeLabel').innerText = Temporal.Duration.from({ seconds: uptime }).round({ largestUnit: 'hours' }).toLocaleString('en', { style: 'digital' });
|
||||||
|
|
||||||
|
if (index && limit && uptime) {
|
||||||
|
let rate = index / uptime;
|
||||||
|
let remaining = Math.floor((limit - index) / rate);
|
||||||
|
$('#remainingLabel').innerHTML = Temporal.Duration.from({ seconds: remaining }).round({ largestUnit: 'hours' }).toLocaleString('en');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'STANDBY':
|
||||||
|
if (Latest !== null) {
|
||||||
|
alert(`(${Latest.levelname}) ${Latest.msg}\n${Latest.exc_text ?? ''}`);
|
||||||
|
Latest = 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');
|
||||||
|
$('#cancel').disabled = !actions['Cancel'];
|
||||||
|
$('#skip').disabled = !actions['Skip'];
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
margin: 2em 0 2em 0;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
784
main.py
Normal file
784
main.py
Normal file
@@ -0,0 +1,784 @@
|
|||||||
|
import unicodedata
|
||||||
|
import argparse
|
||||||
|
import openpyxl
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
from selenium.webdriver import Chrome, ChromeOptions
|
||||||
|
from selenium.webdriver.remote.webdriver import WebDriver
|
||||||
|
from selenium.common.exceptions import TimeoutException, NoSuchElementException
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.common.keys import Keys
|
||||||
|
|
||||||
|
from common import jsonrpc2
|
||||||
|
from common.utils import *
|
||||||
|
from common.actionflow import Action, ActionFlow
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from wakepy import keep
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from itertools import repeat, count
|
||||||
|
from urllib3 import PoolManager
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Order Import")
|
||||||
|
parser.add_argument('account', type=str, nargs='?')
|
||||||
|
parser.add_argument('password', type=str, nargs='?')
|
||||||
|
parser.add_argument('-d', '--directory', type=str, default=str(Path.home().joinpath('Downloads')))
|
||||||
|
parser.add_argument('-o', '--profile', nargs='+', action='append', required=True)
|
||||||
|
parser.add_argument('-t', '--timeout', type=int, default=60)
|
||||||
|
parser.add_argument('-r', '--attempts', type=int, default=3)
|
||||||
|
parser.add_argument('-i', '--interval', type=int, default=3)
|
||||||
|
parser.add_argument('-l', '--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'])
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
WEBURL = "https://crm.xiaoman.cn/%s"
|
||||||
|
APIURL = "https://%s.vosfactures.fr"
|
||||||
|
|
||||||
|
def main(driver: WebDriver, logger = logging.getLogger('main')):
|
||||||
|
http = PoolManager()
|
||||||
|
parameters = vars(args)
|
||||||
|
|
||||||
|
class Status(Enum):
|
||||||
|
IDLE = 0
|
||||||
|
READY = 1
|
||||||
|
RUNNING = 3
|
||||||
|
STANDBY = 4
|
||||||
|
|
||||||
|
class Profile:
|
||||||
|
def __init__(self, name, subdomain, token, remise, person=None, prefix=None, suffix=None):
|
||||||
|
self.name = name
|
||||||
|
self.subdomain = subdomain
|
||||||
|
self.token = token
|
||||||
|
self.remise = remise
|
||||||
|
self.person = person
|
||||||
|
self.prefix = prefix
|
||||||
|
self.suffix = suffix
|
||||||
|
|
||||||
|
def format(self, number: str):
|
||||||
|
result = ''.join(filter(bool, [self.prefix, number, self.suffix]))
|
||||||
|
return result
|
||||||
|
|
||||||
|
timer = Timer()
|
||||||
|
options = dict()
|
||||||
|
profiles: list[Profile] = list()
|
||||||
|
status = Status.IDLE
|
||||||
|
|
||||||
|
def begin(opts: dict, args: dict):
|
||||||
|
nonlocal options, status
|
||||||
|
options.update(opts)
|
||||||
|
status = Status.RUNNING
|
||||||
|
parameters.update(args)
|
||||||
|
timer.clear()
|
||||||
|
timer.start()
|
||||||
|
|
||||||
|
def pause():
|
||||||
|
nonlocal status
|
||||||
|
status = Status.STANDBY
|
||||||
|
timer.pause()
|
||||||
|
|
||||||
|
def resume():
|
||||||
|
nonlocal status
|
||||||
|
status = Status.RUNNING
|
||||||
|
driver.switch_to.window(driver.current_window_handle)
|
||||||
|
timer.start()
|
||||||
|
|
||||||
|
jsonrpc2.define('begin', begin)
|
||||||
|
jsonrpc2.define('pause', pause)
|
||||||
|
jsonrpc2.define('resume', resume)
|
||||||
|
jsonrpc2.define('status', lambda: status.name)
|
||||||
|
jsonrpc2.define('uptime', lambda: timer.delta())
|
||||||
|
|
||||||
|
try:
|
||||||
|
for source, profile in zip(parameters.get('profile'), repeat(dict())):
|
||||||
|
for key, value in map(lambda o: str.split(o, '=', 2), source):
|
||||||
|
profile[key.lower().strip()] = value.strip()
|
||||||
|
|
||||||
|
item = Profile(**profile)
|
||||||
|
profiles.append(item)
|
||||||
|
except Exception as e:
|
||||||
|
logger.critical('Unable to load profiles', exc_info=e)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = json.dumps(profiles, default=lambda o: vars(o))
|
||||||
|
driver.get(str(Path('index.html').resolve()))
|
||||||
|
driver.execute_script(jsonrpc2.prelude(), manifest, parameters)
|
||||||
|
except Exception as e:
|
||||||
|
logger.critical('Unable to load starup page', exc_info=e)
|
||||||
|
return 3
|
||||||
|
|
||||||
|
try:
|
||||||
|
driver.switch_to.new_window('tab')
|
||||||
|
driver.set_page_load_timeout(parameters.get('timeout'))
|
||||||
|
driver.get(WEBURL % 'product')
|
||||||
|
except TimeoutException:
|
||||||
|
logger.warning('Timeout')
|
||||||
|
driver.execute_script("window.stop();")
|
||||||
|
|
||||||
|
setup(driver, parameters.get('attempts'), parameters.get('timeout'), parameters.get('interval'))
|
||||||
|
until(lambda x: 'loginProgress' in x.find_element(By.TAG_NAME, "body").get_attribute('class'), watch=False)
|
||||||
|
logger.info('Waiting for authentication to complete...')
|
||||||
|
|
||||||
|
if (account := parameters.get('account')) and (password := parameters.get('password')):
|
||||||
|
try:
|
||||||
|
logger.info('Logging in as %s (%s)', str.split(account, '@', 1).pop(0).capitalize(), account)
|
||||||
|
locate("input.account").send_keys(account)
|
||||||
|
locate("input#password").send_keys(password)
|
||||||
|
click("input.agree-checkbox")
|
||||||
|
click("button.login-btn")
|
||||||
|
except Exception as e:
|
||||||
|
logger.critical('Unable to login to %s', account, exc_info=e)
|
||||||
|
return 3
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
locate("#container", wait=False)
|
||||||
|
logger.info('Done')
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
sleep(parameters.get('interval'))
|
||||||
|
|
||||||
|
class ProductInfo:
|
||||||
|
def __init__(self, file):
|
||||||
|
self.wb = openpyxl.load_workbook(file, read_only=True)
|
||||||
|
self.headers = list()
|
||||||
|
|
||||||
|
for col in range(1, self.wb.active.max_column + 1):
|
||||||
|
value = str(self.wb.active.cell(1, col).value)
|
||||||
|
self.headers.append(value)
|
||||||
|
|
||||||
|
def search(self, by: str, needle) -> dict:
|
||||||
|
a = self.headers.index(by)
|
||||||
|
b = None
|
||||||
|
|
||||||
|
for row in range(1, self.wb.active.max_row + 1):
|
||||||
|
if self.wb.active.cell(row, a+1).value == needle:
|
||||||
|
b = row
|
||||||
|
break
|
||||||
|
|
||||||
|
if b is None: return dict()
|
||||||
|
return { k: self.wb.active.cell(b, c+1).value for c, k in enumerate(self.headers) }
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info('Downloading product list...')
|
||||||
|
click("header .okki-space .okki-space-item:nth-child(1) button")
|
||||||
|
click(".okki-dropdown button")
|
||||||
|
sleep(parameters.get('interval'))
|
||||||
|
click(".okki-modal.product-export-wrap .mm-selector-rendered")
|
||||||
|
click(".mm-outside.ui-field-selector-popper .selector-area:nth-child(1) button")
|
||||||
|
click(".okki-modal.product-export-wrap .okki-modal-footer button.okki-btn-primary")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
click(".okki-modal.product-export-wrap .okki-modal-footer button.okki-btn-primary")
|
||||||
|
sleep(parameters.get('interval'))
|
||||||
|
click(".okki-modal.product-export-wrap .okki-modal-body .virtual-list-wrap .vue-recycle-scroller__item-wrapper > div:nth-child(1) button.okki-btn-link", wait=False)
|
||||||
|
filename = locate(".okki-modal.product-export-wrap .okki-modal-body .virtual-list-wrap .vue-recycle-scroller__item-wrapper > div:nth-child(1) > div > div:nth-child(1) span").get_attribute('title')
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
sleep(parameters.get('interval'))
|
||||||
|
|
||||||
|
file = Path(parameters.get('directory')).joinpath(filename)
|
||||||
|
until(lambda _: file.exists(), watch=False)
|
||||||
|
p = ProductInfo(file)
|
||||||
|
driver.close()
|
||||||
|
driver.switch_to.window(driver.window_handles[0])
|
||||||
|
status = Status.READY
|
||||||
|
logger.info('Done')
|
||||||
|
except Exception as e:
|
||||||
|
logger.critical('Unable to load products', exc_info=e)
|
||||||
|
return 4
|
||||||
|
|
||||||
|
def wait(seconds: float):
|
||||||
|
while not sleep(seconds):
|
||||||
|
if status == Status.RUNNING:
|
||||||
|
break
|
||||||
|
|
||||||
|
def ready(driver: WebDriver):
|
||||||
|
html = driver.find_element(By.TAG_NAME, 'html')
|
||||||
|
return 'nprogress-busy' in html.get_attribute('class')
|
||||||
|
|
||||||
|
def fetch(url: str, method = 'GET', retry = parameters.get('attempts')):
|
||||||
|
for attempt in range(1, retry + 1):
|
||||||
|
try:
|
||||||
|
response = http.request(method, url)
|
||||||
|
result = response.json()
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error while fetching data from %s, retrying... (%d)', url, attempt, exc_info=e)
|
||||||
|
assert attempt < retry
|
||||||
|
|
||||||
|
flow = ActionFlow()
|
||||||
|
profile = None
|
||||||
|
progress = { 'task': '' }
|
||||||
|
selection = 0
|
||||||
|
|
||||||
|
class Wait(Action):
|
||||||
|
@classmethod
|
||||||
|
def prepare(cls):
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def perform(cls):
|
||||||
|
flow.do(cls)
|
||||||
|
wait(1)
|
||||||
|
|
||||||
|
class Cancel(Action):
|
||||||
|
@classmethod
|
||||||
|
def prepare(cls):
|
||||||
|
nonlocal status
|
||||||
|
status = Status.RUNNING
|
||||||
|
return True
|
||||||
|
|
||||||
|
class Skip(Action):
|
||||||
|
@classmethod
|
||||||
|
def prepare(cls):
|
||||||
|
return True
|
||||||
|
|
||||||
|
flow.append(Wait)
|
||||||
|
flow.allow(Wait)
|
||||||
|
flow.do(Wait)
|
||||||
|
|
||||||
|
flow.append(Cancel)
|
||||||
|
flow.append(Skip)
|
||||||
|
|
||||||
|
jsonrpc2.define('actions', lambda: flow.capabilities())
|
||||||
|
jsonrpc2.define('cancel', lambda: flow.do(Cancel))
|
||||||
|
jsonrpc2.define('skip', lambda: flow.do(Skip))
|
||||||
|
jsonrpc2.define('progress', lambda: progress)
|
||||||
|
|
||||||
|
while not wait(1):
|
||||||
|
try:
|
||||||
|
progress.clear()
|
||||||
|
progress['task'] = 'Task 1 of 4'
|
||||||
|
flow.allow(Cancel, False)
|
||||||
|
flow.allow(Skip, False)
|
||||||
|
|
||||||
|
if options.get('all'):
|
||||||
|
profile = profiles[selection]
|
||||||
|
else:
|
||||||
|
name = options.pop('profile')
|
||||||
|
profile = next(filter(lambda o: o.name == name, profiles))
|
||||||
|
|
||||||
|
for i in range(len(driver.window_handles), 0):
|
||||||
|
driver.close()
|
||||||
|
driver.switch_to.window(driver.window_handles[i])
|
||||||
|
except (IndexError, KeyError):
|
||||||
|
logger.info('Done')
|
||||||
|
status = Status.READY
|
||||||
|
continue
|
||||||
|
except StopIteration:
|
||||||
|
logger.error("Invalid profile '%s'", name)
|
||||||
|
status = Status.STANDBY
|
||||||
|
continue
|
||||||
|
|
||||||
|
base = APIURL % profile.subdomain
|
||||||
|
data = list()
|
||||||
|
df = options.get('datefrom')
|
||||||
|
dt = options.get('dateto')
|
||||||
|
types = ['vat']
|
||||||
|
if options.get('avoir'): types.append('correction')
|
||||||
|
|
||||||
|
logger.info('Profile selected: %s', profile.name)
|
||||||
|
logger.info('Date from %s to %s', df, dt)
|
||||||
|
flow.allow(Cancel)
|
||||||
|
|
||||||
|
for page in count(1):
|
||||||
|
try:
|
||||||
|
result = fetch(f'{base}/invoices.json?{'&'.join([f'kinds%5B%5D={k}' for k in types])}&api_token={profile.token}&include_positions=true&per_page=25&page={page}&period=more&date_from={df}&date_to={dt}')
|
||||||
|
if 'message' in result: raise Exception(result['message'])
|
||||||
|
if not isinstance(result, list): raise TypeError()
|
||||||
|
if len(result) == 0: break
|
||||||
|
|
||||||
|
logger.info('Downloading invoices (%d)', page)
|
||||||
|
data.extend(result)
|
||||||
|
flow.react()
|
||||||
|
wait(parameters.get('interval'))
|
||||||
|
except Skip:
|
||||||
|
pass
|
||||||
|
except Cancel:
|
||||||
|
status = Status.READY
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error while fetching data from %s', base, exc_info=e)
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(data) == 0:
|
||||||
|
logger.warning('Server returned empty response')
|
||||||
|
selection += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info('Initializing Workbook...')
|
||||||
|
progress['task'] = 'Task 2 of 4'
|
||||||
|
progress['limit'] = len(data)
|
||||||
|
workbook = openpyxl.Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
|
||||||
|
class Record:
|
||||||
|
def __init__(self, fields: dict[int, str]):
|
||||||
|
self.headers = fields
|
||||||
|
self.data = dict()
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self.data.clear()
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
if key not in self.headers: raise KeyError(key)
|
||||||
|
self.data[key] = value
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
if key not in self.headers: raise KeyError(key)
|
||||||
|
return self.data[key]
|
||||||
|
|
||||||
|
# Required fields
|
||||||
|
# See <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):
|
||||||
|
number: str = item['number']
|
||||||
|
logger.info('[%d/%d] Preprocessing data for %s', i, len(data), number)
|
||||||
|
progress['number'] = number
|
||||||
|
progress['index'] = i-1
|
||||||
|
|
||||||
|
if (id := item['category_id']) not in categories:
|
||||||
|
flow.react()
|
||||||
|
categories[id] = fetch(f'{base}/categories/{id}.json?api_token={profile.token}')
|
||||||
|
|
||||||
|
if (id := item['client_id']) not in clients:
|
||||||
|
flow.react()
|
||||||
|
clients[id] = fetch(f'{base}/clients/{id}.json?api_token={profile.token}')
|
||||||
|
|
||||||
|
category = categories.get(item['category_id'])
|
||||||
|
client = clients.get(item['client_id'])
|
||||||
|
|
||||||
|
identity = client['external_id'] if client['company'] else profile.person
|
||||||
|
date: str = item['issue_date']
|
||||||
|
kind: str = item['kind']
|
||||||
|
total = float(item['price_net'])
|
||||||
|
positions: list = item['positions']
|
||||||
|
|
||||||
|
for position in positions:
|
||||||
|
code: str = position['code']
|
||||||
|
product: str = position['name']
|
||||||
|
description: str = position['description']
|
||||||
|
price = float(position['price_net'] or '0')
|
||||||
|
discount = float(position['discount_percent'] or '0')
|
||||||
|
quantity = float(position['quantity'] or '0')
|
||||||
|
|
||||||
|
record.clear()
|
||||||
|
record[1] = profile.format(number)
|
||||||
|
record[2] = number.replace('/', '-')
|
||||||
|
record[3] = date
|
||||||
|
record[4] = category['name']
|
||||||
|
record[5] = profile.name
|
||||||
|
record[6] = identity
|
||||||
|
record[7] = 'USD'
|
||||||
|
|
||||||
|
match kind:
|
||||||
|
case 'vat':
|
||||||
|
record[8] = product
|
||||||
|
record[9] = (p.search('产品型号', code) or p.search('产品名称', product)).get('产品编号')
|
||||||
|
record[10] = code
|
||||||
|
record[11] = '%.2f' % price
|
||||||
|
record[12] = '%g%%' % discount
|
||||||
|
record[13] = '%.2f' % (price * (1 - discount / 100))
|
||||||
|
record[14] = '%g' % quantity
|
||||||
|
record[15] = description
|
||||||
|
case 'correction':
|
||||||
|
record[8] = p.search('产品编号', profile.remise).get('产品名称')
|
||||||
|
record[9] = profile.remise
|
||||||
|
record[13] = '0'
|
||||||
|
record[14] = '0'
|
||||||
|
record[16] = '%.2f' % total
|
||||||
|
positions.clear()
|
||||||
|
|
||||||
|
if record[9] is None: logger.warning("Could not identify product '%s'", product)
|
||||||
|
sheet.append(record.data)
|
||||||
|
flow.react()
|
||||||
|
except Skip:
|
||||||
|
pass
|
||||||
|
except Cancel:
|
||||||
|
status = Status.READY
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error while processing data', exc_info=e)
|
||||||
|
status = Status.STANDBY
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
filename = f'Order-Import-{profile.name}-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.xlsx'
|
||||||
|
file = Path(parameters.get('directory')).joinpath(filename)
|
||||||
|
logger.info('Saving excel file to %s', str(file))
|
||||||
|
flow.react()
|
||||||
|
workbook.save(file)
|
||||||
|
except Skip:
|
||||||
|
pass
|
||||||
|
except Cancel:
|
||||||
|
status = Status.READY
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error while saving excel file', exc_info=e)
|
||||||
|
status = Status.STANDBY
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info('Uploading...')
|
||||||
|
progress.clear()
|
||||||
|
progress['task'] = 'Task 3 of 4'
|
||||||
|
driver.switch_to.new_window('tab')
|
||||||
|
driver.get(WEBURL % 'order/importOrder')
|
||||||
|
|
||||||
|
click(".product-import-img-box .import-img-radio:nth-child(2) .mm-radio-group > label:nth-child(2) .mm-radio-input", condition=None)
|
||||||
|
click(".product-import-img-box .mm-selector-rendered")
|
||||||
|
click(".mm-outside.mm-select-dropdown ul li:nth-child(%d) span" % (1 if options.get('draft') else 6))
|
||||||
|
|
||||||
|
flow.react()
|
||||||
|
locate(".big-file-upload input", wait=False).send_keys(str(file))
|
||||||
|
click(".product-import-img-footer button")
|
||||||
|
click(".product-import-img-footer button.mm-button__primary")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
innerText = driver.find_element(By.CSS_SELECTOR, ".img-box-result-title").get_attribute('innerText')
|
||||||
|
if innerText == '导入完成': break
|
||||||
|
except:
|
||||||
|
wait(parameters.get('interval'))
|
||||||
|
|
||||||
|
click(".mm-notification-container .mm-icon-close")
|
||||||
|
click(".product-import-img-footer button.mm-button__primary")
|
||||||
|
err = locate(".mm-tbody table tbody tr:nth-child(1) td:nth-child(5) .okki-space-item:nth-child(1) button")
|
||||||
|
|
||||||
|
if err.get_attribute('disabled') is None:
|
||||||
|
logger.warning('Incomplete import detected; downloaded 1 related document')
|
||||||
|
err.click()
|
||||||
|
sleep(1)
|
||||||
|
|
||||||
|
click(".mm-tbody table tbody tr:nth-child(1) td:nth-child(3) a")
|
||||||
|
driver.switch_to.window(driver.window_handles[2])
|
||||||
|
until(ready)
|
||||||
|
logger.info('Done')
|
||||||
|
except Skip:
|
||||||
|
pass
|
||||||
|
except Cancel:
|
||||||
|
status = Status.READY
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error while uploading excel file', exc_info=e)
|
||||||
|
status = Status.STANDBY
|
||||||
|
continue
|
||||||
|
|
||||||
|
class Parse:
|
||||||
|
def __init__(self, url: str):
|
||||||
|
from urllib.parse import urlsplit, parse_qs
|
||||||
|
parts = list(urlsplit(url))
|
||||||
|
query = parse_qs(parts[3])
|
||||||
|
self.parts = parts
|
||||||
|
self.query = json.loads(query['query'][0]) if 'query' in query else dict()
|
||||||
|
|
||||||
|
def encode(self, extra=None):
|
||||||
|
from urllib.parse import urlencode, urlunsplit
|
||||||
|
query = { 'query': json.dumps(self.query, separators=(',', ':')) }
|
||||||
|
if extra is not None: query.update(extra)
|
||||||
|
self.parts[3] = urlencode(query)
|
||||||
|
return urlunsplit(self.parts)
|
||||||
|
|
||||||
|
def get(self, key: str):
|
||||||
|
return self.query[key]
|
||||||
|
|
||||||
|
def set(self, key: str, value):
|
||||||
|
self.query[key] = value
|
||||||
|
|
||||||
|
progress['task'] = 'Task 4 of 4'
|
||||||
|
progress['limit'] = len(data)
|
||||||
|
index = 0
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
while index < len(data):
|
||||||
|
try:
|
||||||
|
item = data[index]
|
||||||
|
title = re.search(r'O\d+', item['title'])
|
||||||
|
number = profile.format(item['number'])
|
||||||
|
positions = item['positions']
|
||||||
|
opportunity = None
|
||||||
|
attempts += 1
|
||||||
|
|
||||||
|
if attempts > parameters.get('attempts'):
|
||||||
|
logger.warning('Exhausted all allowed attempts; skipping %s', number)
|
||||||
|
index += 1
|
||||||
|
attempts = 0
|
||||||
|
continue
|
||||||
|
|
||||||
|
progress['number'] = number
|
||||||
|
progress['index'] = index
|
||||||
|
flow.allow(Cancel)
|
||||||
|
flow.allow(Skip)
|
||||||
|
flow.react()
|
||||||
|
|
||||||
|
try:
|
||||||
|
for page in count(1):
|
||||||
|
url = Parse(driver.current_url)
|
||||||
|
url.set('page_size', 1)
|
||||||
|
url.set('page', page)
|
||||||
|
url.set('query_filters', [{
|
||||||
|
'field': 'order_no',
|
||||||
|
'name': '订单号',
|
||||||
|
'operator': 'match',
|
||||||
|
'value': number,
|
||||||
|
'object_name': 'objOrder',
|
||||||
|
'field_type': 35,
|
||||||
|
'unit': '',
|
||||||
|
'array_flag': 0
|
||||||
|
}])
|
||||||
|
driver.get(url.encode())
|
||||||
|
wait(parameters.get('interval'))
|
||||||
|
flow.react()
|
||||||
|
url = Parse(driver.current_url)
|
||||||
|
if url.get('page') != page: raise Exception(number)
|
||||||
|
|
||||||
|
wait(parameters.get('interval'))
|
||||||
|
link = locate(".virtual-list-wrap .vue-recycle-scroller .vue-recycle-scroller__item-wrapper > div:nth-child(1) .cell[data-cci='1'] a", wait=False)
|
||||||
|
if link.text != number: continue
|
||||||
|
logger.info('[%d/%d] Processing %s...', index+1, len(data), number)
|
||||||
|
link.click()
|
||||||
|
driver.switch_to.window(driver.window_handles[3])
|
||||||
|
flow.react()
|
||||||
|
click(".sticky .okki-space-item:nth-child(1) button")
|
||||||
|
break
|
||||||
|
except Skip:
|
||||||
|
index += 1
|
||||||
|
attempts = 0
|
||||||
|
continue
|
||||||
|
except Cancel:
|
||||||
|
status = Status.READY
|
||||||
|
break
|
||||||
|
except NoSuchElementException:
|
||||||
|
logger.warning("Could not find invoice '%s'; skipping", number)
|
||||||
|
index += 1
|
||||||
|
attempts = 0
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error while looking up invoice '%s'", number, exc_info=e)
|
||||||
|
status = Status.STANDBY
|
||||||
|
continue
|
||||||
|
|
||||||
|
if title is not None and (match := title[0]):
|
||||||
|
try:
|
||||||
|
driver.switch_to.new_window('tab')
|
||||||
|
url = Parse(WEBURL % 'crm/business/list')
|
||||||
|
driver.get(url.encode({ 'mode': 'list' }))
|
||||||
|
wait(parameters.get('interval'))
|
||||||
|
flow.react()
|
||||||
|
|
||||||
|
try: click(".new-wrapper .paas-next-invoice-list-filter-line-wrapper .okki-btn-background-ghost", wait=False)
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
for page in count(1):
|
||||||
|
url = Parse(WEBURL % 'crm/business/list')
|
||||||
|
url.set('keyword', match)
|
||||||
|
url.set('search_field', 'serial_keyword')
|
||||||
|
url.set('curPage', page)
|
||||||
|
url.set('pageSize', 1)
|
||||||
|
driver.get(url.encode({ 'keyword': match, 'search_field': 'serial_keyword' }))
|
||||||
|
wait(parameters.get('interval'))
|
||||||
|
flow.react()
|
||||||
|
url = Parse(driver.current_url)
|
||||||
|
if url.get('curPage') != page: raise Exception(match)
|
||||||
|
|
||||||
|
wait(parameters.get('interval'))
|
||||||
|
cell = locate(".virtual-list-wrap .vue-recycle-scroller .row-item > .cell:nth-child(3) .ow-serial-read-pretty_ellipsis", wait=False)
|
||||||
|
if cell.text != match: continue
|
||||||
|
link = locate(".virtual-list-wrap .vue-recycle-scroller .row-item > .cell:nth-child(6) a", wait=False)
|
||||||
|
opportunity = unicodedata.normalize('NFKD', link.get_attribute('title'))
|
||||||
|
break
|
||||||
|
except Skip:
|
||||||
|
index += 1
|
||||||
|
attempts = 0
|
||||||
|
continue
|
||||||
|
except Cancel:
|
||||||
|
status = Status.READY
|
||||||
|
break
|
||||||
|
except NoSuchElementException:
|
||||||
|
logger.warning("Could not find opportunity '%s'; skipping", match)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error while looking up opportunity '%s'", match, exc_info=e)
|
||||||
|
status = Status.STANDBY
|
||||||
|
continue
|
||||||
|
finally:
|
||||||
|
driver.close()
|
||||||
|
driver.switch_to.window(driver.window_handles[3])
|
||||||
|
|
||||||
|
if opportunity is not None:
|
||||||
|
try:
|
||||||
|
flow.react()
|
||||||
|
dropdown = locate("#rc_select_1")
|
||||||
|
dropdown.clear()
|
||||||
|
dropdown.send_keys(opportunity)
|
||||||
|
|
||||||
|
menu = locate(".okki-select-dropdown")
|
||||||
|
menuitems = menu.find_elements(By.CSS_SELECTOR, ".rc-virtual-list-holder-inner > div")
|
||||||
|
|
||||||
|
for menuitem in menuitems:
|
||||||
|
if menuitem.get_attribute('label').strip().startswith(opportunity):
|
||||||
|
click(menuitem)
|
||||||
|
except Skip:
|
||||||
|
index += 1
|
||||||
|
attempts = 0
|
||||||
|
continue
|
||||||
|
except Cancel:
|
||||||
|
status = Status.READY
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('Error while selecting opportunity; skipping', exc_info=e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
click(".okki-pagination-options-size-changer")
|
||||||
|
click(".okki-select-dropdown .rc-virtual-list-holder-inner > div:nth-child(1)")
|
||||||
|
except:
|
||||||
|
logger.warning('Unable to setup pagination; this may cause issues')
|
||||||
|
|
||||||
|
pids = list()
|
||||||
|
wrapper = locate(".paas-order-product-list .row-items", condition=None)
|
||||||
|
class Eureka(Exception): pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
for page in count(1):
|
||||||
|
flow.react()
|
||||||
|
tail = next(reversed(pids), None)
|
||||||
|
driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", wrapper)
|
||||||
|
driver.execute_script("arguments[0].scrollTo(arguments[1], arguments[2]);", wrapper, 0, 0)
|
||||||
|
|
||||||
|
for item in wrapper.find_elements(By.CSS_SELECTOR, ".row-item"):
|
||||||
|
if len(pids) >= len(positions): raise Eureka()
|
||||||
|
div = item.find_element(By.CSS_SELECTOR, ".cell[data-cci='2'] .cell-inner div")
|
||||||
|
|
||||||
|
if (serial := int(div.text)) and serial not in pids:
|
||||||
|
flow.react()
|
||||||
|
base = (serial - tail) if tail is not None else index
|
||||||
|
height = int(item.get_attribute('clientHeight'))
|
||||||
|
offset = (base - 1) * height
|
||||||
|
driver.execute_script("arguments[0].scrollTo(arguments[1], arguments[2]);", wrapper, 0, offset)
|
||||||
|
#span = item.find_element(By.CSS_SELECTOR, ".cell[data-cci='3'] .product-info-group-product-info:nth-child(3) span")
|
||||||
|
#code = span.text[6:]
|
||||||
|
|
||||||
|
value = item.find_element(By.CSS_SELECTOR, ".cell[data-cci='4'] input").get_attribute('value')
|
||||||
|
driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", wrapper)
|
||||||
|
driver.execute_script("arguments[0].scroll(arguments[1], arguments[2]);", wrapper, 400, offset)
|
||||||
|
wait(parameters.get('interval'))
|
||||||
|
target = item.find_element(By.CSS_SELECTOR, ".cell[data-cci='6'] input")
|
||||||
|
|
||||||
|
if (target.get_attribute('value') == '0'):
|
||||||
|
target.send_keys(Keys.BACKSPACE)
|
||||||
|
target.send_keys(value)
|
||||||
|
|
||||||
|
pids.append(serial)
|
||||||
|
|
||||||
|
button = locate(".text-right li.okki-pagination-next button", condition=None)
|
||||||
|
if button.get_attribute('disabled') is not None and len(pids) < len(positions):
|
||||||
|
raise Exception('Product list imcomplete; expected %d, got %d', len(pids), len(positions))
|
||||||
|
flow.react()
|
||||||
|
click(button)
|
||||||
|
except Eureka:
|
||||||
|
pass
|
||||||
|
except Skip:
|
||||||
|
index += 1
|
||||||
|
attempts = 0
|
||||||
|
continue
|
||||||
|
except Cancel:
|
||||||
|
status = Status.READY
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error while modifying invoice', exc_info=e)
|
||||||
|
status = Status.STANDBY
|
||||||
|
continue
|
||||||
|
|
||||||
|
try: click(".ow-box button.okki-btn-round", wait=False)
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
wait(parameters.get('interval'))
|
||||||
|
flow.react()
|
||||||
|
flow.allow(Cancel, False)
|
||||||
|
flow.allow(Skip, False)
|
||||||
|
click(".sticky.bottom-0 button.okki-btn-primary", condition=None)
|
||||||
|
wait(parameters.get('interval'))
|
||||||
|
except Skip:
|
||||||
|
pass
|
||||||
|
except Cancel:
|
||||||
|
status = Status.READY
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error while saving document', exc_info=e)
|
||||||
|
status = Status.STANDBY
|
||||||
|
continue
|
||||||
|
finally:
|
||||||
|
driver.close()
|
||||||
|
driver.switch_to.window(driver.window_handles[2])
|
||||||
|
|
||||||
|
index += 1
|
||||||
|
attempts = 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Unexpected error', exc_info=e)
|
||||||
|
status = Status.STANDBY
|
||||||
|
|
||||||
|
try:
|
||||||
|
driver.close()
|
||||||
|
driver.switch_to.window(driver.window_handles[1])
|
||||||
|
driver.close()
|
||||||
|
driver.switch_to.window(driver.window_handles[0])
|
||||||
|
selection += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Unexpected error', exc_info=e)
|
||||||
|
status = Status.STANDBY
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s", datefmt="%Y-%m-%d %H:%M")
|
||||||
|
logger = logging.getLogger()
|
||||||
|
level = logging.getLevelNamesMapping().get(args.log_level, 'INFO')
|
||||||
|
logger.setLevel(level)
|
||||||
|
|
||||||
|
logger.info('Initializing...')
|
||||||
|
history = jsonrpc2.History()
|
||||||
|
logger.addHandler(history)
|
||||||
|
opts = jsonrpc2.Options()
|
||||||
|
jsonrpc2.define('history', lambda: history.truncate())
|
||||||
|
jsonrpc2.run(opts)
|
||||||
|
|
||||||
|
logger.info('Creating automation instance')
|
||||||
|
opts = ChromeOptions()
|
||||||
|
opts.enable_downloads = True
|
||||||
|
opts.add_argument('--deny-permission-prompts')
|
||||||
|
opts.add_experimental_option('prefs', { 'download.default_directory': args.directory })
|
||||||
|
|
||||||
|
with keep.presenting():
|
||||||
|
driver = Chrome(options=opts)
|
||||||
|
status = main(driver)
|
||||||
|
except Exception as e:
|
||||||
|
logger.critical('Fatal error', exc_info=e)
|
||||||
|
status = 1
|
||||||
|
finally:
|
||||||
|
driver.quit()
|
||||||
|
exit(status)
|
||||||
4
profiles/profile-example.bat
Normal file
4
profiles/profile-example.bat
Normal 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 "Example-1" --token "TOKEN" --subdomain "eg" --remise "100"
|
||||||
|
@pause
|
||||||
4
profiles/setup-virtualenv.bat
Normal file
4
profiles/setup-virtualenv.bat
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
cd /D .\..\
|
||||||
|
python -m venv venv
|
||||||
|
.\venv\Scripts\pip.exe install -r ./requirements.txt
|
||||||
|
@pause
|
||||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
666
销售订单自动导入.py
666
销售订单自动导入.py
@@ -1,666 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import requests
|
|
||||||
import pandas
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
|
|
||||||
from urllib import parse
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from xml.etree import ElementTree as ET
|
|
||||||
|
|
||||||
from selenium import webdriver
|
|
||||||
from selenium.common.exceptions import StaleElementReferenceException, TimeoutException
|
|
||||||
from selenium.webdriver.common.by import By
|
|
||||||
from selenium.webdriver.common.keys import Keys
|
|
||||||
from selenium.webdriver.common.action_chains import ActionChains
|
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
|
||||||
from selenium.webdriver.support.wait import WebDriverWait
|
|
||||||
from selenium.webdriver.remote.webelement import WebElement
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="销售订单自动导入")
|
|
||||||
parser.add_argument('invoices', nargs='?', default='')
|
|
||||||
parser.add_argument('--xm-username', type=str, nargs='?', default='')
|
|
||||||
parser.add_argument('--xm-password', type=str, nargs='?', default='')
|
|
||||||
parser.add_argument('--vf-token', type=str, nargs='?', default='')
|
|
||||||
parser.add_argument('--xm-web-url', type=str, nargs='?', default='https://login.xiaoman.cn/login/')
|
|
||||||
parser.add_argument('--vf-api-url', type=str, nargs='?', default='')
|
|
||||||
parser.add_argument('-C', '--currency', type=str, nargs='?', default='USD')
|
|
||||||
parser.add_argument('-D', '--department', type=str, nargs='?', default='')
|
|
||||||
parser.add_argument('-T', '--duration', type=str, nargs='*', default=[])
|
|
||||||
parser.add_argument('-P', '--prefix', type=str, nargs='?', default='')
|
|
||||||
parser.add_argument('-a', '--automation', type=str, choices=['none', 'draft', 'final', 'override'], nargs='?', default='none')
|
|
||||||
parser.add_argument('-e', '--encoding', type=str, nargs='?', default='utf-8')
|
|
||||||
parser.add_argument('-d', '--outdir', type=str, nargs='?', default='.')
|
|
||||||
parser.add_argument('-o', '--output', type=str, nargs='?', default='')
|
|
||||||
parser.add_argument('-t', '--timeout', type=int, nargs='?', default=60)
|
|
||||||
parser.add_argument('-i', '--interval', type=int, nargs='?', default=5)
|
|
||||||
parser.add_argument('-k', '--kinds', type=str, nargs='*', default=['vat', 'correction'])
|
|
||||||
parser.add_argument('-r', '--retry', type=int, nargs='?', default=3)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
date = datetime.now()
|
|
||||||
|
|
||||||
success = 0
|
|
||||||
warning = 0
|
|
||||||
|
|
||||||
def main(workbook=None):
|
|
||||||
if args.invoices.endswith('.xml'):
|
|
||||||
# 读取发票数据
|
|
||||||
print(f'[信息] 正在读取文件:{args.invoices}')
|
|
||||||
try:
|
|
||||||
with open(args.invoices, 'r', encoding=args.encoding) as file:
|
|
||||||
document = ET.parse(file)
|
|
||||||
root = document.getroot()
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[!!!!] 读取文件时发生了错误:{e}')
|
|
||||||
return 1
|
|
||||||
elif args.invoices.endswith('.xlsx'):
|
|
||||||
# 读取订单数据
|
|
||||||
print(f'[信息] 正在读取文件:{args.invoices}')
|
|
||||||
try:
|
|
||||||
workbook = pandas.read_excel(args.invoices)
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[!!!!] 读取文件时发生了错误:{e}')
|
|
||||||
return 1
|
|
||||||
elif bool(args.invoices):
|
|
||||||
print(f'[!!!!] 无效参数:{args.invoices}')
|
|
||||||
return 1
|
|
||||||
else:
|
|
||||||
root = None
|
|
||||||
index = 1
|
|
||||||
format = "%Y-%m-%d"
|
|
||||||
|
|
||||||
match args.duration:
|
|
||||||
case it if len(it) == 0:
|
|
||||||
yesterday = (datetime.today() - timedelta(days=1))
|
|
||||||
datefrom = yesterday
|
|
||||||
dateto = yesterday
|
|
||||||
print(f'[信息] 正在尝试获取 {yesterday.strftime(format)} 的发票数据')
|
|
||||||
case it if len(it) == 1 and (duration := str(it[0])) and duration.isnumeric():
|
|
||||||
datefrom = (datetime.today() - timedelta(days=int(duration)))
|
|
||||||
dateto = datetime.today()
|
|
||||||
print(f'[信息] 正在尝试获取自 {datefrom.strftime(format)} 到 {dateto.strftime(format)} 的发票数据')
|
|
||||||
case it if len(it) == 2:
|
|
||||||
datefrom = datetime.strptime(it[0], format)
|
|
||||||
dateto = datetime.strptime(it[1], format)
|
|
||||||
print(f'[信息] 正在尝试获取自 {datefrom.strftime(format)} 到 {dateto.strftime(format)} 的发票数据')
|
|
||||||
case _:
|
|
||||||
print(f'[!!!!] 无效参数:{args.duration}')
|
|
||||||
return 11
|
|
||||||
|
|
||||||
format = "%d/%m/%Y"
|
|
||||||
datefrom = parse.quote(datefrom.strftime(format))
|
|
||||||
dateto = parse.quote(dateto.strftime(format))
|
|
||||||
kinds = '&'.join([f'kinds%5B%5D={k}' for k in args.kinds])
|
|
||||||
|
|
||||||
if not args.vf_token or not args.vf_api_url:
|
|
||||||
print('[!!!!] 缺少参数:vf_token 或 vf_api_url')
|
|
||||||
return 12
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
response = requests.get(f'{args.vf_api_url}/invoices.xml?{kinds}&include_positions=true&per_page=25&page={index}&period=more&date_from={datefrom}&date_to={dateto}&search_date_type=issue_date&api_token={args.vf_token}', timeout=args.timeout)
|
|
||||||
string = response.content.decode(args.encoding)
|
|
||||||
data = ET.fromstring(string)
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[警告] 向服务器请求数据时发生了错误:{e}')
|
|
||||||
continue
|
|
||||||
|
|
||||||
if data.tag in ['nil-classes', 'hash']: break
|
|
||||||
else: index += 1
|
|
||||||
|
|
||||||
if root is None: root = data
|
|
||||||
else: root.extend(data.findall('invoice'))
|
|
||||||
|
|
||||||
if root is None:
|
|
||||||
print('[!!!!] 服务器返回了无效数据')
|
|
||||||
return 21
|
|
||||||
|
|
||||||
def lookup(value, fieldname, df: pandas.DataFrame) -> Result:
|
|
||||||
df = df.astype(object).where(df.notna(), None)
|
|
||||||
rows = df[df.isin([value]).any(axis=1)]
|
|
||||||
data = rows.to_dict(orient='list')
|
|
||||||
return Result(data.get(fieldname))
|
|
||||||
|
|
||||||
def fetch(url, error=0) -> Result[requests.Response, Exception]:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
response = requests.get(url, timeout=args.timeout)
|
|
||||||
return Result(response)
|
|
||||||
except Exception as e:
|
|
||||||
if error < args.retry: error += 1
|
|
||||||
else: return Result(None, e)
|
|
||||||
|
|
||||||
def text(element, fieldname) -> str:
|
|
||||||
result = Result(element).map(lambda x: x.find(fieldname).text.strip())
|
|
||||||
text = result.ornone()
|
|
||||||
if bool(text): return text
|
|
||||||
else: return None
|
|
||||||
|
|
||||||
if workbook is None:
|
|
||||||
# 导出发票数据
|
|
||||||
invoices = root.findall('invoice')
|
|
||||||
limit = len(invoices)
|
|
||||||
print(f'[信息] 已读取发票数据 {limit} 条')
|
|
||||||
|
|
||||||
# 订单导入字段
|
|
||||||
# 详情见 <https://crm.xiaoman.cn/order/importOrder>
|
|
||||||
data = FieldArray(
|
|
||||||
'订单号',
|
|
||||||
'商机号',
|
|
||||||
'订单日期',
|
|
||||||
'当前处理人',
|
|
||||||
'业绩归属部门',
|
|
||||||
'客户编号',
|
|
||||||
'币种',
|
|
||||||
'产品名称',
|
|
||||||
'产品编号',
|
|
||||||
'产品型号',
|
|
||||||
'原价',
|
|
||||||
'折扣率',
|
|
||||||
'单价',
|
|
||||||
'数量',
|
|
||||||
'产品描述',
|
|
||||||
'AVOIR'
|
|
||||||
)
|
|
||||||
|
|
||||||
for index, invoice in enumerate(invoices):
|
|
||||||
rate = index / limit
|
|
||||||
kind = text(invoice, 'kind')
|
|
||||||
number = text(invoice, 'number')
|
|
||||||
issue_date = text(invoice, 'issue-date')
|
|
||||||
total = float(text(invoice, 'price-net') or '0')
|
|
||||||
|
|
||||||
print(f'[信息] 正在载入 {number} ... {str(round(rate * 100)).rjust(3)} %')
|
|
||||||
|
|
||||||
relation = fetch(f'{args.vf_api_url}/invoices/{text(invoice, 'from-invoice-id')}.json?api_token={args.vf_token}').map(lambda res: res.json()['number']).orelse(Result(text(invoice, 'title')).map(lambda x: x.split(maxsplit=1)[0])).ornone()
|
|
||||||
category = fetch(f'{args.vf_api_url}/categories/{text(invoice, 'category-id')}.json?api_token={args.vf_token}').map(lambda res: res.json()['name']).ornone()
|
|
||||||
client = fetch(f'{args.vf_api_url}/clients/{text(invoice, 'client-id')}.json?api_token={args.vf_token}').map(lambda res: res.json()['external_id']).ornone()
|
|
||||||
|
|
||||||
for position in invoice.find('positions'):
|
|
||||||
code = text(position, 'code')
|
|
||||||
product = text(position, 'name')
|
|
||||||
description = text(position, 'description')
|
|
||||||
price = float(text(position, 'price-net') or '0')
|
|
||||||
discount = float(text(position, 'discount-percent') or '0')
|
|
||||||
quantity = float(text(position, 'quantity') or '0')
|
|
||||||
|
|
||||||
data.append('订单号', args.prefix + number)
|
|
||||||
data.append('商机号', relation)
|
|
||||||
data.append('订单日期', issue_date)
|
|
||||||
data.append('当前处理人', category)
|
|
||||||
data.append('业绩归属部门', args.department)
|
|
||||||
data.append('客户编号', client)
|
|
||||||
data.append('币种', args.currency)
|
|
||||||
match kind:
|
|
||||||
case 'vat':
|
|
||||||
data.append('产品名称', product)
|
|
||||||
data.append('产品型号', code)
|
|
||||||
data.append('原价', '%.2f' % price)
|
|
||||||
data.append('折扣率', '%g%%' % discount)
|
|
||||||
data.append('单价', '%.2f' % (price * (1 - discount / 100)))
|
|
||||||
data.append('数量', '%g' % quantity)
|
|
||||||
data.append('产品描述', description)
|
|
||||||
case 'correction':
|
|
||||||
data.append('产品名称', 'REMISE SPECIAL')
|
|
||||||
data.append('产品编号', '186')
|
|
||||||
data.append('单价', '0')
|
|
||||||
data.append('数量', '0')
|
|
||||||
data.append('AVOIR', '%.2f' % total)
|
|
||||||
data.newrow()
|
|
||||||
|
|
||||||
# 新建导入数据表
|
|
||||||
if not bool(args.output): args.output = f'{args.outdir}/ultimatron-orders-import-{date.strftime("%Y%m%d-%H%M%S-%f")}.xlsx'
|
|
||||||
print(f'[信息] 正在写入文件:{args.output}')
|
|
||||||
|
|
||||||
try:
|
|
||||||
workbook = pandas.DataFrame(data.map)
|
|
||||||
workbook.to_excel(args.output, index=False, sheet_name='Sheet1')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[!!!!] 写入文件时发生了错误:{e}')
|
|
||||||
return 3
|
|
||||||
|
|
||||||
if args.automation == 'none': return 0
|
|
||||||
print('[信息] 正在启动自动化程序')
|
|
||||||
|
|
||||||
try:
|
|
||||||
opts = webdriver.ChromeOptions()
|
|
||||||
opts.add_experimental_option("excludeSwitches", ["enable-logging"])
|
|
||||||
|
|
||||||
driver = webdriver.Chrome(opts)
|
|
||||||
driver.set_page_load_timeout(args.timeout)
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[!!!!] 初始化时发生了错误:{e}')
|
|
||||||
return 6
|
|
||||||
|
|
||||||
try:
|
|
||||||
driver.get(args.xm_web_url)
|
|
||||||
except TimeoutException:
|
|
||||||
# 停止加载
|
|
||||||
print(f'[警告] 操作超时')
|
|
||||||
driver.execute_script("window.stop();")
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[警告] 载入网页时发生了错误:{e}')
|
|
||||||
return 7
|
|
||||||
|
|
||||||
def locate(selector, wait=True, parent=driver, condition=EC.visibility_of_element_located) -> WebElement:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
locator = (By.CSS_SELECTOR, selector)
|
|
||||||
if not wait: return parent.find_element(*locator)
|
|
||||||
|
|
||||||
wait = WebDriverWait(parent, timeout=args.timeout)
|
|
||||||
element = wait.until(EC.presence_of_element_located(locator))
|
|
||||||
# 查看元素
|
|
||||||
driver.execute_script("arguments[0].scrollIntoView({ block: 'center', inline: 'nearest' });", element)
|
|
||||||
|
|
||||||
if condition is not None:
|
|
||||||
wait = WebDriverWait(parent, timeout=args.timeout)
|
|
||||||
element = wait.until(condition(locator))
|
|
||||||
return element
|
|
||||||
except StaleElementReferenceException:
|
|
||||||
# 如果遇到过期元素,重新尝试查找
|
|
||||||
continue
|
|
||||||
except TimeoutException:
|
|
||||||
# 超时错误
|
|
||||||
raise Exception('操作超时')
|
|
||||||
except Exception as e:
|
|
||||||
# 其他错误
|
|
||||||
raise e
|
|
||||||
|
|
||||||
def click(selector, wait=True, parent=driver, condition=EC.element_to_be_clickable):
|
|
||||||
element = locate(selector, wait, parent, condition) if isinstance(selector, str) else selector
|
|
||||||
counter = lambda: int(element.get_attribute('taximeter') or 0)
|
|
||||||
error = False
|
|
||||||
|
|
||||||
value = counter()
|
|
||||||
driver.execute_script("arguments[0].addEventListener('click', () => arguments[0].setAttribute('taximeter', arguments[1] + 1));", element, value)
|
|
||||||
|
|
||||||
for attempt in range(args.retry):
|
|
||||||
try:
|
|
||||||
if not error: element.click()
|
|
||||||
else: driver.execute_script("arguments[0].click();", element)
|
|
||||||
except StaleElementReferenceException:
|
|
||||||
break
|
|
||||||
except:
|
|
||||||
error = True
|
|
||||||
continue
|
|
||||||
# 检测点击事件
|
|
||||||
try:
|
|
||||||
WebDriverWait(driver, args.interval).until(lambda _: counter() > value)
|
|
||||||
break
|
|
||||||
except TimeoutException: continue
|
|
||||||
except: break
|
|
||||||
|
|
||||||
def ready(driver):
|
|
||||||
try:
|
|
||||||
condition = lambda x: 'nprogress-busy' in x.find_element(By.TAG_NAME, 'html').get_attribute('class')
|
|
||||||
wait = WebDriverWait(driver, timeout=args.interval)
|
|
||||||
wait.until(condition)
|
|
||||||
except (TimeoutException, StaleElementReferenceException):
|
|
||||||
pass
|
|
||||||
wait = WebDriverWait(driver, timeout=args.timeout)
|
|
||||||
wait.until_not(condition)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def keyin(element: WebElement, value):
|
|
||||||
try: element.send_keys(value)
|
|
||||||
except: driver.execute_script("arguments[0].value = arguments[1]", element, value)
|
|
||||||
|
|
||||||
if bool(args.xm_username) and bool(args.xm_password):
|
|
||||||
try:
|
|
||||||
locate("input.account").send_keys(args.xm_username)
|
|
||||||
locate("input#password").send_keys(args.xm_password)
|
|
||||||
click("input.agree-checkbox")
|
|
||||||
click("button.login-btn")
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[!!!!] 登录网页时发生了错误:{e}')
|
|
||||||
return 81
|
|
||||||
|
|
||||||
print("[信息] 正在检测登录状态...")
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
driver.find_element(By.ID, 'container')
|
|
||||||
print("[信息] 已登录")
|
|
||||||
break
|
|
||||||
except:
|
|
||||||
ready(driver) #1
|
|
||||||
|
|
||||||
try:
|
|
||||||
click(".layout-sidebar ul li.list-none.cpq div div")
|
|
||||||
ready(driver)
|
|
||||||
click(".layout-sidebar .layout-second-menu li.order a")
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[!!!!] 尝试检索订单时发生了错误:{e}')
|
|
||||||
return 82
|
|
||||||
|
|
||||||
# 状态菜单映射
|
|
||||||
if index := { 'draft': 1, 'final': 6 }.get(args.automation):
|
|
||||||
try:
|
|
||||||
click(".list-header-top button.okki-dropdown-trigger")
|
|
||||||
click(".okki-dropdown-content button")
|
|
||||||
driver.switch_to.window(driver.window_handles[1])
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[!!!!] 尝试进入录入订单页面时发生了错误:{e}')
|
|
||||||
return 83
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 选择不导入无对应产品的订单
|
|
||||||
click(".product-import-img-box .import-img-radio:nth-child(2) .mm-radio-group > label:nth-child(2) .mm-radio-input", condition=None)
|
|
||||||
# 变更状态
|
|
||||||
click(".product-import-img-box .mm-selector-rendered")
|
|
||||||
click(f".mm-outside.mm-select-dropdown ul li:nth-child({index}) span")
|
|
||||||
|
|
||||||
path = os.path.abspath(args.output or args.invoices)
|
|
||||||
# 上传文件
|
|
||||||
locate(".big-file-upload input", wait=False).send_keys(path)
|
|
||||||
click(".product-import-img-footer button")
|
|
||||||
# 开始导入
|
|
||||||
click(".product-import-img-footer button.mm-button__primary")
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[!!!!] 上传文件时发生了错误:{e}')
|
|
||||||
return 83
|
|
||||||
|
|
||||||
# 等待订单录入
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
innerText = driver.find_element(By.CSS_SELECTOR, ".img-box-result-title").get_attribute('innerText')
|
|
||||||
if innerText == '导入完成': break
|
|
||||||
except:
|
|
||||||
ready(driver) #1
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 查看导入结果
|
|
||||||
click(".mm-notification-container .mm-icon-close")
|
|
||||||
click(".product-import-img-footer button.mm-button__primary")
|
|
||||||
|
|
||||||
cell = locate(".mm-tbody table tbody tr:nth-child(1) td:nth-child(3)", condition=None)
|
|
||||||
indicator = locate("div > div > span", parent=cell, condition=None)
|
|
||||||
error = int(indicator.text[3:])
|
|
||||||
|
|
||||||
if error != 0:
|
|
||||||
print(f'[警告] {error} 个订单导入失败')
|
|
||||||
print(f'[信息] 提示:可下载失败记录')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[!!!!] 等待订单录入时发生了错误:{e}')
|
|
||||||
return 84
|
|
||||||
|
|
||||||
try:
|
|
||||||
click("a", wait=False, parent=cell)
|
|
||||||
driver.switch_to.window(driver.window_handles[2])
|
|
||||||
except:
|
|
||||||
print('[!!!!] 查看导入结果时发生了错误')
|
|
||||||
return 85
|
|
||||||
|
|
||||||
ready(driver) #2
|
|
||||||
modified = []
|
|
||||||
|
|
||||||
def modify(link: WebElement, handle=3):
|
|
||||||
click(link)
|
|
||||||
driver.switch_to.window(driver.window_handles[handle])
|
|
||||||
click(".sticky .okki-space-item:nth-child(1) button")
|
|
||||||
|
|
||||||
warn = False
|
|
||||||
associative = False
|
|
||||||
relation = None
|
|
||||||
number = Result(None).map(lambda _: locate("h1.serial-input-title span").get_attribute('innerText')).ornone()
|
|
||||||
correction = lookup(number, 'AVOIR', workbook).map(lambda x: x[0]).ornone()
|
|
||||||
|
|
||||||
if number is not None and number not in modified and correction is None:
|
|
||||||
try:
|
|
||||||
# 选择商机
|
|
||||||
for attempt in range(args.retry):
|
|
||||||
try:
|
|
||||||
relation = lookup(number, '商机号', workbook).map(lambda x: x[0]).unwrap()
|
|
||||||
needle = str(relation).strip()
|
|
||||||
|
|
||||||
if match := Result(re.search(r'O\d+', needle)).map(lambda x: x[0]).ornone():
|
|
||||||
try:
|
|
||||||
href = locate(".layout-sidebar ul li.list-none.opportunity a").get_attribute('href')
|
|
||||||
driver.switch_to.new_window('tab')
|
|
||||||
driver.get(href)
|
|
||||||
|
|
||||||
# 检索商机
|
|
||||||
locate(".new-header input").send_keys(match)
|
|
||||||
click(".new-header .search-btn span")
|
|
||||||
ready(driver)
|
|
||||||
|
|
||||||
name = locate("#business-board-drag-wrapper .business-card-wrapper > div .name-wrapper a", wait=False)
|
|
||||||
needle = name.text.strip()
|
|
||||||
finally:
|
|
||||||
driver.close()
|
|
||||||
driver.switch_to.window(driver.window_handles[handle])
|
|
||||||
|
|
||||||
dropdown = locate("#rc_select_1")
|
|
||||||
dropdown.clear()
|
|
||||||
dropdown.send_keys(needle)
|
|
||||||
|
|
||||||
wait = WebDriverWait(driver, timeout=args.timeout)
|
|
||||||
menu = wait.until(lambda x: x.find_element(By.CSS_SELECTOR, ".okki-select-dropdown"))
|
|
||||||
|
|
||||||
for item in menu.find_elements(By.CSS_SELECTOR, ".rc-virtual-list-holder-inner > div"):
|
|
||||||
if item.get_attribute('label').strip().startswith(needle):
|
|
||||||
click(item)
|
|
||||||
associative = True
|
|
||||||
raise KeyboardInterrupt()
|
|
||||||
# 关闭菜单栏
|
|
||||||
click(dropdown)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[警告] {number}: 关联商机时发生错误:{e}")
|
|
||||||
warn = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if not associative:
|
|
||||||
print(f'[警告] {number}: 无法找到对应商机 "{relation}"')
|
|
||||||
warn = True
|
|
||||||
|
|
||||||
# 设定分页选项 10 条/页
|
|
||||||
try:
|
|
||||||
click(".okki-pagination-options-size-changer")
|
|
||||||
click(".okki-select-dropdown .rc-virtual-list-holder-inner > div:nth-child(1)")
|
|
||||||
except:
|
|
||||||
print(f'[警告] {number}: 分页选项设置失败')
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 编辑产品
|
|
||||||
try:
|
|
||||||
wrapper = locate(".paas-order-product-list .row-items", condition=None)
|
|
||||||
positions = lookup(number, '产品型号', workbook).unwrap()
|
|
||||||
ids = []
|
|
||||||
|
|
||||||
while (index := 1):
|
|
||||||
for attempts in range(args.retry):
|
|
||||||
if index >= 10: break
|
|
||||||
tail = Result(ids).map(lambda x: x[-1:][0]).ornone()
|
|
||||||
driver.execute_script("arguments[0].scrollIntoView({ block: 'end' });", wrapper)
|
|
||||||
driver.execute_script("arguments[0].scrollTo(arguments[1], arguments[2]);", wrapper, 0, 0)
|
|
||||||
|
|
||||||
for item in wrapper.find_elements(By.CSS_SELECTOR, ".row-item"):
|
|
||||||
if len(ids) >= len(positions): raise KeyboardInterrupt()
|
|
||||||
serial = Result(item.find_elements(By.CSS_SELECTOR, ".cell[data-cci='2'] .cell-inner div")).map(lambda x: int(x[0].text)).ornone()
|
|
||||||
|
|
||||||
if serial is not None and serial not in ids:
|
|
||||||
base = (serial - tail) if tail is not None else index
|
|
||||||
height = int(item.get_attribute('clientHeight'))
|
|
||||||
offset = (base - 1) * height
|
|
||||||
driver.execute_script("arguments[0].scrollTo(arguments[1], arguments[2]);", wrapper, 0, offset)
|
|
||||||
|
|
||||||
click(".product-info-group-product-info a.jump-link", parent=item)
|
|
||||||
price = locate(".okki-drawer-body div[title='含税成本价'] + div div.mm-formily-preview-text", condition=None).text
|
|
||||||
click(".okki-drawer-body .space-header-after button")
|
|
||||||
|
|
||||||
wait = WebDriverWait(driver, timeout=args.timeout)
|
|
||||||
wait.until_not(lambda x: x.find_element(By.CSS_SELECTOR, ".okki-drawer-body").is_displayed())
|
|
||||||
|
|
||||||
original = locate(".cell[data-cci='4'] input", parent=item, condition=None)
|
|
||||||
if price == '--': price = original.get_attribute('value')
|
|
||||||
|
|
||||||
driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", wrapper)
|
|
||||||
driver.execute_script("arguments[0].scroll(arguments[1], arguments[2]);", wrapper, 400, offset)
|
|
||||||
|
|
||||||
# 填入含税成本价
|
|
||||||
target = locate(".cell[data-cci='6'] input", parent=item, condition=None)
|
|
||||||
length = len(target.get_attribute('value'))
|
|
||||||
target.send_keys(Keys.BACKSPACE * length)
|
|
||||||
target.send_keys(price)
|
|
||||||
|
|
||||||
ids.append(serial)
|
|
||||||
index += 1
|
|
||||||
|
|
||||||
# 下一页
|
|
||||||
button = locate(".text-right li.okki-pagination-next button", condition=None)
|
|
||||||
if bool(button.get_attribute('disabled')) and len(ids) < len(positions): raise Exception(f'产品信息不完整。缺少:{', '.join(positions[len(ids):])}')
|
|
||||||
click(button)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 删除 Avoir 费用
|
|
||||||
try: click(".ow-box button.okki-btn-round", wait=False)
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
# 保存订单
|
|
||||||
ready(driver)
|
|
||||||
click(".sticky.bottom-0 button.okki-btn-primary", condition=None)
|
|
||||||
ready(driver)
|
|
||||||
|
|
||||||
print(f"[信息] {number}: 修改完成")
|
|
||||||
modified.append(number)
|
|
||||||
|
|
||||||
global success
|
|
||||||
success += 1
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[警告] {number}: 编辑订单时发生了错误:{e}")
|
|
||||||
warn = True
|
|
||||||
|
|
||||||
global warning
|
|
||||||
if warn: warning += 1
|
|
||||||
|
|
||||||
# 关闭标签页
|
|
||||||
driver.close()
|
|
||||||
driver.switch_to.window(driver.window_handles[handle-1])
|
|
||||||
|
|
||||||
# 设置分页选项 10 条/页
|
|
||||||
try:
|
|
||||||
click(".paas-invoice-list-frame .okki-pagination-options-size-changer")
|
|
||||||
click(".okki-select-dropdown .rc-virtual-list-holder-inner > div:nth-child(1)")
|
|
||||||
ready(driver)
|
|
||||||
except:
|
|
||||||
print(f'[警告] 分页选项设置失败')
|
|
||||||
pass
|
|
||||||
|
|
||||||
if args.automation == 'override':
|
|
||||||
for number in workbook['订单号'].unique():
|
|
||||||
try:
|
|
||||||
box = locate(".list-header-wrap div[data-item-name='订单号']", condition=None)
|
|
||||||
search = locate("input", parent=box, condition=None)
|
|
||||||
length = len(search.get_attribute('value'))
|
|
||||||
click(box)
|
|
||||||
ActionChains(driver).send_keys(Keys.BACKSPACE * length).send_keys(number).send_keys(Keys.ENTER).perform()
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[警告] {number}: 检索订单时发生了错误:{e}')
|
|
||||||
continue
|
|
||||||
|
|
||||||
entry = None
|
|
||||||
department: str = lookup(number, '业绩归属部门', workbook).map(lambda x: x[0]).ornone()
|
|
||||||
|
|
||||||
for attempt in range(args.retry):
|
|
||||||
try:
|
|
||||||
while ready(driver):
|
|
||||||
for link in driver.find_elements(By.CSS_SELECTOR, ".list-frame-table .vue-recycle-scroller__item-wrapper .cell[data-cci='1'] a"):
|
|
||||||
name = link.text.strip()
|
|
||||||
cell = link.find_element(By.XPATH, '../../../../div[@data-cci="6"]/div/span')
|
|
||||||
title = cell.get_attribute('title').strip()
|
|
||||||
|
|
||||||
if link.is_displayed() and name == number and title == department:
|
|
||||||
entry = link
|
|
||||||
raise KeyboardInterrupt()
|
|
||||||
|
|
||||||
# 下一页
|
|
||||||
button = locate(".okki-pagination-next button", condition=None)
|
|
||||||
if bool(button.get_attribute('disabled')): break
|
|
||||||
click(button)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
break
|
|
||||||
except StaleElementReferenceException:
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[警告] {number}: 发生错误:{e}')
|
|
||||||
continue
|
|
||||||
|
|
||||||
if entry is None:
|
|
||||||
print(f'[警告] {number}: 无法找到对应发票')
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
modify(entry, handle=1)
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[!!!!] {number}: 编辑订单时发生了错误:{e}')
|
|
||||||
continue
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
while ready(driver):
|
|
||||||
for link in driver.find_elements(By.CSS_SELECTOR, ".list-frame-table .vue-recycle-scroller__item-wrapper .cell[data-cci='1'] a"):
|
|
||||||
try:
|
|
||||||
modify(link)
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[!!!!] 编辑订单时发生了错误:{e}')
|
|
||||||
return 85
|
|
||||||
|
|
||||||
try:
|
|
||||||
button = locate(".paas-invoice-list-frame .list-okki-footer-wrap li.okki-pagination-next button", wait=False)
|
|
||||||
if bool(button.get_attribute('disabled')): raise KeyboardInterrupt()
|
|
||||||
click(button)
|
|
||||||
except:
|
|
||||||
print('[信息] 已经是最后一页')
|
|
||||||
break
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
class FieldArray[K, V]:
|
|
||||||
def __init__(self, *args: K):
|
|
||||||
self.map: dict[K, list[V]] = { name: [] for name in args }
|
|
||||||
self.index = 0
|
|
||||||
pass
|
|
||||||
|
|
||||||
def append(self, key: K, value: V):
|
|
||||||
array = self.map.get(key, [])
|
|
||||||
array.insert(self.index, value)
|
|
||||||
|
|
||||||
def newrow(self, padding=None):
|
|
||||||
for array in self.map.values():
|
|
||||||
limit = len(array) - 1
|
|
||||||
delta = self.index - limit
|
|
||||||
array.extend([ padding for _ in range(delta) ])
|
|
||||||
self.index += 1
|
|
||||||
|
|
||||||
class Result[T, E]:
|
|
||||||
def __init__(self, value: T, error: E | None = None):
|
|
||||||
self.value = value
|
|
||||||
self.error = error
|
|
||||||
|
|
||||||
def orelse(self, other):
|
|
||||||
if self.error is not None: return other
|
|
||||||
else: return self
|
|
||||||
|
|
||||||
def ornone(self):
|
|
||||||
if self.error is not None: return None
|
|
||||||
else: return self.value
|
|
||||||
|
|
||||||
def unwrap(self):
|
|
||||||
if self.error is not None: raise self.error
|
|
||||||
else: return self.value
|
|
||||||
|
|
||||||
def map(self, f):
|
|
||||||
if self.error is not None: return self
|
|
||||||
try: return Result(f(self.value))
|
|
||||||
except Exception as e: return Result(None, e)
|
|
||||||
|
|
||||||
try: status = main()
|
|
||||||
except KeyboardInterrupt: status = 145
|
|
||||||
|
|
||||||
print(f'[信息] 已修改 {success} 个订单,其中包含 {warning} 条警告信息')
|
|
||||||
print(f'[信息] 总耗时 {datetime.now() - date}')
|
|
||||||
exit(status)
|
|
||||||
Reference in New Issue
Block a user