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 @@
+
+
+
+