fix: handling invoice pagination

This commit is contained in:
2026-05-05 19:16:41 +08:00
parent 4ca78fda50
commit eb6df01c06
2 changed files with 105 additions and 90 deletions

View File

@@ -152,7 +152,7 @@ function main(profiles, args) {
switch (Status) { switch (Status) {
case 'READY': case 'READY':
let name = $('#name').value; let name = $('#name').value;
let options = new Map(); let options = { profile: name };
for (let element of $$("input[type='checkbox']:not(.local-only)")) { for (let element of $$("input[type='checkbox']:not(.local-only)")) {
options[element.id] = element.checked; options[element.id] = element.checked;
@@ -166,7 +166,6 @@ function main(profiles, args) {
args[element.id] = element.valueAsNumber; args[element.id] = element.valueAsNumber;
} }
options['profile'] = name;
await Rpc2.invoke('begin', options, args); await Rpc2.invoke('begin', options, args);
break; break;
case 'RUNNING': case 'RUNNING':
@@ -225,10 +224,12 @@ function main(profiles, args) {
$('#dateto').valueAsNumber = date.setHours(24 * 7) + date.getTimezoneOffset() * 60 * 1000; $('#dateto').valueAsNumber = date.setHours(24 * 7) + date.getTimezoneOffset() * 60 * 1000;
$('#all').dispatchEvent(new Event('change')); $('#all').dispatchEvent(new Event('change'));
let account = new String(args['account']); if (Object.hasOwn(args, 'account')) {
let name = account.split('@', 1).pop() ?? 'unknown'; let account = new String(args['account']);
name = name.charAt(0).toLocaleUpperCase() + name.slice(1); let name = account.split('@', 1).pop();
document.title += ` (${name})`; name = name.charAt(0).toLocaleUpperCase() + name.slice(1);
document.title += ` (${name})`;
}
setInterval(async () => { setInterval(async () => {
let history = await Rpc2.invoke('history'); let history = await Rpc2.invoke('history');
@@ -263,11 +264,11 @@ function main(profiles, args) {
$('#numberLabel').innerText = number ?? ''; $('#numberLabel').innerText = number ?? '';
$('#progressLabel').innerText = limit ? `${task}, ${parseFloat((index / limit * 100).toFixed(2))}% (${index}/${limit})` : task; $('#progressLabel').innerText = limit ? `${task}, ${parseFloat((index / limit * 100).toFixed(2))}% (${index}/${limit})` : task;
let uptime = await Rpc2.invoke('uptime'); let [t1, t2] = await Rpc2.invoke('uptime').catch(() => []);
$('#uptimeLabel').innerText = Temporal.Duration.from({ seconds: uptime }).round({ largestUnit: 'hours' }).toLocaleString('en', { style: 'digital' }); $('#uptimeLabel').innerText = Temporal.Duration.from({ seconds: t1 ?? 0 }).round({ largestUnit: 'hours' }).toLocaleString('en', { style: 'digital' });
if (index && limit && uptime) { if (index && limit && t2) {
let rate = index / uptime; let rate = index / t2;
let remaining = Math.floor((limit - index) / rate); let remaining = Math.floor((limit - index) / rate);
$('#remainingLabel').innerHTML = Temporal.Duration.from({ seconds: remaining }).round({ largestUnit: 'hours' }).toLocaleString('en'); $('#remainingLabel').innerHTML = Temporal.Duration.from({ seconds: remaining }).round({ largestUnit: 'hours' }).toLocaleString('en');
} }

174
main.py
View File

@@ -60,7 +60,8 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
result = ''.join(filter(bool, [self.prefix, number, self.suffix])) result = ''.join(filter(bool, [self.prefix, number, self.suffix]))
return result return result
timer = Timer() t1 = Timer()
t2 = Timer()
options = dict() options = dict()
profiles: list[Profile] = list() profiles: list[Profile] = list()
status = Status.IDLE status = Status.IDLE
@@ -70,25 +71,27 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
options.update(opts) options.update(opts)
status = Status.RUNNING status = Status.RUNNING
parameters.update(args) parameters.update(args)
timer.clear() t1.clear()
timer.start() t1.start()
def pause(): def pause():
nonlocal status nonlocal status
status = Status.STANDBY status = Status.STANDBY
timer.pause() t1.pause()
t2.pause()
def resume(): def resume():
nonlocal status nonlocal status
status = Status.RUNNING status = Status.RUNNING
driver.switch_to.window(driver.current_window_handle) driver.switch_to.window(driver.current_window_handle)
timer.start() t1.start()
t2.start()
jsonrpc2.define('begin', begin) jsonrpc2.define('begin', begin)
jsonrpc2.define('pause', pause) jsonrpc2.define('pause', pause)
jsonrpc2.define('resume', resume) jsonrpc2.define('resume', resume)
jsonrpc2.define('status', lambda: status.name) jsonrpc2.define('status', lambda: status.name)
jsonrpc2.define('uptime', lambda: timer.delta()) jsonrpc2.define('uptime', lambda: [t1.delta()])
try: try:
for source, profile in zip(parameters.get('profile'), repeat(dict())): for source, profile in zip(parameters.get('profile'), repeat(dict())):
@@ -102,7 +105,7 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
return 2 return 2
try: try:
manifest = json.dumps(profiles, default=lambda o: vars(o)) manifest = json.dumps(profiles, default=vars)
driver.get(str(Path('index.html').resolve())) driver.get(str(Path('index.html').resolve()))
driver.execute_script(jsonrpc2.prelude(), manifest, parameters) driver.execute_script(jsonrpc2.prelude(), manifest, parameters)
except Exception as e: except Exception as e:
@@ -196,10 +199,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
if status == Status.RUNNING: if status == Status.RUNNING:
break 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')): def fetch(url: str, method = 'GET', retry = parameters.get('attempts')):
for attempt in range(1, retry + 1): for attempt in range(1, retry + 1):
try: try:
@@ -207,7 +206,7 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
result = response.json() result = response.json()
return result return result
except Exception as e: except Exception as e:
logger.error('Error while fetching data from %s, retrying... (%d)', url, attempt, exc_info=e) logger.warning('Error while fetching data from %s, retrying... (%d)', url, attempt, exc_info=e)
assert attempt < retry assert attempt < retry
flow = ActionFlow() flow = ActionFlow()
@@ -232,15 +231,26 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
status = Status.RUNNING status = Status.RUNNING
return True return True
@classmethod
def perform(cls):
nonlocal status
statis = Status.READY
driver.switch_to.window(driver.window_handles[0])
raise cls
class Skip(Action): class Skip(Action):
@classmethod @classmethod
def prepare(cls): def prepare(cls):
return True return True
@classmethod
def perform(cls):
driver.switch_to.window(driver.current_window_handle)
raise cls
flow.append(Wait) flow.append(Wait)
flow.allow(Wait) flow.allow(Wait)
flow.do(Wait) flow.do(Wait)
flow.append(Cancel) flow.append(Cancel)
flow.append(Skip) flow.append(Skip)
@@ -248,23 +258,21 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
jsonrpc2.define('cancel', lambda: flow.do(Cancel)) jsonrpc2.define('cancel', lambda: flow.do(Cancel))
jsonrpc2.define('skip', lambda: flow.do(Skip)) jsonrpc2.define('skip', lambda: flow.do(Skip))
jsonrpc2.define('progress', lambda: progress) jsonrpc2.define('progress', lambda: progress)
jsonrpc2.remove('uptime')
jsonrpc2.define('uptime', lambda: [t1.delta(), t2.delta()])
while not wait(1): while not wait(1):
try: try:
progress.clear() for i in range(len(driver.window_handles), 1, -1):
progress['task'] = 'Task 1 of 4' driver.switch_to.window(driver.window_handles[i-1])
flow.allow(Cancel, False) driver.close()
flow.allow(Skip, False) driver.switch_to.window(driver.window_handles[i-2])
if options.get('all'): if options.get('all'):
profile = profiles[selection] profile = profiles[selection]
selection += 1
else: else:
name = options.pop('profile') name = options.pop('profile')
profile = next(filter(lambda o: o.name == name, profiles)) 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): except (IndexError, KeyError):
logger.info('Done') logger.info('Done')
status = Status.READY status = Status.READY
@@ -273,7 +281,15 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
logger.error("Invalid profile '%s'", name) logger.error("Invalid profile '%s'", name)
status = Status.STANDBY status = Status.STANDBY
continue 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()
base = APIURL % profile.subdomain base = APIURL % profile.subdomain
data = list() data = list()
df = options.get('datefrom') df = options.get('datefrom')
@@ -284,6 +300,7 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
logger.info('Profile selected: %s', profile.name) logger.info('Profile selected: %s', profile.name)
logger.info('Date from %s to %s', df, dt) logger.info('Date from %s to %s', df, dt)
flow.allow(Cancel) flow.allow(Cancel)
flow.allow(Skip, False)
for page in count(1): for page in count(1):
try: try:
@@ -299,20 +316,20 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
except Skip: except Skip:
pass pass
except Cancel: except Cancel:
status = Status.READY
break break
except Exception as e: except Exception as e:
logger.error('Error while fetching data from %s', base, exc_info=e) logger.error('Error while fetching data from %s', base, exc_info=e)
break break
if len(data) == 0: if len(data) == 0:
logger.warning('Server returned empty response') logger.warning('Server returned an empty response')
selection += 1
continue continue
logger.info('Initializing Workbook...') logger.info('Initializing Workbook...')
progress['task'] = 'Task 2 of 4' progress['task'] = 'Task 2 of 4'
progress['limit'] = len(data) progress['limit'] = len(data)
t2.clear()
t2.start()
workbook = openpyxl.Workbook() workbook = openpyxl.Workbook()
sheet = workbook.active sheet = workbook.active
@@ -421,7 +438,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
except Skip: except Skip:
pass pass
except Cancel: except Cancel:
status = Status.READY
continue continue
except Exception as e: except Exception as e:
logger.error('Error while processing data', exc_info=e) logger.error('Error while processing data', exc_info=e)
@@ -437,7 +453,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
except Skip: except Skip:
pass pass
except Cancel: except Cancel:
status = Status.READY
continue continue
except Exception as e: except Exception as e:
logger.error('Error while saving excel file', exc_info=e) logger.error('Error while saving excel file', exc_info=e)
@@ -448,6 +463,8 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
logger.info('Uploading...') logger.info('Uploading...')
progress.clear() progress.clear()
progress['task'] = 'Task 3 of 4' progress['task'] = 'Task 3 of 4'
t2.clear()
t2.start()
driver.switch_to.new_window('tab') driver.switch_to.new_window('tab')
driver.get(WEBURL % 'order/importOrder') driver.get(WEBURL % 'order/importOrder')
@@ -474,16 +491,14 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
if err.get_attribute('disabled') is None: if err.get_attribute('disabled') is None:
logger.warning('Incomplete import detected; downloaded 1 related document') logger.warning('Incomplete import detected; downloaded 1 related document')
err.click() err.click()
sleep(1) wait(1)
click(".mm-tbody table tbody tr:nth-child(1) td:nth-child(3) a") click(".mm-tbody table tbody tr:nth-child(1) td:nth-child(3) a")
driver.switch_to.window(driver.window_handles[2]) wait(parameters.get('interval'))
until(ready)
logger.info('Done') logger.info('Done')
except Skip: except Skip:
pass pass
except Cancel: except Cancel:
status = Status.READY
continue continue
except Exception as e: except Exception as e:
logger.error('Error while uploading excel file', exc_info=e) logger.error('Error while uploading excel file', exc_info=e)
@@ -513,18 +528,28 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
progress['task'] = 'Task 4 of 4' progress['task'] = 'Task 4 of 4'
progress['limit'] = len(data) progress['limit'] = len(data)
t2.clear()
t2.start()
index = 0 index = 0
attempts = 0 attempts = 0
while index < len(data): while index < len(data):
try: try:
driver.switch_to.window(driver.window_handles[2])
item = data[index] item = data[index]
kind = item['kind']
title = re.search(r'O\d+', item['title']) title = re.search(r'O\d+', item['title'])
number = profile.format(item['number']) number = profile.format(item['number'])
positions = item['positions'] positions = item['positions']
opportunity = None opportunity = None
attempts += 1 attempts += 1
if kind != 'vat':
logger.info('[%d/%d] Skipping %s', index+1, len(data), number)
index += 1
attempts = 0
continue
if attempts > parameters.get('attempts'): if attempts > parameters.get('attempts'):
logger.warning('Exhausted all allowed attempts; skipping %s', number) logger.warning('Exhausted all allowed attempts; skipping %s', number)
index += 1 index += 1
@@ -572,7 +597,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
attempts = 0 attempts = 0
continue continue
except Cancel: except Cancel:
status = Status.READY
break break
except NoSuchElementException: except NoSuchElementException:
logger.warning("Could not find invoice '%s'; skipping", number) logger.warning("Could not find invoice '%s'; skipping", number)
@@ -618,7 +642,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
attempts = 0 attempts = 0
continue continue
except Cancel: except Cancel:
status = Status.READY
break break
except NoSuchElementException: except NoSuchElementException:
logger.warning("Could not find opportunity '%s'; skipping", match) logger.warning("Could not find opportunity '%s'; skipping", match)
@@ -648,77 +671,80 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
attempts = 0 attempts = 0
continue continue
except Cancel: except Cancel:
status = Status.READY
break break
except Exception as e: except Exception as e:
logger.warning('Error while selecting opportunity; skipping', exc_info=e) logger.warning('Could not select opportunity; skipping', exc_info=e)
try: try:
pagination = 10
click(".okki-pagination-options-size-changer") click(".okki-pagination-options-size-changer")
click(".okki-select-dropdown .rc-virtual-list-holder-inner > div:nth-child(1)") click(".okki-select-dropdown .rc-virtual-list-holder-inner > div:nth-child(1)")
except: except:
logger.warning('Unable to setup pagination; this may cause issues') 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: try:
ids = list()
wrapper = locate(".paas-order-product-list .row-items", condition=None)
for page in count(1): for page in count(1):
hits = 0
iteration = 0
flow.react() 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"): while hits < pagination and iteration < parameters.get('attempts'):
if len(pids) >= len(positions): raise Eureka() iteration += 1
div = item.find_element(By.CSS_SELECTOR, ".cell[data-cci='2'] .cell-inner div") 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")
if (serial := int(div.text)) and serial not in pids: for row in reversed(rows) if iteration > 1 else rows:
flow.react() 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].scrollIntoView({ block: 'center' });", wrapper)
driver.execute_script("arguments[0].scroll(arguments[1], arguments[2]);", wrapper, 400, offset) 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)
wait(parameters.get('interval')) wait(parameters.get('interval'))
target = item.find_element(By.CSS_SELECTOR, ".cell[data-cci='6'] input") target = row.find_element(By.CSS_SELECTOR, ".cell[data-cci='6'] input")
if (target.get_attribute('value') == '0'): if (target.get_attribute('value') == '0'):
target.send_keys(Keys.BACKSPACE) target.send_keys(Keys.BACKSPACE)
target.send_keys(value) target.send_keys(value)
pids.append(serial) ids.append(serial)
hits += 1
button = locate(".text-right li.okki-pagination-next button", condition=None) if len(ids) >= len(positions): break
if button.get_attribute('disabled') is not None and len(pids) < len(positions): button = locate(".text-right li.okki-pagination-next button", condition=None)
raise Exception('Product list imcomplete; expected %d, got %d', len(pids), len(positions)) if button.get_attribute('disabled') is not None and len(ids) < len(positions):
flow.react() raise Exception('Product list imcomplete; expected %d, got %d' % (len(positions), len(ids)))
click(button) flow.react()
except Eureka: click(button)
pass
except Skip: except Skip:
index += 1 index += 1
attempts = 0 attempts = 0
continue continue
except Cancel: except Cancel:
status = Status.READY
break break
except Exception as e: except Exception as e:
logger.error('Error while modifying invoice', exc_info=e) logger.error('Error while modifying invoice', exc_info=e)
status = Status.STANDBY status = Status.STANDBY
continue continue
try: click(".ow-box button.okki-btn-round", wait=False) try:
except: pass click(".ow-box button.okki-btn-round", wait=False)
wait(parameters.get('interval'))
except Exception as e:
logger.warning('Unable to unset additional fees; skipping', exc_info=e)
try: try:
wait(parameters.get('interval'))
flow.react() flow.react()
flow.allow(Cancel, False) flow.allow(Cancel, False)
flow.allow(Skip, False) flow.allow(Skip, False)
@@ -727,7 +753,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
except Skip: except Skip:
pass pass
except Cancel: except Cancel:
status = Status.READY
break break
except Exception as e: except Exception as e:
logger.error('Error while saving document', exc_info=e) logger.error('Error while saving document', exc_info=e)
@@ -735,7 +760,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
continue continue
finally: finally:
driver.close() driver.close()
driver.switch_to.window(driver.window_handles[2])
index += 1 index += 1
attempts = 0 attempts = 0
@@ -743,16 +767,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
logger.error('Unexpected error', exc_info=e) logger.error('Unexpected error', exc_info=e)
status = Status.STANDBY 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__': if __name__ == '__main__':
try: try:
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s", datefmt="%Y-%m-%d %H:%M") logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s", datefmt="%Y-%m-%d %H:%M")