From 339b68063e2b094297401441daa349ff406d81a5 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Fri, 17 Apr 2026 17:42:26 +0000 Subject: [PATCH 01/30] tests: init job card tests --- beam/tests/mobile/test_job_card_operation.py | 162 +++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 beam/tests/mobile/test_job_card_operation.py diff --git a/beam/tests/mobile/test_job_card_operation.py b/beam/tests/mobile/test_job_card_operation.py new file mode 100644 index 00000000..f9ab3c3a --- /dev/null +++ b/beam/tests/mobile/test_job_card_operation.py @@ -0,0 +1,162 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +import re + +import frappe +import pytest +from playwright.sync_api import expect + +from beam.tests.test_utils import use_current_db_transaction + +# NOTE: any navigation tests should be done using `expect(page).to_have_url` since +# `page.expect_navigation()` won't work with Beam's hash-based routes + + +def open_first_operation_from_manufacture(page) -> tuple[str, str]: + """Navigate Home -> Manufacture -> Work Order -> first Operation. + + Returns: + tuple[str, str]: work_order_id, operation_id + """ + page.get_by_text("Manufacture").click() + expect(page).to_have_url(re.compile(r"#/manufacture"), timeout=15000) + + page.locator("css=.beam_list-item").first.click() + expect(page).to_have_url(re.compile(r"#/work_order/[^/]+/?$"), timeout=15000) + + operation_link = page.locator("a[href*='/operation/']").first + expect(operation_link).to_be_visible(timeout=15000) + operation_link.click() + expect(page).to_have_url(re.compile(r"#/work_order/[^/]+/operation/[^/?#]+"), timeout=15000) + + match = re.search(r"#/work_order/([^/]+)/operation/([^/?#]+)", page.url) + assert match, f"Could not parse operation route from URL: {page.url}" + return match.group(1), match.group(2) + + +def hms_to_seconds(value: str) -> int: + hours, minutes, seconds = [int(part) for part in value.split(":")] + return (hours * 3600) + (minutes * 60) + seconds + + +@pytest.mark.order(13) +def test_operation_navigation_from_work_order(page): + work_order_id, operation_id = open_first_operation_from_manufacture(page) + assert work_order_id + assert operation_id + + +@pytest.mark.order(14) +def test_operation_description_is_displayed(page): + work_order_id, operation_id = open_first_operation_from_manufacture(page) + description_box = page.locator("css=.container .box").first + expect(description_box).to_be_visible(timeout=15000) + + with use_current_db_transaction(): + operation = frappe.get_value( + "Work Order Operation", + operation_id, + ["name", "description"], + as_dict=True, + ) + + assert operation, f"Operation {operation_id} not found" + if operation.description: + expect(description_box).to_contain_text(operation.description) + else: + # Some fixtures may not set description text; ensure the description area still renders. + expect(page).to_have_url(re.compile(rf"#/work_order/{re.escape(work_order_id)}/operation/{re.escape(operation_id)}")) + + +@pytest.mark.order(15) +def test_operation_elapsed_time_is_visible_format_hh_mm_ss(page): + _, operation_id = open_first_operation_from_manufacture(page) + timer_text = page.locator("css=.fix-height b").first + expect(timer_text).to_be_visible(timeout=15000) + + elapsed = timer_text.inner_text().strip() + assert re.fullmatch(r"\d{2}:\d{2}:\d{2}", elapsed), f"Unexpected elapsed time format: {elapsed}" + + with use_current_db_transaction(): + job_card_name = frappe.get_value("Job Card", {"operation_id": operation_id}, "name") + + assert job_card_name, f"Expected a Job Card linked to operation {operation_id}" + + +@pytest.mark.order(16) +@pytest.mark.xfail(reason="Operation Start/Stop still TODO in Operation.vue") +def test_start_operation_starts_timer_and_toggles_buttons(page): + _, _ = open_first_operation_from_manufacture(page) + + timer_text = page.locator("css=.fix-height b").first + start_button = page.get_by_role("button", name="Start") + stop_button = page.get_by_role("button", name="Stop") + + expect(timer_text).to_be_visible(timeout=15000) + expect(start_button).to_be_enabled(timeout=15000) + expect(stop_button).to_be_disabled(timeout=15000) + + initial_elapsed = timer_text.inner_text().strip() + assert re.fullmatch(r"\d{2}:\d{2}:\d{2}", initial_elapsed) + + page.once("dialog", lambda dialog: dialog.dismiss()) + start_button.click() + + expect(start_button).to_be_disabled(timeout=5000) + expect(stop_button).to_be_enabled(timeout=5000) + + page.wait_for_timeout(1500) + updated_elapsed = timer_text.inner_text().strip() + assert re.fullmatch(r"\d{2}:\d{2}:\d{2}", updated_elapsed) + assert hms_to_seconds(updated_elapsed) > hms_to_seconds(initial_elapsed) + + +@pytest.mark.order(17) +@pytest.mark.xfail(reason="Operation Start/Stop still TODO in Operation.vue") +def test_stop_operation_stops_timer_and_creates_time_log(page): + _, operation_id = open_first_operation_from_manufacture(page) + + timer_text = page.locator("css=.fix-height b").first + start_button = page.get_by_role("button", name="Start") + stop_button = page.get_by_role("button", name="Stop") + + with use_current_db_transaction(): + job_card_name = frappe.get_value("Job Card", {"operation_id": operation_id}, "name") + initial_logs = frappe.get_all( + "Job Card Time Log", + filters={"parent": job_card_name}, + fields=["name", "from_time", "to_time", "time_in_mins"], + ) + + assert job_card_name + + page.once("dialog", lambda dialog: dialog.dismiss()) + start_button.click() + expect(stop_button).to_be_enabled(timeout=5000) + + page.wait_for_timeout(1200) + timer_after_start = timer_text.inner_text().strip() + + page.once("dialog", lambda dialog: dialog.dismiss()) + stop_button.click() + + expect(start_button).to_be_enabled(timeout=5000) + expect(stop_button).to_be_disabled(timeout=5000) + + page.wait_for_timeout(1500) + timer_after_stop = timer_text.inner_text().strip() + page.wait_for_timeout(1200) + timer_after_wait = timer_text.inner_text().strip() + + assert timer_after_wait == timer_after_stop + assert hms_to_seconds(timer_after_stop) >= hms_to_seconds(timer_after_start) + + with use_current_db_transaction(): + final_logs = frappe.get_all( + "Job Card Time Log", + filters={"parent": job_card_name}, + fields=["name", "from_time", "to_time", "time_in_mins"], + ) + + assert len(final_logs) >= len(initial_logs) + 1 From 442116ff83f7572201630d6a324fa3dd573a48a5 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Fri, 17 Apr 2026 17:42:53 +0000 Subject: [PATCH 02/30] feat: job card workflow --- beam/beam/api/__init__.py | 0 beam/beam/api/job_card.py | 129 +++++++++++ beam/www/beam/pages/Operation.vue | 359 ++++++++++++++++++++++++++---- beam/www/beam/stores/beam.ts | 48 ++++ beam/www/beam/types/frappe.ts | 5 + 5 files changed, 494 insertions(+), 47 deletions(-) create mode 100644 beam/beam/api/__init__.py create mode 100644 beam/beam/api/job_card.py diff --git a/beam/beam/api/__init__.py b/beam/beam/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/beam/beam/api/job_card.py b/beam/beam/api/job_card.py new file mode 100644 index 00000000..6e7638ef --- /dev/null +++ b/beam/beam/api/job_card.py @@ -0,0 +1,129 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +import json + +import frappe +from erpnext.manufacturing.doctype.job_card.job_card import make_time_log +from frappe import _ +from frappe.utils import flt, now_datetime + + +def _get_assigned_users(job_card_doc): + if not job_card_doc._assign: + return [] + + try: + return json.loads(job_card_doc._assign) + except Exception: + return [] + + +def _ensure_job_card_is_assigned_to_current_user(job_card_doc): + if frappe.session.user == "Administrator": + return + + assigned_users = _get_assigned_users(job_card_doc) + if assigned_users and frappe.session.user not in assigned_users: + frappe.throw(_("This Job Card is not assigned to the current user."), frappe.PermissionError) + + +def _get_employee_for_current_user(): + employee = frappe.db.get_value( + "Employee", + {"user_id": frappe.session.user, "status": "Active"}, + "name", + ) + + if not employee: + frappe.throw( + _("No active Employee linked to user {0}.").format(frappe.session.user), + frappe.ValidationError, + ) + + return employee + + +def _get_job_card(job_card_id): + job_card = frappe.get_doc("Job Card", job_card_id) + _ensure_job_card_is_assigned_to_current_user(job_card) + return job_card + + +@frappe.whitelist() +def start_job_card(job_card_id): + job_card = _get_job_card(job_card_id) + employee = _get_employee_for_current_user() + + args = { + "job_card_id": job_card.name, + "start_time": now_datetime(), + "status": "Work In Progress", + } + if employee: + args["employees"] = [{"employee": employee}] + + make_time_log(args) + + return frappe.get_doc("Job Card", job_card.name) + + +@frappe.whitelist() +def pause_job_card(job_card_id): + job_card = _get_job_card(job_card_id) + + args = { + "job_card_id": job_card.name, + "complete_time": now_datetime(), + "status": "On Hold", + "completed_qty": 0, + } + make_time_log(args) + + return frappe.get_doc("Job Card", job_card.name) + + +@frappe.whitelist() +def finish_job_card(job_card_id, completed_qty=None): + job_card = _get_job_card(job_card_id) + + remaining_qty = max((job_card.for_quantity or 0) - (job_card.total_completed_qty or 0), 0) + + if completed_qty is None: + completed_qty = remaining_qty + + completed_qty = flt(completed_qty) + if completed_qty <= 0: + frappe.throw(_("Completed quantity must be greater than zero."), frappe.ValidationError) + + if completed_qty > remaining_qty: + frappe.throw( + _("Completed quantity cannot be greater than remaining quantity ({0}).").format(remaining_qty), + frappe.ValidationError, + ) + + has_open_time_log = any(not row.to_time for row in (job_card.time_logs or [])) + complete_time = now_datetime() + + if not has_open_time_log: + make_time_log( + { + "job_card_id": job_card.name, + "start_time": complete_time, + "status": "Work In Progress", + } + ) + + args = { + "job_card_id": job_card.name, + "complete_time": complete_time, + "status": "Complete", + "completed_qty": completed_qty, + } + make_time_log(args) + job_card = frappe.get_doc("Job Card", job_card.name) + + if job_card.docstatus == 0: + job_card.submit() + + return frappe.get_doc("Job Card", job_card.name) diff --git a/beam/www/beam/pages/Operation.vue b/beam/www/beam/pages/Operation.vue index 3ca9f811..1920a1bd 100644 --- a/beam/www/beam/pages/Operation.vue +++ b/beam/www/beam/pages/Operation.vue @@ -1,95 +1,360 @@ diff --git a/beam/www/beam/stores/beam.ts b/beam/www/beam/stores/beam.ts index 83639791..95b4e52a 100644 --- a/beam/www/beam/stores/beam.ts +++ b/beam/www/beam/stores/beam.ts @@ -35,6 +35,9 @@ const PURCHASE_DEMAND_URL = '/api/method/beam.beam.demand.receiving.get_receivin const SALES_DEMAND_URL = '/api/method/beam.beam.demand.demand.get_demand' const SCAN_CONFIG_URL = '/api/method/beam.beam.scan.config.get_scan_doctypes' const SCAN_URL = 'beam.beam.scan.scan' // frappe.xcall doesn't require prefix +const START_JOB_CARD_URL = 'beam.beam.api.job_card.start_job_card' +const PAUSE_JOB_CARD_URL = 'beam.beam.api.job_card.pause_job_card' +const FINISH_JOB_CARD_URL = 'beam.beam.api.job_card.finish_job_card' export const useBeamStore = defineStore('beam', () => { const toast = useBeamToast() @@ -124,8 +127,10 @@ export const useBeamStore = defineStore('beam', () => { const getOne = async (doctype: string, name: string) => { const url = `/api/resource/${doctype}/${name}` + console.log('url', url) const response = await httpStore.get(url) const { data }: { data: T } = await response.json() + console.log({data}) return data } @@ -184,6 +189,46 @@ export const useBeamStore = defineStore('beam', () => { return [] } + const startJobCard = async (jobCardId: string) => { + try { + const response = await frappe.xcall(START_JOB_CARD_URL, { + job_card_id: jobCardId, + }) + toast.success('Job started') + return response + } catch (error) { + console.error(error) + throw error + } + } + + const pauseJobCard = async (jobCardId: string) => { + try { + const response = await frappe.xcall(PAUSE_JOB_CARD_URL, { + job_card_id: jobCardId, + }) + toast.success('Job paused') + return response + } catch (error) { + console.error(error) + throw error + } + } + + const finishJobCard = async (jobCardId: string, completedQty: number) => { + try { + const response = await frappe.xcall(FINISH_JOB_CARD_URL, { + job_card_id: jobCardId, + completed_qty: completedQty, + }) + toast.success('Job finished') + return response + } catch (error) { + console.error(error) + throw error + } + } + const insert = async >(doctype: string, body: T) => { const url = `/api/resource/${doctype}` const response = await httpStore.post(url, body) @@ -327,6 +372,9 @@ export const useBeamStore = defineStore('beam', () => { getOne, getReceiving, getStockEntryItems, + startJobCard, + pauseJobCard, + finishJobCard, logout, makeNewDoc, scan, diff --git a/beam/www/beam/types/frappe.ts b/beam/www/beam/types/frappe.ts index d5884b46..32ba3e99 100644 --- a/beam/www/beam/types/frappe.ts +++ b/beam/www/beam/types/frappe.ts @@ -57,7 +57,12 @@ export type User = ParentDoctype & { } export type JobCard = ParentDoctype & { + current_time?: number total_time_in_mins: number + for_quantity?: number + started_time?: string + status?: 'Open' | 'Work In Progress' | 'Material Transferred' | 'On Hold' | 'Submitted' | 'Cancelled' | 'Complete' | 'Completed' + total_completed_qty?: number items?: JobCardItem[] } From 6a7aa914fa38849c28c08121a4d5304358f1f33c Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 29 Apr 2026 16:58:51 +0000 Subject: [PATCH 03/30] fix: migrate to erpnext functions --- beam/beam/api/__init__.py | 0 beam/beam/api/job_card.py | 129 -------------------------------------- 2 files changed, 129 deletions(-) delete mode 100644 beam/beam/api/__init__.py delete mode 100644 beam/beam/api/job_card.py diff --git a/beam/beam/api/__init__.py b/beam/beam/api/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/beam/beam/api/job_card.py b/beam/beam/api/job_card.py deleted file mode 100644 index 6e7638ef..00000000 --- a/beam/beam/api/job_card.py +++ /dev/null @@ -1,129 +0,0 @@ -# Copyright (c) 2026, AgriTheory and contributors -# For license information, please see license.txt - -import json - -import frappe -from erpnext.manufacturing.doctype.job_card.job_card import make_time_log -from frappe import _ -from frappe.utils import flt, now_datetime - - -def _get_assigned_users(job_card_doc): - if not job_card_doc._assign: - return [] - - try: - return json.loads(job_card_doc._assign) - except Exception: - return [] - - -def _ensure_job_card_is_assigned_to_current_user(job_card_doc): - if frappe.session.user == "Administrator": - return - - assigned_users = _get_assigned_users(job_card_doc) - if assigned_users and frappe.session.user not in assigned_users: - frappe.throw(_("This Job Card is not assigned to the current user."), frappe.PermissionError) - - -def _get_employee_for_current_user(): - employee = frappe.db.get_value( - "Employee", - {"user_id": frappe.session.user, "status": "Active"}, - "name", - ) - - if not employee: - frappe.throw( - _("No active Employee linked to user {0}.").format(frappe.session.user), - frappe.ValidationError, - ) - - return employee - - -def _get_job_card(job_card_id): - job_card = frappe.get_doc("Job Card", job_card_id) - _ensure_job_card_is_assigned_to_current_user(job_card) - return job_card - - -@frappe.whitelist() -def start_job_card(job_card_id): - job_card = _get_job_card(job_card_id) - employee = _get_employee_for_current_user() - - args = { - "job_card_id": job_card.name, - "start_time": now_datetime(), - "status": "Work In Progress", - } - if employee: - args["employees"] = [{"employee": employee}] - - make_time_log(args) - - return frappe.get_doc("Job Card", job_card.name) - - -@frappe.whitelist() -def pause_job_card(job_card_id): - job_card = _get_job_card(job_card_id) - - args = { - "job_card_id": job_card.name, - "complete_time": now_datetime(), - "status": "On Hold", - "completed_qty": 0, - } - make_time_log(args) - - return frappe.get_doc("Job Card", job_card.name) - - -@frappe.whitelist() -def finish_job_card(job_card_id, completed_qty=None): - job_card = _get_job_card(job_card_id) - - remaining_qty = max((job_card.for_quantity or 0) - (job_card.total_completed_qty or 0), 0) - - if completed_qty is None: - completed_qty = remaining_qty - - completed_qty = flt(completed_qty) - if completed_qty <= 0: - frappe.throw(_("Completed quantity must be greater than zero."), frappe.ValidationError) - - if completed_qty > remaining_qty: - frappe.throw( - _("Completed quantity cannot be greater than remaining quantity ({0}).").format(remaining_qty), - frappe.ValidationError, - ) - - has_open_time_log = any(not row.to_time for row in (job_card.time_logs or [])) - complete_time = now_datetime() - - if not has_open_time_log: - make_time_log( - { - "job_card_id": job_card.name, - "start_time": complete_time, - "status": "Work In Progress", - } - ) - - args = { - "job_card_id": job_card.name, - "complete_time": complete_time, - "status": "Complete", - "completed_qty": completed_qty, - } - make_time_log(args) - job_card = frappe.get_doc("Job Card", job_card.name) - - if job_card.docstatus == 0: - job_card.submit() - - return frappe.get_doc("Job Card", job_card.name) From ccd80782b84d567ff7b12038b43ef177b0325e91 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 29 Apr 2026 16:59:16 +0000 Subject: [PATCH 04/30] feat(tests): Manufacturing User --- beam/tests/fixtures.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/beam/tests/fixtures.py b/beam/tests/fixtures.py index 2a8f7a96..43367acf 100644 --- a/beam/tests/fixtures.py +++ b/beam/tests/fixtures.py @@ -867,4 +867,21 @@ # "department": "Operations", "designation": "Baker", }, + { + "name": "Jordan Mills", + "gender": "Male", + "date_of_birth": "1996-04-15", + "date_of_joining": "2024-09-01", + "address": { + "address_line1": "440 Orchard Lane", + "city": "Nashua", + "state": "NH", + "postal_code": "03060", + }, + "phone": "(603) 555-0134", + "roles": ["BEAM Mobile User", "Employee", "Manufacturing User"], + "reports_to": "Tristan Hawkins", + # "department": "Operations", + "designation": "Baker", + }, ] From 8ba6070ebee033d12a3dc3a0163ec83727070fbc Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 29 Apr 2026 16:59:41 +0000 Subject: [PATCH 05/30] feat: set current employee --- beam/www/beam/stores/init.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/beam/www/beam/stores/init.ts b/beam/www/beam/stores/init.ts index d164de51..f152d367 100644 --- a/beam/www/beam/stores/init.ts +++ b/beam/www/beam/stores/init.ts @@ -14,6 +14,7 @@ export const useInitStore = defineStore('init', () => { const resolvedRoute = currentRoute || route await store.getScanDoctypes() + await store.setCurrentEmployee() await store.setForm(resolvedRoute) await store.setMappedDoc(resolvedRoute) await store.setScanContext(resolvedRoute) From 89662c91acd66e8c85237c446307710861176999 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 29 Apr 2026 17:00:39 +0000 Subject: [PATCH 06/30] feat: job card and job card time log types --- beam/www/beam/types/frappe.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/beam/www/beam/types/frappe.ts b/beam/www/beam/types/frappe.ts index 32ba3e99..94dd4c63 100644 --- a/beam/www/beam/types/frappe.ts +++ b/beam/www/beam/types/frappe.ts @@ -56,6 +56,14 @@ export type User = ParentDoctype & { full_name?: string } +export type JobCardTimeLog = ChildDoctypeMeta & { + from_time?: string + to_time?: string + time_in_mins?: number + completed_qty?: number + employee?: string +} + export type JobCard = ParentDoctype & { current_time?: number total_time_in_mins: number @@ -63,7 +71,9 @@ export type JobCard = ParentDoctype & { started_time?: string status?: 'Open' | 'Work In Progress' | 'Material Transferred' | 'On Hold' | 'Submitted' | 'Cancelled' | 'Complete' | 'Completed' total_completed_qty?: number + active_job_card_for_employee?: string | null items?: JobCardItem[] + time_logs?: JobCardTimeLog[] } export type JobCardItem = ChildDoctype & { From 88ea0d843d59b05eafad5f64c026319b1a3a6874 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 29 Apr 2026 17:02:58 +0000 Subject: [PATCH 07/30] feat: job card save --- beam/www/beam/stores/beam.ts | 95 +++++++++++++++++++++++++++++++----- 1 file changed, 82 insertions(+), 13 deletions(-) diff --git a/beam/www/beam/stores/beam.ts b/beam/www/beam/stores/beam.ts index 95b4e52a..779e8eba 100644 --- a/beam/www/beam/stores/beam.ts +++ b/beam/www/beam/stores/beam.ts @@ -13,6 +13,7 @@ import type { DeliveryNoteItem, Demand, FormContext, + JobCard, FrappeResponse, ListContext, ParentDoctypes, @@ -35,9 +36,7 @@ const PURCHASE_DEMAND_URL = '/api/method/beam.beam.demand.receiving.get_receivin const SALES_DEMAND_URL = '/api/method/beam.beam.demand.demand.get_demand' const SCAN_CONFIG_URL = '/api/method/beam.beam.scan.config.get_scan_doctypes' const SCAN_URL = 'beam.beam.scan.scan' // frappe.xcall doesn't require prefix -const START_JOB_CARD_URL = 'beam.beam.api.job_card.start_job_card' -const PAUSE_JOB_CARD_URL = 'beam.beam.api.job_card.pause_job_card' -const FINISH_JOB_CARD_URL = 'beam.beam.api.job_card.finish_job_card' +const JOB_CARD_TIME_LOG_URL = 'erpnext.manufacturing.doctype.job_card.job_card.make_time_log' export const useBeamStore = defineStore('beam', () => { const toast = useBeamToast() @@ -47,6 +46,7 @@ export const useBeamStore = defineStore('beam', () => { const cache = ref({ mappers: {} }) const form = ref>({}) const warehouseList = ref() + const currentEmployee = ref(null) const scanner = reactive({ config: {} as ScanConfig, context: {} as ScanContext, @@ -125,12 +125,31 @@ export const useBeamStore = defineStore('beam', () => { }) } + const setCurrentEmployee = async () => { + try { + if (currentEmployee.value) return + const currentUser = frappe?.session?.user + const employees = await getAll<{ name: string }>('Employee', { + filters: JSON.stringify([ + ['user_id', '=', currentUser], + ['status', '=', 'Active'], + ]), + fields: JSON.stringify(['name']), + limit_page_length: 1, + }) + + const employee = employees?.[0]?.name + currentEmployee.value = employee + } catch (error) { + console.error('Failed to resolve current employee:', error) + throw error + } + } + const getOne = async (doctype: string, name: string) => { const url = `/api/resource/${doctype}/${name}` - console.log('url', url) const response = await httpStore.get(url) const { data }: { data: T } = await response.json() - console.log({data}) return data } @@ -189,11 +208,30 @@ export const useBeamStore = defineStore('beam', () => { return [] } + const getJobCard = async (jobCardId: string): Promise => { + try { + return await getOne('Job Card', jobCardId) + } catch (error) { + console.error(error) + throw error + } + } + const startJobCard = async (jobCardId: string) => { try { - const response = await frappe.xcall(START_JOB_CARD_URL, { - job_card_id: jobCardId, + if (!currentEmployee.value) { + throw new Error('No current employee in session') + } + + await frappe.xcall(JOB_CARD_TIME_LOG_URL, { + args: { + job_card_id: jobCardId, + start_time: frappe.datetime.now_datetime(), + status: 'Work In Progress', + employees: [{ employee: currentEmployee.value }], + }, }) + const response = await getOne('Job Card', jobCardId) toast.success('Job started') return response } catch (error) { @@ -202,11 +240,26 @@ export const useBeamStore = defineStore('beam', () => { } } - const pauseJobCard = async (jobCardId: string) => { + const pauseJobCard = async (jobCardId: string, completedQty?: number) => { try { - const response = await frappe.xcall(PAUSE_JOB_CARD_URL, { - job_card_id: jobCardId, + await frappe.xcall(JOB_CARD_TIME_LOG_URL, { + args: { + job_card_id: jobCardId, + complete_time: frappe.datetime.now_datetime(), + status: 'On Hold', + completed_qty: completedQty || 0, + }, }) + + let response = await getOne('Job Card', jobCardId) + + if (completedQty !== undefined && completedQty > 0) { + await update('Job Card', jobCardId, { + total_completed_qty: completedQty, + }) + response = await getOne('Job Card', jobCardId) + } + toast.success('Job paused') return response } catch (error) { @@ -217,10 +270,23 @@ export const useBeamStore = defineStore('beam', () => { const finishJobCard = async (jobCardId: string, completedQty: number) => { try { - const response = await frappe.xcall(FINISH_JOB_CARD_URL, { - job_card_id: jobCardId, - completed_qty: completedQty, + await frappe.xcall(JOB_CARD_TIME_LOG_URL, { + args: { + job_card_id: jobCardId, + complete_time: frappe.datetime.now_datetime(), + status: 'Complete', + completed_qty: completedQty, + }, }) + + let response = await getOne('Job Card', jobCardId) + if (response?.docstatus === 0) { + const submitted = await submit('Job Card', jobCardId) + if (submitted.data) { + response = submitted.data + } + } + toast.success('Job finished') return response } catch (error) { @@ -350,12 +416,14 @@ export const useBeamStore = defineStore('beam', () => { form, scanner, warehouseList, + currentEmployee, // store context actions getScanDoctypes, setForm, setMappedDoc, setScanContext, setWarehouses, + setCurrentEmployee, // document workflow actions cancel, @@ -372,6 +440,7 @@ export const useBeamStore = defineStore('beam', () => { getOne, getReceiving, getStockEntryItems, + getJobCard, startJobCard, pauseJobCard, finishJobCard, From 869e8e36a2bb3ebc7be8fe4d0bf98f00aafc3450 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 29 Apr 2026 17:03:14 +0000 Subject: [PATCH 08/30] feat: operation workflow --- beam/www/beam/pages/Operation.vue | 249 +++++++++++++++++++----------- 1 file changed, 161 insertions(+), 88 deletions(-) diff --git a/beam/www/beam/pages/Operation.vue b/beam/www/beam/pages/Operation.vue index 1920a1bd..cdca949f 100644 --- a/beam/www/beam/pages/Operation.vue +++ b/beam/www/beam/pages/Operation.vue @@ -17,11 +17,14 @@
{{ elapsedTime }}

