From eb6df01c06a1c6c9104f1d9eb31157d12f25ed93 Mon Sep 17 00:00:00 2001 From: break27 Date: Tue, 5 May 2026 19:16:41 +0800 Subject: [PATCH] fix: handling invoice pagination --- index.html | 21 ++++--- main.py | 174 +++++++++++++++++++++++++++++------------------------ 2 files changed, 105 insertions(+), 90 deletions(-) diff --git a/index.html b/index.html index d55145d..90bcf93 100644 --- a/index.html +++ b/index.html @@ -152,7 +152,7 @@ function main(profiles, args) { switch (Status) { case 'READY': let name = $('#name').value; - let options = new Map(); + let options = { profile: name }; for (let element of $$("input[type='checkbox']:not(.local-only)")) { options[element.id] = element.checked; @@ -166,7 +166,6 @@ function main(profiles, args) { args[element.id] = element.valueAsNumber; } - options['profile'] = name; await Rpc2.invoke('begin', options, args); break; case 'RUNNING': @@ -225,10 +224,12 @@ function main(profiles, args) { $('#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})`; + if (Object.hasOwn(args, 'account')) { + let account = new String(args['account']); + let name = account.split('@', 1).pop(); + name = name.charAt(0).toLocaleUpperCase() + name.slice(1); + document.title += ` (${name})`; + } setInterval(async () => { let history = await Rpc2.invoke('history'); @@ -263,11 +264,11 @@ function main(profiles, args) { $('#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' }); + let [t1, t2] = await Rpc2.invoke('uptime').catch(() => []); + $('#uptimeLabel').innerText = Temporal.Duration.from({ seconds: t1 ?? 0 }).round({ largestUnit: 'hours' }).toLocaleString('en', { style: 'digital' }); - if (index && limit && uptime) { - let rate = index / uptime; + if (index && limit && t2) { + let rate = index / t2; let remaining = Math.floor((limit - index) / rate); $('#remainingLabel').innerHTML = Temporal.Duration.from({ seconds: remaining }).round({ largestUnit: 'hours' }).toLocaleString('en'); } diff --git a/main.py b/main.py index d3d8f09..0f05855 100644 --- a/main.py +++ b/main.py @@ -60,7 +60,8 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): result = ''.join(filter(bool, [self.prefix, number, self.suffix])) return result - timer = Timer() + t1 = Timer() + t2 = Timer() options = dict() profiles: list[Profile] = list() status = Status.IDLE @@ -70,25 +71,27 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): options.update(opts) status = Status.RUNNING parameters.update(args) - timer.clear() - timer.start() + t1.clear() + t1.start() def pause(): nonlocal status status = Status.STANDBY - timer.pause() + t1.pause() + t2.pause() def resume(): nonlocal status status = Status.RUNNING driver.switch_to.window(driver.current_window_handle) - timer.start() + t1.start() + t2.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()) + jsonrpc2.define('uptime', lambda: [t1.delta()]) try: for source, profile in zip(parameters.get('profile'), repeat(dict())): @@ -102,7 +105,7 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): return 2 try: - manifest = json.dumps(profiles, default=lambda o: vars(o)) + manifest = json.dumps(profiles, default=vars) driver.get(str(Path('index.html').resolve())) driver.execute_script(jsonrpc2.prelude(), manifest, parameters) except Exception as e: @@ -196,10 +199,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): 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: @@ -207,7 +206,7 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): result = response.json() return result 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 flow = ActionFlow() @@ -231,16 +230,27 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): nonlocal status status = Status.RUNNING return True + + @classmethod + def perform(cls): + nonlocal status + statis = 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.append(Wait) flow.allow(Wait) flow.do(Wait) - flow.append(Cancel) flow.append(Skip) @@ -248,23 +258,21 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): jsonrpc2.define('cancel', lambda: flow.do(Cancel)) jsonrpc2.define('skip', lambda: flow.do(Skip)) jsonrpc2.define('progress', lambda: progress) + jsonrpc2.remove('uptime') + jsonrpc2.define('uptime', lambda: [t1.delta(), t2.delta()]) while not wait(1): try: - progress.clear() - progress['task'] = 'Task 1 of 4' - flow.allow(Cancel, False) - flow.allow(Skip, False) - + 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)) - - 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 @@ -273,7 +281,15 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): 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() base = APIURL % profile.subdomain data = list() 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('Date from %s to %s', df, dt) flow.allow(Cancel) + flow.allow(Skip, False) for page in count(1): try: @@ -299,20 +316,20 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): 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 + logger.warning('Server returned an empty response') continue logger.info('Initializing Workbook...') progress['task'] = 'Task 2 of 4' progress['limit'] = len(data) + t2.clear() + t2.start() workbook = openpyxl.Workbook() sheet = workbook.active @@ -421,7 +438,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): except Skip: pass except Cancel: - status = Status.READY continue except Exception as e: logger.error('Error while processing data', exc_info=e) @@ -437,7 +453,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): except Skip: pass except Cancel: - status = Status.READY continue except Exception as 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...') progress.clear() progress['task'] = 'Task 3 of 4' + t2.clear() + t2.start() driver.switch_to.new_window('tab') driver.get(WEBURL % 'order/importOrder') @@ -474,16 +491,14 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): if err.get_attribute('disabled') is None: logger.warning('Incomplete import detected; downloaded 1 related document') err.click() - sleep(1) + wait(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) + wait(parameters.get('interval')) 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) @@ -513,18 +528,28 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): progress['task'] = 'Task 4 of 4' progress['limit'] = len(data) + t2.clear() + t2.start() index = 0 attempts = 0 while index < len(data): try: + 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 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'): logger.warning('Exhausted all allowed attempts; skipping %s', number) index += 1 @@ -572,7 +597,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): attempts = 0 continue except Cancel: - status = Status.READY break except NoSuchElementException: logger.warning("Could not find invoice '%s'; skipping", number) @@ -618,7 +642,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): attempts = 0 continue except Cancel: - status = Status.READY break except NoSuchElementException: logger.warning("Could not find opportunity '%s'; skipping", match) @@ -648,77 +671,80 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): attempts = 0 continue except Cancel: - status = Status.READY break 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: + 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') - pids = list() - wrapper = locate(".paas-order-product-list .row-items", condition=None) - class Eureka(Exception): pass - try: + ids = list() + wrapper = locate(".paas-order-product-list .row-items", condition=None) + for page in count(1): + hits = 0 + iteration = 0 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") + while hits < pagination and iteration < parameters.get('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") - if (serial := int(div.text)) and serial not in pids: + for row in reversed(rows) if iteration > 1 else rows: 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) + 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')) - 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'): target.send_keys(Keys.BACKSPACE) target.send_keys(value) - pids.append(serial) + ids.append(serial) + hits += 1 - 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 + 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.react() + click(button) 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: + 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: - wait(parameters.get('interval')) flow.react() flow.allow(Cancel, False) flow.allow(Skip, False) @@ -727,7 +753,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): except Skip: pass except Cancel: - status = Status.READY break except Exception as e: logger.error('Error while saving document', exc_info=e) @@ -735,7 +760,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): continue finally: driver.close() - driver.switch_to.window(driver.window_handles[2]) index += 1 attempts = 0 @@ -743,16 +767,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')): 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")