Compare commits
21 Commits
32cf8599d8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| abae0a5fdb | |||
| a3e3c2a8fa | |||
| 7cb59b5122 | |||
| b3fadc99f3 | |||
| c693d624fc | |||
| 760b88faee | |||
| acb5607e2c | |||
| 3007993ad2 | |||
| e578c31c28 | |||
| 28a4252852 | |||
| eb6df01c06 | |||
| 4ca78fda50 | |||
| cbe68b8f0a | |||
| bedbc9ec42 | |||
| 0289f722de | |||
| 0e8503a0d2 | |||
| 45fcf74fd9 | |||
| 9e505f0a9a | |||
| db217ee457 | |||
| e56a9ae4b0 | |||
| 8c649a8a97 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,5 +1,2 @@
|
||||
.venv/
|
||||
.vscode/
|
||||
.workbooks/
|
||||
*.xlsx
|
||||
*.xml
|
||||
/.vscode
|
||||
/venv
|
||||
358
index.html
Normal file
358
index.html
Normal 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
826
main.py
Normal 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)
|
||||
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 "name=example" "token=TOKEN" "subdomain=eg"
|
||||
@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.
546
销售订单自动导入.py
546
销售订单自动导入.py
@@ -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}')
|
||||
Reference in New Issue
Block a user