{{ statusLabel }}

+

To complete: {{ remainingQty }} units

+

+ You already have an active job: {{ activeJobCardForEmployee }} +

{{ actionError }}

- - - + +
@@ -32,41 +35,56 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' import { useRoute } from 'vue-router' import { useBeamStore } from '@/stores/beam' -import type { JobCard, WorkOrder, WorkOrderOperation } from '@/types' import { useBeamToast } from '@/utils/toast' +import type { JobCard, JobCardTimeLog, WorkOrder, WorkOrderOperation } from '@/types' + +declare const frappe: any + +type TimeLog = JobCardTimeLog + +interface RouteParams { + id?: string + operationId?: string +} const route = useRoute() const store = useBeamStore() const toast = useBeamToast() -const workOrderId = computed(() => String((route.params as { id?: string }).id || '')) -const operationId = computed(() => String((route.params as { operationId?: string }).operationId || '')) +const workOrderId = computed((): string => String((route.params as RouteParams).id || '')) +const operationId = computed((): string => String((route.params as RouteParams).operationId || '')) const workOrder = computed(() => store.form as Partial) const operation = ref>({}) const jobCard = ref>({}) -const jobCardName = ref('') -const timerNow = ref(Date.now()) -const isBusy = ref(false) -const actionError = ref('') -const hasLoadError = ref(false) +const jobCardName = ref('') +const elapsedSeconds = ref(0) +const isBusy = ref(false) +const isRefreshing = ref(false) +const actionError = ref('') +const hasLoadError = ref(false) let timerHandle: ReturnType | null = null -const isCompleted = computed(() => ['Complete', 'Completed'].includes(jobCard.value.status || '')) +const isCompleted = computed((): boolean => (jobCard.value.status || '') === 'Completed') -const isRunning = computed(() => { - if (!jobCard.value || !jobCard.value.started_time) return false +const activeTimeLog = computed((): TimeLog | null => { + const logs: TimeLog[] = (jobCard.value.time_logs as TimeLog[]) || [] + const last = logs[logs.length - 1] + return last && !last.to_time ? last : null +}) + +const isRunning = computed((): boolean => { if (isCompleted.value) return false - return (jobCard.value.status || '') === 'Work In Progress' + return (jobCard.value.status || '') === 'Work In Progress' && Boolean(activeTimeLog.value) }) -const sequenceBlockedBy = computed(() => { - const operations = workOrder.value.operations || [] +const sequenceBlockedBy = computed((): string => { + const operations: Partial[] = workOrder.value.operations || [] if (!operations.length) return '' const current = operations.find((op: Partial) => op.name === operationId.value) if (!current || !current.idx) return '' - const totalQty = workOrder.value.qty || 0 + const totalQty: number = workOrder.value.qty || 0 const previous = operations .filter((op: Partial) => Number(op.idx || 0) < Number(current.idx || 0)) .find((op: Partial) => (op.completed_qty || 0) < totalQty) @@ -74,34 +92,34 @@ const sequenceBlockedBy = computed(() => { return previous?.operation || '' }) -const elapsedSeconds = computed(() => { - const accumulated = Math.max(0, Math.floor(Number(jobCard.value?.current_time || 0))) - if (!isRunning.value || !jobCard.value?.started_time) return accumulated - - const startedAt = new Date(jobCard.value.started_time).getTime() - if (isNaN(startedAt)) return accumulated - - const runningDelta = Math.max(0, Math.floor((timerNow.value - startedAt) / 1000)) - return accumulated + runningDelta -}) - -const elapsedTime = computed(() => { +const elapsedTime = computed((): string => { const date = new Date(0) - date.setSeconds(elapsedSeconds.value) + date.setSeconds(Math.max(0, Math.floor(elapsedSeconds.value))) return isNaN(date.getTime()) ? '00:00:00' : date.toISOString().substring(11, 19) }) -const statusLabel = computed(() => { - if (isCompleted.value) return 'Completed' - return isRunning.value ? 'In Progress' : 'Paused' +const statusLabel = computed((): string => jobCard.value.status || '') +const activeJobCardForEmployee = computed((): string => jobCard.value.active_job_card_for_employee || '') +const hasActiveConflict = computed((): boolean => Boolean(jobCard.value.active_job_card_for_employee)) +const isSubmitted = computed((): boolean => jobCard.value.docstatus !== 0) +const isQtyCompleted = computed((): boolean => { + const completed = Number(jobCard.value.total_completed_qty || 0) + const required = Number(jobCard.value.for_quantity || 0) + return completed >= required && required > 0 }) -const actionsDisabled = computed(() => isBusy.value || hasLoadError.value || !jobCardName.value) +const actionsDisabled = computed((): boolean => isBusy.value || hasLoadError.value || !jobCardName.value || isSubmitted.value) +const toggleLabel = computed((): string => (isRunning.value ? 'Pause' : 'Start')) +const canToggle = computed((): boolean => !isQtyCompleted.value) +const remainingQty = computed((): number => Math.max( + 0, + Number(jobCard.value.for_quantity || 0) - Number(jobCard.value.total_completed_qty || 0), +)) -const startTicking = () => { +const startTicking = (): void => { if (timerHandle) return - timerHandle = setInterval(() => { - timerNow.value = Date.now() + timerHandle = setInterval((): void => { + elapsedSeconds.value += 1 }, 1000) } @@ -111,13 +129,50 @@ const stopTicking = () => { timerHandle = null } -const refreshJobCard = async () => { +const applyJobCard = (card: Partial | null | undefined): void => { + if (!card) { + jobCard.value = {} + elapsedSeconds.value = 0 + stopTicking() + return + } + + jobCard.value = card + if (isRunning.value && !hasActiveConflict.value) { + const closedMins = (jobCard.value.time_logs || []) + .filter((log: TimeLog) => log.to_time) + .reduce((sum: number, log: TimeLog) => sum + (log.time_in_mins || 0), 0) + + let activeMins = 0 + if (activeTimeLog.value?.from_time) { + const fromTime = new Date(activeTimeLog.value.from_time).getTime() + const serverNowStr = frappe.datetime.now_datetime() + const serverNowMs = new Date(serverNowStr).getTime() + activeMins = (serverNowMs - fromTime) / (1000 * 60) + } + + const totalMins = closedMins + activeMins + elapsedSeconds.value = Math.max(0, Math.floor(totalMins * 60)) + startTicking() + } else { + const totalMins = (jobCard.value.time_logs || []) + .reduce((sum: number, log: TimeLog) => sum + (log.time_in_mins || 0), 0) + elapsedSeconds.value = hasActiveConflict.value + ? 0 + : Math.max(0, Math.floor(totalMins * 60)) + stopTicking() + } +} + +const refreshJobCard = async (): Promise => { + if (isRefreshing.value) return + isRefreshing.value = true hasLoadError.value = false actionError.value = '' - let jobList: JobCard[] = [] + let jobList: Partial[] = [] try { - jobList = await store.getAll('Job Card', { + jobList = await store.getAll>('Job Card', { filters: JSON.stringify([ ['operation_id', '=', operationId.value], ['work_order', '=', workOrderId.value], @@ -127,10 +182,12 @@ const refreshJobCard = async () => { hasLoadError.value = true jobCardName.value = '' jobCard.value = {} + elapsedSeconds.value = 0 stopTicking() const message = (error as Error)?.message || 'Unknown error' actionError.value = message toast.error(message) + isRefreshing.value = false return } @@ -138,84 +195,107 @@ const refreshJobCard = async () => { hasLoadError.value = true jobCardName.value = '' jobCard.value = {} + elapsedSeconds.value = 0 stopTicking() actionError.value = 'Unknown error' toast.error(actionError.value) + isRefreshing.value = false return } if (jobList.length > 0 && jobList[0].name) { jobCardName.value = jobList[0].name try { - const res = await store.getOne('Job Card', jobCardName.value) + const res = await store.getJobCard(jobCardName.value) if (!res) { hasLoadError.value = true jobCardName.value = '' jobCard.value = {} + elapsedSeconds.value = 0 stopTicking() actionError.value = 'Unknown error' toast.error(actionError.value) + isRefreshing.value = false return } - jobCard.value = res + applyJobCard(res) } catch (error) { hasLoadError.value = true jobCardName.value = '' jobCard.value = {} + elapsedSeconds.value = 0 stopTicking() const message = (error as Error)?.message || 'Unknown error' actionError.value = message toast.error(message) + isRefreshing.value = false return } } else { jobCardName.value = '' jobCard.value = {} - } - - timerNow.value = Date.now() - if (isRunning.value) { - startTicking() - } else { + elapsedSeconds.value = 0 stopTicking() } + + isRefreshing.value = false +} + +const syncFromBackendOnReturn = async (): Promise => { + if (document.visibilityState !== 'visible') return + if (isBusy.value) return + await refreshJobCard() } -onMounted(async () => { +onMounted(async (): Promise => { operation.value = workOrder.value.operations?.find((op: Partial) => op.name === operationId.value) || {} await refreshJobCard() + document.addEventListener('visibilitychange', syncFromBackendOnReturn) + window.addEventListener('focus', syncFromBackendOnReturn) }) -onUnmounted(() => { +onUnmounted((): void => { + document.removeEventListener('visibilitychange', syncFromBackendOnReturn) + window.removeEventListener('focus', syncFromBackendOnReturn) stopTicking() }) -const startOperation = async () => { - if (!jobCardName.value || isBusy.value || isRunning.value || isCompleted.value) return +const toggleOperation = async (): Promise => { + if (!jobCardName.value || isBusy.value || isCompleted.value) return isBusy.value = true actionError.value = '' try { - await store.startJobCard(jobCardName.value) await refreshJobCard() - } catch (error) { - const message = (error as Error)?.message || 'Unknown error' - actionError.value = message - toast.error(message) - } finally { - isBusy.value = false - } -} + if (!jobCardName.value || isCompleted.value) return + + if (isRunning.value) { + const remainingQty: number = Math.max( + 0, + Number(jobCard.value.for_quantity || 0) - Number(jobCard.value.total_completed_qty || 0), + ) + const defaultQty: string = String(remainingQty) + const input: string | null = window.prompt('Completed quantity in this session', defaultQty) + if (input === null) { + isBusy.value = false + return + } -const pauseOperation = async () => { - if (!jobCardName.value || isBusy.value || !isRunning.value || isCompleted.value) return + const completedQty: number = Number(input) + if (!Number.isFinite(completedQty) || completedQty < 0) { + actionError.value = 'Invalid quantity' + toast.error('Invalid quantity') + isBusy.value = false + return + } - isBusy.value = true - actionError.value = '' - try { - await store.pauseJobCard(jobCardName.value) - await refreshJobCard() + const card = await store.pauseJobCard(jobCardName.value, completedQty) + applyJobCard(card) + } else { + const card = await store.startJobCard(jobCardName.value) + applyJobCard(card) + } } catch (error) { const message = (error as Error)?.message || 'Unknown error' actionError.value = message @@ -225,29 +305,15 @@ const pauseOperation = async () => { } } -const finishOperation = async () => { - if (!jobCardName.value || isBusy.value || isCompleted.value) return - - const remainingQty = Math.max( - 0, - Number(jobCard.value.for_quantity || 0) - Number(jobCard.value.total_completed_qty || 0), - ) - const defaultQty = remainingQty > 0 ? String(remainingQty) : '1' - const input = window.prompt('Completed quantity', defaultQty) - if (input === null) return - - const completedQty = Number(input) - if (!Number.isFinite(completedQty) || completedQty <= 0) { - actionError.value = 'Unknown error' - toast.error('Unknown error') - return - } +const finishOperation = async (): Promise => { + if (!jobCardName.value || isBusy.value || !isQtyCompleted.value) return isBusy.value = true actionError.value = '' try { - await store.finishJobCard(jobCardName.value, completedQty) - await refreshJobCard() + const currentQty = Number(jobCard.value.total_completed_qty || 0) + const card = await store.finishJobCard(jobCardName.value, currentQty) + applyJobCard(card) } catch (error) { const message = (error as Error)?.message || 'Unknown error' actionError.value = message @@ -327,6 +393,13 @@ const finishOperation = async () => { opacity: 0.8; } +.qty-info { + margin: 0; + font-size: 0.9rem; + font-weight: 600; + color: #333; +} + .action-error { margin: 0; font-size: 0.85rem; From 566431e56ab0d5fd6b36e11d22af5f4a3b69a390 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 29 Apr 2026 17:20:34 +0000 Subject: [PATCH 09/30] doc: how to run backend and frontend test --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ac45bf9e..ba94e9f9 100644 --- a/README.md +++ b/README.md @@ -81,11 +81,20 @@ For a complete database reset to re-run tests, run the following bench reinstall --yes --admin-password admin --mariadb-root-password admin && bench execute 'beam.tests.setup.before_test' ``` -To run mypy and pytest +To run backend tests + +```shell +source env/bin/activate +pytest ./apps/beam/beam/tests --ignore=./beam/tests/mobile --disable-warnings -s --tracing=retain-on-failure +``` + +To run frontend tests + +Start bench in a separate terminal, then run: + ```shell source env/bin/activate -mypy ./apps/beam/beam --ignore-missing-imports -pytest ./apps/beam/beam/tests -s --disable-warnings +pytest ./beam/tests/mobile --browser chromium --disable-warnings ``` ### Beam Portal setup From f92f85e9c4c3adeffcef4e193279fb06e0b06031 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 29 Apr 2026 17:32:45 +0000 Subject: [PATCH 10/30] chore: pytest order scope module --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9b3c7c8a..a63c5f5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ frappe = ">=15.0.0,<16.0.0" erpnext = ">=15.0.0,<16.0.0" [tool.pytest.ini_options] -addopts = "--cov=beam --cov-report term-missing" +addopts = "--cov=beam --cov-report term-missing --order-scope=module" [tool.black] line-length = 99 From e2d322c3454163621b4e9f31e15d8e033aa9e4ff Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 29 Apr 2026 17:32:59 +0000 Subject: [PATCH 11/30] fix: department changed --- beam/tests/fixtures.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/beam/tests/fixtures.py b/beam/tests/fixtures.py index 43367acf..771caf94 100644 --- a/beam/tests/fixtures.py +++ b/beam/tests/fixtures.py @@ -728,7 +728,7 @@ }, "phone": "(704) 885-0542", "roles": ["Stock Manager", "Item Manager"], - # "department": "Operations", + "department": "Management - APC", "designation": "Bakery Manager", }, { @@ -745,7 +745,7 @@ "phone": "(658) 583-5499", "roles": ["Stock User", "BEAM Mobile User"], "reports_to": "Tristan Hawkins", - # "department": "Operations", + "department": "Management - APC", "designation": "Baker", }, { @@ -762,7 +762,7 @@ "phone": "(962) 762-5895", "roles": ["Stock User", "BEAM Mobile User"], "reports_to": "Tristan Hawkins", - # "department": "Operations", + "department": "Management - APC", "designation": "Baker", }, { @@ -779,7 +779,7 @@ "phone": "(366) 357-8223", "roles": ["Stock User", "BEAM Mobile User"], "reports_to": "Tristan Hawkins", - # "department": "Operations", + "department": "Management - APC", "designation": "Bakery Manager", }, { @@ -796,7 +796,7 @@ "phone": "(930) 920-4520", "roles": ["Stock User", "BEAM Mobile User"], "reports_to": "Tristan Hawkins", - # "department": "Operations", + "department": "Management - APC", "designation": "Baker", }, { @@ -813,7 +813,7 @@ "phone": "(054) 893-8970", "roles": ["Stock User", "BEAM Mobile User"], "reports_to": "Tristan Hawkins", - # "department": "Operations", + "department": "Management - APC", "designation": "Baker", }, { @@ -830,7 +830,7 @@ "phone": "(814) 677-9322", "roles": ["Stock User", "BEAM Mobile User"], "reports_to": "Tristan Hawkins", - # "department": "Operations", + "department": "Management - APC", "designation": "Baker", }, { @@ -847,7 +847,7 @@ "phone": "(133) 195-7828", "roles": ["Stock User", "BEAM Mobile User"], "reports_to": "Tristan Hawkins", - # "department": "Operations", + "department": "Management - APC", "designation": "Baker", }, { @@ -864,7 +864,7 @@ "phone": "(041) 000-2569", "roles": ["Stock User", "BEAM Mobile User"], "reports_to": "Tristan Hawkins", - # "department": "Operations", + "department": "Management - APC", "designation": "Baker", }, { @@ -881,7 +881,7 @@ "phone": "(603) 555-0134", "roles": ["BEAM Mobile User", "Employee", "Manufacturing User"], "reports_to": "Tristan Hawkins", - # "department": "Operations", + "department": "Management - APC", "designation": "Baker", }, ] From 5257c8eb1f123118ee14e948566961dda4e29f99 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 29 Apr 2026 17:43:16 +0000 Subject: [PATCH 12/30] fix: linters --- .pre-commit-config.yaml | 2 +- beam/tests/mobile/test_job_card_operation.py | 8 +++-- beam/www/beam/pages/Operation.vue | 32 ++++++++++++-------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 32e6881e..424ca668 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,7 +53,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.20.1 + rev: v1.23.0 hooks: - id: update_pre_commit_config - id: validate_frappe_project diff --git a/beam/tests/mobile/test_job_card_operation.py b/beam/tests/mobile/test_job_card_operation.py index f9ab3c3a..1a59b05a 100644 --- a/beam/tests/mobile/test_job_card_operation.py +++ b/beam/tests/mobile/test_job_card_operation.py @@ -17,7 +17,7 @@ def open_first_operation_from_manufacture(page) -> tuple[str, str]: """Navigate Home -> Manufacture -> Work Order -> first Operation. Returns: - tuple[str, str]: work_order_id, operation_id + tuple[str, str]: work_order_id, operation_id """ page.get_by_text("Manufacture").click() expect(page).to_have_url(re.compile(r"#/manufacture"), timeout=15000) @@ -36,7 +36,7 @@ def open_first_operation_from_manufacture(page) -> tuple[str, str]: def hms_to_seconds(value: str) -> int: - hours, minutes, seconds = [int(part) for part in value.split(":")] + hours, minutes, seconds = (int(part) for part in value.split(":")) return (hours * 3600) + (minutes * 60) + seconds @@ -66,7 +66,9 @@ def test_operation_description_is_displayed(page): expect(description_box).to_contain_text(operation.description) else: # Some fixtures may not set description text; ensure the description area still renders. - expect(page).to_have_url(re.compile(rf"#/work_order/{re.escape(work_order_id)}/operation/{re.escape(operation_id)}")) + expect(page).to_have_url( + re.compile(rf"#/work_order/{re.escape(work_order_id)}/operation/{re.escape(operation_id)}") + ) @pytest.mark.order(15) diff --git a/beam/www/beam/pages/Operation.vue b/beam/www/beam/pages/Operation.vue index cdca949f..fbe699d0 100644 --- a/beam/www/beam/pages/Operation.vue +++ b/beam/www/beam/pages/Operation.vue @@ -23,7 +23,11 @@

