diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index de282cf5..d8b30f14 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,7 +55,7 @@ repos: - tomli - repo: https://github.com/agritheory/test_utils - rev: v1.0.0 + rev: v1.14.0 hooks: - id: update_pre_commit_config - id: validate_copyright diff --git a/beam/beam/demand/receiving.py b/beam/beam/demand/receiving.py index 466c35a4..8fce4bd1 100644 --- a/beam/beam/demand/receiving.py +++ b/beam/beam/demand/receiving.py @@ -1,7 +1,7 @@ # Copyright (c) 2024, AgriTheory and contributors # For license information, please see license.txt -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, Optional import frappe from frappe.query_builder import DocType @@ -21,9 +21,6 @@ if TYPE_CHECKING: from sqlite3 import Cursor - from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import PurchaseInvoice - from erpnext.buying.doctype.purchase_order.purchase_order import PurchaseOrder - def _get_receiving_demand( name: str | None = None, item_code: str | None = None @@ -139,13 +136,24 @@ def _get_receiving_demand( @validate_demand_enabled -def modify_receiving( - doc: Union["PurchaseOrder", "PurchaseInvoice"], method: str | None = None -) -> None: +def modify_receiving(doc, method: str | None = None) -> None: + """Update receiving table for Purchase Orders, Purchase Invoices, and Purchase Receipts""" if method == "on_submit": - add_receiving(doc.name) + if doc.doctype == "Purchase Receipt": + purchase_orders = {item.purchase_order for item in doc.items if item.purchase_order} + for po_name in purchase_orders: + remove_receiving(po_name) + add_receiving(po_name) + else: + add_receiving(doc.name) elif method == "on_cancel": - remove_receiving(doc.name) + if doc.doctype == "Purchase Receipt": + purchase_orders = {item.purchase_order for item in doc.items if item.purchase_order} + for po_name in purchase_orders: + remove_receiving(po_name) + add_receiving(po_name) + else: + remove_receiving(doc.name) def add_receiving(name: str) -> None: @@ -282,6 +290,8 @@ def get_receiving_demand(*args, **kwargs) -> list[Receiving]: for r_filter in r_filters: receiving_query = receiving_query.where(*r_filter) + receiving_query = receiving_query.where(receiving.received_qty < receiving.stock_qty) + record_offset = records_per_page * (page - 1) query = f"{receiving_query} LIMIT {records_per_page} OFFSET {record_offset}" diff --git a/beam/hooks.py b/beam/hooks.py index 82cfb93a..1a54abca 100644 --- a/beam/hooks.py +++ b/beam/hooks.py @@ -162,6 +162,10 @@ "on_submit": ["beam.beam.demand.receiving.modify_receiving"], "on_cancel": ["beam.beam.demand.receiving.modify_receiving"], }, + "Purchase Receipt": { + "on_submit": ["beam.beam.demand.receiving.modify_receiving"], + "on_cancel": ["beam.beam.demand.receiving.modify_receiving"], + }, "Purchase Invoice": { "on_submit": [ "beam.beam.demand.receiving.modify_receiving", diff --git a/beam/install.py b/beam/install.py index 001e8aeb..ae631351 100644 --- a/beam/install.py +++ b/beam/install.py @@ -21,6 +21,81 @@ def create_beam_mobile_user_role(): role.insert(ignore_permissions=True) +def setup_beam_mobile_user_permissions(): + """Grant necessary permissions to BEAM Mobile User role for mobile app functionality""" + role = "BEAM Mobile User" + + # Core doctypes - READ only (for reference data) + read_only_doctypes = [ + "Address", + "Contact", + "Company", + "Currency", + "Customer", + "Supplier", + "Item", + "Warehouse", + "UOM", + "Price List", + "Item Price", + "Batch", + "Serial No", + "Item Group", + "Brand", + "UOM Conversion Detail", + ] + + # Source doctypes - READ only (for mapping to new documents) + source_doctypes = [ + "Purchase Order", + "Sales Order", + "Work Order", + ] + + # Target doctypes - Full CRUD permissions + crud_doctypes = [ + "Purchase Receipt", + "Delivery Note", + "Stock Entry", + ] + + # Add READ permissions + for doctype in read_only_doctypes + source_doctypes: + if not frappe.db.exists("Custom DocPerm", {"parent": doctype, "role": role, "permlevel": 0}): + frappe.get_doc( + { + "doctype": "Custom DocPerm", + "parent": doctype, + "parenttype": "DocType", + "parentfield": "permissions", + "role": role, + "read": 1, + "permlevel": 0, + } + ).insert(ignore_permissions=True) + + # Add CRUD permissions for transactional doctypes + for doctype in crud_doctypes: + if not frappe.db.exists("Custom DocPerm", {"parent": doctype, "role": role, "permlevel": 0}): + frappe.get_doc( + { + "doctype": "Custom DocPerm", + "parent": doctype, + "parenttype": "DocType", + "parentfield": "permissions", + "role": role, + "read": 1, + "write": 1, + "create": 1, + "submit": 1, + "cancel": 1, + "permlevel": 0, + } + ).insert(ignore_permissions=True) + + frappe.db.commit() + + def after_install(): load_customizations() print("Setting up Handling Unit Inventory Dimension") @@ -63,4 +138,5 @@ def after_install(): build_demand_allocation_map() reset_build_receiving_map() create_beam_mobile_user_role() + setup_beam_mobile_user_permissions() execute() diff --git a/beam/tests/mobile/test_attach_file.py b/beam/tests/mobile/test_attach_file.py new file mode 100644 index 00000000..309f07ed --- /dev/null +++ b/beam/tests/mobile/test_attach_file.py @@ -0,0 +1,106 @@ +# Copyright (c) 2024, AgriTheory and contributors +# For license information, please see license.txt + +import frappe +import pytest +from playwright.sync_api import expect + +from beam.tests.test_utils import use_current_db_transaction + + +@pytest.mark.order(10) +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" diff --git a/beam/tests/mobile/test_camera.py b/beam/tests/mobile/test_camera.py new file mode 100644 index 00000000..7c5422d7 --- /dev/null +++ b/beam/tests/mobile/test_camera.py @@ -0,0 +1,103 @@ +# Copyright (c) 2024, AgriTheory and contributors +# For license information, please see license.txt + +import pytest +from playwright.sync_api import expect + + +@pytest.mark.order(7) +def test_camera_button_visible(page, setup): + page.add_init_script( + """ + 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); + } + }, + }); + """ + ) + + page.get_by_text("Receive").click() + page.locator("css=.beam_list-item").first.click() + + page.wait_for_timeout(1000) + + camera_button = page.locator("button:has-text('Take photo')") + expect(camera_button).to_be_visible() + + +@pytest.mark.order(8) +def test_camera_component_hidden(page, setup): + page.add_init_script( + """ + Object.defineProperty(navigator, 'mediaDevices', { + value: undefined, + writable: false, + configurable: true + }); + """ + ) + + page.get_by_text("Receive").click() + page.locator("css=.beam_list-item").first.click() + + page.wait_for_timeout(1000) + + camera_component = page.locator(".camera-component") + expect(camera_component).to_have_count(0) + + +@pytest.mark.order(9) +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(1000) + + camera_button = page.locator("button:has-text('Take photo')") + camera_button.click() + page.wait_for_timeout(1000) + + 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(1000) + + expect(close_camera_button).not_to_be_visible() + expect(camera_button).to_be_visible() diff --git a/beam/tests/mobile/test_manufacture.py b/beam/tests/mobile/test_manufacture.py index 12b1d171..28546efb 100644 --- a/beam/tests/mobile/test_manufacture.py +++ b/beam/tests/mobile/test_manufacture.py @@ -50,6 +50,7 @@ def test_complete_partial_stock_entry(page): # navigate in the following order: Home -> Manufacture -> Work Order page.get_by_text("Manufacture").click() + page.wait_for_url("**/beam#/manufacture") page.locator("css=.beam_list-item").first.click() # get the selected Work Order diff --git a/beam/tests/mobile/test_receive.py b/beam/tests/mobile/test_receive.py index fac92732..52f3f052 100644 --- a/beam/tests/mobile/test_receive.py +++ b/beam/tests/mobile/test_receive.py @@ -226,6 +226,8 @@ def test_rapid_barcode_scanning(page): order_id = path_parts[-1] if path_parts else None assert order_id + page.wait_for_timeout(1500) + # find the first item in the list item = page.locator("css=.box .beam_list-item").first item_code, *others = item.inner_text().split("\n") diff --git a/beam/www/beam/components/Camera.vue b/beam/www/beam/components/Camera.vue new file mode 100644 index 00000000..3dd684b9 --- /dev/null +++ b/beam/www/beam/components/Camera.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/beam/www/beam/pages/PurchaseReceipt.vue b/beam/www/beam/pages/PurchaseReceipt.vue index 5bf2b5ca..4641904a 100644 --- a/beam/www/beam/pages/PurchaseReceipt.vue +++ b/beam/www/beam/pages/PurchaseReceipt.vue @@ -10,9 +10,13 @@ +
+ +
+
- +
@@ -22,18 +26,39 @@