diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 32e6881e..568c6566 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -22,7 +22,7 @@ repos:
rev: v2.4.1
hooks:
- id: codespell
- args: ["--ignore-words-list", "notin"]
+ args: ['--ignore-words-list', 'notin']
exclude: 'yarn.lock|poetry.lock'
additional_dependencies:
- tomli
@@ -53,7 +53,7 @@ repos:
additional_dependencies: ['flake8-bugbear']
- repo: https://github.com/agritheory/test_utils
- rev: v1.20.1
+ rev: v1.28.0
hooks:
- id: update_pre_commit_config
- id: validate_frappe_project
diff --git a/beam/beam/custom/user.json b/beam/beam/custom/user.json
index c7efa884..9075ecb1 100644
--- a/beam/beam/custom/user.json
+++ b/beam/beam/custom/user.json
@@ -1,85 +1,85 @@
{
- "custom_fields": [
- {
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "default": null,
- "dt": "User",
- "fetch_if_empty": 0,
- "fieldname": "barcode_section",
- "fieldtype": "Section Break",
- "hidden": 0,
- "hide_border": 0,
- "hide_days": 0,
- "hide_seconds": 0,
- "idx": 26,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_preview": 0,
- "in_standard_filter": 0,
- "insert_after": "mobile_no",
- "label": "Barcodes",
- "length": 0,
- "module": "BEAM",
- "name": "User-barcode_section",
- "no_copy": 0,
- "non_negative": 0,
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "translatable": 0,
- "unique": 0
- },
- {
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "default": null,
- "dt": "User",
- "fetch_if_empty": 0,
- "fieldname": "barcodes",
- "fieldtype": "Table",
- "hidden": 0,
- "hide_border": 0,
- "hide_days": 0,
- "hide_seconds": 0,
- "idx": 27,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_preview": 0,
- "in_standard_filter": 0,
- "insert_after": "barcode_section",
- "length": 0,
- "module": "BEAM",
- "name": "User-barcodes",
- "no_copy": 0,
- "non_negative": 0,
- "options": "Item Barcode",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "translatable": 0,
- "unique": 0
- }
- ],
- "doctype": "User",
- "property_setters": [],
- "sync_on_migrate": 1
-}
\ No newline at end of file
+ "custom_fields": [
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "default": null,
+ "dt": "User",
+ "fetch_if_empty": 0,
+ "fieldname": "barcode_section",
+ "fieldtype": "Section Break",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "idx": 26,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "mobile_no",
+ "label": "Barcodes",
+ "length": 0,
+ "module": "BEAM",
+ "name": "User-barcode_section",
+ "no_copy": 0,
+ "non_negative": 0,
+ "permlevel": 0,
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "translatable": 0,
+ "unique": 0
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "default": null,
+ "dt": "User",
+ "fetch_if_empty": 0,
+ "fieldname": "barcodes",
+ "fieldtype": "Table",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "idx": 27,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "barcode_section",
+ "length": 0,
+ "module": "BEAM",
+ "name": "User-barcodes",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": "Item Barcode",
+ "permlevel": 0,
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "translatable": 0,
+ "unique": 0
+ }
+ ],
+ "doctype": "User",
+ "property_setters": [],
+ "sync_on_migrate": 1
+}
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/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]
diff --git a/beam/hooks.py b/beam/hooks.py
index b00d7d1e..9be08b70 100644
--- a/beam/hooks.py
+++ b/beam/hooks.py
@@ -169,6 +169,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 e76a11ee..5f2ba88f 100644
--- a/beam/install.py
+++ b/beam/install.py
@@ -20,6 +20,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():
print("Setting up Handling Unit Inventory Dimension")
if frappe.db.exists("Inventory Dimension", "Handling Unit"):
@@ -61,4 +136,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 @@
+
+
+
+