{{ actionError }}

- +
@@ -108,13 +112,14 @@ const isQtyCompleted = computed((): boolean => { return completed >= required && required > 0 }) -const actionsDisabled = computed((): boolean => isBusy.value || hasLoadError.value || !jobCardName.value || isSubmitted.value) +const actionsDisabled = computed( + (): boolean => isBusy.value || hasLoadError.value || !jobCardName.value || isSubmitted.value +) const toggleLabel = computed((): string => (isRunning.value ? 'Pause' : 'Start')) const canToggle = computed((): boolean => !isQtyCompleted.value) -const remainingQty = computed((): number => Math.max( - 0, - Number(jobCard.value.for_quantity || 0) - Number(jobCard.value.total_completed_qty || 0), -)) +const remainingQty = computed((): number => + Math.max(0, Number(jobCard.value.for_quantity || 0) - Number(jobCard.value.total_completed_qty || 0)) +) const startTicking = (): void => { if (timerHandle) return @@ -155,11 +160,11 @@ const applyJobCard = (card: Partial | null | undefined): void => { elapsedSeconds.value = Math.max(0, Math.floor(totalMins * 60)) startTicking() } else { - const totalMins = (jobCard.value.time_logs || []) - .reduce((sum: number, log: TimeLog) => sum + (log.time_in_mins || 0), 0) - elapsedSeconds.value = hasActiveConflict.value - ? 0 - : Math.max(0, Math.floor(totalMins * 60)) + const totalMins = (jobCard.value.time_logs || []).reduce( + (sum: number, log: TimeLog) => sum + (log.time_in_mins || 0), + 0 + ) + elapsedSeconds.value = hasActiveConflict.value ? 0 : Math.max(0, Math.floor(totalMins * 60)) stopTicking() } } @@ -249,7 +254,8 @@ const syncFromBackendOnReturn = async (): Promise => { } onMounted(async (): Promise => { - operation.value = workOrder.value.operations?.find((op: Partial) => op.name === operationId.value) || {} + operation.value = + workOrder.value.operations?.find((op: Partial) => op.name === operationId.value) || {} await refreshJobCard() document.addEventListener('visibilitychange', syncFromBackendOnReturn) window.addEventListener('focus', syncFromBackendOnReturn) @@ -273,7 +279,7 @@ const toggleOperation = async (): Promise => { if (isRunning.value) { const remainingQty: number = Math.max( 0, - Number(jobCard.value.for_quantity || 0) - Number(jobCard.value.total_completed_qty || 0), + Number(jobCard.value.for_quantity || 0) - Number(jobCard.value.total_completed_qty || 0) ) const defaultQty: string = String(remainingQty) const input: string | null = window.prompt('Completed quantity in this session', defaultQty) From bdc44745d7352a4d9250a62d67dab233d4ec69d1 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 29 Apr 2026 17:53:39 +0000 Subject: [PATCH 13/30] fix: typing --- beam/beam/demand/sqlite.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/beam/beam/demand/sqlite.py b/beam/beam/demand/sqlite.py index e01f1e70..378ffb94 100644 --- a/beam/beam/demand/sqlite.py +++ b/beam/beam/demand/sqlite.py @@ -3,6 +3,7 @@ import pathlib import sqlite3 +from typing import Any import frappe from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions @@ -153,7 +154,7 @@ def reset_receiving_db() -> None: cursor.execute("DELETE FROM receiving") -def dict_factory(cursor: sqlite3.Cursor, row: sqlite3.Row) -> frappe._dict: +def dict_factory(cursor: sqlite3.Cursor, row: tuple[Any, ...]) -> frappe._dict: _dict = frappe._dict() for idx, col in enumerate(cursor.description): _dict[col[0]] = row[idx] From 30553e04c0014cac769070574483c3f9aa6deaa3 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Thu, 30 Apr 2026 16:43:28 +0000 Subject: [PATCH 14/30] feat: employees with simple password --- beam/tests/setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/beam/tests/setup.py b/beam/tests/setup.py index c17f8369..5973450d 100644 --- a/beam/tests/setup.py +++ b/beam/tests/setup.py @@ -802,10 +802,12 @@ def create_employees(settings, only_create=None): user.email = f"{empl.first_name[0].lower()}{empl.last_name.lower()}@cfc.co" user.first_name = empl.first_name user.last_name = empl.last_name + user.new_password = "admin" user.send_welcome_email = 0 user.enabled = 1 user.language = settings.language user.time_zone = settings.time_zone + user.flags.ignore_password_policy = True for r in employee.get("roles", []): user.append("roles", {"role": r}) From b6850f9e5e11bc6fed3df3e2959d8886b6c07dc1 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Thu, 30 Apr 2026 16:43:55 +0000 Subject: [PATCH 15/30] fix: startJobCard args --- beam/www/beam/stores/beam.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/beam/www/beam/stores/beam.ts b/beam/www/beam/stores/beam.ts index 779e8eba..5cc0da2c 100644 --- a/beam/www/beam/stores/beam.ts +++ b/beam/www/beam/stores/beam.ts @@ -219,17 +219,17 @@ export const useBeamStore = defineStore('beam', () => { const startJobCard = async (jobCardId: string) => { try { - if (!currentEmployee.value) { - throw new Error('No current employee in session') + const args: Record = { + job_card_id: jobCardId, + start_time: frappe.datetime.now_datetime(), + status: 'Work In Progress', + } + if (currentEmployee.value) { + args.employees = [{ employee: currentEmployee.value }] } await frappe.xcall(JOB_CARD_TIME_LOG_URL, { - args: { - job_card_id: jobCardId, - start_time: frappe.datetime.now_datetime(), - status: 'Work In Progress', - employees: [{ employee: currentEmployee.value }], - }, + args, }) const response = await getOne('Job Card', jobCardId) toast.success('Job started') From 314bdc9ef849f4e64724ad31862bc639a751d9a9 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Thu, 30 Apr 2026 18:23:31 +0000 Subject: [PATCH 16/30] test: operation basic tests --- beam/tests/mobile/test_job_card_operation.py | 203 ++++++++++++------- 1 file changed, 135 insertions(+), 68 deletions(-) diff --git a/beam/tests/mobile/test_job_card_operation.py b/beam/tests/mobile/test_job_card_operation.py index 1a59b05a..95b72180 100644 --- a/beam/tests/mobile/test_job_card_operation.py +++ b/beam/tests/mobile/test_job_card_operation.py @@ -13,26 +13,48 @@ # `page.expect_navigation()` won't work with Beam's hash-based routes -def open_first_operation_from_manufacture(page) -> tuple[str, str]: +@pytest.fixture(autouse=True) +def login_as_jordan_mills(page): + base_url = frappe.utils.get_url() + page.context.clear_cookies() + page.goto(base_url) + email_field = page.get_by_role("textbox", name="Email") + expect(email_field).to_be_visible(timeout=1000) + email_field.fill("jmills@cfc.co") + + password_field = page.get_by_role("textbox", name="Password") + expect(password_field).to_be_visible(timeout=1000) + password_field.fill("admin") + + login_button = page.get_by_role("button", name="Login") + expect(login_button).to_be_visible(timeout=1000) + login_button.click() + expect(page).to_have_url(re.compile(r"beam#/"), timeout=1000) + yield + + +def open_first_operation_id_from_manufacture(page) -> str: """Navigate Home -> Manufacture -> Work Order -> first Operation. Returns: - tuple[str, str]: work_order_id, operation_id + str: operation_id """ page.get_by_text("Manufacture").click() - expect(page).to_have_url(re.compile(r"#/manufacture"), timeout=15000) + expect(page).to_have_url(re.compile(r"#/manufacture"), timeout=1000) - page.locator("css=.beam_list-item").first.click() - expect(page).to_have_url(re.compile(r"#/work_order/[^/]+/?$"), timeout=15000) + list_item = page.locator("css=.beam_list-item").first + expect(list_item).to_be_visible(timeout=1000) + list_item.click() + expect(page).to_have_url(re.compile(r"#/work_order/[^/]+/?$"), timeout=1000) operation_link = page.locator("a[href*='/operation/']").first - expect(operation_link).to_be_visible(timeout=15000) + expect(operation_link).to_be_visible(timeout=1000) operation_link.click() - expect(page).to_have_url(re.compile(r"#/work_order/[^/]+/operation/[^/?#]+"), timeout=15000) + expect(page).to_have_url(re.compile(r"#/work_order/[^/]+/operation/[^/?#]+"), timeout=1000) match = re.search(r"#/work_order/([^/]+)/operation/([^/?#]+)", page.url) assert match, f"Could not parse operation route from URL: {page.url}" - return match.group(1), match.group(2) + return match.group(2) def hms_to_seconds(value: str) -> int: @@ -40,18 +62,24 @@ def hms_to_seconds(value: str) -> int: return (hours * 3600) + (minutes * 60) + seconds -@pytest.mark.order(13) -def test_operation_navigation_from_work_order(page): - work_order_id, operation_id = open_first_operation_from_manufacture(page) - assert work_order_id - assert operation_id +def get_operation_elements(page): + description_box = page.locator("css=.metadata-box").first + timer_text = page.locator("css=.timer-value").first + toggle_button = page.locator(".actions button").first + finish_button = page.get_by_role("button", name="Finish") + return description_box, timer_text, toggle_button, finish_button -@pytest.mark.order(14) -def test_operation_description_is_displayed(page): - work_order_id, operation_id = open_first_operation_from_manufacture(page) - description_box = page.locator("css=.container .box").first - expect(description_box).to_be_visible(timeout=15000) +@pytest.mark.order(1) +def test_operation_details_are_visible(page): + operation_id = open_first_operation_id_from_manufacture(page) + description_box, timer_text, toggle_button, finish_button = get_operation_elements(page) + + expect(description_box).to_be_visible(timeout=1000) + expect(timer_text).to_be_visible(timeout=1000) + expect(toggle_button).to_be_visible(timeout=1000) + expect(finish_button).to_be_visible(timeout=1000) + expect(toggle_button).to_contain_text(re.compile("Start|Pause"), timeout=1000) with use_current_db_transaction(): operation = frappe.get_value( @@ -62,66 +90,98 @@ def test_operation_description_is_displayed(page): ) assert operation, f"Operation {operation_id} not found" + if operation.description: expect(description_box).to_contain_text(operation.description) - else: - # Some fixtures may not set description text; ensure the description area still renders. - expect(page).to_have_url( - re.compile(rf"#/work_order/{re.escape(work_order_id)}/operation/{re.escape(operation_id)}") - ) - - -@pytest.mark.order(15) -def test_operation_elapsed_time_is_visible_format_hh_mm_ss(page): - _, operation_id = open_first_operation_from_manufacture(page) - timer_text = page.locator("css=.fix-height b").first - expect(timer_text).to_be_visible(timeout=15000) elapsed = timer_text.inner_text().strip() assert re.fullmatch(r"\d{2}:\d{2}:\d{2}", elapsed), f"Unexpected elapsed time format: {elapsed}" - with use_current_db_transaction(): - job_card_name = frappe.get_value("Job Card", {"operation_id": operation_id}, "name") - assert job_card_name, f"Expected a Job Card linked to operation {operation_id}" - - -@pytest.mark.order(16) -@pytest.mark.xfail(reason="Operation Start/Stop still TODO in Operation.vue") +@pytest.mark.order(2) def test_start_operation_starts_timer_and_toggles_buttons(page): - _, _ = open_first_operation_from_manufacture(page) + operation_id = open_first_operation_id_from_manufacture(page) + _, timer_text, toggle_button, _ = get_operation_elements(page) - timer_text = page.locator("css=.fix-height b").first - start_button = page.get_by_role("button", name="Start") - stop_button = page.get_by_role("button", name="Stop") + expect(toggle_button).to_be_enabled(timeout=1000) + with use_current_db_transaction(): + job_card = frappe.get_value( + "Job Card", + {"operation_id": operation_id}, + ["name", "status"], + as_dict=True, + ) - expect(timer_text).to_be_visible(timeout=15000) - expect(start_button).to_be_enabled(timeout=15000) - expect(stop_button).to_be_disabled(timeout=15000) + assert job_card and job_card.get("name"), f"Expected a Job Card linked to operation {operation_id}" + + # Determine expected initial label from Job Card status and latest time log + expected_label = "Start" + # check latest time log first — an open time log means the operation is running + last_logs = frappe.get_all( + "Job Card Time Log", + filters={"parent": job_card.get("name")}, + fields=["from_time", "to_time"], + order_by="creation desc", + limit=1, + ) + if last_logs and last_logs[0].get("from_time") and not last_logs[0].get("to_time"): + expected_label = "Pause" + # if the job card is explicitly On Hold, treat as paused (Start) + if job_card.get("status") == "On Hold": + expected_label = "Start" + + page.wait_for_timeout(1000) + with use_current_db_transaction(): + job_card = frappe.get_value( + "Job Card", + {"operation_id": operation_id}, + ["name", "status"], + as_dict=True, + ) + expected_label = "Start" + last_logs = frappe.get_all( + "Job Card Time Log", + filters={"parent": job_card.get("name")}, + fields=["from_time", "to_time"], + order_by="creation desc", + limit=1, + ) + if last_logs and last_logs[0].get("from_time") and not last_logs[0].get("to_time"): + expected_label = "Pause" + if job_card.get("status") == "On Hold": + expected_label = "Start" + label = toggle_button.inner_text().strip() + expect(toggle_button).to_contain_text(expected_label, timeout=1000) + + if label == "Pause": + page.once("dialog", lambda dialog: dialog.accept("0")) + toggle_button.click() + expect(toggle_button).to_contain_text("Start", timeout=10000) + elif label != "Start": + pytest.fail(f"Unexpected toggle label before start: {label}") initial_elapsed = timer_text.inner_text().strip() assert re.fullmatch(r"\d{2}:\d{2}:\d{2}", initial_elapsed) - page.once("dialog", lambda dialog: dialog.dismiss()) - start_button.click() - - expect(start_button).to_be_disabled(timeout=5000) - expect(stop_button).to_be_enabled(timeout=5000) + toggle_button.click() + expect(toggle_button).to_be_enabled(timeout=10000) + expect(toggle_button).to_contain_text("Pause", timeout=10000) page.wait_for_timeout(1500) updated_elapsed = timer_text.inner_text().strip() assert re.fullmatch(r"\d{2}:\d{2}:\d{2}", updated_elapsed) assert hms_to_seconds(updated_elapsed) > hms_to_seconds(initial_elapsed) + with use_current_db_transaction(): + job_card_name = frappe.get_value("Job Card", {"operation_id": operation_id}, "name") + + assert job_card_name, f"Expected a Job Card linked to operation {operation_id}" -@pytest.mark.order(17) -@pytest.mark.xfail(reason="Operation Start/Stop still TODO in Operation.vue") -def test_stop_operation_stops_timer_and_creates_time_log(page): - _, operation_id = open_first_operation_from_manufacture(page) - timer_text = page.locator("css=.fix-height b").first - start_button = page.get_by_role("button", name="Start") - stop_button = page.get_by_role("button", name="Stop") +@pytest.mark.order(3) +def test_stop_operation_stops_timer_and_records_time_log(page): + operation_id = open_first_operation_id_from_manufacture(page) + _, timer_text, toggle_button, _ = get_operation_elements(page) with use_current_db_transaction(): job_card_name = frappe.get_value("Job Card", {"operation_id": operation_id}, "name") @@ -131,28 +191,33 @@ def test_stop_operation_stops_timer_and_creates_time_log(page): fields=["name", "from_time", "to_time", "time_in_mins"], ) - assert job_card_name + assert job_card_name, f"Expected a Job Card linked to operation {operation_id}" + initial_closed_logs = len([log for log in initial_logs if log.to_time]) - page.once("dialog", lambda dialog: dialog.dismiss()) - start_button.click() - expect(stop_button).to_be_enabled(timeout=5000) + current_label = toggle_button.inner_text().strip() + if current_label == "Start": + toggle_button.click() + expect(toggle_button).to_contain_text("Pause", timeout=10000) + elif current_label != "Pause": + pytest.fail(f"Unexpected toggle label before stop: {current_label}") page.wait_for_timeout(1200) timer_after_start = timer_text.inner_text().strip() - page.once("dialog", lambda dialog: dialog.dismiss()) - stop_button.click() - - expect(start_button).to_be_enabled(timeout=5000) - expect(stop_button).to_be_disabled(timeout=5000) + page.once("dialog", lambda dialog: dialog.accept("0")) + toggle_button.click() + expect(toggle_button).to_contain_text("Start", timeout=10000) page.wait_for_timeout(1500) timer_after_stop = timer_text.inner_text().strip() page.wait_for_timeout(1200) timer_after_wait = timer_text.inner_text().strip() - assert timer_after_wait == timer_after_stop - assert hms_to_seconds(timer_after_stop) >= hms_to_seconds(timer_after_start) + assert timer_after_stop == timer_after_wait + + start_sec = hms_to_seconds(timer_after_start) + stop_sec = hms_to_seconds(timer_after_stop) + assert stop_sec >= start_sec - 1, f"Timer decreased unexpectedly: {timer_after_start} -> {timer_after_stop}" with use_current_db_transaction(): final_logs = frappe.get_all( @@ -161,4 +226,6 @@ def test_stop_operation_stops_timer_and_creates_time_log(page): fields=["name", "from_time", "to_time", "time_in_mins"], ) - assert len(final_logs) >= len(initial_logs) + 1 + final_closed_logs = len([log for log in final_logs if log.to_time]) + assert final_closed_logs >= initial_closed_logs + 1 + assert any(log.to_time for log in final_logs), "Expected the stopped job card to have a closed time log" From e80baa50c0138abff2656182d0ae53a993c73273 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Thu, 30 Apr 2026 18:32:06 +0000 Subject: [PATCH 17/30] fix: linters --- beam/tests/mobile/test_job_card_operation.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/beam/tests/mobile/test_job_card_operation.py b/beam/tests/mobile/test_job_card_operation.py index 95b72180..4ea29a1f 100644 --- a/beam/tests/mobile/test_job_card_operation.py +++ b/beam/tests/mobile/test_job_card_operation.py @@ -37,7 +37,7 @@ def open_first_operation_id_from_manufacture(page) -> str: """Navigate Home -> Manufacture -> Work Order -> first Operation. Returns: - str: operation_id + str: operation_id """ page.get_by_text("Manufacture").click() expect(page).to_have_url(re.compile(r"#/manufacture"), timeout=1000) @@ -112,7 +112,9 @@ def test_start_operation_starts_timer_and_toggles_buttons(page): as_dict=True, ) - assert job_card and job_card.get("name"), f"Expected a Job Card linked to operation {operation_id}" + assert job_card and job_card.get( + "name" + ), f"Expected a Job Card linked to operation {operation_id}" # Determine expected initial label from Job Card status and latest time log expected_label = "Start" @@ -217,7 +219,9 @@ def test_stop_operation_stops_timer_and_records_time_log(page): start_sec = hms_to_seconds(timer_after_start) stop_sec = hms_to_seconds(timer_after_stop) - assert stop_sec >= start_sec - 1, f"Timer decreased unexpectedly: {timer_after_start} -> {timer_after_stop}" + assert ( + stop_sec >= start_sec - 1 + ), f"Timer decreased unexpectedly: {timer_after_start} -> {timer_after_stop}" with use_current_db_transaction(): final_logs = frappe.get_all( @@ -228,4 +232,6 @@ def test_stop_operation_stops_timer_and_records_time_log(page): final_closed_logs = len([log for log in final_logs if log.to_time]) assert final_closed_logs >= initial_closed_logs + 1 - assert any(log.to_time for log in final_logs), "Expected the stopped job card to have a closed time log" + assert any( + log.to_time for log in final_logs + ), "Expected the stopped job card to have a closed time log" From d5a1d29d873c0eca64b104a99f903df1c6548b8d Mon Sep 17 00:00:00 2001 From: lauty95 Date: Mon, 4 May 2026 11:33:32 +0000 Subject: [PATCH 18/30] fix: timeouts --- beam/tests/mobile/test_manufacture.py | 9 ++++++--- beam/tests/mobile/test_mobile.py | 9 ++++++--- beam/tests/mobile/test_receive.py | 24 ++++++++++++++++++------ 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/beam/tests/mobile/test_manufacture.py b/beam/tests/mobile/test_manufacture.py index 12b1d171..2bfb7a36 100644 --- a/beam/tests/mobile/test_manufacture.py +++ b/beam/tests/mobile/test_manufacture.py @@ -50,7 +50,9 @@ def test_complete_partial_stock_entry(page): # navigate in the following order: Home -> Manufacture -> Work Order page.get_by_text("Manufacture").click() - page.locator("css=.beam_list-item").first.click() + work_order_item = page.locator("css=.beam_list-item").first + expect(work_order_item).to_be_visible(timeout=15000) + work_order_item.click() # get the selected Work Order order_id = page.url.split("/")[-1] @@ -65,9 +67,10 @@ def test_complete_partial_stock_entry(page): # find the first item in the list item = page.locator("css=.box .beam_list-item").first + expect(item).to_be_visible(timeout=15000) item_code, *others = item.inner_text().split("\n") item_count = page.locator("css=.box .beam_item-count").first - expect(item_count).to_have_text(re.compile("0/")) + expect(item_count).to_have_text(re.compile("0/"), timeout=15000) assert item_code == "Butter" @@ -82,7 +85,7 @@ def test_complete_partial_stock_entry(page): lambda request: request.headers.get("x-frappe-cmd") == "beam.beam.scan.scan" ): page.evaluate("barcode => scanner.simulate(window, barcode)", barcodes[0]) - expect(item_count).to_have_text(re.compile("1/")) + expect(item_count).to_have_text(re.compile("1/"), timeout=15000) # check that a draft Stock Entry is created page.get_by_text("SAVE", exact=True).click() diff --git a/beam/tests/mobile/test_mobile.py b/beam/tests/mobile/test_mobile.py index de57efa8..c7648ed1 100644 --- a/beam/tests/mobile/test_mobile.py +++ b/beam/tests/mobile/test_mobile.py @@ -21,13 +21,16 @@ def test_scan_item_barcode(page, route): # navigate in the following order: Home -> List -> Form page.get_by_text(route).click() - page.locator("css=.beam_list-item").first.click() + list_item = page.locator("css=.beam_list-item").first + expect(list_item).to_be_visible(timeout=15000) + list_item.click() # find the first item in the list item = page.locator("css=.box .beam_list-item").first + expect(item).to_be_visible(timeout=15000) item_name, *others = item.inner_text().split("\n") item_count = page.locator("css=.box .beam_item-count").first - expect(item_count).to_have_text(re.compile("0/")) + expect(item_count).to_have_text(re.compile("0/"), timeout=15000) # ensure that the item has barcodes barcodes = frappe.get_all( @@ -40,4 +43,4 @@ def test_scan_item_barcode(page, route): lambda request: request.headers.get("x-frappe-cmd") == "beam.beam.scan.scan" ): page.evaluate("barcode => scanner.simulate(window, barcode)", barcodes[0]) - expect(item_count).to_have_text(re.compile("1/")) + expect(item_count).to_have_text(re.compile("1/"), timeout=15000) diff --git a/beam/tests/mobile/test_receive.py b/beam/tests/mobile/test_receive.py index fac92732..069d0217 100644 --- a/beam/tests/mobile/test_receive.py +++ b/beam/tests/mobile/test_receive.py @@ -22,7 +22,9 @@ @pytest.mark.order(2) def test_scan_invalid_barcode(page): page.get_by_text("Receive").click() - page.locator("css=.beam_list-item").first.click() + receive_item = page.locator("css=.beam_list-item").first + expect(receive_item).to_be_visible(timeout=15000) + receive_item.click() # get the selected Purchase Order parsed_url = urlparse(page.url.replace("#", "")) @@ -32,6 +34,7 @@ def test_scan_invalid_barcode(page): # find all items in the list all_item_counts = page.locator("css=.box .beam_item-count") + expect(all_item_counts.first).to_be_visible(timeout=15000) # get all item counts before scanning invalid barcode initial_counts = [] @@ -83,7 +86,9 @@ def test_receive_without_scanning(page): """Test trying to receive without scanning any items""" # navigate to a Purchase Order page.get_by_text("Receive").click() - page.locator("css=.beam_list-item").first.click() + po_item = page.locator("css=.beam_list-item").first + expect(po_item).to_be_visible(timeout=15000) + po_item.click() # get the selected Purchase Order parsed_url = urlparse(page.url.replace("#", "")) @@ -92,6 +97,7 @@ def test_receive_without_scanning(page): assert order_id item = page.locator("css=.box .beam_list-item").first + expect(item).to_be_visible(timeout=15000) item_code, *others = item.inner_text().split("\n") # find all items in the list @@ -136,7 +142,9 @@ def test_receive_without_scanning(page): def test_complete_partial_receipt(page): # navigate in the following order: Home -> Receive -> Purchase Order page.get_by_text("Receive").click() - page.locator("css=.beam_list-item").first.click() + po_item = page.locator("css=.beam_list-item").first + expect(po_item).to_be_visible(timeout=15000) + po_item.click() # get the selected Purchase Order # NOTE: URL format changed: the id lives in the path after the hash (e.g. #/purchase-receipt/PUR-ORD-...) @@ -150,9 +158,10 @@ def test_complete_partial_receipt(page): # find the first item in the list item = page.locator("css=.box .beam_list-item").first + expect(item).to_be_visible(timeout=15000) item_code, *others = item.inner_text().split("\n") item_count = page.locator("css=.box .beam_item-count").first - expect(item_count).to_have_text(re.compile("0/")) + expect(item_count).to_have_text(re.compile("0/"), timeout=15000) assert item_code == "Cloudberry" @@ -218,7 +227,9 @@ def test_rapid_barcode_scanning(page): """Test scanning multiple barcodes quickly""" # navigate to a Purchase Order page.get_by_text("Receive").click() - page.locator("css=.beam_list-item").first.click() + po_item = page.locator("css=.beam_list-item").first + expect(po_item).to_be_visible(timeout=15000) + po_item.click() # get the selected Purchase Order parsed_url = urlparse(page.url.replace("#", "")) @@ -228,9 +239,10 @@ def test_rapid_barcode_scanning(page): # find the first item in the list item = page.locator("css=.box .beam_list-item").first + expect(item).to_be_visible(timeout=15000) item_code, *others = item.inner_text().split("\n") item_count = page.locator("css=.box .beam_item-count").first - expect(item_count).to_have_text(re.compile("0/")) + expect(item_count).to_have_text(re.compile("0/"), timeout=15000) # get barcode for the item with use_current_db_transaction(): From 985ef4174d95267ee9afb7fab38d68d3aa1b1f0e Mon Sep 17 00:00:00 2001 From: lauty95 Date: Thu, 7 May 2026 13:34:35 +0000 Subject: [PATCH 19/30] feat: description on accordion --- beam/www/beam/pages/Operation.vue | 115 ++++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 31 deletions(-) diff --git a/beam/www/beam/pages/Operation.vue b/beam/www/beam/pages/Operation.vue index fbe699d0..0e8bdfdd 100644 --- a/beam/www/beam/pages/Operation.vue +++ b/beam/www/beam/pages/Operation.vue @@ -1,7 +1,7 @@