From 0867b48a73b5e6468dc91ce12a9c5e9d4d694bb7 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Mon, 17 Nov 2025 13:01:56 +0000 Subject: [PATCH 01/26] feat: camera implemented --- beam/www/beam/components/Camera.vue | 286 ++++++++++++++++++++++++ beam/www/beam/pages/PurchaseReceipt.vue | 30 ++- beam/www/beam/stores/beam.ts | 40 ++++ 3 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 beam/www/beam/components/Camera.vue diff --git a/beam/www/beam/components/Camera.vue b/beam/www/beam/components/Camera.vue new file mode 100644 index 00000000..2399d4ce --- /dev/null +++ b/beam/www/beam/components/Camera.vue @@ -0,0 +1,286 @@ + + + + + diff --git a/beam/www/beam/pages/PurchaseReceipt.vue b/beam/www/beam/pages/PurchaseReceipt.vue index 5bf2b5ca..4c125d90 100644 --- a/beam/www/beam/pages/PurchaseReceipt.vue +++ b/beam/www/beam/pages/PurchaseReceipt.vue @@ -10,9 +10,11 @@ + +
- +
@@ -25,6 +27,7 @@ import { computed, ref } from 'vue' import { useRoute } from 'vue-router' import ControlButtons from '@/components/ControlButtons.vue' +import Camera from '@/components/Camera.vue' import { useBeamStore } from '@/stores/beam' import type { ControlButton, PurchaseReceipt, PurchaseReceiptItem } from '@/types' @@ -34,6 +37,25 @@ const purchaseOrderId = route.params.id?.toString() || 'new-purchase-receipt' const purchaseReceipt = ref(store.cache.mappers[purchaseOrderId] as PurchaseReceipt) const refreshKey = ref(0) +const capturedFiles = ref([]) + +const handlePhotosCaptured = (photos: File[]) => { + capturedFiles.value = photos +} + +const handleItemUpdate = (updatedItem: PurchaseReceiptItem & ListViewItem) => { + const itemIndex = purchaseReceipt.value.items.findIndex( + item => item.item_code === updatedItem.item_code + ) + + if (itemIndex !== -1) { + if (updatedItem.count?.count !== undefined) { + purchaseReceipt.value.items[itemIndex].received_qty = updatedItem.count.count + } + + purchaseReceipt.value.dirty = true + } +} // hack: since array reactivity is not present in Vue 3, force-refresh the listviews on store update store.$subscribe(mutation => { @@ -65,7 +87,11 @@ const create = async () => { } const { data, response } = await store.insert('Purchase Receipt', document) - if (response.ok) { + if (response.ok && data) { + if (capturedFiles.value.length > 0) { + await store.uploadFiles('Purchase Receipt', data.name || '', capturedFiles.value) + } + store.$patch(() => { purchaseReceipt.value = data purchaseReceipt.value.dirty = false diff --git a/beam/www/beam/stores/beam.ts b/beam/www/beam/stores/beam.ts index 83639791..6e677081 100644 --- a/beam/www/beam/stores/beam.ts +++ b/beam/www/beam/stores/beam.ts @@ -285,6 +285,45 @@ export const useBeamStore = defineStore('beam', () => { window.location.href = '/login?redirect-to=/beam#' } + const uploadFiles = async (doctype: string, docname: string, files: File[]) => { + if (files.length === 0) return + + for (const file of files) { + const formData = new FormData() + formData.append('file', file, file.name) + formData.append('file_name', file.name) + formData.append('is_private', '0') + formData.append('doctype', doctype) + formData.append('docname', docname) + formData.append('fieldname', 'image') + formData.append('folder', 'Home') + + try { + // Do NOT use httpStore.post, we mustn't use Content-Type: application/json + const response = await fetch('/api/method/upload_file', { + method: 'POST', + headers: { + 'X-Frappe-CSRF-Token': frappe.csrf_token, + 'Accept': 'application/json', + }, + body: formData + }) + + if (response.ok) { + const result = await response.json() + toast.success(`File ${file.name} attached successfully`) + } else { + const errorData = await response.json() + const errorMsg = errorData?.exception || errorData?.message || 'Unknown error' + toast.error(errorMsg) + } + } catch (error: any) { + const errorMsg = error?.message || 'Conection error' + toast.error(errorMsg) + } + } + } + const formatDate = (date: Date) => { if (isNaN(Date.parse(date.toString()))) { return '' @@ -317,6 +356,7 @@ export const useBeamStore = defineStore('beam', () => { insert, update, submit, + uploadFiles, // other api actions formatDate, From b34a73fc575bb3e967f6c844165f943797d93bcf Mon Sep 17 00:00:00 2001 From: lauty95 Date: Mon, 17 Nov 2025 13:19:44 +0000 Subject: [PATCH 02/26] linters --- beam/www/beam/components/Camera.vue | 374 ++++++++++++------------ beam/www/beam/pages/PurchaseReceipt.vue | 4 +- beam/www/beam/stores/beam.ts | 6 +- 3 files changed, 197 insertions(+), 187 deletions(-) diff --git a/beam/www/beam/components/Camera.vue b/beam/www/beam/components/Camera.vue index 2399d4ce..e61f9fce 100644 --- a/beam/www/beam/components/Camera.vue +++ b/beam/www/beam/components/Camera.vue @@ -1,46 +1,48 @@ diff --git a/beam/www/beam/pages/PurchaseReceipt.vue b/beam/www/beam/pages/PurchaseReceipt.vue index 4c125d90..e89911b3 100644 --- a/beam/www/beam/pages/PurchaseReceipt.vue +++ b/beam/www/beam/pages/PurchaseReceipt.vue @@ -44,9 +44,7 @@ const handlePhotosCaptured = (photos: File[]) => { } const handleItemUpdate = (updatedItem: PurchaseReceiptItem & ListViewItem) => { - const itemIndex = purchaseReceipt.value.items.findIndex( - item => item.item_code === updatedItem.item_code - ) + const itemIndex = purchaseReceipt.value.items.findIndex(item => item.item_code === updatedItem.item_code) if (itemIndex !== -1) { if (updatedItem.count?.count !== undefined) { diff --git a/beam/www/beam/stores/beam.ts b/beam/www/beam/stores/beam.ts index 6e677081..6e45b056 100644 --- a/beam/www/beam/stores/beam.ts +++ b/beam/www/beam/stores/beam.ts @@ -287,7 +287,7 @@ export const useBeamStore = defineStore('beam', () => { const uploadFiles = async (doctype: string, docname: string, files: File[]) => { if (files.length === 0) return - + for (const file of files) { const formData = new FormData() formData.append('file', file, file.name) @@ -304,9 +304,9 @@ export const useBeamStore = defineStore('beam', () => { method: 'POST', headers: { 'X-Frappe-CSRF-Token': frappe.csrf_token, - 'Accept': 'application/json', + Accept: 'application/json', }, - body: formData + body: formData, }) if (response.ok) { From bf07bd59dae1e04d1d97a0687298301ef85ff7ea Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 10 Dec 2025 13:44:26 +0000 Subject: [PATCH 03/26] test: camera basic tests --- beam/tests/mobile/test_camera.py | 99 ++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 beam/tests/mobile/test_camera.py diff --git a/beam/tests/mobile/test_camera.py b/beam/tests/mobile/test_camera.py new file mode 100644 index 00000000..061f089f --- /dev/null +++ b/beam/tests/mobile/test_camera.py @@ -0,0 +1,99 @@ +# Copyright (c) 2024, AgriTheory and contributors +# For license information, please see license.txt + + +from playwright.sync_api import expect + + +def test_camera_button_visible(page, setup): + page.add_init_script( + """ + navigator.mediaDevices.enumerateDevices = async () => { + return [ + { + kind: 'videoinput', + deviceId: 'mock-camera-1', + label: 'Mock Camera', + groupId: 'mock-group' + } + ]; + }; + """ + ) + + page.get_by_text("Receive").click() + page.locator("css=.beam_list-item").first.click() + + page.wait_for_timeout(500) + + camera_button = page.locator("button:has-text('Take photo')") + expect(camera_button).to_be_visible() + + +def test_camera_button_hidden_without_support(page, setup): + page.add_init_script( + """ + Object.defineProperty(navigator, 'mediaDevices', { + value: { + getUserMedia: async (constraints) => { + const error = new Error('No camera found'); + error.name = 'NotFoundError'; + throw error; + } + }, + }); + """ + ) + + page.get_by_text("Receive").click() + page.locator("css=.beam_list-item").first.click() + + page.wait_for_timeout(500) + + camera_button = page.locator("button:has-text('Take photo')") + camera_button.click() + page.wait_for_timeout(500) + + error_message = page.locator(".error-message") + expect(error_message).to_contain_text("There is no camera found on this device") + + +def test_camera_opens_and_closes(page, setup): + page.add_init_script( + """ + Object.defineProperty(navigator, 'mediaDevices', { + value: { + getUserMedia: async (constraints) => { + const canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 480; + return canvas.captureStream(30); + } + }, + writable: false, + configurable: true + }); + """ + ) + + page.get_by_text("Receive").click() + page.locator("css=.beam_list-item").first.click() + + page.wait_for_timeout(500) + + camera_button = page.locator("button:has-text('Take photo')") + camera_button.click() + page.wait_for_timeout(500) + + video_element = page.locator("video.camera-video") + expect(video_element).to_be_visible() + + close_camera_button = page.locator(".camera-btn:has-text('Close')") + expect(close_camera_button).to_be_visible() + + close_camera_button.click() + page.wait_for_timeout(500) + + expect(close_camera_button).not_to_be_visible() + expect(camera_button).to_be_visible() + From bdac78e41f9f3a7bd881354056bbfe4009a810d1 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 10 Dec 2025 13:46:52 +0000 Subject: [PATCH 04/26] linters --- beam/tests/mobile/test_camera.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/beam/tests/mobile/test_camera.py b/beam/tests/mobile/test_camera.py index 061f089f..9cf85e28 100644 --- a/beam/tests/mobile/test_camera.py +++ b/beam/tests/mobile/test_camera.py @@ -4,7 +4,7 @@ from playwright.sync_api import expect - + def test_camera_button_visible(page, setup): page.add_init_script( """ @@ -96,4 +96,3 @@ def test_camera_opens_and_closes(page, setup): expect(close_camera_button).not_to_be_visible() expect(camera_button).to_be_visible() - From 0e55175d5b5c11799f91f042b499ace157ad15ef Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 10 Dec 2025 14:20:53 +0000 Subject: [PATCH 05/26] test: attach photo to purchase receipt --- beam/tests/mobile/test_attach_file.py | 105 ++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 beam/tests/mobile/test_attach_file.py diff --git a/beam/tests/mobile/test_attach_file.py b/beam/tests/mobile/test_attach_file.py new file mode 100644 index 00000000..f4d06d13 --- /dev/null +++ b/beam/tests/mobile/test_attach_file.py @@ -0,0 +1,105 @@ +# Copyright (c) 2024, AgriTheory and contributors +# For license information, please see license.txt + +import frappe +from playwright.sync_api import expect + +from beam.tests.test_utils import use_current_db_transaction + + +def test_upload_photo_to_purchase_receipt(page, setup): + page.add_init_script( + """ + Object.defineProperty(navigator, 'mediaDevices', { + value: { + getUserMedia: async (constraints) => { + const canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 480; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'blue'; + ctx.fillRect(0, 0, 640, 480); + return canvas.captureStream(30); + } + }, + writable: false, + configurable: true + }); + """ + ) + + page.get_by_text("Receive").click() + page.wait_for_url("**/beam#/receive") + page.wait_for_timeout(1000) + + page.locator("css=.beam_list-item").first.click() + page.wait_for_timeout(1000) + + order_id = page.url.split("/")[-1] + assert order_id + + camera_button = page.locator("button:has-text('Take photo')") + expect(camera_button).to_be_visible() + + camera_button.click() + page.wait_for_timeout(1000) + + video_element = page.locator("video.camera-video") + expect(video_element).to_be_visible() + + # Click capture button to take photo + capture_button = page.locator(".capture-btn") + capture_button.click() + page.wait_for_timeout(1000) + + # Verify photo preview appears + photo_preview = page.locator(".photos-preview .photo-item") + expect(photo_preview).to_have_count(1) + + item = page.locator("css=.box .beam_list-item").first + item_code = item.inner_text().split("\n")[0] + + with use_current_db_transaction(): + barcodes = frappe.get_all( + "Item Barcode", + filters={"parenttype": "Item", "parent": item_code}, + pluck="barcode" + ) + + assert len(barcodes) > 0, f"No barcodes found for item {item_code}" + + # Scan the barcode to add item to receipt + page.evaluate("barcode => scanner.simulate(window, barcode)", barcodes[0]) + page.wait_for_timeout(1000) + + # Click SAVE button to create Purchase Receipt with photo + save_button = page.locator("button:has-text('SAVE')") + save_button.click() + page.wait_for_timeout(1500) + + with use_current_db_transaction(): + receipts = frappe.get_all( + "Purchase Receipt", + filters={"docstatus": 0}, + fields=["name"], + order_by="creation desc", + limit=1, + ) + + assert len(receipts) > 0, "No Purchase Receipt was created" + receipt_name = receipts[0]["name"] + + with use_current_db_transaction(): + files = frappe.get_all( + "File", + filters={ + "attached_to_doctype": "Purchase Receipt", + "attached_to_name": receipt_name + }, + fields=["name", "file_name", "file_url"], + ) + + assert len(files) == 1, f"Expected 1 attached file, found {len(files)}" + assert files[0]["file_name"].startswith("photo_"), f"File name should start with 'photo_', got {files[0]['file_name']}" + assert files[0]["file_name"].endswith(".jpg"), f"File should be a .jpg, got {files[0]['file_name']}" + assert files[0]["file_url"], "File URL should not be empty" From 62346ec494a51cfaede0cb66405beb0cf08fe419 Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 10 Dec 2025 14:22:04 +0000 Subject: [PATCH 06/26] linters --- beam/tests/mobile/test_attach_file.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/beam/tests/mobile/test_attach_file.py b/beam/tests/mobile/test_attach_file.py index f4d06d13..bf6240d6 100644 --- a/beam/tests/mobile/test_attach_file.py +++ b/beam/tests/mobile/test_attach_file.py @@ -61,11 +61,9 @@ def test_upload_photo_to_purchase_receipt(page, setup): with use_current_db_transaction(): barcodes = frappe.get_all( - "Item Barcode", - filters={"parenttype": "Item", "parent": item_code}, - pluck="barcode" + "Item Barcode", filters={"parenttype": "Item", "parent": item_code}, pluck="barcode" ) - + assert len(barcodes) > 0, f"No barcodes found for item {item_code}" # Scan the barcode to add item to receipt @@ -85,21 +83,22 @@ def test_upload_photo_to_purchase_receipt(page, setup): order_by="creation desc", limit=1, ) - + assert len(receipts) > 0, "No Purchase Receipt was created" receipt_name = receipts[0]["name"] with use_current_db_transaction(): files = frappe.get_all( "File", - filters={ - "attached_to_doctype": "Purchase Receipt", - "attached_to_name": receipt_name - }, + filters={"attached_to_doctype": "Purchase Receipt", "attached_to_name": receipt_name}, fields=["name", "file_name", "file_url"], ) assert len(files) == 1, f"Expected 1 attached file, found {len(files)}" - assert files[0]["file_name"].startswith("photo_"), f"File name should start with 'photo_', got {files[0]['file_name']}" - assert files[0]["file_name"].endswith(".jpg"), f"File should be a .jpg, got {files[0]['file_name']}" + assert files[0]["file_name"].startswith( + "photo_" + ), f"File name should start with 'photo_', got {files[0]['file_name']}" + assert files[0]["file_name"].endswith( + ".jpg" + ), f"File should be a .jpg, got {files[0]['file_name']}" assert files[0]["file_url"], "File URL should not be empty" From 2312239f3855663c072ad5baf7a753c320c0db0b Mon Sep 17 00:00:00 2001 From: lauty95 Date: Wed, 10 Dec 2025 14:40:46 +0000 Subject: [PATCH 07/26] fix: test button render onMounted component --- beam/tests/mobile/test_camera.py | 48 +++++++++++++++-------------- beam/www/beam/components/Camera.vue | 6 +++- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/beam/tests/mobile/test_camera.py b/beam/tests/mobile/test_camera.py index 9cf85e28..fdd5e6f9 100644 --- a/beam/tests/mobile/test_camera.py +++ b/beam/tests/mobile/test_camera.py @@ -8,16 +8,26 @@ def test_camera_button_visible(page, setup): page.add_init_script( """ - navigator.mediaDevices.enumerateDevices = async () => { - return [ - { - kind: 'videoinput', - deviceId: 'mock-camera-1', - label: 'Mock Camera', - groupId: 'mock-group' + Object.defineProperty(navigator, 'mediaDevices', { + value: { + enumerateDevices: async () => { + return [ + { + kind: 'videoinput', + deviceId: 'mock-camera-1', + label: 'Mock Camera', + groupId: 'mock-group' + } + ]; + }, + getUserMedia: async (constraints) => { + const canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 480; + return canvas.captureStream(30); } - ]; - }; + }, + }); """ ) @@ -30,17 +40,13 @@ def test_camera_button_visible(page, setup): expect(camera_button).to_be_visible() -def test_camera_button_hidden_without_support(page, setup): +def test_camera_component_hidden(page, setup): page.add_init_script( """ Object.defineProperty(navigator, 'mediaDevices', { - value: { - getUserMedia: async (constraints) => { - const error = new Error('No camera found'); - error.name = 'NotFoundError'; - throw error; - } - }, + value: undefined, + writable: false, + configurable: true }); """ ) @@ -50,12 +56,8 @@ def test_camera_button_hidden_without_support(page, setup): page.wait_for_timeout(500) - camera_button = page.locator("button:has-text('Take photo')") - camera_button.click() - page.wait_for_timeout(500) - - error_message = page.locator(".error-message") - expect(error_message).to_contain_text("There is no camera found on this device") + camera_component = page.locator(".camera-component") + expect(camera_component).to_have_count(0) def test_camera_opens_and_closes(page, setup): diff --git a/beam/www/beam/components/Camera.vue b/beam/www/beam/components/Camera.vue index e61f9fce..0145656c 100644 --- a/beam/www/beam/components/Camera.vue +++ b/beam/www/beam/components/Camera.vue @@ -33,7 +33,7 @@