├── .editorconfig ├── .flake8 ├── .github ├── helper │ ├── install.sh │ ├── install_dependencies.sh │ └── site_config.json ├── validate_customizations.py └── workflows │ ├── backport.yml │ ├── generate_matrix.yaml │ ├── lint.yaml │ ├── overrides.yaml │ ├── pytest.yaml │ └── release.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierignore ├── .prettierrc.js ├── CHANGELOG.md ├── MANIFEST.in ├── README.md ├── beam ├── __init__.py ├── beam │ ├── __init__.py │ ├── barcodes.py │ ├── boot.py │ ├── custom │ │ ├── bom_scrap_item.json │ │ ├── item.json │ │ ├── item_barcode.json │ │ ├── stock_entry_detail.json │ │ └── warehouse.json │ ├── doctype │ │ ├── __init__.py │ │ └── handling_unit │ │ │ ├── __init__.py │ │ │ ├── handling_unit.js │ │ │ ├── handling_unit.json │ │ │ ├── handling_unit.py │ │ │ └── test_handling_unit.py │ ├── handling_unit.py │ ├── overrides │ │ ├── stock_entry.py │ │ └── subcontracting_receipt.py │ ├── print_format │ │ ├── __init__.py │ │ ├── handling_unit_6x4_zpl_format │ │ │ ├── __init__.py │ │ │ └── handling_unit_6x4_zpl_format.json │ │ ├── handling_unit_label │ │ │ ├── __init__.py │ │ │ └── handling_unit_label.json │ │ ├── item_barcode │ │ │ ├── __init__.py │ │ │ └── item_barcode.json │ │ ├── labelary_print_preview │ │ │ ├── __init__.py │ │ │ └── labelary_print_preview.json │ │ └── warehouse_barcode │ │ │ ├── __init__.py │ │ │ └── warehouse_barcode.json │ ├── printing.py │ ├── report │ │ ├── __init__.py │ │ └── handling_unit_traceability │ │ │ ├── __init__.py │ │ │ ├── handling_unit_traceability.js │ │ │ ├── handling_unit_traceability.json │ │ │ └── handling_unit_traceability.py │ └── scan │ │ ├── __init__.py │ │ └── config.py ├── customize.py ├── docs │ ├── assets │ │ ├── bom_scrap_item.png │ │ ├── form_view_delivery_note.png │ │ ├── handling_unit_list.png │ │ ├── hu_trace_filters.png │ │ ├── hu_trace_report_output.png │ │ ├── listview_wh_navigation.png │ │ ├── network_printer_settings.png │ │ ├── print_hu.png │ │ ├── recombine.png │ │ ├── select_printer_dialog.png │ │ ├── stock_ledger_after_receipt.png │ │ ├── stock_ledger_after_sale.png │ │ ├── testing.png │ │ └── warehouse_barcodes.png │ ├── form.md │ ├── generate_matrix.py │ ├── handling_unit.md │ ├── hooks.md │ ├── hu_traceability_report.md │ ├── index.md │ ├── listview.md │ ├── matrix.md │ ├── print_server.md │ ├── testing.md │ └── zebra_printing.md ├── hooks.py ├── install.py ├── modules.txt ├── patches.txt ├── public │ ├── .gitkeep │ └── js │ │ ├── beam.bundle.js │ │ ├── example_custom_callback.js │ │ ├── print │ │ └── print.js │ │ ├── scan │ │ └── scan.js │ │ └── stock_entry_custom.js ├── tests │ ├── conftest.py │ ├── fixtures.py │ ├── setup.py │ ├── test_handling_unit.py │ ├── test_hooks_override.py │ └── test_scan.py └── www │ └── __init__.py ├── cups ├── .env.example ├── README.md ├── caddy │ ├── Caddyfile.localhost │ ├── Caddyfile.not_ssl │ ├── Caddyfile.ssl │ ├── Containerfile │ └── entrypoint.sh ├── cups │ ├── Containerfile │ └── cupsd.conf ├── docker-compose.yml └── podman-compose.yml ├── license.txt ├── mypy.ini ├── package.json ├── poetry.lock ├── pyproject.toml ├── setup.py └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # Root editor config file 2 | root = true 3 | 4 | # Common settings 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | 11 | # python, js indentation settings 12 | [{*.py,*.js,*.vue,*.css,*.scss,*.html}] 13 | indent_style = tab 14 | indent_size = 2 15 | max_line_length = 99 16 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | B001, 4 | B007, 5 | B009, 6 | B010, 7 | B950, 8 | E101, 9 | E111, 10 | E114, 11 | E116, 12 | E117, 13 | E121, 14 | E122, 15 | E123, 16 | E124, 17 | E125, 18 | E126, 19 | E127, 20 | E128, 21 | E131, 22 | E201, 23 | E202, 24 | E203, 25 | E211, 26 | E221, 27 | E222, 28 | E223, 29 | E224, 30 | E225, 31 | E226, 32 | E228, 33 | E231, 34 | E241, 35 | E242, 36 | E251, 37 | E261, 38 | E262, 39 | E265, 40 | E266, 41 | E271, 42 | E272, 43 | E273, 44 | E274, 45 | E301, 46 | E302, 47 | E303, 48 | E305, 49 | E306, 50 | E402, 51 | E501, 52 | E502, 53 | E701, 54 | E702, 55 | E703, 56 | E741, 57 | F401, 58 | F403, 59 | F405, 60 | W191, 61 | W291, 62 | W292, 63 | W293, 64 | W391, 65 | W503, 66 | W504, 67 | E711, 68 | E129, 69 | F841, 70 | E713, 71 | E712, 72 | B028, 73 | 74 | max-line-length = 200 75 | exclude=,test_*.py 76 | -------------------------------------------------------------------------------- /.github/helper/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export PIP_ROOT_USER_ACTION=ignore 4 | 5 | set -e 6 | 7 | # Check for merge conflicts before proceeding 8 | python -m compileall -f "${GITHUB_WORKSPACE}" 9 | if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}" 10 | then echo "Found merge conflicts" 11 | exit 1 12 | fi 13 | 14 | cd ~ || exit 15 | 16 | # sudo apt update -y && sudo apt install redis-server -y 17 | 18 | pip install --upgrade pip 19 | pip install frappe-bench 20 | 21 | mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL character_set_server = 'utf8mb4'" 22 | mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" 23 | 24 | mysql --host 127.0.0.1 --port 3306 -u root -e "CREATE OR REPLACE DATABASE test_site" 25 | mysql --host 127.0.0.1 --port 3306 -u root -e "CREATE OR REPLACE USER 'test_site'@'localhost' IDENTIFIED BY 'test_site'" 26 | mysql --host 127.0.0.1 --port 3306 -u root -e "GRANT ALL PRIVILEGES ON \`test_site\`.* TO 'test_site'@'localhost'" 27 | 28 | mysql --host 127.0.0.1 --port 3306 -u root -e "ALTER USER 'root'@'localhost' IDENTIFIED BY 'root'" # match site_cofig 29 | mysql --host 127.0.0.1 --port 3306 -u root -e "FLUSH PRIVILEGES" 30 | 31 | git clone https://github.com/frappe/frappe --branch version-14 32 | bench init frappe-bench --frappe-path ~/frappe --python "$(which python)" --skip-assets --ignore-exist 33 | 34 | mkdir ~/frappe-bench/sites/test_site 35 | cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config.json" ~/frappe-bench/sites/test_site/ 36 | 37 | cd ~/frappe-bench || exit 38 | 39 | sed -i 's/watch:/# watch:/g' Procfile 40 | sed -i 's/schedule:/# schedule:/g' Procfile 41 | sed -i 's/socketio:/# socketio:/g' Procfile 42 | sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile 43 | 44 | bench get-app https://github.com/frappe/erpnext --branch version-14 --resolve-deps --skip-assets 45 | bench get-app beam "${GITHUB_WORKSPACE}" --skip-assets --resolve-deps 46 | 47 | printf '%s\n' 'frappe' 'erpnext' 'beam' > ~/frappe-bench/sites/apps.txt 48 | bench setup requirements --python 49 | bench use test_site 50 | 51 | bench start &> bench_run_logs.txt & 52 | CI=Yes & 53 | bench --site test_site reinstall --yes --admin-password admin 54 | 55 | # bench --site test_site install-app erpnext beam 56 | bench setup requirements --dev 57 | 58 | echo "BENCH VERSION NUMBERS:" 59 | bench version 60 | echo "SITE LIST-APPS:" 61 | bench list-apps 62 | 63 | bench start &> bench_run_logs.txt & 64 | CI=Yes & 65 | bench execute 'beam.tests.setup.before_test' 66 | -------------------------------------------------------------------------------- /.github/helper/install_dependencies.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check for merge conflicts before proceeding 4 | python -m compileall -f $GITHUB_WORKSPACE 5 | if grep -lr --exclude-dir=node_modules "^<<<<<<< " $GITHUB_WORKSPACE 6 | then echo "Found merge conflicts" 7 | exit 1 8 | fi 9 | 10 | sudo apt update -y && sudo apt install redis-server libcups2-dev mariadb-client -y 11 | -------------------------------------------------------------------------------- /.github/helper/site_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "allow_tests": true, 3 | "db_host": "127.0.0.1", 4 | "db_port": 3306, 5 | "db_name": "test_site", 6 | "db_password": "admin", 7 | "auto_email_id": "test@example.com", 8 | "mail_server": "smtp.example.com", 9 | "mail_login": "test@example.com", 10 | "mail_password": "test", 11 | "admin_password": "admin", 12 | "root_login": "root", 13 | "root_password": "admin", 14 | "host_name": "http://test_site:8000", 15 | "install_apps": ["erpnext", "beam"], 16 | "throttle_user_limit": 100, 17 | "developer_mode": 1 18 | } -------------------------------------------------------------------------------- /.github/validate_customizations.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pathlib 3 | import sys 4 | 5 | 6 | def scrub(txt: str) -> str: 7 | return txt.replace(" ", "_").replace("-", "_").lower() 8 | 9 | 10 | def unscrub(txt: str) -> str: 11 | return txt.replace("_", " ").replace("-", " ").title() 12 | 13 | 14 | def get_customized_doctypes(): 15 | apps_dir = pathlib.Path(__file__).resolve().parent.parent.parent 16 | apps_order = pathlib.Path(__file__).resolve().parent.parent.parent.parent / "sites" / "apps.txt" 17 | apps_order = apps_order.read_text().split("\n") 18 | customized_doctypes = {} 19 | for _app_dir in apps_order: 20 | app_dir = (apps_dir / _app_dir).resolve() 21 | if not app_dir.is_dir(): 22 | continue 23 | modules = (app_dir / _app_dir / "modules.txt").read_text().split("\n") 24 | for module in modules: 25 | if not (app_dir / _app_dir / scrub(module) / "custom").exists(): 26 | continue 27 | for custom_file in list((app_dir / _app_dir / scrub(module) / "custom").glob("**/*.json")): 28 | if custom_file.stem in customized_doctypes: 29 | customized_doctypes[custom_file.stem].append(custom_file.resolve()) 30 | else: 31 | customized_doctypes[custom_file.stem] = [custom_file.resolve()] 32 | 33 | return dict(sorted(customized_doctypes.items())) 34 | 35 | 36 | def validate_module(customized_doctypes, set_module=False): 37 | exceptions = [] 38 | app_dir = pathlib.Path(__file__).resolve().parent.parent 39 | this_app = app_dir.stem 40 | modules = (app_dir / this_app / "modules.txt").read_text().split("\n") 41 | for doctype, customize_files in customized_doctypes.items(): 42 | for customize_file in customize_files: 43 | if not this_app in str(customize_file): 44 | continue 45 | module = customize_file.parent.parent.stem 46 | file_contents = json.loads(customize_file.read_text()) 47 | if file_contents.get("custom_fields"): 48 | for custom_field in file_contents.get("custom_fields"): 49 | if set_module: 50 | custom_field["module"] = unscrub(module) 51 | continue 52 | if not custom_field.get("module"): 53 | exceptions.append( 54 | f"Custom Field for {custom_field.get('dt')} in {this_app} '{custom_field.get('fieldname')}' does not have a module key" 55 | ) 56 | continue 57 | elif custom_field.get("module") not in modules: 58 | exceptions.append( 59 | f"Custom Field for {custom_field.get('dt')} in {this_app} '{custom_field.get('fieldname')}' has module key ({custom_field.get('module')}) associated with another app" 60 | ) 61 | continue 62 | if file_contents.get("property_setters"): 63 | for ps in file_contents.get("property_setters"): 64 | if set_module: 65 | ps["module"] = unscrub(module) 66 | continue 67 | if not ps.get("module"): 68 | exceptions.append( 69 | f"Property Setter for {ps.get('doc_type')} in {this_app} '{ps.get('property')}' on {ps.get('field_name')} does not have a module key" 70 | ) 71 | continue 72 | elif ps.get("module") not in modules: 73 | exceptions.append( 74 | f"Property Setter for {ps.get('doc_type')} in {this_app} '{ps.get('property')}' on {ps.get('field_name')} has module key ({ps.get('module')}) associated with another app" 75 | ) 76 | continue 77 | if set_module: 78 | with customize_file.open("w", encoding="UTF-8") as target: 79 | json.dump(file_contents, target, sort_keys=True, indent=2) 80 | 81 | return exceptions 82 | 83 | 84 | def validate_no_custom_perms(customized_doctypes): 85 | exceptions = [] 86 | this_app = pathlib.Path(__file__).resolve().parent.parent.stem 87 | for doctype, customize_files in customized_doctypes.items(): 88 | for customize_file in customize_files: 89 | if not this_app in str(customize_file): 90 | continue 91 | file_contents = json.loads(customize_file.read_text()) 92 | if file_contents.get("custom_perms"): 93 | exceptions.append(f"Customization for {doctype} in {this_app} contains custom permissions") 94 | return exceptions 95 | 96 | 97 | def validate_duplicate_customizations(customized_doctypes): 98 | exceptions = [] 99 | common_fields = {} 100 | common_property_setters = {} 101 | app_dir = pathlib.Path(__file__).resolve().parent.parent 102 | this_app = app_dir.stem 103 | for doctype, customize_files in customized_doctypes.items(): 104 | if len(customize_files) == 1: 105 | continue 106 | common_fields[doctype] = {} 107 | common_property_setters[doctype] = {} 108 | for customize_file in customize_files: 109 | module = customize_file.parent.parent.stem 110 | app = customize_file.parent.parent.parent.parent.stem 111 | file_contents = json.loads(customize_file.read_text()) 112 | if file_contents.get("custom_fields"): 113 | fields = [cf.get("fieldname") for cf in file_contents.get("custom_fields")] 114 | common_fields[doctype][module] = fields 115 | if file_contents.get("property_setters"): 116 | ps = [ps.get("name") for ps in file_contents.get("property_setters")] 117 | common_property_setters[doctype][module] = ps 118 | 119 | for doctype, module_and_fields in common_fields.items(): 120 | if this_app not in module_and_fields.keys(): 121 | continue 122 | this_modules_fields = module_and_fields.pop(this_app) 123 | for module, fields in module_and_fields.items(): 124 | for field in fields: 125 | if field in this_modules_fields: 126 | exceptions.append( 127 | f"Custom Field for {unscrub(doctype)} in {this_app} '{field}' also appears in customizations for {module}" 128 | ) 129 | 130 | for doctype, module_and_ps in common_property_setters.items(): 131 | if this_app not in module_and_ps.keys(): 132 | continue 133 | this_modules_ps = module_and_ps.pop(this_app) 134 | for module, ps in module_and_ps.items(): 135 | for p in ps: 136 | if p in this_modules_ps: 137 | exceptions.append( 138 | f"Property Setter for {unscrub(doctype)} in {this_app} on '{p}' also appears in customizations for {module}" 139 | ) 140 | 141 | return exceptions 142 | 143 | 144 | def validate_customizations(set_module): 145 | customized_doctypes = get_customized_doctypes() 146 | exceptions = validate_no_custom_perms(customized_doctypes) 147 | exceptions += validate_module(customized_doctypes, set_module) 148 | exceptions += validate_duplicate_customizations(customized_doctypes) 149 | 150 | return exceptions 151 | 152 | 153 | if __name__ == "__main__": 154 | exceptions = [] 155 | set_module = False 156 | for arg in sys.argv: 157 | if arg == "--set-module": 158 | set_module = True 159 | exceptions.append(validate_customizations(set_module)) 160 | 161 | if exceptions: 162 | for exception in exceptions: 163 | [print(e) for e in exception] # TODO: colorize 164 | 165 | sys.exit(1) if all(exceptions) else sys.exit(0) 166 | -------------------------------------------------------------------------------- /.github/workflows/backport.yml: -------------------------------------------------------------------------------- 1 | name: Backport 2 | on: 3 | pull_request_target: 4 | types: 5 | - closed 6 | - labeled 7 | 8 | jobs: 9 | backport: 10 | name: Backport 11 | runs-on: ubuntu-latest 12 | # Only react to merged PRs for security reasons. 13 | # See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target. 14 | if: > 15 | github.event.pull_request.merged 16 | && ( 17 | github.event.action == 'closed' 18 | || ( 19 | github.event.action == 'labeled' 20 | && contains(github.event.label.name, 'backport') 21 | ) 22 | ) 23 | steps: 24 | - uses: tibdex/backport@v2 25 | with: 26 | github_token: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/generate_matrix.yaml: -------------------------------------------------------------------------------- 1 | name: Generate Matrix 2 | 3 | on: 4 | push: 5 | branches: 6 | - version-14 7 | pull_request: 8 | 9 | env: 10 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 11 | 12 | jobs: 13 | generate: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Setup Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: '3.10' 22 | 23 | - name: Generate Scanning Decision Matrix 24 | run: python3 ./beam/docs/generate_matrix.py -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Linters 2 | 3 | on: 4 | push: 5 | branches: 6 | - version-15 7 | - version-14 8 | pull_request: 9 | 10 | env: 11 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 12 | 13 | jobs: 14 | mypy: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.10' 24 | 25 | - name: Install mypy 26 | run: pip install mypy 27 | 28 | - name: Install mypy types 29 | run: mypy ./beam/. --install-types --non-interactive 30 | 31 | - name: Run mypy 32 | uses: sasanquaneuf/mypy-github-action@releases/v1 33 | with: 34 | checkName: 'mypy' 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | black: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v4 43 | 44 | - name: Setup Python 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: '3.10' 48 | 49 | - name: Install Black (Frappe) 50 | run: pip install git+https://github.com/frappe/black.git 51 | 52 | - name: Run Black (Frappe) 53 | run: black --check . 54 | 55 | prettier: 56 | runs-on: ubuntu-latest 57 | steps: 58 | - name: Checkout 59 | uses: actions/checkout@v4 60 | 61 | - name: Prettify code 62 | uses: rutajdash/prettier-cli-action@v1.0.0 63 | with: 64 | config_path: ./.prettierrc.js 65 | ignore_path: ./.prettierignore 66 | 67 | - name: Prettier Output 68 | if: ${{ failure() }} 69 | shell: bash 70 | run: | 71 | echo "The following files are not formatted:" 72 | echo "${{steps.prettier-run.outputs.prettier_output}}" 73 | 74 | -------------------------------------------------------------------------------- /.github/workflows/overrides.yaml: -------------------------------------------------------------------------------- 1 | name: Track Overrides 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - version-14 7 | - version-15 8 | 9 | jobs: 10 | track_overrides: 11 | runs-on: ubuntu-latest 12 | name: Track Overrides 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Track Overrides 18 | uses: diamorafaela/track-overrides@main 19 | with: 20 | github-token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/pytest.yaml: -------------------------------------------------------------------------------- 1 | name: Pytest CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - version-14 7 | pull_request: 8 | branches: 9 | - version-14 10 | 11 | # concurrency: 12 | # group: develop-cloud_storage-${{ github.event.number }} 13 | # cancel-in-progress: true 14 | 15 | jobs: 16 | tests: 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | matrix: 20 | os: [ubuntu-latest] 21 | fail-fast: false 22 | name: Server 23 | 24 | services: 25 | mariadb: 26 | image: mariadb:10.6 27 | env: 28 | MYSQL_ALLOW_EMPTY_PASSWORD: YES 29 | MYSQL_ROOT_PASSWORD: 'admin' 30 | ports: 31 | - 3306:3306 32 | options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 33 | 34 | steps: 35 | - name: Clone 36 | uses: actions/checkout@v4 37 | 38 | - name: Setup Python 39 | uses: actions/setup-python@v5 40 | with: 41 | python-version: '3.10' 42 | 43 | - name: Setup Node 44 | uses: actions/setup-node@v4 45 | with: 46 | node-version: 14 47 | check-latest: true 48 | cache: 'yarn' # Replaces `Get yarn cache directory path` and `yarn-cache` steps 49 | 50 | # Uncomment if running locally, remove after local testing (already available in github actions environment) 51 | # - name: Install Yarn 52 | # run: npm install -g yarn 53 | 54 | - name: Add to Hosts 55 | run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts 56 | 57 | - name: Cache pip 58 | uses: actions/cache@v4 59 | with: 60 | path: ~/.cache/pip 61 | key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py', '**/setup.cfg') }} 62 | restore-keys: | 63 | ${{ runner.os }}-pip- 64 | ${{ runner.os }}- 65 | 66 | - name: Install Poetry 67 | uses: snok/install-poetry@v1 68 | 69 | # - name: Get yarn cache directory path 70 | # id: yarn-cache-dir-path 71 | # run: 'echo "::set-output name=dir::$(yarn cache dir)"' 72 | 73 | # - uses: actions/cache@v3 74 | # id: yarn-cache 75 | # with: 76 | # path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 77 | # key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 78 | # restore-keys: | 79 | # ${{ runner.os }}-yarn- 80 | 81 | - name: Install JS Dependencies 82 | run: yarn --prefer-offline 83 | 84 | - name: Install App Dependencies 85 | run: bash ${{ github.workspace }}/.github/helper/install_dependencies.sh 86 | 87 | - name: Install Bench Site and Apps 88 | env: 89 | MYSQL_HOST: 'localhost' 90 | MYSQL_PWD: 'admin' 91 | run: | 92 | bash ${{ github.workspace }}/.github/helper/install.sh 93 | 94 | - name: Run Tests 95 | working-directory: /home/runner/frappe-bench 96 | run: | 97 | source env/bin/activate 98 | cd apps/beam 99 | poetry install 100 | pytest --cov=beam --cov-report=xml --disable-warnings -s | tee pytest-coverage.txt 101 | 102 | - name: Pytest coverage comment 103 | uses: MishaKav/pytest-coverage-comment@main 104 | with: 105 | pytest-coverage-path: /home/runner/frappe-bench/apps/beam/pytest-coverage.txt 106 | pytest-xml-coverage-path: /home/runner/frappe-bench/apps/beam/coverage.xml -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - version-14 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | concurrency: release 11 | permissions: 12 | id-token: write 13 | contents: write 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | - name: Python Semantic Release 20 | uses: python-semantic-release/python-semantic-release@master 21 | with: 22 | github_token: ${{ secrets.GITHUB_TOKEN }} 23 | git_committer_name: AgriTheory 24 | git_committer_email: support@agritheory.dev -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.py~ 3 | *.comp.js 4 | *.DS_Store 5 | locale 6 | .wnf-lang-status 7 | *.swp 8 | *.egg-info 9 | dist/ 10 | # build/ 11 | cloud_storage/docs/current 12 | cloud_storage/public/dist 13 | .vscode 14 | .vs 15 | node_modules 16 | .kdev4/ 17 | *.kdev4 18 | *debug.log 19 | 20 | # Not Recommended, but will remove once webpack ready 21 | package-lock.json 22 | 23 | # Byte-compiled / optimized / DLL files 24 | __pycache__/ 25 | *.py[cod] 26 | *$py.class 27 | 28 | # C extensions 29 | *.so 30 | 31 | # Distribution / packaging 32 | .Python 33 | # build/ 34 | develop-eggs/ 35 | dist/ 36 | downloads/ 37 | eggs/ 38 | .eggs/ 39 | lib64/ 40 | parts/ 41 | sdist/ 42 | var/ 43 | wheels/ 44 | *.egg-info/ 45 | .installed.cfg 46 | *.egg 47 | MANIFEST 48 | 49 | # PyInstaller 50 | # Usually these files are written by a python script from a template 51 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 52 | *.manifest 53 | *.spec 54 | 55 | # Installer logs 56 | pip-log.txt 57 | pip-delete-this-directory.txt 58 | 59 | # Unit test / coverage reports 60 | htmlcov/ 61 | .tox/ 62 | .coverage 63 | .coverage.* 64 | .cache 65 | nosetests.xml 66 | coverage.xml 67 | *.cover 68 | .hypothesis/ 69 | .pytest_cache/ 70 | .cypress-coverage 71 | 72 | # Translations 73 | *.mo 74 | *.pot 75 | 76 | # Django stuff: 77 | *.log 78 | .static_storage/ 79 | .media/ 80 | local_settings.py 81 | 82 | # Flask stuff: 83 | instance/ 84 | .webassets-cache 85 | 86 | # Scrapy stuff: 87 | .scrapy 88 | 89 | # Sphinx documentation 90 | docs/_build/ 91 | 92 | # PyBuilder 93 | target/ 94 | 95 | # Jupyter Notebook 96 | .ipynb_checkpoints 97 | 98 | # pyenv 99 | .python-version 100 | 101 | # celery beat schedule file 102 | celerybeat-schedule 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | 129 | # Logs 130 | logs 131 | *.log 132 | npm-debug.log* 133 | yarn-debug.log* 134 | yarn-error.log* 135 | 136 | # Runtime data 137 | pids 138 | *.pid 139 | *.seed 140 | *.pid.lock 141 | 142 | # Directory for instrumented libs generated by jscoverage/JSCover 143 | lib-cov 144 | 145 | # Coverage directory used by tools like istanbul 146 | coverage 147 | 148 | # nyc test coverage 149 | .nyc_output 150 | 151 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 152 | .grunt 153 | 154 | # Bower dependency directory (https://bower.io/) 155 | bower_components 156 | 157 | # node-waf configuration 158 | .lock-wscript 159 | 160 | # Compiled binary addons (https://nodejs.org/api/addons.html) 161 | build/Release 162 | 163 | # Dependency directories 164 | node_modules/ 165 | jspm_packages/ 166 | 167 | # Typescript v1 declaration files 168 | typings/ 169 | 170 | # Optional npm cache directory 171 | .npm 172 | 173 | # Optional eslint cache 174 | .eslintcache 175 | 176 | # Optional REPL history 177 | .node_repl_history 178 | 179 | # Output of 'npm pack' 180 | *.tgz 181 | 182 | # Yarn Integrity file 183 | .yarn-integrity 184 | 185 | # dotenv environment variables file 186 | .env 187 | 188 | # next.js build output 189 | .next 190 | 191 | # cypress 192 | cypress/screenshots 193 | cypress/videos 194 | 195 | # JetBrains IDEs 196 | .idea/ 197 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: 'node_modules|.git' 2 | default_stages: [commit] 3 | fail_fast: false 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.3.0 8 | hooks: 9 | - id: trailing-whitespace 10 | files: 'beam.*' 11 | exclude: '.*json$|.*txt$|.*csv|.*md|.*svg' 12 | - id: check-yaml 13 | - id: no-commit-to-branch 14 | args: ['--branch', 'develop'] 15 | - id: check-merge-conflict 16 | - id: check-ast 17 | - id: check-json 18 | - id: check-toml 19 | - id: check-yaml 20 | - id: debug-statements 21 | 22 | - repo: https://github.com/asottile/pyupgrade 23 | rev: v2.34.0 24 | hooks: 25 | - id: pyupgrade 26 | args: ['--py38-plus'] 27 | 28 | - repo: https://github.com/frappe/black 29 | rev: 951ccf4d5bb0d692b457a5ebc4215d755618eb68 30 | hooks: 31 | - id: black 32 | 33 | - repo: local 34 | hooks: 35 | - id: prettier 36 | name: prettier 37 | entry: npx prettier . --write --ignore-path .prettierignore 38 | language: node 39 | 40 | - repo: https://github.com/PyCQA/isort 41 | rev: 5.12.0 42 | hooks: 43 | - id: isort 44 | 45 | - repo: https://github.com/PyCQA/flake8 46 | rev: 5.0.4 47 | hooks: 48 | - id: flake8 49 | additional_dependencies: ['flake8-bugbear'] 50 | 51 | - repo: https://github.com/codespell-project/codespell 52 | rev: v2.3.0 53 | hooks: 54 | - id: codespell 55 | additional_dependencies: 56 | - tomli 57 | 58 | - repo: https://github.com/agritheory/test_utils 59 | rev: v0.15.0 60 | hooks: 61 | - id: update_pre_commit_config 62 | - id: validate_copyright 63 | files: '\.(js|ts|py|md)$' 64 | args: ["--app", "beam"] 65 | - id: clean_customized_doctypes 66 | args: ["--app", "beam"] 67 | - id: validate_customizations 68 | - id: validate_python_dependencies 69 | - id: validate_javascript_dependencies 70 | 71 | ci: 72 | autoupdate_schedule: weekly 73 | skip: [] 74 | submodules: false 75 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | #------------------------------------------------------------------------------------------------------------------- 2 | # Keep this section in sync with .gitignore 3 | #------------------------------------------------------------------------------------------------------------------- 4 | 5 | *.pyc 6 | *.py~ 7 | *.comp.js 8 | *.DS_Store 9 | locale 10 | .wnf-lang-status 11 | *.swp 12 | *.egg-info 13 | dist/ 14 | # build/ 15 | beam/docs/current 16 | beam/public/dist 17 | .vscode 18 | .vs 19 | node_modules 20 | .kdev4/ 21 | *.kdev4 22 | *debug.log 23 | 24 | # Not Recommended, but will remove once webpack ready 25 | package-lock.json 26 | 27 | # Byte-compiled / optimized / DLL files 28 | __pycache__/ 29 | *.py[cod] 30 | *$py.class 31 | 32 | # C extensions 33 | *.so 34 | 35 | # Distribution / packaging 36 | .Python 37 | # build/ 38 | develop-eggs/ 39 | dist/ 40 | downloads/ 41 | eggs/ 42 | .eggs/ 43 | lib64/ 44 | parts/ 45 | sdist/ 46 | var/ 47 | wheels/ 48 | *.egg-info/ 49 | .installed.cfg 50 | *.egg 51 | MANIFEST 52 | 53 | # PyInstaller 54 | # Usually these files are written by a python script from a template 55 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 56 | *.manifest 57 | *.spec 58 | 59 | # Installer logs 60 | pip-log.txt 61 | pip-delete-this-directory.txt 62 | 63 | # Unit test / coverage reports 64 | htmlcov/ 65 | .tox/ 66 | .coverage 67 | .coverage.* 68 | .cache 69 | nosetests.xml 70 | coverage.xml 71 | *.cover 72 | .hypothesis/ 73 | .pytest_cache/ 74 | .cypress-coverage 75 | 76 | # Translations 77 | *.mo 78 | *.pot 79 | 80 | # Django stuff: 81 | *.log 82 | .static_storage/ 83 | .media/ 84 | local_settings.py 85 | 86 | # Flask stuff: 87 | instance/ 88 | .webassets-cache 89 | 90 | # Scrapy stuff: 91 | .scrapy 92 | 93 | # Sphinx documentation 94 | docs/_build/ 95 | 96 | # PyBuilder 97 | target/ 98 | 99 | # Jupyter Notebook 100 | .ipynb_checkpoints 101 | 102 | # pyenv 103 | .python-version 104 | 105 | # celery beat schedule file 106 | celerybeat-schedule 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | 133 | # Logs 134 | logs 135 | *.log 136 | npm-debug.log* 137 | yarn-debug.log* 138 | yarn-error.log* 139 | 140 | # Runtime data 141 | pids 142 | *.pid 143 | *.seed 144 | *.pid.lock 145 | 146 | # Directory for instrumented libs generated by jscoverage/JSCover 147 | lib-cov 148 | 149 | # Coverage directory used by tools like istanbul 150 | coverage 151 | 152 | # nyc test coverage 153 | .nyc_output 154 | 155 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 156 | .grunt 157 | 158 | # Bower dependency directory (https://bower.io/) 159 | bower_components 160 | 161 | # node-waf configuration 162 | .lock-wscript 163 | 164 | # Compiled binary addons (https://nodejs.org/api/addons.html) 165 | build/Release 166 | 167 | # Dependency directories 168 | node_modules/ 169 | jspm_packages/ 170 | 171 | # Typescript v1 declaration files 172 | typings/ 173 | 174 | # Optional npm cache directory 175 | .npm 176 | 177 | # Optional eslint cache 178 | .eslintcache 179 | 180 | # Optional REPL history 181 | .node_repl_history 182 | 183 | # Output of 'npm pack' 184 | *.tgz 185 | 186 | # Yarn Integrity file 187 | .yarn-integrity 188 | 189 | # dotenv environment variables file 190 | .env 191 | 192 | # next.js build output 193 | .next 194 | 195 | # cypress 196 | cypress/screenshots 197 | cypress/videos 198 | 199 | # JetBrains IDEs 200 | .idea/ 201 | 202 | #------------------------------------------------------------------------------------------------------------------- 203 | # Prettier-specific overrides 204 | #------------------------------------------------------------------------------------------------------------------- 205 | 206 | 207 | # Package manager files 208 | pnpm-lock.yaml 209 | yarn.lock 210 | package-lock.json 211 | shrinkwrap.json 212 | 213 | # Build outputs 214 | lib 215 | .github 216 | 217 | # Prettier reformats code blocks inside Markdown, which affects rendered output 218 | *.md 219 | 220 | # In a Frappe context HTML files are either Jinja or Resig's microtemplate format and should not be checked 221 | *.html -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | bracketSameLine: true, 4 | bracketSpacing: true, 5 | embeddedLanguageFormatting: 'auto', 6 | htmlWhitespaceSensitivity: 'css', 7 | insertPragma: false, 8 | jsxSingleQuote: false, 9 | printWidth: 120, 10 | proseWrap: 'preserve', 11 | quoteProps: 'as-needed', 12 | requirePragma: false, 13 | semi: false, 14 | singleQuote: true, 15 | tabWidth: 2, 16 | trailingComma: 'es5', 17 | useTabs: true, 18 | vueIndentScriptAndStyle: false, 19 | } 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include requirements.txt 3 | include *.json 4 | include *.md 5 | include *.py 6 | include *.txt 7 | recursive-include beam *.css 8 | recursive-include beam *.csv 9 | recursive-include beam *.html 10 | recursive-include beam *.ico 11 | recursive-include beam *.js 12 | recursive-include beam *.json 13 | recursive-include beam *.md 14 | recursive-include beam *.png 15 | recursive-include beam *.py 16 | recursive-include beam *.svg 17 | recursive-include beam *.txt 18 | recursive-exclude beam *.pyc -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## BEAM 2 | 3 | Barcode Scanning for ERPNext 4 | 5 | #### License 6 | 7 | MIT 8 | 9 | ## Install Instructions 10 | 11 | Set up a new bench, substitute a path to the python version to use, which should 3.10 latest 12 | 13 | ``` 14 | # for linux development 15 | bench init --frappe-branch version-14 {{ bench name }} --python ~/.pyenv/versions/3.10.4/bin/python3 16 | ``` 17 | Create a new site in that bench 18 | ``` 19 | cd {{ bench name }} 20 | bench new-site {{ site name }} --force --db-name {{ site name }} 21 | bench use {{ site name }} 22 | ``` 23 | Download the ERPNext app, its prerequisite Payments, and the HR module 24 | ``` 25 | bench get-app erpnext --branch version-14 26 | ``` 27 | Download this application and install all apps 28 | ``` 29 | bench get-app beam git@github.com:agritheory/beam.git 30 | ``` 31 | Set developer mode in `site_config.json` 32 | ``` 33 | cd {{ site name }} 34 | nano site_config.json 35 | 36 | "developer_mode": 1, 37 | ``` 38 | 39 | Update and get the site ready 40 | ``` 41 | bench start 42 | ``` 43 | In a new terminal window 44 | ``` 45 | bench update 46 | bench migrate 47 | bench build 48 | ``` 49 | 50 | Setup test data 51 | ```shell 52 | bench execute 'beam.tests.setup.before_test' 53 | # for complete reset to run before tests: 54 | bench reinstall --yes --admin-password admin --mariadb-root-password admin && bench execute 'beam.tests.setup.before_test' 55 | ``` 56 | 57 | To run mypy and pytest 58 | ```shell 59 | source env/bin/activate 60 | mypy ./apps/beam/beam --ignore-missing-imports 61 | pytest ./apps/beam/beam/tests -s --disable-warnings 62 | ``` 63 | 64 | ### Printer Server setup 65 | ```shell 66 | sudo apt-get install gcc cups python3-dev libcups2-dev -y 67 | # for development it helps to have the CUPS PDF printer installed 68 | # sudo apt-get -y install printer-driver-cups-pdf 69 | 70 | bench pip install pycups 71 | sudo usermod -a -G lpadmin {username} # the "frappe" user in most installations 72 | ``` 73 | Go to `{server URL or localhost}:631` to access the CUPS web interface 74 | Configuration on a remote server will take extra steps to secure: 75 | https://askubuntu.com/questions/23936/how-do-you-administer-cups-remotely-using-the-web-interface 76 | -------------------------------------------------------------------------------- /beam/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "14.8.6" 2 | -------------------------------------------------------------------------------- /beam/beam/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/beam/__init__.py -------------------------------------------------------------------------------- /beam/beam/barcodes.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import uuid 3 | from io import BytesIO 4 | 5 | import frappe 6 | from barcode import Code128 7 | from barcode.writer import ImageWriter 8 | from zebra_zpl import Barcode, Label, Printable, Text 9 | 10 | 11 | @frappe.whitelist() 12 | def create_beam_barcode(doc, method=None): 13 | if doc.doctype == "Item" and doc.is_stock_item == 0: 14 | return 15 | if ( 16 | doc.get("item_group") 17 | and doc.doctype == "Item" 18 | and frappe.db.exists("Item Group", "Products") 19 | and doc.item_group 20 | in frappe.get_all("Item Group", {"name": ("descendants of", "Products")}, pluck="name") 21 | ): 22 | # TODO: refactor this to be configurable to "Products" or "sold" items that do not require handling units 23 | return 24 | if any([b for b in doc.barcodes if b.barcode_type == "Code128"]): 25 | return 26 | # move all other rows back 27 | for row_index, b in enumerate(doc.barcodes, start=1): 28 | b.idx = row_index + 1 29 | doc.append( 30 | "barcodes", 31 | {"barcode": f"{str(uuid.uuid4().int >> 64):020}", "barcode_type": "Code128", "idx": 1}, 32 | ) 33 | return doc 34 | 35 | 36 | @frappe.whitelist() 37 | @frappe.read_only() 38 | def barcode128(barcode_text: str) -> str: 39 | if not barcode_text: 40 | return "" 41 | temp = BytesIO() 42 | instance = Code128(barcode_text, writer=ImageWriter()) 43 | instance.write( 44 | temp, 45 | options={"module_width": 0.4, "module_height": 10, "font_size": 0, "compress": True}, 46 | ) 47 | encoded = base64.b64encode(temp.getvalue()).decode("ascii") 48 | return f'' 49 | 50 | 51 | @frappe.whitelist() 52 | @frappe.read_only() 53 | def formatted_zpl_barcode(barcode_text: str) -> str: 54 | bc = Barcode( 55 | barcode_text, 56 | type="C", 57 | human_readable="Y", 58 | width=4, 59 | height=260, 60 | ratio=1, 61 | justification="C", 62 | position=(20, 40), 63 | ) 64 | return bc.to_zpl() 65 | 66 | 67 | @frappe.whitelist() 68 | @frappe.read_only() 69 | def formatted_zpl_label( 70 | width: int, length: int, dpi: int = 203, print_speed: int = 2, copies: int = 1 71 | ) -> frappe._dict: 72 | l = frappe._dict() 73 | # ^XA Start format 74 | # ^LL, 75 | # ^LH 76 | # ^LS 77 | # ^PW 78 | # Print Rate(speed) (^PR command) 79 | l.start = f"^XA^LL{length}^LH0,0^LS10^PW{width}^PR{print_speed}" 80 | # Specify how many copies to print 81 | # End format 82 | l.end = f"^PQ{copies}^XZ\n" 83 | return l 84 | 85 | 86 | @frappe.whitelist() 87 | @frappe.read_only() 88 | def formatted_zpl_text(text: str, width: int | None = None) -> str: 89 | tf = Text(text, font_type=0, font_size=28, position=(0, 25), width=width, y=25, justification="C") 90 | return tf.to_zpl() 91 | 92 | 93 | @frappe.whitelist() 94 | @frappe.read_only() 95 | def zebra_zpl_label(*args, **kwargs): 96 | return ZPLLabelStringOutput(*args, **kwargs) 97 | 98 | 99 | @frappe.whitelist() 100 | @frappe.read_only() 101 | def zebra_zpl_barcode(data: str, **kwargs): 102 | return Barcode(data, **kwargs) 103 | 104 | 105 | @frappe.whitelist() 106 | @frappe.read_only() 107 | def zebra_zpl_text(data: str, **kwargs): 108 | return Text(data, **kwargs) 109 | 110 | 111 | @frappe.whitelist() 112 | @frappe.read_only() 113 | def add_to_label(label: Label, element: Printable): 114 | label.add(element) 115 | 116 | 117 | class ZPLLabelStringOutput(Label): 118 | def __init__( 119 | self, width: int = 100, length: int = 100, dpi: int = 203, print_speed: int = 2, copies: int = 1 120 | ): 121 | super().__init__(width, length, dpi, print_speed, copies) 122 | 123 | def dump_contents(self, io=None): 124 | s = "" 125 | s += f"^XA^LL{self.length}^LH0,0^LS10^PW{self.width}^PR{self.print_speed}" 126 | for e in self.elements: 127 | s += e.to_zpl() 128 | 129 | s += f"^PQ{self.copies}^XZ\n" 130 | return s 131 | 132 | 133 | """ 134 | {% set label = namespace(zebra_zpl_label(width=4*203, length=6*203, dpi=203)) %} 135 | 136 | {{ zpl_text(hu.item_code + " " + hu.warehouse) }} 137 | {{ zpl_text(hu.posting_date + " " + hu.posting_time) }} 138 | 139 | {% label.add(zebra_zpl_barcode(hu.handling_unit, width=4, height=260, position=(20, 40), justification="C", ratio=1, human_readable='Y') -%} 140 | {{ label.dump_contents() }} 141 | 142 | """ 143 | -------------------------------------------------------------------------------- /beam/beam/boot.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | from beam.beam.scan.config import get_scan_doctypes 4 | 5 | 6 | def boot_session(bootinfo): 7 | bootinfo.beam = get_scan_doctypes() 8 | -------------------------------------------------------------------------------- /beam/beam/custom/bom_scrap_item.json: -------------------------------------------------------------------------------- 1 | { 2 | "custom_fields": [ 3 | { 4 | "_assign": null, 5 | "_comments": null, 6 | "_liked_by": null, 7 | "_user_tags": null, 8 | "allow_in_quick_entry": 0, 9 | "allow_on_submit": 1, 10 | "bold": 0, 11 | "collapsible": 0, 12 | "collapsible_depends_on": null, 13 | "columns": 0, 14 | "creation": "2023-08-22 15:23:16.272692", 15 | "default": null, 16 | "depends_on": null, 17 | "description": null, 18 | "docstatus": 0, 19 | "dt": "BOM Scrap Item", 20 | "fetch_from": null, 21 | "fetch_if_empty": 0, 22 | "fieldname": "create_handling_unit", 23 | "fieldtype": "Check", 24 | "hidden": 0, 25 | "hide_border": 0, 26 | "hide_days": 0, 27 | "hide_seconds": 0, 28 | "idx": 3, 29 | "ignore_user_permissions": 0, 30 | "ignore_xss_filter": 0, 31 | "in_global_search": 0, 32 | "in_list_view": 1, 33 | "in_preview": 0, 34 | "in_standard_filter": 0, 35 | "insert_after": "item_name", 36 | "is_system_generated": 0, 37 | "is_virtual": 0, 38 | "label": "Create Handling Unit", 39 | "length": 0, 40 | "mandatory_depends_on": null, 41 | "modified": "2023-08-22 15:23:52.267428", 42 | "modified_by": "Administrator", 43 | "module": "BEAM", 44 | "name": "BOM Scrap Item-create_handling_unit", 45 | "no_copy": 0, 46 | "non_negative": 0, 47 | "options": null, 48 | "owner": "Administrator", 49 | "permlevel": 0, 50 | "precision": "", 51 | "print_hide": 0, 52 | "print_hide_if_no_value": 0, 53 | "print_width": null, 54 | "read_only": 0, 55 | "read_only_depends_on": null, 56 | "report_hide": 0, 57 | "reqd": 0, 58 | "search_index": 0, 59 | "sort_options": 0, 60 | "translatable": 0, 61 | "unique": 0, 62 | "width": null 63 | } 64 | ], 65 | "custom_perms": [], 66 | "doctype": "BOM Scrap Item", 67 | "links": [], 68 | "property_setters": [], 69 | "sync_on_migrate": 1 70 | } 71 | -------------------------------------------------------------------------------- /beam/beam/custom/item.json: -------------------------------------------------------------------------------- 1 | { 2 | "custom_fields": [ 3 | { 4 | "_assign": null, 5 | "_comments": null, 6 | "_liked_by": null, 7 | "_user_tags": null, 8 | "allow_in_quick_entry": 0, 9 | "allow_on_submit": 0, 10 | "bold": 0, 11 | "collapsible": 0, 12 | "collapsible_depends_on": null, 13 | "columns": 0, 14 | "creation": "2024-02-26 23:52:53.051024", 15 | "default": "1", 16 | "depends_on": null, 17 | "description": null, 18 | "docstatus": 0, 19 | "dt": "Item", 20 | "fetch_from": null, 21 | "fetch_if_empty": 0, 22 | "fieldname": "enable_handling_unit", 23 | "fieldtype": "Check", 24 | "hidden": 0, 25 | "hide_border": 0, 26 | "hide_days": 0, 27 | "hide_seconds": 0, 28 | "idx": 11, 29 | "ignore_user_permissions": 0, 30 | "ignore_xss_filter": 0, 31 | "in_global_search": 0, 32 | "in_list_view": 0, 33 | "in_preview": 0, 34 | "in_standard_filter": 0, 35 | "insert_after": "has_variants", 36 | "is_system_generated": 0, 37 | "is_virtual": 0, 38 | "label": "Enable Handling Unit", 39 | "length": 0, 40 | "mandatory_depends_on": null, 41 | "modified": "2024-02-26 23:52:53.051024", 42 | "modified_by": "Administrator", 43 | "module": "BEAM", 44 | "name": "Item-enable_handling_unit", 45 | "no_copy": 0, 46 | "non_negative": 0, 47 | "options": null, 48 | "owner": "Administrator", 49 | "permlevel": 0, 50 | "precision": "", 51 | "print_hide": 0, 52 | "print_hide_if_no_value": 0, 53 | "print_width": null, 54 | "read_only": 0, 55 | "read_only_depends_on": null, 56 | "report_hide": 0, 57 | "reqd": 0, 58 | "search_index": 0, 59 | "sort_options": 0, 60 | "translatable": 0, 61 | "unique": 0, 62 | "width": null 63 | } 64 | ], 65 | "custom_perms": [], 66 | "doctype": "Item", 67 | "links": [], 68 | "property_setters": [], 69 | "sync_on_migrate": 1 70 | } -------------------------------------------------------------------------------- /beam/beam/custom/item_barcode.json: -------------------------------------------------------------------------------- 1 | { 2 | "custom_fields": [], 3 | "custom_perms": [], 4 | "doctype": "Item Barcode", 5 | "property_setters": [ 6 | { 7 | "_assign": null, 8 | "_comments": null, 9 | "_liked_by": null, 10 | "_user_tags": null, 11 | "creation": "2022-06-16 09:40:22.875922", 12 | "default_value": null, 13 | "doc_type": "Item Barcode", 14 | "docstatus": 0, 15 | "doctype_or_field": "DocField", 16 | "field_name": "barcode_type", 17 | "idx": 0, 18 | "modified": "2022-06-16 09:40:22.875922", 19 | "modified_by": "Administrator", 20 | "module": "BEAM", 21 | "name": "Item Barcode-barcode_type-options", 22 | "owner": "Administrator", 23 | "parent": null, 24 | "parentfield": null, 25 | "parenttype": null, 26 | "property": "options", 27 | "property_type": "Text", 28 | "row_name": null, 29 | "value": "\nEAN\nUPC-A\nCode128" 30 | } 31 | ], 32 | "sync_on_migrate": 1 33 | } 34 | -------------------------------------------------------------------------------- /beam/beam/custom/stock_entry_detail.json: -------------------------------------------------------------------------------- 1 | { 2 | "custom_fields": [ 3 | { 4 | "_assign": null, 5 | "_comments": null, 6 | "_liked_by": null, 7 | "_user_tags": null, 8 | "allow_in_quick_entry": 0, 9 | "allow_on_submit": 1, 10 | "bold": 0, 11 | "collapsible": 0, 12 | "collapsible_depends_on": null, 13 | "columns": 0, 14 | "creation": "2023-09-13 12:51:04.950175", 15 | "default": null, 16 | "depends_on": "", 17 | "description": null, 18 | "docstatus": 0, 19 | "dt": "Stock Entry Detail", 20 | "fetch_from": null, 21 | "fetch_if_empty": 0, 22 | "fieldname": "recombine_on_cancel", 23 | "fieldtype": "Check", 24 | "hidden": 0, 25 | "hide_border": 0, 26 | "hide_days": 0, 27 | "hide_seconds": 0, 28 | "idx": 70, 29 | "ignore_user_permissions": 0, 30 | "ignore_xss_filter": 0, 31 | "in_global_search": 0, 32 | "in_list_view": 0, 33 | "in_preview": 0, 34 | "in_standard_filter": 0, 35 | "insert_after": "to_handling_unit", 36 | "is_system_generated": 0, 37 | "is_virtual": 0, 38 | "label": "Recombine On Cancel", 39 | "length": 0, 40 | "mandatory_depends_on": null, 41 | "modified": "2023-09-13 12:51:04.950175", 42 | "modified_by": "Administrator", 43 | "module": "BEAM", 44 | "name": "Stock Entry Detail-recombine_on_cancel", 45 | "no_copy": 1, 46 | "non_negative": 0, 47 | "options": null, 48 | "owner": "Administrator", 49 | "permlevel": 0, 50 | "precision": "", 51 | "print_hide": 0, 52 | "print_hide_if_no_value": 0, 53 | "print_width": null, 54 | "read_only": 1, 55 | "read_only_depends_on": null, 56 | "report_hide": 0, 57 | "reqd": 0, 58 | "search_index": 0, 59 | "sort_options": 0, 60 | "translatable": 0, 61 | "unique": 0, 62 | "width": null 63 | } 64 | ], 65 | "custom_perms": [], 66 | "doctype": "Stock Entry Detail", 67 | "links": [], 68 | "property_setters": [], 69 | "sync_on_migrate": 1 70 | } 71 | -------------------------------------------------------------------------------- /beam/beam/custom/warehouse.json: -------------------------------------------------------------------------------- 1 | { 2 | "custom_fields": [ 3 | { 4 | "_assign": null, 5 | "_comments": null, 6 | "_liked_by": null, 7 | "_user_tags": null, 8 | "allow_in_quick_entry": 0, 9 | "allow_on_submit": 0, 10 | "bold": 0, 11 | "collapsible": 0, 12 | "collapsible_depends_on": null, 13 | "columns": 0, 14 | "creation": "2022-06-16 09:48:36.521275", 15 | "default": null, 16 | "depends_on": null, 17 | "description": null, 18 | "docstatus": 0, 19 | "dt": "Warehouse", 20 | "fetch_from": null, 21 | "fetch_if_empty": 0, 22 | "fieldname": "barcode_section", 23 | "fieldtype": "Section Break", 24 | "hidden": 0, 25 | "hide_border": 0, 26 | "hide_days": 0, 27 | "hide_seconds": 0, 28 | "idx": 26, 29 | "ignore_user_permissions": 0, 30 | "ignore_xss_filter": 0, 31 | "in_global_search": 0, 32 | "in_list_view": 0, 33 | "in_preview": 0, 34 | "in_standard_filter": 0, 35 | "insert_after": "pin", 36 | "label": "Barcodes", 37 | "length": 0, 38 | "mandatory_depends_on": null, 39 | "modified": "2022-06-16 09:48:36.521275", 40 | "modified_by": "Administrator", 41 | "module": "BEAM", 42 | "name": "Warehouse-barcode_section", 43 | "no_copy": 0, 44 | "non_negative": 0, 45 | "options": null, 46 | "owner": "Administrator", 47 | "parent": null, 48 | "parentfield": null, 49 | "parenttype": null, 50 | "permlevel": 0, 51 | "precision": "", 52 | "print_hide": 0, 53 | "print_hide_if_no_value": 0, 54 | "print_width": null, 55 | "read_only": 0, 56 | "read_only_depends_on": null, 57 | "report_hide": 0, 58 | "reqd": 0, 59 | "search_index": 0, 60 | "translatable": 0, 61 | "unique": 0, 62 | "width": null 63 | }, 64 | { 65 | "_assign": null, 66 | "_comments": null, 67 | "_liked_by": null, 68 | "_user_tags": null, 69 | "allow_in_quick_entry": 0, 70 | "allow_on_submit": 0, 71 | "bold": 0, 72 | "collapsible": 0, 73 | "collapsible_depends_on": null, 74 | "columns": 0, 75 | "creation": "2022-06-16 09:48:36.701251", 76 | "default": null, 77 | "depends_on": null, 78 | "description": null, 79 | "docstatus": 0, 80 | "dt": "Warehouse", 81 | "fetch_from": null, 82 | "fetch_if_empty": 0, 83 | "fieldname": "barcodes", 84 | "fieldtype": "Table", 85 | "hidden": 0, 86 | "hide_border": 0, 87 | "hide_days": 0, 88 | "hide_seconds": 0, 89 | "idx": 27, 90 | "ignore_user_permissions": 0, 91 | "ignore_xss_filter": 0, 92 | "in_global_search": 0, 93 | "in_list_view": 0, 94 | "in_preview": 0, 95 | "in_standard_filter": 0, 96 | "insert_after": "barcode_section", 97 | "label": "", 98 | "length": 0, 99 | "mandatory_depends_on": null, 100 | "modified": "2022-06-16 09:48:36.701251", 101 | "modified_by": "Administrator", 102 | "module": "BEAM", 103 | "name": "Warehouse-barcodes", 104 | "no_copy": 0, 105 | "non_negative": 0, 106 | "options": "Item Barcode", 107 | "owner": "Administrator", 108 | "parent": null, 109 | "parentfield": null, 110 | "parenttype": null, 111 | "permlevel": 0, 112 | "precision": "", 113 | "print_hide": 0, 114 | "print_hide_if_no_value": 0, 115 | "print_width": null, 116 | "read_only": 0, 117 | "read_only_depends_on": null, 118 | "report_hide": 0, 119 | "reqd": 0, 120 | "search_index": 0, 121 | "translatable": 0, 122 | "unique": 0, 123 | "width": null 124 | } 125 | ], 126 | "custom_perms": [], 127 | "doctype": "Warehouse", 128 | "property_setters": [], 129 | "sync_on_migrate": 1 130 | } 131 | -------------------------------------------------------------------------------- /beam/beam/doctype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/beam/doctype/__init__.py -------------------------------------------------------------------------------- /beam/beam/doctype/handling_unit/__init__.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from pypika import Order 3 | 4 | 5 | @frappe.whitelist() 6 | @frappe.read_only() 7 | @frappe.validate_and_sanitize_search_inputs 8 | def handling_unit_query(doctype, txt, searchfield, start=0, page_len=20, filters=None): 9 | filters = frappe._dict({}) if not filters else filters 10 | stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry") 11 | return ( 12 | frappe.qb.from_(stock_ledger_entry) 13 | .select(stock_ledger_entry.handling_unit) 14 | .where(stock_ledger_entry.item_code == filters.get("item_code")) 15 | .where(stock_ledger_entry.warehouse == filters.get("warehouse")) 16 | .orderby("modified", order=Order.desc) 17 | .limit(page_len) 18 | .offset(start) 19 | ).run() 20 | -------------------------------------------------------------------------------- /beam/beam/doctype/handling_unit/handling_unit.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on('Handling Unit', { 2 | refresh: frm => {}, 3 | }) 4 | -------------------------------------------------------------------------------- /beam/beam/doctype/handling_unit/handling_unit.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_import": 1, 4 | "allow_rename": 1, 5 | "creation": "2022-06-13 16:06:53.912272", 6 | "doctype": "DocType", 7 | "editable_grid": 1, 8 | "engine": "InnoDB", 9 | "field_order": ["handling_unit_name"], 10 | "fields": [ 11 | { 12 | "fieldname": "handling_unit_name", 13 | "fieldtype": "Data", 14 | "label": "Handling Unit Name", 15 | "no_copy": 1, 16 | "set_only_once": 1, 17 | "unique": 1 18 | } 19 | ], 20 | "index_web_pages_for_search": 1, 21 | "links": [], 22 | "modified": "2023-08-22 15:04:08.773589", 23 | "modified_by": "Administrator", 24 | "module": "BEAM", 25 | "name": "Handling Unit", 26 | "owner": "Administrator", 27 | "permissions": [ 28 | { 29 | "create": 1, 30 | "delete": 1, 31 | "email": 1, 32 | "export": 1, 33 | "import": 1, 34 | "print": 1, 35 | "read": 1, 36 | "report": 1, 37 | "role": "System Manager", 38 | "select": 1, 39 | "share": 1, 40 | "write": 1 41 | }, 42 | { 43 | "create": 1, 44 | "delete": 1, 45 | "email": 1, 46 | "export": 1, 47 | "print": 1, 48 | "read": 1, 49 | "report": 1, 50 | "role": "Stock User", 51 | "select": 1, 52 | "share": 1, 53 | "write": 1 54 | }, 55 | { 56 | "create": 1, 57 | "delete": 1, 58 | "email": 1, 59 | "export": 1, 60 | "import": 1, 61 | "print": 1, 62 | "read": 1, 63 | "report": 1, 64 | "role": "Stock Manager", 65 | "select": 1, 66 | "share": 1, 67 | "write": 1 68 | } 69 | ], 70 | "sort_field": "modified", 71 | "sort_order": "DESC", 72 | "states": [] 73 | } 74 | -------------------------------------------------------------------------------- /beam/beam/doctype/handling_unit/handling_unit.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import uuid 3 | 4 | import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class HandlingUnit(Document): 9 | def autoname(self): 10 | self.handling_unit_name = self.name = str(uuid.uuid4().int >> 64) 11 | 12 | def validate(self): 13 | barcode = frappe.new_doc("Item Barcode") 14 | barcode.parenttype = "Handling Unit" 15 | barcode.barcode_type = "Code128" 16 | barcode.barcode = self.name 17 | barcode.parent = self.name 18 | barcode.flags.ignore_permissions = True 19 | barcode.save() 20 | -------------------------------------------------------------------------------- /beam/beam/doctype/handling_unit/test_handling_unit.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022, NetProfitXL and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | import unittest 6 | 7 | 8 | class TestHandlingUnit(unittest.TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /beam/beam/handling_unit.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import frappe 4 | from erpnext.stock.stock_ledger import NegativeStockError 5 | 6 | from beam.beam.scan import get_handling_unit 7 | 8 | """ 9 | See docs/handling_unit.md 10 | """ 11 | 12 | 13 | @frappe.whitelist() 14 | def generate_handling_units(doc, method=None): 15 | if doc.doctype == "Purchase Invoice" and not doc.update_stock: 16 | return doc 17 | 18 | if doc.doctype == "Stock Entry" and doc.purpose == "Material Issue": 19 | return doc 20 | 21 | for row in doc.items: 22 | is_stock_item, enable_handling_unit = frappe.get_value( 23 | "Item", row.item_code, ["is_stock_item", "enable_handling_unit"] 24 | ) 25 | if not (is_stock_item and enable_handling_unit): 26 | continue 27 | 28 | if ( 29 | doc.doctype == "Stock Entry" 30 | and doc.purpose 31 | in ("Material Transfer", "Send to Subcontractor", "Material Transfer for Manufacture") 32 | and row.handling_unit 33 | ): 34 | handling_unit = frappe.new_doc("Handling Unit") 35 | handling_unit.save() 36 | row.to_handling_unit = handling_unit.name 37 | continue 38 | 39 | if doc.doctype == "Subcontracting Receipt" and not row.handling_unit: 40 | handling_unit = frappe.new_doc("Handling Unit") 41 | handling_unit.save() 42 | row.handling_unit = handling_unit.name 43 | 44 | if doc.doctype == "Stock Entry" and doc.purpose == "Manufacture" and row.is_scrap_item: 45 | create_handling_unit = frappe.get_value( 46 | "BOM Scrap Item", {"item_code": row.item_code, "parent": doc.bom_no}, "create_handling_unit" 47 | ) 48 | if bool(create_handling_unit): 49 | handling_unit = frappe.new_doc("Handling Unit") 50 | handling_unit.save() 51 | row.handling_unit = handling_unit.name 52 | continue 53 | 54 | if row.get("handling_unit"): 55 | continue 56 | 57 | if doc.doctype == "Stock Entry" and not ( 58 | any([row.is_finished_item, doc.purpose == "Material Receipt", row.is_scrap_item]) 59 | ): 60 | continue 61 | 62 | handling_unit = frappe.new_doc("Handling Unit") 63 | handling_unit.save() 64 | row.handling_unit = handling_unit.name 65 | 66 | return doc 67 | 68 | 69 | @frappe.whitelist() 70 | def validate_handling_unit_overconsumption(doc, method=None): 71 | if doc.doctype == "Sales Invoice" and not doc.update_stock: 72 | return doc 73 | 74 | if doc.doctype == "Purchase Receipt" and not doc.is_return: 75 | return doc 76 | 77 | if doc.doctype == "Stock Entry" and doc.purpose == "Material Receipt": 78 | return doc 79 | 80 | qty_field = "transfer_qty" if doc.doctype == "Stock Entry" else "stock_qty" 81 | 82 | for row in doc.get("items"): 83 | error = False 84 | if not hasattr(row, "handling_unit") or not row.handling_unit: 85 | continue 86 | 87 | hu = get_handling_unit(row.handling_unit) 88 | if not hu: 89 | continue 90 | 91 | precision_denominator = 1 / pow(100, frappe.get_precision(row.doctype, qty_field)) 92 | 93 | if doc.doctype == "Stock Entry": 94 | # outgoing 95 | if row.get("t_warehouse") and not row.get("s_warehouse"): 96 | if ( 97 | abs(hu.stock_qty - row.get(qty_field)) > 0.0 98 | and (hu.stock_qty - row.get(qty_field) > precision_denominator) 99 | and not row.is_scrap_item 100 | ): 101 | error = True 102 | else: # incoming and transfer / same warehouse 103 | if ( 104 | abs(hu.stock_qty - row.get(qty_field)) > 0.0 105 | and hu.stock_qty - row.get(qty_field) < precision_denominator 106 | ): 107 | error = True 108 | 109 | elif doc.doctype in ("Sales Invoice", "Delivery Note"): 110 | if abs(hu.stock_qty - row.get(qty_field)) != 0.0 and ( 111 | hu.stock_qty - row.get(qty_field) < precision_denominator 112 | ): 113 | error = True 114 | 115 | if error == True: 116 | frappe.throw( 117 | frappe._( 118 | f"Row #{row.idx}: Handling Unit for {row.item_code} cannot be more than {hu.stock_qty:.1f} {hu.stock_uom}. You have {row.get(qty_field):.1f} {row.stock_uom}" 119 | ), 120 | NegativeStockError, 121 | title=frappe._("Insufficient Stock"), 122 | ) 123 | 124 | return doc 125 | -------------------------------------------------------------------------------- /beam/beam/overrides/stock_entry.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | import frappe 4 | from erpnext.stock.doctype.stock_entry.stock_entry import StockEntry 5 | from erpnext.stock.doctype.stock_entry_detail.stock_entry_detail import StockEntryDetail 6 | from frappe import _ 7 | from frappe.utils import cstr, flt 8 | from typing_extensions import Self 9 | 10 | 11 | class BEAMStockEntry(StockEntry): 12 | def update_stock_ledger(self): 13 | """ 14 | HASH: 153e0ba81b62acc170a951a289363fff5579edc7 15 | REPO: https://github.com/frappe/erpnext/ 16 | PATH: erpnext/stock/doctype/stock_entry/stock_entry.py 17 | METHOD: update_stock_ledger 18 | """ 19 | sl_entries = [] 20 | finished_item_row = self.get_finished_item_row() 21 | self.get_sle_for_source_warehouse(sl_entries, finished_item_row) 22 | self.get_sle_for_target_warehouse(sl_entries, finished_item_row) 23 | if self.docstatus == 2: 24 | sl_entries.reverse() 25 | self.make_sl_entries(sl_entries) 26 | 27 | if self.docstatus == 2: 28 | hu_sles = self.make_handling_unit_sles() 29 | self.make_sl_entries(hu_sles) 30 | 31 | def make_handling_unit_sles(self): 32 | hu_sles = [] 33 | for d in self.get("items"): 34 | if self.docstatus == 2 and not d.recombine_on_cancel and d.handling_unit and d.to_handling_unit: 35 | sle = self.get_sl_entries( 36 | d, 37 | { 38 | "warehouse": cstr(d.s_warehouse), 39 | "actual_qty": -flt(d.transfer_qty), 40 | "incoming_rate": flt(d.valuation_rate), 41 | }, 42 | ) 43 | sle["handling_unit"] = d.handling_unit 44 | sle["is_cancelled"] = 0 45 | hu_sles.append(sle) 46 | _sle = self.get_sl_entries( 47 | d, 48 | { 49 | "warehouse": cstr(d.t_warehouse), 50 | "actual_qty": flt(d.transfer_qty), 51 | "incoming_rate": flt(d.valuation_rate), 52 | }, 53 | ) 54 | _sle["handling_unit"] = d.to_handling_unit 55 | _sle["is_cancelled"] = 0 56 | hu_sles.append(_sle) 57 | return hu_sles 58 | 59 | 60 | @frappe.whitelist() 61 | def set_rows_to_recombine(docname: str, to_recombine=None) -> None: 62 | doc = frappe.get_doc("Stock Entry", docname) 63 | if not to_recombine: 64 | return 65 | for row in doc.items: 66 | if row.name in to_recombine: 67 | row.db_set("recombine_on_cancel", True) 68 | return 69 | 70 | 71 | @frappe.whitelist() 72 | @frappe.read_only() 73 | def get_handling_units_for_item_code(doctype, txt, searchfield, start, page_len, filters): 74 | StockLedgerEntry = frappe.qb.DocType("Stock Ledger Entry") 75 | return ( 76 | frappe.qb.from_(StockLedgerEntry) 77 | .select(StockLedgerEntry.handling_unit) 78 | .where( 79 | (StockLedgerEntry.item_code == filters.get("item_code")) 80 | & (StockLedgerEntry.handling_unit != "") 81 | ) 82 | .orderby(StockLedgerEntry.posting_date, order=frappe.qb.desc) 83 | .groupby(StockLedgerEntry.handling_unit) 84 | .run(as_dict=False) 85 | ) 86 | 87 | 88 | @frappe.whitelist() 89 | @frappe.read_only() 90 | def get_handling_unit_qty(voucher_no, handling_unit, warehouse): 91 | return frappe.db.get_value( 92 | "Stock Ledger Entry", 93 | { 94 | "voucher_no": voucher_no, 95 | "handling_unit": handling_unit, 96 | "warehouse": warehouse, 97 | }, 98 | ["qty_after_transaction"], 99 | ) 100 | 101 | 102 | # This function validates stock entry items to prevent missing handling units. 103 | def validate_items_with_handling_unit(doc, method=None): 104 | if doc.stock_entry_type != "Material Receipt": 105 | for row in doc.items: 106 | if not frappe.get_value("Item", row.item_code, "enable_handling_unit"): 107 | continue 108 | elif row.is_scrap_item and not frappe.get_value( 109 | "BOM Scrap Item", 110 | {"item_code": row.item_code, "parent": doc.get("bom_no")}, 111 | "create_handling_unit", 112 | ): 113 | continue 114 | elif ( 115 | doc.stock_entry_type in ["Repack", "Manufacture"] 116 | and not (row.t_warehouse or row.is_finished_item or row.is_scrap_item) 117 | and not row.handling_unit 118 | ): 119 | frappe.throw(frappe._(f"Row #{row.idx}: Handling Unit is missing for item {row.item_code}")) 120 | elif not row.handling_unit: 121 | frappe.throw(frappe._(f"Row #{row.idx}: Handling Unit is missing for item {row.item_code}")) 122 | -------------------------------------------------------------------------------- /beam/beam/overrides/subcontracting_receipt.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from erpnext.controllers.stock_controller import StockController 3 | from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos 4 | from erpnext.stock.utils import get_incoming_rate 5 | from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import ( 6 | SubcontractingReceipt, 7 | ) 8 | from frappe import _ 9 | from frappe.utils import cint, cstr, flt, get_link_to_form 10 | 11 | 12 | class BEAMSubcontractingReceipt(SubcontractingReceipt): 13 | def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_voucher=False): 14 | """ 15 | HASH: 4d34b1ead73baf4c5430a2ecbe44b9e8468d7626 16 | REPO: https://github.com/frappe/erpnext/ 17 | PATH: erpnext/controllers/subcontracting_controller.py 18 | METHOD: update_stock_ledger 19 | """ 20 | self.update_ordered_and_reserved_qty() 21 | 22 | sl_entries = [] 23 | stock_items = self.get_stock_items() 24 | 25 | for item in self.get("items"): 26 | if item.item_code in stock_items and item.warehouse: 27 | scr_qty = flt(item.qty) * flt(item.conversion_factor) 28 | 29 | if scr_qty: 30 | sle = self.get_sl_entries( 31 | item, {"actual_qty": flt(scr_qty), "serial_no": cstr(item.serial_no).strip()} 32 | ) 33 | rate_db_precision = 6 if cint(self.precision("rate", item)) <= 6 else 9 34 | incoming_rate = flt(item.rate, rate_db_precision) 35 | sle.update( 36 | {"incoming_rate": incoming_rate, "recalculate_rate": 1, "handling_unit": item.handling_unit} 37 | ) 38 | sl_entries.append(sle) 39 | 40 | if flt(item.rejected_qty) != 0: 41 | sl_entries.append( 42 | self.get_sl_entries( 43 | item, 44 | { 45 | "warehouse": item.rejected_warehouse, 46 | "actual_qty": flt(item.rejected_qty) * flt(item.conversion_factor), 47 | "serial_no": cstr(item.rejected_serial_no).strip(), 48 | "incoming_rate": 0.0, 49 | "handling_unit": item.handling_unit, 50 | }, 51 | ) 52 | ) 53 | 54 | make_sl_entries_for_supplier_warehouse(self, sl_entries) 55 | self.make_sl_entries( 56 | sl_entries, 57 | allow_negative_stock=allow_negative_stock, 58 | via_landed_cost_voucher=via_landed_cost_voucher, 59 | ) 60 | 61 | 62 | def make_sl_entries_for_supplier_warehouse(self, sl_entries): 63 | if hasattr(self, "supplied_items"): 64 | sle_hu = get_sle(self) 65 | for item in self.get("supplied_items"): 66 | # negative quantity is passed, as raw material qty has to be decreased 67 | # when SCR is submitted and it has to be increased when SCR is cancelled 68 | handling_unit = get_handling_unit_for_consumption(sle_hu, item) 69 | sl_entries.append( 70 | self.get_sl_entries( 71 | item, 72 | { 73 | "item_code": item.rm_item_code, 74 | "warehouse": self.supplier_warehouse, 75 | "actual_qty": -1 * flt(item.consumed_qty, item.precision("consumed_qty")), 76 | "dependant_sle_voucher_detail_no": item.reference_name, 77 | "handling_unit": handling_unit, 78 | }, 79 | ) 80 | ) 81 | 82 | 83 | def get_handling_unit_for_consumption(sle_hu, item): 84 | if sle_hu.get(item.subcontracting_order): 85 | for row in sle_hu.get(item.subcontracting_order): 86 | if row.item_code == item.rm_item_code: 87 | return row.handling_unit 88 | 89 | 90 | def get_sle(self): 91 | sle_hu_map = {} 92 | for row in self.items: 93 | if row.subcontracting_order: 94 | if stock_entry := frappe.db.exists( 95 | "Stock Entry", {"subcontracting_order": row.subcontracting_order, "docstatus": 1} 96 | ): 97 | sle_hu = frappe.get_all( 98 | "Stock Ledger Entry", 99 | filters={ 100 | "voucher_type": "Stock Entry", 101 | "voucher_no": stock_entry, 102 | "warehouse": self.supplier_warehouse, 103 | }, 104 | fields=["name", "handling_unit", "item_code"], 105 | ) 106 | sle_hu_map[row.subcontracting_order] = sle_hu 107 | return sle_hu_map 108 | -------------------------------------------------------------------------------- /beam/beam/print_format/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/beam/print_format/__init__.py -------------------------------------------------------------------------------- /beam/beam/print_format/handling_unit_6x4_zpl_format/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/beam/print_format/handling_unit_6x4_zpl_format/__init__.py -------------------------------------------------------------------------------- /beam/beam/print_format/handling_unit_6x4_zpl_format/handling_unit_6x4_zpl_format.json: -------------------------------------------------------------------------------- 1 | { 2 | "absolute_value": 0, 3 | "align_labels_right": 0, 4 | "creation": "2024-07-01 15:46:33.585470", 5 | "custom_format": 1, 6 | "default_print_language": "en", 7 | "disabled": 0, 8 | "doc_type": "Handling Unit", 9 | "docstatus": 0, 10 | "doctype": "Print Format", 11 | "font_size": 14, 12 | "idx": 0, 13 | "line_breaks": 0, 14 | "margin_bottom": 15.0, 15 | "margin_left": 15.0, 16 | "margin_right": 15.0, 17 | "margin_top": 15.0, 18 | "modified": "2024-07-01 18:53:58.230171", 19 | "modified_by": "Administrator", 20 | "module": "BEAM", 21 | "name": "Handling Unit 6x4 ZPL Format", 22 | "owner": "Administrator", 23 | "page_number": "Hide", 24 | "print_format_builder": 0, 25 | "print_format_builder_beta": 0, 26 | "print_format_type": "Jinja", 27 | "raw_commands": "{% set hu = get_handling_unit(doc.name) %}\n{% set label = zebra_zpl_label(width=4*203, length=6*203, dpi=203) -%}\n\n{{ label.add(zebra_zpl_barcode(hu.handling_unit, width=4, height=260, position=(20, 40), justification=\"C\", ratio=1, human_readable='N')) -}}\n{{ label.add(zebra_zpl_text(hu.handling_unit, position=(40, 320), width=(4*203-80), font_size=40, justification=\"C\")) }}\n{{ label.add(zebra_zpl_text(frappe.utils.cstr(hu.qty) + \" \" + hu.uom, position=(40, 400), width=(4*203-80), font_size=40)) }}\n{{ label.add(zebra_zpl_text(hu.item_code, position=(40, 480), width=(4*203-80), font_size=40)) }}\n{{ label.add(zebra_zpl_text(hu.warehouse, position=(40, 560), width=(4*203-80), font_size=40)) }}\n{{ label.add(zebra_zpl_text(frappe.utils.format_datetime(hu.posting_datetime), position=(40, 640), width=(4*203-80), font_size=40)) }}\n\n{{ label.dump_contents() }}", 28 | "raw_printing": 1, 29 | "show_section_headings": 0, 30 | "standard": "Yes" 31 | } -------------------------------------------------------------------------------- /beam/beam/print_format/handling_unit_label/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/beam/print_format/handling_unit_label/__init__.py -------------------------------------------------------------------------------- /beam/beam/print_format/handling_unit_label/handling_unit_label.json: -------------------------------------------------------------------------------- 1 | { 2 | "absolute_value": 0, 3 | "align_labels_right": 0, 4 | "creation": "2023-05-19 11:19:07.018442", 5 | "custom_format": 1, 6 | "default_print_language": "en", 7 | "disabled": 0, 8 | "doc_type": "Handling Unit", 9 | "docstatus": 0, 10 | "doctype": "Print Format", 11 | "font_size": 14, 12 | "html": "{% set hu = get_handling_unit(doc.name) %}\n\n\n\n 0mm\n 0mm\n 0mm\n 0mm\n\n \n \n {{barcode128(hu.handling_unit)}}\n \n \n {{ hu.handling_unit }}\n {{ frappe.utils.format_datetime(hu.posting_datetime) }}\n {{ hu.actual_qty }}\n {{ hu.uom }}\n {{ hu.item_name }}\n \n \n \n\n\n", 13 | "idx": 0, 14 | "line_breaks": 0, 15 | "margin_bottom": 15.0, 16 | "margin_left": 15.0, 17 | "margin_right": 15.0, 18 | "margin_top": 15.0, 19 | "modified": "2023-05-19 11:28:47.311376", 20 | "modified_by": "Administrator", 21 | "module": "BEAM", 22 | "name": "Handling Unit Label", 23 | "owner": "Administrator", 24 | "page_number": "Hide", 25 | "print_format_builder": 0, 26 | "print_format_builder_beta": 0, 27 | "print_format_type": "Jinja", 28 | "raw_printing": 0, 29 | "show_section_headings": 0, 30 | "standard": "Yes" 31 | } 32 | -------------------------------------------------------------------------------- /beam/beam/print_format/item_barcode/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/beam/print_format/item_barcode/__init__.py -------------------------------------------------------------------------------- /beam/beam/print_format/item_barcode/item_barcode.json: -------------------------------------------------------------------------------- 1 | { 2 | "absolute_value": 0, 3 | "align_labels_right": 0, 4 | "creation": "2023-05-19 10:39:46.064230", 5 | "custom_format": 1, 6 | "default_print_language": "en", 7 | "disabled": 0, 8 | "doc_type": "Item", 9 | "docstatus": 0, 10 | "doctype": "Print Format", 11 | "font_size": 14, 12 | "html": "\n\n 0mm\n 0mm\n 0mm\n 0mm\n\n \n \n {{barcode128(doc.barcodes[0].barcode)}}\n \n \n {{ doc.item_code }}\n {{ doc.item_name if doc.item_code != doc.item_name else '' }}\n \n \n\n\n", 13 | "idx": 0, 14 | "line_breaks": 0, 15 | "margin_bottom": 15.0, 16 | "margin_left": 15.0, 17 | "margin_right": 15.0, 18 | "margin_top": 15.0, 19 | "modified": "2023-05-19 10:45:34.221309", 20 | "modified_by": "Administrator", 21 | "module": "BEAM", 22 | "name": "Item Barcode", 23 | "owner": "Administrator", 24 | "page_number": "Hide", 25 | "print_format_builder": 0, 26 | "print_format_builder_beta": 0, 27 | "print_format_type": "Jinja", 28 | "raw_printing": 0, 29 | "show_section_headings": 0, 30 | "standard": "Yes" 31 | } 32 | -------------------------------------------------------------------------------- /beam/beam/print_format/labelary_print_preview/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/beam/print_format/labelary_print_preview/__init__.py -------------------------------------------------------------------------------- /beam/beam/print_format/labelary_print_preview/labelary_print_preview.json: -------------------------------------------------------------------------------- 1 | { 2 | "absolute_value": 0, 3 | "align_labels_right": 0, 4 | "creation": "2024-07-01 15:53:35.152254", 5 | "css": ".print-format-preview {\n background-color: var(--gray-200);\n}\n\n.zpl-label {\n min-height: 4in;\n max-height: 4in;\n min-width: 6in;\n max-width: 6in;\n background-color: white;\n}\n\n.print-format {\n max-width: unset;\n min-height: unset;\n}\n\n.print-preview {\n background: unset;\n}", 6 | "custom_format": 1, 7 | "default_print_language": "en", 8 | "disabled": 0, 9 | "doc_type": "Handling Unit", 10 | "docstatus": 0, 11 | "doctype": "Print Format", 12 | "font_size": 14, 13 | "html": "\n \n\n", 14 | "idx": 0, 15 | "line_breaks": 0, 16 | "margin_bottom": 15.0, 17 | "margin_left": 15.0, 18 | "margin_right": 15.0, 19 | "margin_top": 15.0, 20 | "modified": "2024-07-01 18:54:12.428312", 21 | "modified_by": "Administrator", 22 | "module": "BEAM", 23 | "name": "Labelary Print Preview", 24 | "owner": "Administrator", 25 | "page_number": "Hide", 26 | "print_format_builder": 0, 27 | "print_format_builder_beta": 0, 28 | "print_format_type": "Jinja", 29 | "raw_commands": "", 30 | "raw_printing": 0, 31 | "show_section_headings": 0, 32 | "standard": "Yes" 33 | } -------------------------------------------------------------------------------- /beam/beam/print_format/warehouse_barcode/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/beam/print_format/warehouse_barcode/__init__.py -------------------------------------------------------------------------------- /beam/beam/print_format/warehouse_barcode/warehouse_barcode.json: -------------------------------------------------------------------------------- 1 | { 2 | "absolute_value": 0, 3 | "align_labels_right": 0, 4 | "creation": "2024-06-11 10:14:57.183542", 5 | "custom_format": 1, 6 | "default_print_language": "en", 7 | "disabled": 0, 8 | "doc_type": "Warehouse", 9 | "docstatus": 0, 10 | "doctype": "Print Format", 11 | "font_size": 14, 12 | "html": "\n\n 0mm\n 0mm\n 0mm\n 0mm\n\n \n \n {{barcode128(doc.barcodes[0].barcode)}}\n \n \n {{ doc.name }}\n \n \n\n\n", 13 | "idx": 0, 14 | "line_breaks": 0, 15 | "margin_bottom": 15.0, 16 | "margin_left": 15.0, 17 | "margin_right": 15.0, 18 | "margin_top": 15.0, 19 | "modified": "2024-06-11 10:16:45.288929", 20 | "modified_by": "Administrator", 21 | "module": "BEAM", 22 | "name": "Warehouse Barcode", 23 | "owner": "Administrator", 24 | "page_number": "Hide", 25 | "print_format_builder": 0, 26 | "print_format_builder_beta": 0, 27 | "print_format_type": "Jinja", 28 | "raw_printing": 0, 29 | "show_section_headings": 0, 30 | "standard": "Yes" 31 | } -------------------------------------------------------------------------------- /beam/beam/printing.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import os 4 | from pathlib import Path 5 | 6 | import frappe 7 | import requests 8 | from frappe.utils.jinja import get_jinja_hooks 9 | from frappe.utils.safe_exec import get_safe_globals 10 | from jinja2 import DebugUndefined, Environment 11 | from PyPDF2 import PdfFileWriter 12 | 13 | try: 14 | import cups 15 | except Exception as e: 16 | frappe.log_error(e, "CUPS is not installed on this server") 17 | 18 | 19 | @frappe.whitelist() 20 | def print_by_server( 21 | doctype, 22 | name, 23 | printer_setting, 24 | print_format=None, 25 | doc=None, 26 | no_letterhead=0, 27 | file_path=None, 28 | ): 29 | print_settings = frappe.get_doc("Network Printer Settings", printer_setting) 30 | if isinstance(doc, str): 31 | doc = frappe._dict(json.loads(doc)) 32 | if not print_format: 33 | print_format = frappe.get_meta(doctype).get("default_print_format") 34 | print_format = frappe.get_doc("Print Format", print_format) 35 | try: 36 | cups.setServer(print_settings.server_ip) 37 | cups.setPort(print_settings.port) 38 | conn = cups.Connection() 39 | if print_format.raw_printing == 1: 40 | output = "" 41 | # using a custom jinja environment so we don't have to use frappe's formatting 42 | methods, filters = get_jinja_hooks() 43 | e = Environment(undefined=DebugUndefined) 44 | e.globals.update(get_safe_globals()) 45 | e.filters.update( 46 | { 47 | "json": frappe.as_json, 48 | "len": len, 49 | "int": frappe.utils.data.cint, 50 | "str": frappe.utils.data.cstr, 51 | "flt": frappe.utils.data.flt, 52 | } 53 | ) 54 | if methods: 55 | e.globals.update(methods) 56 | template = e.from_string(print_format.raw_commands) 57 | output = template.render(doc=doc) 58 | if not file_path: 59 | # use this path for testing, it will be available from the public server with the file name 60 | # file_path = f"{get_bench_path()}/sites{get_files_path()[1:]}/{name}{random_string(10).upper()}.txt" 61 | # use this technique for production 62 | file_path = os.path.join("/", "tmp", f"frappe-zpl-{frappe.generate_hash()}.txt") 63 | Path(file_path).write_text(output) 64 | else: 65 | output = PdfFileWriter() 66 | output = frappe.get_print( 67 | doctype, 68 | name, 69 | print_format.name, 70 | doc=doc, 71 | no_letterhead=no_letterhead, 72 | as_pdf=True, 73 | output=output, 74 | ) 75 | if not file_path: 76 | file_path = os.path.join("/", "tmp", f"frappe-pdf-{frappe.generate_hash()}.pdf") 77 | output.write(open(file_path, "wb")) 78 | conn.printFile(print_settings.printer_name, file_path, name, {}) 79 | frappe.msgprint( 80 | f"{name } printing on {print_settings.printer_name}", alert=True, indicator="green" 81 | ) 82 | except OSError as e: 83 | if ( 84 | "ContentNotFoundError" in e.message 85 | or "ContentOperationNotPermittedError" in e.message 86 | or "UnknownContentError" in e.message 87 | or "RemoteHostClosedError" in e.message 88 | ): 89 | frappe.throw(frappe._("PDF generation failed")) 90 | except cups.IPPError: 91 | frappe.throw(frappe._("Printing failed")) 92 | 93 | 94 | @frappe.whitelist() 95 | def print_handling_units( 96 | doctype=None, name=None, printer_setting=None, print_format=None, doc=None 97 | ): 98 | if isinstance(doc, str): 99 | doc = frappe._dict(json.loads(doc)) 100 | 101 | for row in doc.get("items"): 102 | if not row.get("handling_unit"): 103 | continue 104 | # only print output / scrap items from Stock Entry 105 | if doctype == "Stock Entry" and not row.get("t_warehouse"): 106 | continue 107 | # if one of the transfer types, use the 'to_handling_unit' field instead 108 | if doctype == "Stock Entry" and doc.get("purpose") in ( 109 | "Material Transfer", 110 | "Send to Subcontractor", 111 | "Material Transfer for Manufacture", 112 | ): 113 | handling_unit = frappe.get_doc("Handling Unit", row.get("to_handling_unit")) 114 | else: 115 | handling_unit = frappe.get_doc("Handling Unit", row.get("handling_unit")) 116 | print_by_server( 117 | handling_unit.doctype, 118 | handling_unit.name, 119 | printer_setting, 120 | print_format, 121 | handling_unit, 122 | ) 123 | 124 | 125 | """ 126 | 127 | """ 128 | 129 | 130 | def labelary_api(doc, print_format, settings=None): 131 | if not settings: 132 | settings = {} 133 | print_format = frappe.get_doc("Print Format", print_format) 134 | if print_format.raw_printing != 1: 135 | frappe.throw("This is a not a RAW print format") 136 | output = "" 137 | # using a custom jinja environment so we don't have to use frappe's formatting 138 | methods, filters = get_jinja_hooks() 139 | e = Environment(undefined=DebugUndefined) 140 | e.globals.update(get_safe_globals()) 141 | if methods: 142 | e.globals.update(methods) 143 | template = e.from_string(print_format.raw_commands) 144 | output = template.render(doc=doc) 145 | url = "http://api.labelary.com/v1/printers/8dpmm/labels/6x4/0/" 146 | r = requests.post(url, files={"file": output}) 147 | return base64.b64encode(r.content).decode("ascii") 148 | -------------------------------------------------------------------------------- /beam/beam/report/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/beam/report/__init__.py -------------------------------------------------------------------------------- /beam/beam/report/handling_unit_traceability/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/beam/report/handling_unit_traceability/__init__.py -------------------------------------------------------------------------------- /beam/beam/report/handling_unit_traceability/handling_unit_traceability.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023, AgriTheory and contributors 2 | // For license information, please see license.txt 3 | /* eslint-disable */ 4 | 5 | frappe.query_reports['Handling Unit Traceability'] = { 6 | filters: [ 7 | { 8 | fieldname: 'handling_unit', 9 | label: __('Handling Unit'), 10 | fieldtype: 'Link', 11 | options: 'Handling Unit', 12 | }, 13 | { 14 | fieldname: 'delivery_note', 15 | label: __('Delivery Note'), 16 | fieldtype: 'Link', 17 | options: 'Delivery Note', 18 | }, 19 | { 20 | fieldname: 'sales_invoice', 21 | label: __('Sales Invoice'), 22 | fieldtype: 'Link', 23 | options: 'Sales Invoice', 24 | get_query: function () { 25 | return { 26 | filters: { 27 | update_stock: 1, 28 | }, 29 | } 30 | }, 31 | }, 32 | ], 33 | formatter: function (value, row, column, data, default_formatter) { 34 | value = default_formatter(value, row, column, data) 35 | 36 | if (column.fieldname == 'actual_qty' && data && data.actual_qty > 0) { 37 | value = "" + value + '' 38 | } else if (column.fieldname == 'actual_qty' && data && data.actual_qty < 0) { 39 | value = "" + value + '' 40 | } 41 | return value 42 | }, 43 | } 44 | -------------------------------------------------------------------------------- /beam/beam/report/handling_unit_traceability/handling_unit_traceability.json: -------------------------------------------------------------------------------- 1 | { 2 | "add_total_row": 0, 3 | "columns": [], 4 | "creation": "2023-07-14 08:07:09.997698", 5 | "disable_prepared_report": 0, 6 | "disabled": 0, 7 | "docstatus": 0, 8 | "doctype": "Report", 9 | "filters": [], 10 | "idx": 0, 11 | "is_standard": "Yes", 12 | "modified": "2023-07-14 08:07:09.997698", 13 | "modified_by": "Administrator", 14 | "module": "BEAM", 15 | "name": "Handling Unit Traceability", 16 | "owner": "Administrator", 17 | "prepared_report": 0, 18 | "ref_doctype": "Handling Unit", 19 | "report_name": "Handling Unit Traceability", 20 | "report_type": "Script Report", 21 | "roles": [ 22 | { 23 | "role": "System Manager" 24 | }, 25 | { 26 | "role": "Stock User" 27 | }, 28 | { 29 | "role": "Stock Manager" 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /beam/beam/report/handling_unit_traceability/handling_unit_traceability.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, AgriTheory and contributors 2 | # For license information, please see license.txt 3 | 4 | import frappe 5 | from frappe import _ 6 | from frappe.query_builder import DocType 7 | 8 | 9 | def get_stock_ledger_entries_hu(handling_unit): 10 | stock_ledger_entry = DocType("Stock Ledger Entry") 11 | return ( 12 | frappe.qb.from_(stock_ledger_entry) 13 | .select( 14 | stock_ledger_entry.name, 15 | stock_ledger_entry.creation, 16 | stock_ledger_entry.company, 17 | stock_ledger_entry.voucher_type, 18 | stock_ledger_entry.voucher_no, 19 | stock_ledger_entry.voucher_detail_no, 20 | stock_ledger_entry.handling_unit, 21 | stock_ledger_entry.warehouse, 22 | stock_ledger_entry.actual_qty, 23 | stock_ledger_entry.item_code, 24 | ) 25 | .where(stock_ledger_entry.handling_unit == handling_unit) 26 | .orderby("creation", frappe.qb.desc) 27 | ).run(as_dict=True) 28 | 29 | 30 | def execute(filters=None): 31 | if not any([filters.handling_unit, filters.delivery_note, filters.sales_invoice]): 32 | return 33 | 34 | data = [] 35 | work_orders = {} 36 | 37 | if filters.delivery_note: 38 | handling_units = frappe.get_all( 39 | "Delivery Note Item", filters={"parent": filters.delivery_note}, pluck="handling_unit" 40 | ) 41 | if filters.sales_invoice: 42 | handling_units = frappe.get_all( 43 | "Sales Invoice Item", filters={"parent": filters.sales_invoice}, pluck="handling_unit" 44 | ) 45 | if filters.handling_unit: 46 | handling_units = [filters.handling_unit] 47 | 48 | for handling_unit in handling_units: 49 | hu_results = [] 50 | 51 | for result in get_stock_ledger_entries_hu(handling_unit): 52 | hu_results.append(result) 53 | 54 | if result["voucher_type"] != "Stock Entry": 55 | continue 56 | 57 | previous_handling_units = frappe.get_all( 58 | "Stock Entry Detail", 59 | filters={"parent": result["voucher_no"], "handling_unit": ("not in", handling_unit)}, 60 | pluck="handling_unit", 61 | ) 62 | 63 | for previous_handling_unit in previous_handling_units: 64 | previous_stock_entries = get_stock_ledger_entries_hu(previous_handling_unit) 65 | hu_results += previous_stock_entries 66 | 67 | for previous_stock_entry in previous_stock_entries: 68 | if previous_stock_entry["voucher_type"] != "Stock Entry": 69 | continue 70 | 71 | work_order_name = frappe.db.get_value( 72 | previous_stock_entry["voucher_type"], previous_stock_entry["voucher_no"], "work_order" 73 | ) 74 | if work_order_name: 75 | work_order = frappe.get_doc("Work Order", work_order_name) 76 | if work_order.name not in work_orders: 77 | work_orders[work_order.name] = hu_results 78 | 79 | for work_order, rows in work_orders.items(): 80 | rows = sorted(rows, key=lambda r: r["creation"]) 81 | data.append({"indent": 0, "work_order": work_order}) 82 | 83 | for row in rows: 84 | row["indent"] = 1 85 | data.append(row) 86 | return get_columns(), data 87 | 88 | 89 | def get_columns(): 90 | return [ 91 | { 92 | "label": _("Work Order"), 93 | "fieldname": "work_order", 94 | "fieldtype": "Link", 95 | "options": "Work Order", 96 | "width": 220, 97 | }, 98 | {"label": _("Voucher Type"), "fieldname": "voucher_type", "fieldtype": "Data", "width": 150}, 99 | { 100 | "label": _("Voucher No"), 101 | "fieldname": "voucher_no", 102 | "fieldtype": "Dynamic Link", 103 | "options": "voucher_type", 104 | "width": 180, 105 | }, 106 | { 107 | "label": _("Item Code"), 108 | "fieldname": "item_code", 109 | "fieldtype": "Link", 110 | "options": "Item", 111 | "width": 120, 112 | }, 113 | { 114 | "label": _("Qty"), 115 | "fieldname": "actual_qty", 116 | "fieldtype": "Data", 117 | "width": 50, 118 | }, 119 | { 120 | "label": _("Warehouse"), 121 | "fieldname": "warehouse", 122 | "fieldtype": "Link", 123 | "options": "Warehouse", 124 | "width": 180, 125 | }, 126 | { 127 | "label": _("Handling Unit"), 128 | "fieldname": "handling_unit", 129 | "fieldtype": "Link", 130 | "options": "Handling Unit", 131 | "width": 200, 132 | }, 133 | ] 134 | -------------------------------------------------------------------------------- /beam/beam/scan/config.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | from beam.beam.scan import frm, listview 4 | 5 | 6 | @frappe.whitelist() 7 | @frappe.read_only() 8 | def get_scan_doctypes(): 9 | scannable_doctypes = set() 10 | listview_doctypes = set() 11 | frm_doctypes = set() 12 | hooked_listview = frappe.get_hooks("beam_listview") 13 | hooked_frm = frappe.get_hooks("beam_frm") 14 | beam_client = frappe.get_hooks("beam_client") 15 | 16 | for key, values in listview.items(): 17 | scannable_doctypes.add(key) 18 | [listview_doctypes.add(value) for value in values.keys()] 19 | 20 | if hooked_listview: 21 | for key, values in hooked_frm.items(): 22 | scannable_doctypes.add(key) 23 | [listview_doctypes.add(value) for value in values.keys()] 24 | 25 | for key, values in frm.items(): 26 | scannable_doctypes.add(key) 27 | [frm_doctypes.add(value) for value in values.keys()] 28 | 29 | if hooked_frm: 30 | for key, values in hooked_frm.items(): 31 | scannable_doctypes.add(key) 32 | [frm_doctypes.add(value) for value in values.keys()] 33 | 34 | return { 35 | "scannable_doctypes": list(scannable_doctypes), 36 | "listview": list(listview_doctypes), 37 | "frm": list(frm_doctypes), 38 | "client": beam_client, 39 | } 40 | -------------------------------------------------------------------------------- /beam/customize.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import frappe 5 | 6 | 7 | def load_customizations(): 8 | customizations_directory = Path().cwd().parent / "apps" / "beam" / "beam" / "beam" / "custom" 9 | files = list(customizations_directory.glob("**/*.json")) 10 | for file in files: 11 | customizations = json.loads(Path(file).read_text()) 12 | for field in customizations.get("custom_fields"): 13 | if field.get("module") != "BEAM": 14 | continue 15 | existing_field = frappe.get_value("Custom Field", field.get("name")) 16 | custom_field = ( 17 | frappe.get_doc("Custom Field", field.get("name")) 18 | if existing_field 19 | else frappe.new_doc("Custom Field") 20 | ) 21 | field.pop("modified") 22 | {custom_field.set(key, value) for key, value in field.items()} 23 | custom_field.flags.ignore_permissions = True 24 | custom_field.flags.ignore_version = True 25 | custom_field.save() 26 | for prop in customizations.get("property_setters"): 27 | if field.get("module") != "BEAM": 28 | continue 29 | property_setter = frappe.get_doc( 30 | { 31 | "name": prop.get("name"), 32 | "doctype": "Property Setter", 33 | "doctype_or_field": prop.get("doctype_or_field"), 34 | "doc_type": prop.get("doc_type"), 35 | "field_name": prop.get("field_name"), 36 | "property": prop.get("property"), 37 | "value": prop.get("value"), 38 | "property_type": prop.get("property_type"), 39 | } 40 | ) 41 | property_setter.flags.ignore_permissions = True 42 | property_setter.insert() 43 | -------------------------------------------------------------------------------- /beam/docs/assets/bom_scrap_item.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/docs/assets/bom_scrap_item.png -------------------------------------------------------------------------------- /beam/docs/assets/form_view_delivery_note.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/docs/assets/form_view_delivery_note.png -------------------------------------------------------------------------------- /beam/docs/assets/handling_unit_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/docs/assets/handling_unit_list.png -------------------------------------------------------------------------------- /beam/docs/assets/hu_trace_filters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/docs/assets/hu_trace_filters.png -------------------------------------------------------------------------------- /beam/docs/assets/hu_trace_report_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/docs/assets/hu_trace_report_output.png -------------------------------------------------------------------------------- /beam/docs/assets/listview_wh_navigation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/docs/assets/listview_wh_navigation.png -------------------------------------------------------------------------------- /beam/docs/assets/network_printer_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/docs/assets/network_printer_settings.png -------------------------------------------------------------------------------- /beam/docs/assets/print_hu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/docs/assets/print_hu.png -------------------------------------------------------------------------------- /beam/docs/assets/recombine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/docs/assets/recombine.png -------------------------------------------------------------------------------- /beam/docs/assets/select_printer_dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/docs/assets/select_printer_dialog.png -------------------------------------------------------------------------------- /beam/docs/assets/stock_ledger_after_receipt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/docs/assets/stock_ledger_after_receipt.png -------------------------------------------------------------------------------- /beam/docs/assets/stock_ledger_after_sale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/docs/assets/stock_ledger_after_sale.png -------------------------------------------------------------------------------- /beam/docs/assets/testing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/docs/assets/testing.png -------------------------------------------------------------------------------- /beam/docs/assets/warehouse_barcodes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/docs/assets/warehouse_barcodes.png -------------------------------------------------------------------------------- /beam/docs/form.md: -------------------------------------------------------------------------------- 1 | # Form 2 | 3 | The result of scanning a barcode in the form depends on several factors: 4 | 5 | - Is the barcode recognized? 6 | - What doctype is it associated with? 7 | 8 | For example, when an Item is scanned while viewing a Delivery Note record, it will add a row for that item if one doesn't exist, or increment the highest-indexed existing row with that Item's item_code in it. 9 | 10 | | Scanned Doctype | Form | Action | Target | 11 | |-----------------|-----------------------|--------|--------| 12 | |Item|Delivery Note|add_or_increment|item_code| 13 | 14 | Beam uses a [decision matrix](./matrix.md) to decide what action to take based on what kind of doctype has been scanned. 15 | 16 | Custom actions and client side functions can be added by using [hooks](./hooks.md). 17 | 18 | -------------------------------------------------------------------------------- /beam/docs/generate_matrix.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | from pathlib import Path 3 | 4 | 5 | def generate_matrix(): 6 | from beam.beam.scan import frm, listview 7 | 8 | output = "# Listview Actions\n" 9 | output += "| Scanned Doctype | Listview | Action | Target |\n" 10 | output += "|-----------------|-----------------------|--------|--------|\n" 11 | 12 | for doctype, listviews in listview.items(): 13 | for lv, actions in listviews.items(): 14 | for action in actions: 15 | output += f"|{doctype}|{lv}|{action.get('action')}|{action.get('field')}|\n" 16 | 17 | output += "\n --- \n\n" 18 | output += "# Form Actions\n" 19 | output += "| Scanned Doctype | Form | Action | Target |\n" 20 | output += "|-----------------|-----------------------|--------|--------|\n" 21 | 22 | for doctype, forms in frm.items(): 23 | for form, actions in forms.items(): 24 | for action in actions: 25 | output += f"|{doctype}|{form}|{action.get('action')}|{action.get('field')}|\n" 26 | 27 | filepath = Path(__file__).parent / "matrix.md" 28 | 29 | with filepath.open("w", encoding="utf-8") as f: 30 | f.write(output) 31 | -------------------------------------------------------------------------------- /beam/docs/handling_unit.md: -------------------------------------------------------------------------------- 1 | # Handling Unit 2 | 3 | A Handling Unit is an abstraction for tracking quantities of items that are moved or stored together. It does not replace Batch or Serial numbers, the manufacture of an Item, or the functionality of the Product Bundle, but can supplement these as a way of conveniently grabbing information that would otherwise require a lot of keystrokes to enter. 4 | 5 | By assigning a unique ID to the Handling Unit, it is possible to capture via scanner the item, net quantity, unit of measure and timestamp of the previous transaction, and then act upon that information in context, according to the [decision matrix](./matrix.md). Beam adds a new doctype, Handling Unit, to implement this functionality in ERPNext. 6 | 7 |  8 | 9 | ## Listviews 10 | Generally scanning a Handling Unit in a list view will filter to show all the transactions of the doctype with the appropriate Handling Unit. 11 | 12 | ## Purchase Receipt 13 | For Purchase Receipts, Handling Units are generated and cannot be supplied by the user. 14 | 15 | | Item | Warehouse | Handling Unit | Quantity | 16 | | ---------------- | ------------------ | -------------- | --------------:| 17 | | Cocoplum | Storeroom | 123 | 40 Ea | 18 | 19 | 20 | ## Purchase Invoice 21 | For Purchase Invoices with "Update Stock" checked, Handling Units are generated and cannot be supplied by the user. 22 | 23 | | Item | Warehouse | Handling Unit | Quantity | 24 | | ---------------- | ------------------ | -------------- | --------------:| 25 | | Cocoplum | Storeroom | 123 | 40 Ea | 26 | 27 | When "Update Stock" is _not_ checked, they can be scanned to facilitate data entry but there's no effect in the Stock Ledger. 28 | 29 | ## Delivery Note 30 | For Delivery Note, Handling Units are consumed. In the case where less than the total quantity associated with the Handling Unit is being delivered, the existing Handling Unit will refer to the remaining (net) quantity. 31 | 32 | | Item | Warehouse | Handling Unit | Quantity | 33 | | ---------------- | ------------------ | -------------- | --------------:| 34 | | Cocoplum | Storeroom | 123 | 20 Ea | 35 | 36 | Stock Ledger or subsequent transaction 37 | | Item | Warehouse | Handling Unit | Quantity | 38 | | ---------------- | ------------------ | -------------- | --------------:| 39 | | Cocoplum | Storeroom | 123 | 20 Ea | 40 | 41 | 42 | ## Sales Invoice 43 | For a Sales Invoice where "Update Stock" is checked, Handling Units are consumed. In the case where less than the total quantity associated with the Handling Unit is being delivered, the existing Handling Unit will refer to the remaining (net) quantity. 44 | 45 | | Item | Warehouse | Handling Unit | Quantity | 46 | | ---------------- | ------------------ | -------------- | --------------:| 47 | | Cocoplum | Storeroom | 123 | 15 Ea | 48 | 49 | Stock Ledger or subsequent transaction 50 | | Item | Warehouse | Handling Unit | Quantity | 51 | | ---------------- | ------------------ | -------------- | --------------:| 52 | | Cocoplum | Storeroom | 123 | 5 Ea | 53 | 54 | When "Update Stock" is _not_ checked, they can be scanned to facilitate data entry but there's no effect in the Stock Ledger. 55 | 56 | The following screen shot shows the stock ledger for the Cloudberry item. The first row shows the receipt of 60 pounds of the fruit via a Purchase Receipt, and the second row is after the sale of 25 pounds via a Sales Invoice that had 'update stock'. Note that both transactions reference the same Handling Unit. 57 | 58 |  59 | 60 | ## Stock Entry 61 | 62 | ### Send to Contractor, Material Transfer for Manufacture and Material Transfer 63 | When material is transferred from one warehouse to another, it will generate a new Handling Unit, even if the entire Handling Unit is being transferred. In a case where less than the total quantity associated with a Handling Unit is moved from one warehouse to another, a new Handling Unit is generated for the new units. Subsequent scans or lookups of the original Handling Unit (123) will return the remainder or net quantity. 64 | 65 | | Item | Warehouse | Handling Unit | Quantity | 66 | | ---------------- | ----------------------- | -------------- | --------------:| 67 | | Cocoplum | Storeroom | 123 | -40 Ea | 68 | | Cocoplum | Subcontractor Warehouse | 456 | 40 Ea | 69 | 70 | | Item | Warehouse | Handling Unit | Quantity | 71 | | ---------------- | ------------------ | -------------- | --------------:| 72 | | Cocoplum | Storeroom | 123 | -20 Ea | 73 | | Cocoplum | Work In Progress | 456 | 20 Ea | 74 | 75 | 76 | When cancelling a Stock Entry, the user will be given an option to re-combine or let handling units remain tracked separately. 77 | 78 |  79 | 80 | ### Repack and Manufacture 81 | 82 | In the case of a Repack, Material Issue or Material Consumption for Manufacture, a new Handling Unit is generated for the new quantities. 83 | 84 | | Item | Warehouse | Handling Unit | Quantity | 85 | | ---------------- | ------------------ | -------------- | --------------:| 86 | | Cocoplum | Storeroom | 123 | -40 Ea | 87 | | Cocoplum | Storeroom | 789 | 1 Box of 40 | 88 | 89 | 90 | In a case where less than the total quantity associated with a Handling Unit is consumed, subsequent scans or lookups of the original Handling Unit (123) will return the remainder or net quantity. 91 | 92 | | Item | Warehouse | Handling Unit | Quantity | 93 | | ---------------- | ------------------ | -------------- | --------------:| 94 | | Cocoplum | Storeroom | 123 | -20 Ea | 95 | | Cocoplum Puree | Work In Progress | 012 | 1 liter | 96 | | Cocoplum | Scrap | | 1 Ea | 97 | 98 | #### BOM Scrap Item 99 | In a Manufacturing or Repack Stock Entry, scrap items can be toggled to create a Handling Unit corresponding with their scrap quantity. This can be changed after a BOM is submitted. 100 | 101 |  102 | 103 | ### Material Issue, Material Consumption for Manufacture 104 | 105 | In both these cases, there is no offsetting movement or creation of items. 106 | 107 | | Item | Warehouse | Handling Unit | Quantity | 108 | | ---------------- | ------------------ | -------------- | --------------:| 109 | | Cocoplum | Storeroom | 123 | -20 Ea | 110 | 111 | 112 | | Item | Warehouse | Handling Unit | Quantity | 113 | | ---------------- | ------------------ | -------------- | --------------:| 114 | | Cocoplum | Work In Progress | 123 | -20 Ea | 115 | 116 | ### Material Receipt 117 | In the case of Material Receipt, a new Handling Unit is generated for each item. 118 | 119 | | Item | Warehouse | Handling Unit | Quantity | 120 | | ---------------- | ------------------ | -------------- | --------------:| 121 | | Cocoplum | Storeroom | 123 | 20 Ea | 122 | -------------------------------------------------------------------------------- /beam/docs/hooks.md: -------------------------------------------------------------------------------- 1 | # Extending Beam With Custom Hooks 2 | 3 | Beam can be extended by adding configurations to your application's `hooks.py`. 4 | 5 | To make scanning available on a custom doctype, add a table field for "Item Barcode" directly in the doctype or via customize form. Then add a key that is a peer with "Item" in the example below. 6 | 7 | To extend scanning functionality within a doctype, add a key that is a peer with "Delivery Note" in the example below. 8 | 9 | ```python 10 | # hooks.py 11 | 12 | beam_listview = { 13 | "Item": { 14 | "Delivery Note": [ 15 | {"action": "filter", "doctype": "Delivery Note Item", "field": "item_code"}, 16 | {"action": "filter", "doctype": "Packed Item", "field": "item_code"} 17 | ], 18 | } 19 | } 20 | 21 | beam_frm = { 22 | "Item": { 23 | "Delivery Note": [ 24 | { 25 | "action": "add_or_increment", 26 | "doctype": "Delivery Note Item", 27 | "field": "item_code", 28 | "target": "target.item_code", 29 | }, 30 | { 31 | "action": "add_or_increment", 32 | "doctype": "Delivery Note Item", 33 | "field": "uom", 34 | "target": "target.uom", 35 | }, 36 | ] 37 | } 38 | } 39 | ``` 40 | To add a custom JavaScript function, add the following hook to your application's `hooks.py`. An example implementation is available in the source code. 41 | 42 | ```python 43 | # hooks.py 44 | 45 | beam_client = { 46 | "show_message": "custom_app.show_message" 47 | } 48 | 49 | ``` -------------------------------------------------------------------------------- /beam/docs/hu_traceability_report.md: -------------------------------------------------------------------------------- 1 | # Handling Unit Traceability Report 2 | 3 | The Handling Unit Traceability report provides a simple interface to track a Handling Unit over its life cycle through your company's processes. Filters for the Handling Unit ID, Delivery Note name, and Sales Invoice name allow for fine-tuning of the report's results. 4 | 5 |  6 | 7 | The following example shows the report output for a Handling Unit associated with finished Kaduka Key Lime Pies. There are three work orders to create the pie - the one shown at the top of the report is for assembling and baking the pie itself, the other two partially or not shown are for making the pie crust or the pie filling. The report shows the Voucher Type and Number, Item Code, Quantity, Warehouse, and Handling Unit (all grouped by Work Order) for all transactions that impact the Stock Ledger that included the given Handling Unit. 8 | 9 |  10 | -------------------------------------------------------------------------------- /beam/docs/index.md: -------------------------------------------------------------------------------- 1 | # Beam 2 | 3 | Beam is a general purpose 2D barcode scanning application for ERPNext. 4 | 5 | ## What does this application do? 6 | 7 | Beam allows a user to scan a 2D barcode from either a listview or a form view, then helps enter data that would otherwise require numerous keystrokes. Unlike ERPNext's built-in barcode scanning, Beam expects the user to have a hardware barcode scanner connected to their device. 8 | 9 | For example, if the user scans a barcode associated with an Item in the Item listview, it will take them to that item's record. 10 | 11 | The following screen shot shows the outcome of simulating a scan from the browser's console using a barcode associated with the Stores warehouse. The browser automatically navigates to that warehouse page. 12 | 13 |  14 | 15 | Read more about [how scanning in listviews works](./listview.md). 16 | 17 | If the user scans an Item in a Delivery Note, it will populate everything it knows about that item, just as it would if they were to type in the item code. If they scan that item again, it will increment the last row with that item in it. The following screen shot shows a row for the Cloudberry item added in a new Delivery Note after simulating a scan from the browser's console of that item's Handling Unit barcode. 18 | 19 |  20 | 21 | Read more about [how scanning in form views works](./form.md). 22 | 23 | ## What is a Handling Unit? 24 | 25 | A Handling Unit is the combination of a container, any packaging material, and the items within or on it. This could be a pallet of raw materials used in a manufacturing process, a crate containing several other Handling Units, or a delivery vehicle transporting the crates and pallets. 26 | 27 | Handling Units have unique, scannable identification numbers that are used in any stock transaction involving the items contained within the unit. The ID allows the user to reference everything about the stock transaction, saved from previous transactions. It also enables you to track the Handling Unit throughout its life cycle. The Beam application includes a [Handling Unit Traceability report](./hu_traceability_report.md) to summarize the transactions, related documents, quantities, and warehouses that involved a given Handling Unit. 28 | 29 | A Handling Unit is generated when materials are received or created in the manufacturing process. 30 | 31 | Read more [about Handling Units here](./handling_unit.md). 32 | 33 | ## Installation and Customization 34 | 35 | Beam comes packed with features, but can be extended with custom hooks both on the server side and in the client as needed. See the following pages for detailed instructions on installing and customizing the application: 36 | 37 | - [Installation](https://github.com/agritheory/beam) 38 | - [Customization](./hooks.md) 39 | 40 | ## Warehouses 41 | 42 | Warehouses may also have unique barcodes associated with them. The user can navigate to a given warehouse, add a row to the Barcodes table, then manually enter the code, type, and optionally the UOM. 43 | 44 |  45 | 46 | ## Print Server Integration 47 | 48 | Beam offers the ability to print to raw input printers like Zebra printers directly from the browser. Also included are several debugging and example print formats. For more details about configuring this, see the [print server section](./print_server.md). 49 | 50 | ### Zebra Printing 51 | 52 | The app also includes Jinja utility functions to generate [ZPL](https://en.wikipedia.org/wiki/Zebra_Programming_Language) code and labels (using the [free Labelary API](https://labelary.com/)) against any document, which can then be used within a Print Format to preview the print output before sending it to your Zebra printer. For more details about using these utilities, see the [Zebra printing section](./zebra_printing.md). 53 | 54 | ## Roadmap and Planned Features 55 | 56 | Feature requests, support requests and bug reports can be made via [GitHub Issues](https://github.com/agritheory/beam/issues). 57 | 58 | To test the scanning functionality without actually having a hardware scanner, see the [testing section](./testing.md). 59 | -------------------------------------------------------------------------------- /beam/docs/listview.md: -------------------------------------------------------------------------------- 1 | # Listview 2 | 3 | The result of scanning a barcode in the listview depends on several factors: 4 | 5 | - Is the barcode recognized? 6 | - What doctype is it associated with? 7 | 8 | For example, when an Item is scanned while viewing the Item list, the user is routed to the record for that Item: 9 | 10 | | Scanned Doctype | Listview | Action | Target | 11 | |-----------------|-----------------------|--------|--------| 12 | |Item|Item|route|Item| 13 | 14 | 15 | Another example: If an Item is scanned while viewing the Purchase Receipt list, a filter is added that shows the Delivery Notes with those items: 16 | 17 | | Scanned Doctype | Listview | Action | Target | 18 | |-----------------|-----------------------|--------|--------| 19 | |Item|Purchase Receipt|filter|item_code| 20 | 21 | 22 | Beam uses a [decision matrix](./matrix.md) to decide what action to take based on what kind of doctype has been scanned. 23 | 24 | Custom actions and client side functions can be added by using [hooks](./hooks.md) 25 | 26 | -------------------------------------------------------------------------------- /beam/docs/matrix.md: -------------------------------------------------------------------------------- 1 | # Listview Actions 2 | | Scanned Doctype | Listview | Action | Target | 3 | |-----------------|-----------------------|--------|--------| 4 | |Handling Unit|Delivery Note|route|Delivery Note| 5 | |Handling Unit|Item|route|Item| 6 | |Handling Unit|Packing Slip|route|Packing Slip| 7 | |Handling Unit|Purchase Invoice|route|Purchase Invoice| 8 | |Handling Unit|Purchase Receipt|route|Purchase Receipt| 9 | |Handling Unit|Putaway Rule|filter|item_code| 10 | |Handling Unit|Quality Inspection|filter|handling_unit| 11 | |Handling Unit|Sales Invoice|route|Sales Invoice| 12 | |Handling Unit|Stock Entry|route|Stock Entry| 13 | |Handling Unit|Stock Reconciliation|route|Stock Reconciliation| 14 | |Item|Delivery Note|filter|item_code| 15 | |Item|Item|route|Item| 16 | |Item|Item Price|filter|item_code| 17 | |Item|Packing Slip|filter|item_code| 18 | |Item|Purchase Invoice|filter|item_code| 19 | |Item|Purchase Receipt|filter|item_code| 20 | |Item|Putaway Rule|filter|item_code| 21 | |Item|Quality Inspection|filter|item_code| 22 | |Item|Sales Invoice|filter|item_code| 23 | |Item|Stock Entry|filter|item_code| 24 | |Item|Stock Reconciliation|filter|item_code| 25 | |Item|Warranty Claim|filter|item_code| 26 | |Warehouse|Delivery Note|filter|warehouse| 27 | |Warehouse|Item|filter|default_warehouse| 28 | |Warehouse|Packing Slip|filter|warehouse| 29 | |Warehouse|Purchase Invoice|filter|warehouse| 30 | |Warehouse|Purchase Receipt|filter|warehouse| 31 | |Warehouse|Sales Invoice|filter|warehouse| 32 | |Warehouse|Stock Entry|filter|warehouse| 33 | |Warehouse|Stock Reconciliation|filter|warehouse| 34 | |Warehouse|Warehouse|route|Warehouse| 35 | 36 | --- 37 | 38 | # Form Actions 39 | | Scanned Doctype | Form | Action | Target | 40 | |-----------------|-----------------------|--------|--------| 41 | |Handling Unit|Delivery Note|add_or_associate|handling_unit| 42 | |Handling Unit|Delivery Note|add_or_associate|rate| 43 | |Handling Unit|Item Price|set_item_code_and_handling_unit|item_code| 44 | |Handling Unit|Packing Slip|add_or_associate|conversion_factor| 45 | |Handling Unit|Packing Slip|add_or_associate|handling_unit| 46 | |Handling Unit|Packing Slip|add_or_associate|pulled_quantity| 47 | |Handling Unit|Packing Slip|add_or_associate|rate| 48 | |Handling Unit|Packing Slip|add_or_associate|stock_qty| 49 | |Handling Unit|Packing Slip|add_or_associate|warehouse| 50 | |Handling Unit|Purchase Invoice|add_or_associate|handling_unit| 51 | |Handling Unit|Putaway Rule|set_item_code_and_handling_unit|item_code| 52 | |Handling Unit|Quality Inspection|set_item_code_and_handling_unit|item_code| 53 | |Handling Unit|Quality Inspection|set_item_code_and_handling_unit|handling_unit| 54 | |Handling Unit|Sales Invoice|add_or_associate|handling_unit| 55 | |Handling Unit|Stock Entry|add_or_associate|basic_rate| 56 | |Handling Unit|Stock Entry|add_or_associate|conversion_factor| 57 | |Handling Unit|Stock Entry|add_or_associate|handling_unit| 58 | |Handling Unit|Stock Entry|add_or_associate|s_warehouse| 59 | |Handling Unit|Stock Entry|add_or_associate|transfer_qty| 60 | |Handling Unit|Stock Reconciliation|add_or_associate|handling_unit| 61 | |Handling Unit|Warranty Claim|set_item_code_and_handling_unit|item_code| 62 | |Handling Unit|Warranty Claim|set_item_code_and_handling_unit|handling_unit| 63 | |Item|Delivery Note|add_or_increment|item_code| 64 | |Item|Item Price|set_item_code_and_handling_unit|item_code| 65 | |Item|Packing Slip|add_or_increment|item_code| 66 | |Item|Purchase Invoice|add_or_increment|item_code| 67 | |Item|Purchase Receipt|add_or_increment|item_code| 68 | |Item|Putaway Rule|set_item_code_and_handling_unit|item_code| 69 | |Item|Quality Inspection|set_item_code_and_handling_unit|item_code| 70 | |Item|Sales Invoice|add_or_increment|item_code| 71 | |Item|Stock Entry|add_or_increment|item_code| 72 | |Item|Stock Reconciliation|add_or_increment|item_code| 73 | |Item|Warranty Claim|set_item_code_and_handling_unit|item_code| 74 | |Warehouse|Delivery Note|set_warehouse|warehouse| 75 | |Warehouse|Purchase Invoice|set_warehouse|warehouse| 76 | |Warehouse|Purchase Receipt|set_warehouse|warehouse| 77 | |Warehouse|Sales Invoice|set_warehouse|warehouse| 78 | |Warehouse|Stock Entry|set_warehouse|warehouse| 79 | |Warehouse|Stock Reconciliation|set_warehouse|warehouse| 80 | -------------------------------------------------------------------------------- /beam/docs/print_server.md: -------------------------------------------------------------------------------- 1 | # Print Server 2 | 3 | There are several steps to get a print server connected in ERPNext. 4 | 5 | 1. First, the `pycups` dependency needs to be installed on the system, which in turn depends on the CUPS project's `libcups` library. See the following links for installation instructions: 6 | - [OpenPrinting CUPS installation and configuration instructions](https://github.com/OpenPrinting/cups/blob/master/INSTALL.md) 7 | - [PyCUPS dependencies, compiling, and installation information](https://github.com/OpenPrinting/pycups) 8 | 9 | 2. Next, the user must add their Zebra printer to the CUPS server. This can be done by following the official CUPS documentation, which can be found [here](https://supportcommunity.zebra.com/s/article/Adding-a-Zebra-Printer-in-a-CUPS-Printing-System). 10 | 11 | 3. The user must also create a new `Network Printer Settings` document and fill in the relevant information. 12 | 13 |  14 | 15 | A convenient Print Handling Unit button on relevant doctypes enables the user to print new Handling Unit labels directly from the ERPNext user interface. 16 | 17 |  18 | 19 | Any configured network printers will display as options in the Select Printer Setting dialog. 20 | 21 |  22 | -------------------------------------------------------------------------------- /beam/docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | ## Simulating a Scanner 4 | 5 | Open the browser console. This assumes a barcode of `'9968934975826708157'` which must be sent as a string. 6 | 7 | ```js 8 | window.scanHandler.scanner.simulate(document, '9968934975826708157') 9 | ``` 10 | 11 |  12 | 13 | ## About the Test Suite 14 | 15 | Coming soon 16 | -------------------------------------------------------------------------------- /beam/docs/zebra_printing.md: -------------------------------------------------------------------------------- 1 | # Zebra Printing 2 | 3 | To create a Zebra print format, you need the following documents: 4 | - A ZPL Print Format made against Doctype that may contain barcodes (Item, Warehouse, Handling Units, etc.) that uses the available Jinja utility functions to generate ZPL code. 5 | - A document Print Format that uses the free Labelary API to convert the above ZPL code and generate a preview of the print output for the linked document. 6 | 7 | ### ZPL Code Generation 8 | 9 | Currently, only three types of printable ZPL data can be generated with utilties within Beam: 10 | - `Text` 11 | - `Barcode` 12 | - `Label` 13 | 14 | Beam uses the [py-zebra-zpl](https://github.com/mtking2/py-zebra-zpl) library to generate the above types, as it provides a basic interface to create ZPL code using Python objects. Please refer to the library's documentation for more information on how to use it. 15 | 16 | **Note:** Additional ZPL elements (like graphic fields) and commands (text mirroring, character encoding, etc.) can be developed separately and added as text directly to the ZPL Print Format. For more information, visit the [official documentation page](https://supportcommunity.zebra.com/s/article/ZPL-Command-Information-and-DetailsV2?language=en_US) or the [Labelary ZPL Programming Guide](https://labelary.com/zpl.html). 17 | 18 | In addition, Beam exposes the following Jinja functions to be used within a Print Format: 19 | 20 | --- 21 | 22 | #### `barcode128` 23 | 24 | Generate a [Code 128](https://en.wikipedia.org/wiki/Code_128) barcode image. It takes the following arguments: 25 | 26 | - `barcode_text`: The text to be encoded in the barcode. Required. 27 | 28 | ##### Example 29 | ```jinja 30 | {{ barcode128(doc.barcodes[0].barcode) }} 31 | ``` 32 | 33 | --- 34 | 35 | #### `formatted_zpl_barcode` 36 | 37 | Generate a formatted ZPL barcode. It takes the following arguments: 38 | 39 | - `barcode_text`: The text to be encoded in the barcode. Required. 40 | 41 | ##### Example 42 | ```jinja 43 | {{ formatted_zpl_barcode(doc.barcodes[0].barcode) }} 44 | ``` 45 | 46 | --- 47 | 48 | #### `formatted_zpl_label` 49 | 50 | Generate a formatted ZPL label object. It takes the following arguments: 51 | 52 | - `width`: The width of the label in dots. Required. 53 | - This value is typically the width of the expected label multiplied by the printer's DPI value. 54 | - `height`: The height of the label in dots. Required. 55 | - This value is typically the height of the expected label multiplied by the printer's DPI value. 56 | - `dpi`: The dots-per-inch (DPI) value of the printer. Defaults to 203. 57 | - Visit the [official documentation](https://supportcommunity.zebra.com/s/article/000026166) to determine the DPI for your Zebra printer model. 58 | - `print_speed`: The print speed of the printer in inches per second (ips). Defaults to 2. 59 | - Slower print speeds typically yield better print quality. 60 | - Visit the [official documentation](https://supportcommunity.zebra.com/s/article/Setting-the-Print-Speed-via-ZPL) to determine the acceptable print speed values for your Zebra printer model. 61 | - `copies`: The number of copies to print. Defaults to 1. 62 | 63 | ##### Example 64 | ```jinja 65 | {% set label = formatted_zpl_label(width=6*203, height=4*203, dpi=203) %} 66 | {{ label.start }} 67 | 68 | {{ label.end }} 69 | ``` 70 | 71 | --- 72 | 73 | #### `formatted_zpl_text` 74 | 75 | Generate formatted ZPL text. It takes the following arguments: 76 | 77 | - `text`: The text to be printed. Required. 78 | - `width`: The width of the text in dots. 79 | 80 | ##### Example 81 | ```jinja 82 | {{ formatted_zpl_text('Hello, World!', 100) }} 83 | ``` 84 | 85 | --- 86 | 87 | #### `zebra_zpl_barcode` 88 | 89 | Generate a Zebra ZPL `Barcode` object. It takes the following arguments: 90 | 91 | - `data`: The text to be encoded in the barcode. Required. 92 | 93 | Additional arguments can be passed to the function to customize the barcode. Please refer to the [py-zebra-zpl documentation](https://github.com/mtking2/py-zebra-zpl#usage) for more information. 94 | 95 | ##### Example 96 | ```jinja 97 | {% set label = zebra_zpl_label(width=6*203, length=4*203, dpi=203) -%} 98 | {{ label.add(zebra_zpl_barcode(doc.barcodes[0].barcode)) }} 99 | {{ label.dump_contents() }} 100 | ``` 101 | 102 | --- 103 | 104 | #### `zebra_zpl_label` 105 | 106 | Generate a Zebra ZPL `Label` object. Arguments can be passed to the function to customize the label. Please refer to the [py-zebra-zpl documentation](https://github.com/mtking2/py-zebra-zpl#usage) for more information. 107 | 108 | ##### Example 109 | ```jinja 110 | {% set label = zebra_zpl_label(width=6*203, length=4*203, dpi=203) -%} 111 | {{ label.dump_contents() }} 112 | ``` 113 | 114 | --- 115 | 116 | #### `zebra_zpl_text` 117 | 118 | Generate a Zebra ZPL `Text` object. It takes the following arguments: 119 | 120 | - `data`: The text to be printed. Required. 121 | 122 | Additional arguments can be passed to the function to customize the text. Please refer to the [py-zebra-zpl documentation](https://github.com/mtking2/py-zebra-zpl#usage) for more information. 123 | 124 | ##### Example 125 | ```jinja 126 | {% set label = zebra_zpl_label(width=6*203, length=4*203, dpi=203) -%} 127 | {{ label.add(zebra_zpl_text('Hello, World!')) }} 128 | {{ label.dump_contents() }} 129 | ``` 130 | 131 | --- 132 | 133 | #### `labelary_api` 134 | 135 | Generate an encoded Zebra printing label via the free Labelary API. It takes the following arguments: 136 | 137 | - `doc`: The document to be printed. Required. 138 | - `print_format`: The ZPL Print Format to be used for generating the label. Required. 139 | - `settings`: Additional settings to be passed to the Labelary API. Allows setting up the following parameters: 140 | - `dpmm`: The desired print density, in dots per millimeter. Defaults to 8. 141 | - `width`: The desired label width, in inches. Defaults to 6. 142 | - `height`: The desired label height, in inches. Defaults to 4. 143 | - `index`: The label index (base 0). Some ZPL code will generate multiple labels, and this parameter can be used to access these different labels. Defaults to 0. 144 | 145 | ##### Example 146 | ```jinja 147 | 148 | ``` 149 | 150 | --- 151 | 152 | #### `get_handling_unit` 153 | 154 | Get the Handling Unit associated with the document. It takes the following arguments: 155 | 156 | - `handling_unit`: The Handling Unit to be associated with the document. Required. 157 | - `parent_doctype`: The parent document type for the Handling Unit. 158 | 159 | ##### Example 160 | ```jinja 161 | {% set handling_unit = get_handling_unit('HU-00001', 'Delivery Note') %} 162 | ``` 163 | 164 | --- 165 | 166 | #### `add_to_label` 167 | 168 | Add text, barcodes, and other printable elements to a ZPL label. It takes the following arguments: 169 | 170 | - `label`: The existing ZPL `Label` object to which the element should be added 171 | - `element`: The element to be added to the label. Can be an instance of the following types: 172 | - `Barcode` 173 | - `Text` 174 | 175 | ##### Example 176 | ```jinja 177 | {% set label = zebra_zpl_label(width=6*203, length=4*203, dpi=203) -%} 178 | {% set barcode = zebra_zpl_barcode(doc.barcodes[0].barcode) %} 179 | {% add_to_label(label, barcode) %} 180 | {{ label.dump_contents() }} 181 | ``` 182 | -------------------------------------------------------------------------------- /beam/hooks.py: -------------------------------------------------------------------------------- 1 | from . import __version__ as app_version 2 | 3 | app_name = "beam" 4 | app_title = "BEAM" 5 | app_publisher = "AgriTheory" 6 | app_description = "Barcode Scanning for ERPNext" 7 | app_email = "support@agritheory.dev" 8 | app_license = "MIT" 9 | 10 | # Includes in 11 | # ------------------ 12 | 13 | # include js, css files in header of desk.html 14 | # app_include_css = "/assets/beam/css/beam.css" 15 | app_include_js = ["beam.bundle.js"] 16 | 17 | # include js, css files in header of web template 18 | # web_include_css = "/assets/beam/css/beam.css" 19 | # web_include_js = "/assets/beam/js/beam.js" 20 | 21 | # include custom scss in every website theme (without file extension ".scss") 22 | # website_theme_scss = "beam/public/scss/website" 23 | 24 | # include js, css files in header of web form 25 | # webform_include_js = {"doctype": "public/js/doctype.js"} 26 | # webform_include_css = {"doctype": "public/css/doctype.css"} 27 | 28 | # include js in page 29 | # page_js = {"page" : "public/js/file.js"} 30 | 31 | # include js in doctype views 32 | doctype_js = {"Stock Entry": "public/js/stock_entry_custom.js"} 33 | # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} 34 | # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} 35 | # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} 36 | 37 | # Home Pages 38 | # ---------- 39 | 40 | # application home page (will override Website Settings) 41 | # home_page = "login" 42 | 43 | # website user home page (by Role) 44 | # role_home_page = { 45 | # "Role": "home_page" 46 | # } 47 | 48 | # Generators 49 | # ---------- 50 | 51 | # automatically create page for each record of this doctype 52 | # website_generators = ["Web Page"] 53 | 54 | # Jinja 55 | # ---------- 56 | 57 | jinja = { 58 | "methods": [ 59 | "beam.beam.barcodes.add_to_label", 60 | "beam.beam.barcodes.barcode128", 61 | "beam.beam.barcodes.formatted_zpl_barcode", 62 | "beam.beam.barcodes.formatted_zpl_label", 63 | "beam.beam.barcodes.formatted_zpl_text", 64 | "beam.beam.barcodes.zebra_zpl_barcode", 65 | "beam.beam.barcodes.zebra_zpl_label", 66 | "beam.beam.barcodes.zebra_zpl_text", 67 | "beam.beam.printing.labelary_api", 68 | "beam.beam.scan.get_handling_unit", 69 | ], 70 | } 71 | 72 | # Installation 73 | # ------------ 74 | 75 | # before_install = "beam.install.before_install" 76 | after_install = "beam.install.after_install" 77 | after_migrate = "beam.install.after_install" 78 | 79 | # Uninstallation 80 | # ------------ 81 | 82 | # before_uninstall = "beam.uninstall.before_uninstall" 83 | # after_uninstall = "beam.uninstall.after_uninstall" 84 | 85 | # Boot 86 | extend_bootinfo = "beam.beam.boot.boot_session" 87 | 88 | 89 | # Desk Notifications 90 | # ------------------ 91 | # See frappe.core.notifications.get_notification_config 92 | 93 | # notification_config = "beam.notifications.get_notification_config" 94 | 95 | # Permissions 96 | # ----------- 97 | # Permissions evaluated in scripted ways 98 | 99 | # permission_query_conditions = { 100 | # "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", 101 | # } 102 | # 103 | # has_permission = { 104 | # "Event": "frappe.desk.doctype.event.event.has_permission", 105 | # } 106 | 107 | # DocType Class 108 | # --------------- 109 | # Override standard doctype classes 110 | override_doctype_class = { 111 | "Stock Entry": "beam.beam.overrides.stock_entry.BEAMStockEntry", 112 | "Subcontracting Receipt": "beam.beam.overrides.subcontracting_receipt.BEAMSubcontractingReceipt", 113 | } 114 | 115 | 116 | # Document Events 117 | # --------------- 118 | # Hook on document methods and events 119 | 120 | doc_events = { 121 | "Item": { 122 | "validate": [ 123 | "beam.beam.barcodes.create_beam_barcode", 124 | ] 125 | }, 126 | "Warehouse": { 127 | "validate": [ 128 | "beam.beam.barcodes.create_beam_barcode", 129 | ] 130 | }, 131 | "Purchase Receipt": { 132 | "before_submit": [ 133 | "beam.beam.handling_unit.generate_handling_units", 134 | ], 135 | "validate": [ 136 | # "beam.beam.handling_unit.validate_handling_unit_overconsumption", 137 | ], 138 | }, 139 | "Purchase Invoice": { 140 | "before_submit": [ 141 | "beam.beam.handling_unit.generate_handling_units", 142 | ], 143 | }, 144 | "Stock Entry": { 145 | "validate": [ 146 | # "beam.beam.handling_unit.validate_handling_unit_overconsumption", 147 | ], 148 | "before_submit": [ 149 | "beam.beam.handling_unit.generate_handling_units", 150 | "beam.beam.overrides.stock_entry.validate_items_with_handling_unit", 151 | ], 152 | }, 153 | "Sales Invoice": { 154 | "validate": [ 155 | # "beam.beam.handling_unit.validate_handling_unit_overconsumption", 156 | ], 157 | }, 158 | "Delivery Note": { 159 | "validate": [ 160 | # "beam.beam.handling_unit.validate_handling_unit_overconsumption", 161 | ], 162 | }, 163 | "Subcontracting Receipt": { 164 | "before_submit": [ 165 | "beam.beam.handling_unit.generate_handling_units", 166 | ], 167 | }, 168 | } 169 | 170 | # Scheduled Tasks 171 | # --------------- 172 | 173 | # scheduler_events = { 174 | # "all": [ 175 | # "beam.tasks.all" 176 | # ], 177 | # "daily": [ 178 | # "beam.tasks.daily" 179 | # ], 180 | # "hourly": [ 181 | # "beam.tasks.hourly" 182 | # ], 183 | # "weekly": [ 184 | # "beam.tasks.weekly" 185 | # ], 186 | # "monthly": [ 187 | # "beam.tasks.monthly" 188 | # ], 189 | # } 190 | 191 | # Testing 192 | # ------- 193 | 194 | # before_tests = "beam.install.before_tests" 195 | 196 | # Overriding Methods 197 | # ------------------------------ 198 | # 199 | # override_whitelisted_methods = { 200 | # "frappe.desk.doctype.event.event.get_events": "beam.event.get_events" 201 | # } 202 | # 203 | # each overriding function accepts a `data` argument; 204 | # generated from the base implementation of the doctype dashboard, 205 | # along with any modifications made in other Frappe apps 206 | # override_doctype_dashboards = { 207 | # "Task": "beam.task.get_dashboard_data" 208 | # } 209 | 210 | # exempt linked doctypes from being automatically cancelled 211 | # 212 | # auto_cancel_exempted_doctypes = ["Auto Repeat"] 213 | 214 | # Ignore links to specified DocTypes when deleting documents 215 | # ----------------------------------------------------------- 216 | 217 | # ignore_links_on_delete = ["Communication", "ToDo"] 218 | 219 | # Request Events 220 | # ---------------- 221 | # before_request = ["beam.utils.before_request"] 222 | # after_request = ["beam.utils.after_request"] 223 | 224 | # Job Events 225 | # ---------- 226 | # before_job = ["beam.utils.before_job"] 227 | # after_job = ["beam.utils.after_job"] 228 | 229 | # User Data Protection 230 | # -------------------- 231 | 232 | # user_data_fields = [ 233 | # { 234 | # "doctype": "{doctype_1}", 235 | # "filter_by": "{filter_by}", 236 | # "redact_fields": ["{field_1}", "{field_2}"], 237 | # "partial": 1, 238 | # }, 239 | # { 240 | # "doctype": "{doctype_2}", 241 | # "filter_by": "{filter_by}", 242 | # "partial": 1, 243 | # }, 244 | # { 245 | # "doctype": "{doctype_3}", 246 | # "strict": False, 247 | # }, 248 | # { 249 | # "doctype": "{doctype_4}" 250 | # } 251 | # ] 252 | 253 | # Authentication and authorization 254 | # -------------------------------- 255 | 256 | # auth_hooks = [ 257 | # "beam.auth.validate" 258 | # ] 259 | -------------------------------------------------------------------------------- /beam/install.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | from beam.beam.scan.config import get_scan_doctypes 4 | 5 | 6 | def after_install(): 7 | print("Setting up Handling Unit Inventory Dimension") 8 | if frappe.db.exists("Inventory Dimension", "Handling Unit"): 9 | return 10 | huid = frappe.new_doc("Inventory Dimension") 11 | huid.dimension_name = "Handling Unit" 12 | huid.reference_document = "Handling Unit" 13 | huid.apply_to_all_doctypes = 1 14 | huid.save() 15 | 16 | # re-label 17 | for custom_field in frappe.get_all("Custom Field", {"label": "Source Handling Unit"}): 18 | frappe.set_value("Custom Field", custom_field, "label", "Handling Unit") 19 | 20 | # hide target fields 21 | for custom_field in frappe.get_all( 22 | "Custom Field", {"label": "Target Handling Unit"}, ["name", "dt"] 23 | ): 24 | if custom_field.dt == "Purchase Invoice Item": 25 | frappe.set_value("Custom Field", custom_field, "label", "Handling Unit") 26 | else: 27 | frappe.set_value("Custom Field", custom_field, "read_only", 1) 28 | frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1) 29 | 30 | frm_doctypes = get_scan_doctypes()["frm"] 31 | 32 | for custom_field in frappe.get_all("Custom Field", {"label": "Handling Unit"}, ["name", "dt"]): 33 | frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1) 34 | 35 | if ( 36 | custom_field["dt"] not in frm_doctypes 37 | and custom_field["dt"].replace(" Item", "").replace(" Detail", "") not in frm_doctypes 38 | ): 39 | frappe.set_value("Custom Field", custom_field["name"], "read_only", 1) 40 | frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1) 41 | -------------------------------------------------------------------------------- /beam/modules.txt: -------------------------------------------------------------------------------- 1 | BEAM -------------------------------------------------------------------------------- /beam/patches.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/patches.txt -------------------------------------------------------------------------------- /beam/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/public/.gitkeep -------------------------------------------------------------------------------- /beam/public/js/beam.bundle.js: -------------------------------------------------------------------------------- 1 | import './scan/scan.js' 2 | import './print/print.js' 3 | // import './example_custom_callback.js' 4 | -------------------------------------------------------------------------------- /beam/public/js/example_custom_callback.js: -------------------------------------------------------------------------------- 1 | frappe.provide('beam') 2 | 3 | beam.show_message = function () { 4 | frappe.msgprint('example callback') 5 | } 6 | -------------------------------------------------------------------------------- /beam/public/js/print/print.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on('Stock Entry', { 2 | refresh(frm) { 3 | custom_print_button(frm) 4 | }, 5 | }) 6 | frappe.ui.form.on('Stock Reconciliation', { 7 | refresh(frm) { 8 | custom_print_button(frm) 9 | }, 10 | }) 11 | frappe.ui.form.on('Purchase Invoice', { 12 | refresh(frm) { 13 | if (frm.doc.update_stock) { 14 | custom_print_button(frm) 15 | } 16 | }, 17 | }) 18 | frappe.ui.form.on('Purchase Receipt', { 19 | refresh(frm) { 20 | custom_print_button(frm) 21 | }, 22 | }) 23 | frappe.ui.form.on('Sales Invoice', { 24 | refresh(frm) { 25 | if (frm.doc.update_stock) { 26 | custom_print_button(frm) 27 | } 28 | }, 29 | }) 30 | frappe.ui.form.on('Delivery Note', { 31 | refresh(frm) { 32 | custom_print_button(frm) 33 | }, 34 | }) 35 | 36 | function custom_print_button(frm) { 37 | if (frm.doc.docstatus != 1) { 38 | return 39 | } 40 | frm.add_custom_button(__(' Print Handling Unit'), () => { 41 | let d = new frappe.ui.Dialog({ 42 | title: __('Select Printer Setting'), 43 | fields: [ 44 | { 45 | label: __('Printer Setting'), 46 | fieldname: 'printer_setting', 47 | fieldtype: 'Link', 48 | options: 'Network Printer Settings', 49 | }, 50 | { 51 | label: __('Printer Format'), 52 | fieldname: 'print_format', 53 | fieldtype: 'Link', 54 | options: 'Print Format', 55 | get_query: function () { 56 | return { 57 | filters: { doc_type: 'Handling Unit' }, 58 | } 59 | }, 60 | }, 61 | ], 62 | primary_action_label: 'Select', 63 | primary_action(selection) { 64 | d.hide() 65 | frappe.call({ 66 | method: 'beam.beam.printing.print_handling_units', 67 | args: { 68 | doctype: frm.doc.doctype, 69 | name: frm.doc.name, 70 | printer_setting: selection.printer_setting, 71 | print_format: selection.print_format, 72 | doc: frm.doc, 73 | }, 74 | }) 75 | }, 76 | }) 77 | d.show() 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /beam/public/js/scan/scan.js: -------------------------------------------------------------------------------- 1 | import onScan from 'onscan.js' 2 | 3 | function waitForElement(selector) { 4 | return new Promise(resolve => { 5 | if (document.querySelector(selector)) { 6 | return resolve(document.querySelector(selector)) 7 | } 8 | const observer = new MutationObserver(mutations => { 9 | if (document.querySelector(selector)) { 10 | resolve(document.querySelector(selector)) 11 | observer.disconnect() 12 | } 13 | }) 14 | observer.observe(document.body, { 15 | childList: true, 16 | subtree: true, 17 | }) 18 | }) 19 | } 20 | 21 | waitForElement('[data-route]').then(element => { 22 | let observer = new MutationObserver(() => { 23 | new ScanHandler() 24 | }) 25 | const config = { attributes: true, childList: false, characterData: true } 26 | observer.observe(element, config) 27 | }) 28 | 29 | class ScanHandler { 30 | constructor() { 31 | let me = this 32 | if ( 33 | !window.hasOwnProperty('scanHandler') || 34 | !window.scanHandler.hasOwnProperty('scanner') || 35 | !window.scanHandler.scanner.isAttachedTo(document) 36 | ) { 37 | me.scanner = onScan.attachTo(document, { 38 | onScan: async function (sCode, iQty) { 39 | await me.get_scanned_context(sCode, iQty) 40 | }, 41 | ignoreIfFocusOn: '.frappe-input', 42 | }) 43 | window.scanHandler = me 44 | } 45 | } 46 | reduceContext() { 47 | if (!frappe.boot.beam_doctypes) { 48 | frappe.xcall('beam.beam.scan.config.get_scan_doctypes').then(r => { 49 | frappe.boot.beam = r 50 | }) 51 | } 52 | const route = frappe.get_route() 53 | if (route[0] == 'List' && frappe.boot.beam.listview.includes(route[1])) { 54 | return { 55 | listview: route[1], 56 | } 57 | } else if (route[0] == 'Form' && frappe.boot.beam.frm.includes(route[1])) { 58 | return { 59 | frm: route[1], 60 | doc: cur_frm.doc, 61 | } 62 | } 63 | } 64 | async get_scanned_context(sCode, iQty) { 65 | return new Promise(resolve => { 66 | const context = this.reduceContext() 67 | frappe.xcall('beam.beam.scan.scan', { barcode: sCode, context: context, current_qty: iQty }).then(r => { 68 | if (r && r.length) { 69 | if (Object.keys(frappe.boot.beam.client).includes(r[0].action)) { 70 | let path = frappe.boot.beam.client[r[0].action][0] 71 | resolve(path.split('.').reduce((o, i) => o[i], window)(r)) // calls (first) custom built callback registered in hooks 72 | } else { 73 | resolve(this[String(r[0].action)](r)) // TODO: this only calls the first function 74 | } 75 | } 76 | // TODO: else error 77 | }) 78 | }) 79 | } 80 | route(barcode_context) { 81 | frappe.set_route('Form', barcode_context[0].field, barcode_context[0].target) 82 | } 83 | filter(barcode_context) { 84 | const filters_to_apply = barcode_context.map(filterset => { 85 | window.fltr.add_filter(filterset.doctype, filterset.field, '=', filterset.target) 86 | }) 87 | Promise.all(filters_to_apply).then(() => { 88 | window.fltr.apply() 89 | }) 90 | } 91 | add_or_associate(barcode_context) { 92 | if (barcode_context.length < 1) { 93 | return 94 | } 95 | barcode_context.forEach(field => { 96 | if ( 97 | !cur_frm.doc.items.some(row => { 98 | if ( 99 | cur_frm.doc.doctype == 'Stock Entry' && 100 | [ 101 | 'Send to Subcontractor', 102 | 'Material Transfer for Manufacture', 103 | 'Material Transfer', 104 | 'Material Receipt', 105 | 'Manufacture', 106 | ].includes(cur_frm.doc.stock_entry_type) 107 | ) { 108 | return row.item_code == field.context.item_code || row.handling_unit 109 | } 110 | return ( 111 | (row.item_code == field.context.item_code && row.stock_qty == field.context.stock_qty) || 112 | row.handling_unit == field.context.handling_unit 113 | ) 114 | }) 115 | ) { 116 | if (!cur_frm.doc.items.length || !cur_frm.doc.items[0].item_code) { 117 | cur_frm.doc.items = [] 118 | } 119 | let child = cur_frm.add_child('items', field.context) 120 | if (cur_frm.doc.doctype == 'Stock Entry') { 121 | frappe.model.set_value(child.doctype, child.name, 's_warehouse', field.context.warehouse) 122 | } 123 | } else { 124 | for (let row of cur_frm.doc.items) { 125 | if ( 126 | cur_frm.doc.doctype == 'Stock Entry' && 127 | [ 128 | 'Send to Subcontractor', 129 | 'Material Transfer for Manufacture', 130 | 'Material Transfer', 131 | 'Material Receipt', 132 | 'Manufacture', 133 | ].includes(cur_frm.doc.stock_entry_type) && 134 | row.item_code == field.context.item_code && 135 | !row.handling_unit 136 | ) { 137 | frappe.model.set_value(row.doctype, row.name, field.field, field.target) 138 | continue 139 | } 140 | if ( 141 | (row.item_code == field.context.item_code && row.stock_qty == field.context.stock_qty) || 142 | row.handling_unit == field.context.handling_unit 143 | ) { 144 | if (cur_frm.doc.doctype == 'Stock Entry') { 145 | if (field.field == 'basic_rate') { 146 | cur_frm.events.set_basic_rate(cur_frm, row.doctype, row.name) 147 | } else { 148 | frappe.model.set_value(row.doctype, row.name, field.field, field.target) 149 | } 150 | } 151 | } 152 | } 153 | } 154 | }) 155 | cur_frm.refresh_field('items') 156 | } 157 | set_warehouse(barcode_context) { 158 | if (barcode_context.length > 0) { 159 | barcode_context = barcode_context[0] 160 | } else { 161 | return 162 | } 163 | const source_warehouses = ['Material Consumption for Manufacture', 'Material Issue'] 164 | const target_warehouses = ['Material Receipt', 'Manufacture'] 165 | const both_warehouses = [ 166 | 'Material Transfer for Manufacture', 167 | 'Material Transfer', 168 | 'Send to Subcontractor', 169 | 'Repack', 170 | ] 171 | if (barcode_context.doctype == 'Stock Entry' && source_warehouses.includes(cur_frm.doc.stock_entry_type)) { 172 | cur_frm.set_value('from_warehouse', barcode_context.target) 173 | for (let row of cur_frm.doc.items) { 174 | frappe.model.set_value(row.doctype, row.name, 's_warehouse', barcode_context.target) 175 | } 176 | } else if (barcode_context.doctype == 'Stock Entry' && target_warehouses.includes(cur_frm.doc.stock_entry_type)) { 177 | cur_frm.set_value('to_warehouse', barcode_context.target) 178 | for (let row of cur_frm.doc.items) { 179 | frappe.model.set_value(row.doctype, row.name, 't_warehouse', barcode_context.target) 180 | } 181 | } else if (barcode_context.doctype == 'Stock Entry' && both_warehouses.includes(cur_frm.doc.stock_entry_type)) { 182 | cur_frm.set_value('from_warehouse', barcode_context.target) 183 | cur_frm.set_value('to_warehouse', barcode_context.target) 184 | for (let row of cur_frm.doc.items) { 185 | frappe.model.set_value(row.doctype, row.name, 's_warehouse', barcode_context.target) 186 | frappe.model.set_value(row.doctype, row.name, 't_warehouse', barcode_context.target) 187 | } 188 | } 189 | } 190 | add_or_increment(barcode_context) { 191 | if (barcode_context.length > 0) { 192 | barcode_context = barcode_context[0] 193 | } else { 194 | return 195 | } 196 | // if not item code, add row 197 | // else find last row with item code and increment 198 | if ( 199 | !cur_frm.doc.items.some(row => { 200 | return ( 201 | (row.item_code == barcode_context.context.item_code && !row.handling_unit) || 202 | row.barcode == barcode_context.context.barcode 203 | ) 204 | }) 205 | ) { 206 | if (!cur_frm.doc.items.length || !cur_frm.doc.items[0].item_code) { 207 | cur_frm.doc.items = [] 208 | } 209 | const row = cur_frm.add_child('items', barcode_context.context) 210 | // a first-time scan of an item in Stock Entry does not automatically set the rate, so run it manually 211 | if (cur_frm.doc.doctype == 'Stock Entry') { 212 | cur_frm.events.set_basic_rate(cur_frm, row.doctype, row.name) 213 | } 214 | } else { 215 | for (let row of cur_frm.doc.items) { 216 | if ( 217 | (row.item_code == barcode_context.context.item_code && !row.handling_unit) || 218 | row.barcode == barcode_context.context.barcode 219 | ) { 220 | frappe.model.set_value(row.doctype, row.name, 'qty', row.qty + 1) 221 | } 222 | } 223 | } 224 | cur_frm.refresh_field('items') 225 | } 226 | set_item_code_and_handling_unit(barcode_context) { 227 | barcode_context.forEach(action => { 228 | cur_frm.set_value(action.field, action.target) 229 | }) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /beam/public/js/stock_entry_custom.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on('Stock Entry', { 2 | async before_cancel(frm) { 3 | await set_recombine_handling_units(frm) 4 | }, 5 | setup: function (frm) { 6 | frm.set_query('handling_unit', 'items', function (doc, cdt, cdn) { 7 | let row = locals[cdt][cdn] 8 | if (!row.item_code) { 9 | return 10 | } 11 | return { 12 | query: 'beam.beam.overrides.stock_entry.get_handling_units_for_item_code', 13 | filters: { 14 | item_code: row.item_code, 15 | }, 16 | } 17 | }) 18 | }, 19 | }) 20 | 21 | async function show_handling_unit_recombine_dialog(frm) { 22 | const data = await get_handling_units(frm) 23 | if (!data) { 24 | return resolve({}) 25 | } 26 | let fields = [ 27 | { 28 | fieldtype: 'Data', 29 | fieldname: 'row_name', 30 | in_list_view: 0, 31 | read_only: 1, 32 | disabled: 0, 33 | hidden: 1, 34 | }, 35 | { 36 | fieldtype: 'Link', 37 | fieldname: 'item_code', 38 | options: 'Item', 39 | in_list_view: 1, 40 | read_only: 1, 41 | disabled: 0, 42 | label: __('Item Code'), 43 | }, 44 | { 45 | fieldtype: 'Data', 46 | fieldname: 'item_name', 47 | in_list_view: 0, 48 | disabled: 0, 49 | hidden: 1, 50 | }, 51 | { 52 | fieldtype: 'Data', 53 | fieldname: 'handling_unit', 54 | label: __('Handling Unit'), 55 | in_list_view: 1, 56 | read_only: 1, 57 | }, 58 | { 59 | fieldtype: 'Float', 60 | fieldname: 'remaining_qty', 61 | label: __('Remaining Qty'), 62 | in_list_view: 1, 63 | read_only: 1, 64 | }, 65 | { 66 | fieldtype: 'Data', 67 | fieldname: 'to_handling_unit', 68 | label: __('Handling Unit to recombine'), 69 | in_list_view: 1, 70 | read_only: 1, 71 | }, 72 | { 73 | fieldtype: 'Float', 74 | fieldname: 'transferred_qty', 75 | label: __('Transferred Qty'), 76 | in_list_view: 1, 77 | read_only: 1, 78 | }, 79 | ] 80 | 81 | return new Promise(resolve => { 82 | let dialog = new frappe.ui.Dialog({ 83 | title: __('Please select Handling Units to re-combine'), 84 | fields: [ 85 | { 86 | fieldname: 'handling_units', 87 | fieldtype: 'Table', 88 | in_place_edit: false, 89 | editable_grid: false, 90 | cannot_add_rows: true, 91 | cannot_delete_rows: true, 92 | reqd: 1, 93 | data: data, 94 | get_data: () => { 95 | return data 96 | }, 97 | fields: fields, 98 | description: __( 99 | 'Please select Handling Units to re-combine. Unselected Handling Units will be returned to inventory with their new quantities and Handling Units' 100 | ), 101 | }, 102 | ], 103 | primary_action: () => { 104 | let to_recombine = dialog.fields_dict.handling_units.grid.get_selected_children().map(row => { 105 | return row.row_name 106 | }) 107 | dialog.hide() 108 | return resolve(to_recombine) 109 | }, 110 | primary_action_label: __('Cancel and Recombine'), 111 | size: 'extra-large', 112 | }) 113 | dialog.show() 114 | dialog.get_close_btn() 115 | }) 116 | } 117 | 118 | async function get_handling_units(frm) { 119 | let handling_units = [] 120 | for (const row of frm.doc.items) { 121 | if (row.handling_unit && row.to_handling_unit) { 122 | let remaining_qty = await get_handling_unit_stock_qty(frm.doc.name, row.handling_unit, row.s_warehouse) 123 | handling_units.push({ 124 | row_name: row.name, 125 | item_code: row.item_code, 126 | item_name: row.item_name, 127 | handling_unit: row.handling_unit, 128 | to_handling_unit: row.to_handling_unit, 129 | remaining_qty: remaining_qty, 130 | transferred_qty: row.qty, 131 | }) 132 | } 133 | } 134 | return handling_units 135 | } 136 | async function get_handling_unit_stock_qty(name, handling_unit, s_warehouse) { 137 | let result = await frappe.xcall('beam.beam.overrides.stock_entry.get_handling_unit_qty', { 138 | voucher_no: name, 139 | handling_unit: handling_unit, 140 | warehouse: s_warehouse, 141 | }) 142 | return flt(result) 143 | } 144 | 145 | //re combine 146 | async function set_recombine_handling_units(frm) { 147 | let to_recombine = await show_handling_unit_recombine_dialog(frm) 148 | await frappe.xcall('beam.beam.overrides.stock_entry.set_rows_to_recombine', { 149 | docname: frm.doc.name, 150 | to_recombine: to_recombine, 151 | }) 152 | } 153 | -------------------------------------------------------------------------------- /beam/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from unittest.mock import MagicMock 3 | 4 | import frappe 5 | import pytest 6 | from frappe.utils import get_bench_path 7 | 8 | 9 | def _get_logger(*args, **kwargs): 10 | from frappe.utils.logger import get_logger 11 | 12 | return get_logger( 13 | module=None, 14 | with_more_info=False, 15 | allow_site=True, 16 | filter=None, 17 | max_size=100_000, 18 | file_count=20, 19 | stream_only=True, 20 | ) 21 | 22 | 23 | @pytest.fixture(scope="module") 24 | def monkeymodule(): 25 | with pytest.MonkeyPatch.context() as mp: 26 | yield mp 27 | 28 | 29 | @pytest.fixture(scope="session", autouse=True) 30 | def db_instance(): 31 | frappe.logger = _get_logger 32 | 33 | currentsite = "test_site" 34 | sites = Path(get_bench_path()) / "sites" 35 | if (sites / "currentsite.txt").is_file(): 36 | currentsite = (sites / "currentsite.txt").read_text() 37 | 38 | frappe.init(site=currentsite, sites_path=sites) 39 | frappe.connect() 40 | frappe.db.commit = MagicMock() 41 | yield frappe.db 42 | -------------------------------------------------------------------------------- /beam/tests/setup.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import types 3 | from itertools import groupby 4 | 5 | import frappe 6 | from erpnext.accounts.doctype.account.account import update_account_number 7 | from erpnext.manufacturing.doctype.production_plan.production_plan import ( 8 | get_items_for_material_requests, 9 | ) 10 | from erpnext.setup.utils import enable_all_roles_and_domains, set_defaults_for_tests 11 | from erpnext.stock.get_item_details import get_item_details 12 | from frappe.desk.page.setup_wizard.setup_wizard import setup_complete 13 | 14 | from beam.tests.fixtures import boms, customers, items, operations, suppliers, workstations 15 | 16 | 17 | def before_test(): 18 | frappe.clear_cache() 19 | today = frappe.utils.getdate() 20 | setup_complete( 21 | { 22 | "currency": "USD", 23 | "full_name": "Administrator", 24 | "company_name": "Ambrosia Pie Company", 25 | "timezone": "America/New_York", 26 | "company_abbr": "APC", 27 | "domains": ["Distribution"], 28 | "country": "United States", 29 | "fy_start_date": today.replace(month=1, day=1).isoformat(), 30 | "fy_end_date": today.replace(month=12, day=31).isoformat(), 31 | "language": "english", 32 | "company_tagline": "Ambrosia Pie Company", 33 | "email": "support@agritheory.dev", 34 | "password": "admin", 35 | "chart_of_accounts": "Standard with Numbers", 36 | "bank_account": "Primary Checking", 37 | } 38 | ) 39 | enable_all_roles_and_domains() 40 | set_defaults_for_tests() 41 | frappe.db.commit() 42 | create_test_data() 43 | for modu in frappe.get_all("Module Onboarding"): 44 | frappe.db.set_value("Module Onboarding", modu, "is_complete", 1) 45 | frappe.set_value("Website Settings", "Website Settings", "home_page", "login") 46 | frappe.db.commit() 47 | 48 | 49 | def create_test_data(): 50 | settings = frappe._dict( 51 | { 52 | "day": datetime.date( 53 | int(frappe.defaults.get_defaults().get("fiscal_year", datetime.datetime.now().year)), 1, 1 54 | ), 55 | "company": frappe.defaults.get_defaults().get("company"), 56 | "company_account": frappe.get_value( 57 | "Account", 58 | { 59 | "account_type": "Bank", 60 | "company": frappe.defaults.get_defaults().get("company"), 61 | "is_group": 0, 62 | }, 63 | ), 64 | } 65 | ) 66 | company_address = frappe.new_doc("Address") 67 | company_address.title = settings.company 68 | company_address.address_type = "Office" 69 | company_address.address_line1 = "67C Sweeny Street" 70 | company_address.city = "Chelsea" 71 | company_address.state = "MA" 72 | company_address.pincode = "89077" 73 | company_address.is_your_company_address = 1 74 | company_address.append("links", {"link_doctype": "Company", "link_name": settings.company}) 75 | company_address.save() 76 | frappe.set_value("Company", settings.company, "tax_id", "04-1871930") 77 | create_warehouses(settings) 78 | setup_manufacturing_settings(settings) 79 | create_workstations() 80 | create_operations() 81 | create_item_groups(settings) 82 | create_suppliers(settings) 83 | create_customers(settings) 84 | create_items(settings) 85 | create_boms(settings) 86 | prod_plan_from_doc = "Sales Order" 87 | if prod_plan_from_doc == "Sales Order": 88 | create_sales_order(settings) 89 | else: 90 | create_material_request(settings) 91 | create_production_plan(settings, prod_plan_from_doc) 92 | create_purchase_receipt_for_received_qty_test(settings) 93 | create_network_printer_settings(settings) 94 | 95 | 96 | def create_suppliers(settings): 97 | if not frappe.db.exists("Supplier Group", "Bakery"): 98 | bsg = frappe.new_doc("Supplier Group") 99 | bsg.supplier_group_name = "Bakery" 100 | bsg.parent_supplier_group = "All Supplier Groups" 101 | bsg.save() 102 | 103 | addresses = frappe._dict({}) 104 | for supplier in suppliers: 105 | biz = frappe.new_doc("Supplier") 106 | biz.supplier_name = supplier[0] 107 | biz.supplier_group = "Bakery" 108 | biz.country = "United States" 109 | biz.supplier_default_mode_of_payment = supplier[2] 110 | if biz.supplier_default_mode_of_payment == "ACH/EFT": 111 | biz.bank = "Local Bank" 112 | biz.bank_account = "123456789" 113 | biz.currency = "USD" 114 | biz.default_price_list = "Standard Buying" 115 | biz.save() 116 | 117 | existing_address = frappe.get_value("Address", {"address_line1": supplier[5]["address_line1"]}) 118 | if not existing_address: 119 | addr = frappe.new_doc("Address") 120 | addr.address_title = f"{supplier[0]} - {supplier[5]['city']}" 121 | addr.address_type = "Billing" 122 | addr.address_line1 = supplier[5]["address_line1"] 123 | addr.city = supplier[5]["city"] 124 | addr.state = supplier[5]["state"] 125 | addr.country = supplier[5]["country"] 126 | addr.pincode = supplier[5]["pincode"] 127 | else: 128 | addr = frappe.get_doc("Address", existing_address) 129 | addr.append("links", {"link_doctype": "Supplier", "link_name": supplier[0]}) 130 | addr.save() 131 | 132 | 133 | def create_customers(settings): 134 | for customer_name in customers: 135 | customer = frappe.new_doc("Customer") 136 | customer.customer_name = customer_name 137 | customer.customer_group = "Commercial" 138 | customer.customer_type = "Company" 139 | customer.territory = "United States" 140 | customer.save() 141 | 142 | 143 | def setup_manufacturing_settings(settings): 144 | mfg_settings = frappe.get_doc("Manufacturing Settings", "Manufacturing Settings") 145 | mfg_settings.material_consumption = 1 146 | mfg_settings.default_wip_warehouse = "Kitchen - APC" 147 | mfg_settings.default_fg_warehouse = "Baked Goods - APC" 148 | mfg_settings.overproduction_percentage_for_work_order = 5.00 149 | mfg_settings.job_Card_excess_transfer = 1 150 | mfg_settings.save() 151 | 152 | if frappe.db.exists("Account", {"account_name": "Work In Progress", "company": settings.company}): 153 | return 154 | wip = frappe.new_doc("Account") 155 | wip.account_name = "Work in Progress" 156 | wip.parent_account = "1400 - Stock Assets - APC" 157 | wip.account_number = "1420" 158 | wip.company = settings.company 159 | wip.currency = "USD" 160 | wip.report_type = "Balance Sheet" 161 | wip.root_type = "Asset" 162 | wip.save() 163 | 164 | if frappe.db.exists("Account", {"account_name": "Work In Progress", "company": settings.company}): 165 | return 166 | wip = frappe.new_doc("Account") 167 | wip.account_name = "Standard Costing Reconciliation" 168 | wip.parent_account = "1400 - Stock Assets - APC" 169 | wip.account_number = "1430" 170 | wip.company = settings.company 171 | wip.currency = "USD" 172 | wip.report_type = "Balance Sheet" 173 | wip.root_type = "Asset" 174 | wip.save() 175 | 176 | frappe.set_value("Warehouse", "Kitchen - APC", "account", wip.name) 177 | 178 | 179 | def create_workstations(): 180 | for ws in workstations: 181 | if frappe.db.exists("Workstation", ws[0]): 182 | continue 183 | work = frappe.new_doc("Workstation") 184 | work.workstation_name = ws[0] 185 | work.production_capacity = ws[1] 186 | work.save() 187 | 188 | 189 | def create_operations(): 190 | for op in operations: 191 | if frappe.db.exists("Operation", op[0]): 192 | continue 193 | oper = frappe.new_doc("Operation") 194 | oper.name = op[0] 195 | oper.workstation = op[1] 196 | oper.batch_size = op[2] 197 | oper.description = op[3] 198 | oper.save() 199 | 200 | 201 | def create_item_groups(settings): 202 | for ig_name in ( 203 | "Baked Goods", 204 | "Bakery Supplies", 205 | "Ingredients", 206 | "Bakery Equipment", 207 | "Sub Assemblies", 208 | ): 209 | if frappe.db.exists("Item Group", ig_name): 210 | continue 211 | ig = frappe.new_doc("Item Group") 212 | ig.item_group_name = ig_name 213 | ig.parent_item_group = "All Item Groups" 214 | ig.save() 215 | 216 | 217 | def create_items(settings): 218 | if not frappe.db.exists("Price List", "Bakery Buying"): 219 | pl = frappe.new_doc("Price List") 220 | pl.price_list_name = "Bakery Buying" 221 | pl.buying = 1 222 | pl.append("countries", {"country": "United States"}) 223 | pl.save() 224 | 225 | if not frappe.db.exists("Price List", "Bakery Wholesale"): 226 | pl = frappe.new_doc("Price List") 227 | pl.price_list_name = "Bakery Wholesale" 228 | pl.selling = 1 229 | pl.append("countries", {"country": "United States"}) 230 | pl.save() 231 | 232 | if not frappe.db.exists("Pricing Rule", "Bakery Retail"): 233 | pr = frappe.new_doc("Pricing Rule") 234 | pr.title = "Bakery Retail" 235 | pr.selling = 1 236 | pr.apply_on = "Item Group" 237 | pr.company = settings.company 238 | pr.margin_type = "Percentage" 239 | pr.margin_rate_or_amount = 2.00 240 | pr.valid_from = settings.day 241 | pr.for_price_list = "Bakery Wholesale" 242 | pr.append("item_groups", {"item_group": "Baked Goods"}) 243 | pr.save() 244 | 245 | for item in items: 246 | if frappe.db.exists("Item", item.get("item_code")): 247 | continue 248 | i = frappe.new_doc("Item") 249 | i.item_code = i.item_name = item.get("item_code") 250 | i.item_group = item.get("item_group") 251 | i.stock_uom = item.get("uom") 252 | i.description = item.get("description") 253 | i.maintain_stock = 1 254 | i.enable_handling_unit = 0 if i.item_code in ("Water", "Ice Water") else 1 255 | i.include_item_in_manufacturing = 1 256 | i.default_warehouse = settings.get("warehouse") 257 | i.default_material_request_type = ( 258 | "Purchase" if item.get("item_group") in ("Bakery Supplies", "Ingredients") else "Manufacture" 259 | ) 260 | i.valuation_method = "FIFO" 261 | i.is_purchase_item = 1 if item.get("item_group") in ("Bakery Supplies", "Ingredients") else 0 262 | i.is_sales_item = 1 if item.get("item_group") == "Baked Goods" else 0 263 | i.append( 264 | "item_defaults", 265 | {"company": settings.company, "default_warehouse": item.get("default_warehouse")}, 266 | ) 267 | if i.is_purchase_item and item.get("supplier"): 268 | i.append("supplier_items", {"supplier": item.get("supplier")}) 269 | if i.item_code == "Parchment Paper": 270 | i.append("uoms", {"uom": "Box", "conversion_factor": 100}) 271 | i.purchase_uom = "Box" 272 | i.save() 273 | if item.get("item_price"): 274 | ip = frappe.new_doc("Item Price") 275 | ip.item_code = i.item_code 276 | ip.uom = i.stock_uom 277 | ip.price_list = "Bakery Wholesale" if i.is_sales_item else "Bakery Buying" 278 | ip.buying = 1 279 | ip.valid_from = "2018-1-1" 280 | ip.price_list_rate = item.get("item_price") 281 | ip.save() 282 | 283 | water = frappe.new_doc("Stock Entry") 284 | water.stock_entry_type = water.purpose = "Material Receipt" 285 | water.append( 286 | "items", 287 | { 288 | "item_code": "Water", 289 | "qty": 10000000, 290 | "t_warehouse": "Refrigerator - APC", 291 | "basic_rate": 0.0, 292 | "allow_zero_valuation_rate": 1, 293 | }, 294 | ) 295 | water.append( 296 | "items", 297 | { 298 | "item_code": "Ice Water", 299 | "qty": 10000000, 300 | "t_warehouse": "Refrigerator - APC", 301 | "basic_rate": 0.0, 302 | "allow_zero_valuation_rate": 1, 303 | }, 304 | ) 305 | water.save() 306 | water.submit() 307 | 308 | 309 | def create_warehouses(settings): 310 | warehouses = [item.get("default_warehouse") for item in items] 311 | root_wh = frappe.get_value("Warehouse", {"company": settings.company, "is_group": 1}) 312 | if frappe.db.exists("Warehouse", "Stores - APC"): 313 | frappe.rename_doc("Warehouse", "Stores - APC", "Storeroom - APC", force=True) 314 | for wh in frappe.get_all("Warehouse", {"company": settings.company}, ["name", "is_group"]): 315 | if wh.name not in warehouses and not wh.is_group: 316 | frappe.delete_doc("Warehouse", wh.name) 317 | for item in items: 318 | if frappe.db.exists("Warehouse", item.get("default_warehouse")): 319 | continue 320 | wh = frappe.new_doc("Warehouse") 321 | wh.warehouse_name = item.get("default_warehouse").split(" - ")[0] 322 | wh.parent_warehouse = root_wh 323 | wh.company = settings.company 324 | wh.save() 325 | 326 | 327 | def create_boms(settings): 328 | for bom in boms[::-1]: # reversed 329 | if frappe.db.exists("BOM", {"item": bom.get("item")}): 330 | continue 331 | b = frappe.new_doc("BOM") 332 | b.item = bom.get("item") 333 | b.quantity = bom.get("quantity") 334 | b.uom = bom.get("uom") 335 | b.company = settings.company 336 | b.rm_cost_as_per = "Price List" 337 | b.buying_price_list = "Bakery Buying" 338 | b.currency = "USD" 339 | b.with_operations = 1 340 | for item in bom.get("items"): 341 | b.append("items", {**item, "stock_uom": item.get("uom")}) 342 | b.items[-1].bom_no = frappe.get_value("BOM", {"item": item.get("item_code")}) 343 | for operation in bom.get("operations"): 344 | b.append("operations", {**operation, "hour_rate": 15.00}) 345 | if bom.get("scrap_items"): 346 | for scrap_item in bom.get("scrap_items"): 347 | b.append("scrap_items", {**scrap_item}) 348 | b.save() 349 | b.submit() 350 | 351 | 352 | def create_sales_order(settings): 353 | so = frappe.new_doc("Sales Order") 354 | so.transaction_date = settings.day 355 | so.customer = customers[0] 356 | so.order_type = "Sales" 357 | so.currency = "USD" 358 | so.selling_price_list = "Bakery Wholesale" 359 | so.append( 360 | "items", 361 | { 362 | "item_code": "Ambrosia Pie", 363 | "delivery_date": so.transaction_date, 364 | "qty": 40, 365 | "warehouse": "Baked Goods - APC", 366 | }, 367 | ) 368 | so.append( 369 | "items", 370 | { 371 | "item_code": "Double Plum Pie", 372 | "delivery_date": so.transaction_date, 373 | "qty": 40, 374 | "warehouse": "Baked Goods - APC", 375 | }, 376 | ) 377 | so.append( 378 | "items", 379 | { 380 | "item_code": "Gooseberry Pie", 381 | "delivery_date": so.transaction_date, 382 | "qty": 10, 383 | "warehouse": "Baked Goods - APC", 384 | }, 385 | ) 386 | so.append( 387 | "items", 388 | { 389 | "item_code": "Kaduka Key Lime Pie", 390 | "delivery_date": so.transaction_date, 391 | "qty": 10, 392 | "warehouse": "Baked Goods - APC", 393 | }, 394 | ) 395 | so.save() 396 | so.submit() 397 | 398 | 399 | def create_material_request(settings): 400 | mr = frappe.new_doc("Material Request") 401 | mr.material_request_type = "Manufacture" 402 | mr.schedule_date = mr.transaction_date = settings.day 403 | mr.title = "Pies" 404 | mr.append( 405 | "items", 406 | { 407 | "item_code": "Ambrosia Pie", 408 | "schedule_date": mr.schedule_date, 409 | "qty": 40, 410 | "warehouse": "Baked Goods - APC", 411 | }, 412 | ) 413 | mr.append( 414 | "items", 415 | { 416 | "item_code": "Double Plum Pie", 417 | "schedule_date": mr.schedule_date, 418 | "qty": 40, 419 | "warehouse": "Baked Goods - APC", 420 | }, 421 | ) 422 | mr.append( 423 | "items", 424 | { 425 | "item_code": "Gooseberry Pie", 426 | "schedule_date": mr.schedule_date, 427 | "qty": 10, 428 | "warehouse": "Baked Goods - APC", 429 | }, 430 | ) 431 | mr.append( 432 | "items", 433 | { 434 | "item_code": "Kaduka Key Lime Pie", 435 | "schedule_date": mr.schedule_date, 436 | "qty": 10, 437 | "warehouse": "Baked Goods - APC", 438 | }, 439 | ) 440 | mr.save() 441 | mr.submit() 442 | 443 | 444 | def create_production_plan(settings, prod_plan_from_doc): 445 | pp = frappe.new_doc("Production Plan") 446 | pp.posting_date = settings.day 447 | pp.company = settings.company 448 | pp.combine_sub_items = 1 449 | if prod_plan_from_doc == "Sales Order": 450 | pp.get_items_from = "Sales Order" 451 | pp.append( 452 | "sales_orders", 453 | { 454 | "sales_order": frappe.get_last_doc("Sales Order").name, 455 | }, 456 | ) 457 | pp.get_items() 458 | else: 459 | pp.get_items_from = "Material Request" 460 | pp.append( 461 | "material_requests", 462 | { 463 | "material_request": frappe.get_last_doc("Material Request").name, 464 | }, 465 | ) 466 | pp.get_mr_items() 467 | for item in pp.po_items: 468 | item.planned_start_date = settings.day 469 | pp.get_sub_assembly_items() 470 | for item in pp.sub_assembly_items: 471 | item.schedule_date = settings.day 472 | pp.for_warehouse = "Storeroom - APC" 473 | raw_materials = get_items_for_material_requests( 474 | pp.as_dict(), warehouses=None, get_parent_warehouse_data=None 475 | ) 476 | for row in raw_materials: 477 | pp.append( 478 | "mr_items", 479 | { 480 | **row, 481 | "warehouse": frappe.get_value( 482 | "Item Default", {"parent": row.get("item_code")}, "default_warehouse" 483 | ), 484 | }, 485 | ) 486 | pp.save() 487 | pp.submit() 488 | 489 | pp.make_material_request() 490 | mr = frappe.get_last_doc("Material Request") 491 | mr.schedule_date = mr.transaction_date = settings.day 492 | mr.save() 493 | mr.submit() 494 | 495 | for item in mr.items: 496 | supplier = frappe.get_value("Item Supplier", {"parent": item.get("item_code")}, "supplier") 497 | item.supplier = supplier or "No Supplier" 498 | 499 | for supplier, _items in groupby( 500 | sorted((m for m in mr.items if m.supplier), key=lambda d: d.supplier), 501 | lambda x: x.get("supplier"), 502 | ): 503 | items = list(_items) 504 | if supplier == "No Supplier": 505 | # make a stock entry here? 506 | continue 507 | if supplier == "Freedom Provisions": 508 | pr = frappe.new_doc("Purchase Invoice") 509 | pr.update_stock = 1 510 | else: 511 | pr = frappe.new_doc("Purchase Receipt") 512 | pr.company = settings.company 513 | pr.supplier = supplier 514 | pr.posting_date = settings.day 515 | pr.set_posting_time = 1 516 | pr.buying_price_list = "Bakery Buying" 517 | for item in items: 518 | item_details = get_item_details( 519 | { 520 | "item_code": item.item_code, 521 | "qty": item.qty, 522 | "supplier": pr.supplier, 523 | "company": pr.company, 524 | "doctype": pr.doctype, 525 | "currency": pr.currency, 526 | "buying_price_list": pr.buying_price_list, 527 | } 528 | ) 529 | pr.append("items", {**item_details}) 530 | pr.save() 531 | # pr.submit() # don't submit - needed to test handling unit generation 532 | 533 | pp.make_work_order() 534 | wos = frappe.get_all("Work Order", {"production_plan": pp.name}) 535 | for wo in wos: 536 | wo = frappe.get_doc("Work Order", wo) 537 | wo.wip_warehouse = "Kitchen - APC" 538 | wo.save() 539 | wo.submit() 540 | job_cards = frappe.get_all("Job Card", {"work_order": wo.name}) 541 | for job_card in job_cards: 542 | job_card = frappe.get_doc("Job Card", job_card) 543 | job_card.time_logs[0].completed_qty = wo.qty 544 | job_card.save() 545 | job_card.submit() 546 | 547 | 548 | def create_purchase_receipt_for_received_qty_test(settings): 549 | pr = frappe.new_doc("Purchase Receipt") 550 | pr.company = settings.company 551 | pr.supplier = "Freedom Provisions" 552 | pr.posting_date = settings.day 553 | pr.set_posting_time = 1 554 | pr.buying_price_list = "Bakery Buying" 555 | item = frappe.get_doc("Item", "Gooseberry") 556 | pr.append( 557 | "items", 558 | { 559 | "item_code": item.item_code, 560 | "warehouse": "Refrigerator - APC", 561 | "rejected_warehouse": "Storeroom - APC", 562 | "received_qty": 15, 563 | "rejected_qty": 5, 564 | "qty": 10, 565 | "rate": 5, 566 | "supplier": pr.supplier, 567 | "company": pr.company, 568 | "doctype": pr.doctype, 569 | "currency": pr.currency, 570 | "buying_price_list": pr.buying_price_list, 571 | }, 572 | ) 573 | pr.save() 574 | 575 | 576 | def create_network_printer_settings(settings): 577 | printer_settings = [ 578 | {"name": "Receiving Printer", "server_ip": "localhost", "port": 8888}, 579 | {"name": "Kitchen Printer", "server_ip": "localhost", "port": 9999}, 580 | ] 581 | for ps in printer_settings: 582 | if not frappe.db.exists("Network Printer Settings", ps): 583 | nps = frappe.new_doc("Network Printer Settings") 584 | nps.name = ps["name"] 585 | nps.server_ip = ps["server_ip"] 586 | nps.port = ps["port"] 587 | nps.printer_name = ps["name"] 588 | nps.save() 589 | -------------------------------------------------------------------------------- /beam/tests/test_hooks_override.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | import pytest 3 | from frappe import get_hooks 4 | 5 | from beam.tests.conftest import monkeymodule 6 | 7 | 8 | @pytest.fixture() 9 | def patch_frappe_get_hooks(monkeymodule, *args, **kwargs): 10 | def patched_hooks(*args, **kwargs): 11 | hooks = get_hooks(*args, **kwargs) 12 | if "beam_frm" in args: 13 | return { 14 | "Item": { 15 | "Delivery Note": [ 16 | { 17 | "action": "add_or_increment", 18 | "doctype": "Delivery Note Item", 19 | "field": "item_code", 20 | "target": "target.item_code", 21 | }, 22 | { 23 | "action": "add_or_increment", 24 | "doctype": "Delivery Note Item", 25 | "field": "uom", 26 | "target": "target.uom", 27 | }, 28 | ] 29 | } 30 | } 31 | if "beam_listview" in args: 32 | return { 33 | "Item": { 34 | "Delivery Note": [ 35 | {"action": "filter", "doctype": "Delivery Note Item", "field": "item_code"}, 36 | {"action": "filter", "doctype": "Packed Item", "field": "item_code"}, 37 | ], 38 | } 39 | } 40 | return hooks 41 | 42 | monkeymodule.setattr("frappe.get_hooks", patched_hooks) 43 | 44 | 45 | def test_beam_frm_hooks_override(patch_frappe_get_hooks): 46 | item_barcode = frappe.get_value("Item Barcode", {"parent": "Kaduka Key Lime Pie"}, "barcode") 47 | dn = frappe.new_doc("Delivery Note") 48 | dn.customer = "Almacs Food Group" 49 | scan = frappe.call( 50 | "beam.beam.scan.scan", 51 | **{ 52 | "barcode": str(item_barcode), 53 | "context": {"frm": dn.doctype, "doc": dn.as_dict()}, 54 | "current_qty": 1, 55 | } 56 | ) 57 | 58 | assert len(scan) == 2 59 | assert scan[0].get("action") == "add_or_increment" 60 | assert scan[0].get("doctype") == "Delivery Note Item" 61 | assert scan[0].get("field") == "item_code" 62 | assert scan[0].get("target") == "Kaduka Key Lime Pie" 63 | assert scan[1].get("action") == "add_or_increment" 64 | assert scan[1].get("doctype") == "Delivery Note Item" 65 | assert scan[1].get("field") == "uom" 66 | assert scan[1].get("target") == "Nos" 67 | 68 | 69 | def test_beam_listview_hooks_override(patch_frappe_get_hooks): 70 | item_barcode = frappe.get_value("Item Barcode", {"parent": "Kaduka Key Lime Pie"}, "barcode") 71 | scan = frappe.call( 72 | "beam.beam.scan.scan", 73 | **{"barcode": str(item_barcode), "context": {"listview": "Delivery Note"}, "current_qty": 1} 74 | ) 75 | 76 | assert len(scan) == 2 77 | assert scan[0].get("action") == "filter" 78 | assert scan[0].get("doctype") == "Delivery Note Item" 79 | assert scan[0].get("field") == "item_code" 80 | assert scan[0].get("target") == "Kaduka Key Lime Pie" 81 | assert scan[1].get("action") == "filter" 82 | assert scan[1].get("doctype") == "Packed Item" 83 | assert scan[1].get("field") == "item_code" 84 | assert scan[1].get("target") == "Kaduka Key Lime Pie" 85 | -------------------------------------------------------------------------------- /beam/tests/test_scan.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | import pytest 3 | 4 | """ 5 | 1. Test that a scanned item code in a list view returns the correct values for filtering 6 | 2. Test that a scanned item code in a list view returns the correct value for route change 7 | 3. Test that a scanned item code in a form view returns an object like `get_item_details` 8 | 4. Test that a scanned handling unit in a list view returns the correct value for route change 9 | 5. Test that a scanned handling unit in a form view returns an object like `get_item_details` 10 | """ 11 | 12 | 13 | def test_item_scan_from_list_view_for_filter(): 14 | # purchase receipt listview 15 | item_barcode = frappe.get_value("Item Barcode", {"parent": "Butter"}, "barcode") 16 | scan = frappe.call( 17 | "beam.beam.scan.scan", 18 | **{"barcode": str(item_barcode), "context": {"listview": "Purchase Receipt"}, "current_qty": 1} 19 | ) 20 | assert scan[0].get("action") == "filter" 21 | assert scan[0].get("doctype") == "Purchase Receipt Item" 22 | assert scan[0].get("field") == "item_code" 23 | assert scan[0].get("target") == "Butter" 24 | 25 | 26 | def test_item_scan_from_list_view_for_route(): 27 | # item listview 28 | item_barcode = frappe.get_value("Item Barcode", {"parent": "Butter"}, "barcode") 29 | scan = frappe.call( 30 | "beam.beam.scan.scan", 31 | **{"barcode": str(item_barcode), "context": {"listview": "Item"}, "current_qty": 1} 32 | ) 33 | assert scan[0].get("action") == "route" 34 | assert scan[0].get("doctype") == "Item" 35 | assert scan[0].get("field") == "Item" 36 | assert scan[0].get("target") == "Butter" 37 | 38 | 39 | def test_item_scan_from_form_view(): 40 | context = { 41 | "frm": "Purchase Receipt", 42 | "doc": { 43 | "docstatus": 0, 44 | "doctype": "Purchase Receipt", 45 | "name": "new-purchase-receipt-1", 46 | "__islocal": 1, 47 | "__unsaved": 1, 48 | "owner": "Administrator", 49 | "naming_series": "MAT-PRE-.YYYY.-", 50 | "posting_date": "2023-06-21", 51 | "set_posting_time": 0, 52 | "company": "Ambrosia Pie Company", 53 | "apply_putaway_rule": 0, 54 | "is_return": 0, 55 | "currency": "USD", 56 | "buying_price_list": "Standard Buying", 57 | "price_list_currency": "USD", 58 | "ignore_pricing_rule": 0, 59 | "is_subcontracted": 0, 60 | "disable_rounded_total": 0, 61 | "apply_discount_on": "Grand Total", 62 | "status": "Draft", 63 | "group_same_items": 0, 64 | "is_internal_supplier": 0, 65 | "is_old_subcontracting_flow": 0, 66 | "items": [ 67 | { 68 | "docstatus": 0, 69 | "doctype": "Purchase Receipt Item", 70 | "name": "new-purchase-receipt-item-1", 71 | "__islocal": 1, 72 | "__unsaved": 1, 73 | "owner": "Administrator", 74 | "has_item_scanned": 0, 75 | "received_qty": 0, 76 | "stock_uom": "Nos", 77 | "retain_sample": 0, 78 | "margin_type": "", 79 | "is_free_item": 0, 80 | "is_fixed_asset": 0, 81 | "allow_zero_valuation_rate": 0, 82 | "include_exploded_items": 0, 83 | "cost_center": "Main - APC", 84 | "page_break": 0, 85 | "parent": "new-purchase-receipt-1", 86 | "parentfield": "items", 87 | "parenttype": "Purchase Receipt", 88 | "idx": 1, 89 | "qty": 0, 90 | "rejected_qty": 0, 91 | "conversion_factor": 0, 92 | "received_stock_qty": 0, 93 | "stock_qty": 0, 94 | "returned_qty": 0, 95 | "price_list_rate": 0, 96 | "base_price_list_rate": 0, 97 | "margin_rate_or_amount": 0, 98 | "rate_with_margin": 0, 99 | "discount_amount": 0, 100 | "base_rate_with_margin": 0, 101 | "rate": 0, 102 | "amount": 0, 103 | "base_rate": 0, 104 | "base_amount": 0, 105 | "stock_uom_rate": 0, 106 | "net_rate": 0, 107 | "net_amount": 0, 108 | "base_net_rate": 0, 109 | "base_net_amount": 0, 110 | "valuation_rate": 0, 111 | "item_tax_amount": 0, 112 | "rm_supp_cost": 0, 113 | "landed_cost_voucher_amount": 0, 114 | "rate_difference_with_purchase_invoice": 0, 115 | "billed_amt": 0, 116 | "weight_per_unit": 0, 117 | "total_weight": 0, 118 | } 119 | ], 120 | "posting_time": "03:51:13", 121 | "conversion_rate": 1, 122 | "plc_conversion_rate": 1, 123 | "taxes_and_charges": "US ST 6% - APC", 124 | "taxes": [ 125 | { 126 | "docstatus": 0, 127 | "doctype": "Purchase Taxes and Charges", 128 | "name": "new-purchase-taxes-and-charges-1", 129 | "__islocal": 1, 130 | "__unsaved": 1, 131 | "owner": "Administrator", 132 | "category": "Total", 133 | "add_deduct_tax": "Add", 134 | "charge_type": "On Net Total", 135 | "included_in_print_rate": 0, 136 | "included_in_paid_amount": 0, 137 | "cost_center": "Main - APC", 138 | "account_currency": None, 139 | "parent": "new-purchase-receipt-1", 140 | "parentfield": "taxes", 141 | "parenttype": "Purchase Receipt", 142 | "idx": 1, 143 | "row_id": None, 144 | "account_head": "ST 6% - APC", 145 | "description": "ST 6% @ 6.0", 146 | "rate": 6, 147 | "tax_amount": 0, 148 | "tax_amount_after_discount_amount": 0, 149 | "total": 0, 150 | "base_tax_amount": 0, 151 | "base_total": 0, 152 | "base_tax_amount_after_discount_amount": 0, 153 | "item_wise_tax_detail": '{"undefined":[6,0]}', 154 | } 155 | ], 156 | "base_net_total": 0, 157 | "net_total": 0, 158 | "base_total": 0, 159 | "total": 0, 160 | "total_qty": 0, 161 | "rounding_adjustment": 0, 162 | "grand_total": 0, 163 | "taxes_and_charges_deducted": 0, 164 | "taxes_and_charges_added": 0, 165 | "base_grand_total": 0, 166 | "base_taxes_and_charges_added": 0, 167 | "base_taxes_and_charges_deducted": 0, 168 | "total_taxes_and_charges": 0, 169 | "base_total_taxes_and_charges": 0, 170 | "base_rounding_adjustment": 0, 171 | "rounded_total": 0, 172 | "base_rounded_total": 0, 173 | "in_words": "", 174 | "base_in_words": "", 175 | "base_discount_amount": 0, 176 | }, 177 | } 178 | item_barcode = frappe.get_value("Item Barcode", {"parent": "Butter"}, "barcode") 179 | scan = frappe.call( 180 | "beam.beam.scan.scan", **{"barcode": str(item_barcode), "context": context, "current_qty": 1} 181 | ) 182 | assert scan[0].get("action") == "add_or_increment" 183 | assert scan[0].get("doctype") == "Purchase Receipt Item" 184 | assert scan[0].get("field") == "item_code" 185 | assert scan[0].get("target") == "Butter" 186 | # TODO: add assertions that show harmonization with get_item_details 187 | -------------------------------------------------------------------------------- /beam/www/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agritheory/beam/6d162576438ee91b23781b96ce0d01d2653d6846/beam/www/__init__.py -------------------------------------------------------------------------------- /cups/.env.example: -------------------------------------------------------------------------------- 1 | # Prefix for services and networks. 2 | CONTAINER_NAME_PREFIX=printers 3 | 4 | CONTAINER_CADDY_SERVICE_HTTP_PORT=80 5 | CONTAINER_CADDY_SERVICE_HTTPS_PORT=443 6 | 7 | # The CONTAINER_TARGET could be `localhost` or `production`. 8 | # Production mode requires a DOMAIN and EMAIL for certbot. 9 | CONTAINER_TARGET=localhost 10 | CERTBOT_EMAIL= 11 | CERTBOT_DOMAIN= 12 | 13 | CUPS_ADMIN_USER=admin 14 | CUPS_ADMIN_PASSWORD=root 15 | -------------------------------------------------------------------------------- /cups/README.md: -------------------------------------------------------------------------------- 1 | # Printers 2 | 3 | Printers service containerized. 4 | Caddy (with TLS) + CUPS. 5 | 6 | Podman 7 | 8 | ## Initial Setup 9 | 10 | ```sh 11 | echo 'unqualified-search-registries = ["docker.io"]' | sudo tee -a /etc/containers/registries.conf 12 | cp .env.example .env 13 | podman-compose build 14 | ``` 15 | 16 | ## Run the container containers 17 | 18 | ### Develop 19 | 20 | 1) Ensure the `CONTAINER_TARGET` env variable is `localhost`. 21 | 2) Run the container: 22 | ```sh 23 | podman-compose up --remove-orphans --abort-on-container-exit 24 | ``` 25 | 26 | ### Production 27 | 28 | 1) Ensure the `CONTAINER_TARGET` env variable is `production`. 29 | 2) Complete the `CERTBOT_EMAIL` and `CERTBOT_DOMAIN` env variables. 30 | 3) Run the container in background: 31 | ```sh 32 | podman-compose up --remove-orphans -d 33 | ``` 34 | 35 | ## Handle containers 36 | 37 | Stop the containers: 38 | ```sh 39 | prefix="$(grep -E '^CONTAINER_NAME_PREFIX=' .env | cut -d '=' -f2)" \ 40 | podman ps --format="{{.Names}}" | grep "$prefix" | xargs -r podman kill 41 | ``` 42 | 43 | Delete everything related to containers (requires them to be stopped): 44 | ```sh 45 | prefix="$(grep -E '^CONTAINER_NAME_PREFIX=' .env | cut -d '=' -f2)" \ 46 | && # Delete containers: 47 | podman ps -a --format="{{.Names}}" | grep "$prefix" | xargs -r podman rm \ 48 | && # Delete volumes: 49 | podman volume ls --format="{{.Name}}" | grep "$prefix" | xargs -r podman volume rm \ 50 | && # Delete bind mounts: 51 | awk '/volumes:/ { while (getline > 0) { if ($1 ~ /^-/) { split($2, parts, ":"); if (parts[1] ~ /^\.\//) { print parts[1] } } else { break } } }' podman-compose.yml \ 52 | | xargs -I {} sudo rm -rf {} \ 53 | && # Delete networks: 54 | podman network ls --format="{{.Name}}" | grep "$prefix" | xargs -r podman network rm 55 | ``` 56 | 57 | 58 | 59 | Docker 60 | 61 | ## Initial Setup 62 | 63 | ```sh 64 | cp .env.example .env 65 | docker compose build 66 | ``` 67 | 68 | ## Run the container containers 69 | 70 | ### Develop 71 | 72 | 1) Ensure the `CONTAINER_TARGET` env variable is `localhost`. 73 | 2) Run the container: 74 | ```sh 75 | docker compose up --remove-orphans --abort-on-container-exit 76 | ``` 77 | 78 | ### Production 79 | 80 | 1) Ensure the `CONTAINER_TARGET` env variable is `production`. 81 | 2) Complete the `CERTBOT_EMAIL` and `CERTBOT_DOMAIN` env variables. 82 | 3) Run the container in background: 83 | ```sh 84 | docker compose up --remove-orphans -d 85 | ``` 86 | 87 | ## Handle containers 88 | 89 | Stop the containers: 90 | ```sh 91 | prefix="$(grep -E '^CONTAINER_NAME_PREFIX=' .env | cut -d '=' -f2)" \ 92 | docker ps --format="{{.Names}}" | grep "$prefix" | xargs -r docker kill 93 | ``` 94 | 95 | Delete everything related to containers (requires them to be stopped): 96 | ```sh 97 | prefix="$(grep -E '^CONTAINER_NAME_PREFIX=' .env | cut -d '=' -f2)" \ 98 | && # Delete containers: 99 | docker ps -a --format="{{.Names}}" | grep "$prefix" | xargs -r docker rm \ 100 | && # Delete volumes: 101 | docker volume ls --format="{{.Name}}" | grep "$prefix" | xargs -r docker volume rm \ 102 | && # Delete bind mounts: 103 | awk '/volumes:/ { while (getline > 0) { if ($1 ~ /^-/) { split($2, parts, ":"); if (parts[1] ~ /^\.\//) { print parts[1] } } else { break } } }' docker-compose.yml \ 104 | | xargs -I {} sudo rm -rf {} \ 105 | && # Delete networks: 106 | docker network ls --format="{{.Name}}" | grep "$prefix" | xargs -r docker network rm 107 | ``` 108 | 109 | 110 | -------------------------------------------------------------------------------- /cups/caddy/Caddyfile.localhost: -------------------------------------------------------------------------------- 1 | http://localhost:80 { 2 | reverse_proxy * cups:631 3 | } 4 | 5 | https://localhost:443 { 6 | redir http://{host}{uri} 7 | tls internal 8 | } 9 | -------------------------------------------------------------------------------- /cups/caddy/Caddyfile.not_ssl: -------------------------------------------------------------------------------- 1 | http://:80 { 2 | route /.well-known/acme-challenge/* { 3 | root * /var/www/html 4 | file_server 5 | } 6 | reverse_proxy * cups:631 7 | } 8 | -------------------------------------------------------------------------------- /cups/caddy/Caddyfile.ssl: -------------------------------------------------------------------------------- 1 | https://:443 { 2 | route /.well-known/acme-challenge/* { 3 | root * /var/www/html 4 | file_server 5 | } 6 | reverse_proxy * cups:631 7 | tls /var/www/certs/fullchain.pem /var/www/certs/privkey.pem 8 | } 9 | -------------------------------------------------------------------------------- /cups/caddy/Containerfile: -------------------------------------------------------------------------------- 1 | FROM caddy:2.7.5-alpine AS base 2 | 3 | RUN apk update \ 4 | && apk add --no-cache \ 5 | certbot 6 | 7 | COPY \ 8 | Caddyfile.ssl \ 9 | Caddyfile.not_ssl \ 10 | Caddyfile.localhost \ 11 | /etc/caddy/ 12 | 13 | RUN cd /etc/caddy \ 14 | && sed -i "s//${DOMAIN}/g" Caddyfile.* 15 | 16 | FROM base AS localhost 17 | 18 | CMD [\ 19 | "caddy", \ 20 | "run", \ 21 | "--config=/etc/caddy/Caddyfile.localhost" \ 22 | ] 23 | 24 | FROM base AS production 25 | 26 | COPY entrypoint.sh /entrypoint.sh 27 | RUN chmod +x /entrypoint.sh 28 | ENTRYPOINT ["/entrypoint.sh"] 29 | -------------------------------------------------------------------------------- /cups/caddy/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Generate and validate the certificate for the first time. 5 | if [ ! -d /var/www/certs ] || [ -z "$(ls -A /var/www/certs)" ]; then 6 | mkdir /var/www/certs 7 | mkdir -p /var/www/html/.well-known/acme-challenge 8 | 9 | caddy run --config /etc/caddy/Caddyfile.not_ssl > caddy.log 2>&1 & 10 | echo $! > caddy.pid 11 | 12 | certbot certonly --webroot -w /var/www/html -d "$CERTBOT_DOMAIN" --email "$CERTBOT_EMAIL" --agree-tos 13 | cp /etc/letsencrypt/live/"$CERTBOT_DOMAIN"/fullchain.pem /var/www/certs/fullchain.pem 14 | cp /etc/letsencrypt/live/"$CERTBOT_DOMAIN"/privkey.pem /var/www/certs/privkey.pem 15 | 16 | kill -SIGINT "$(cat caddy.pid)" 17 | rm caddy.pid 18 | fi 19 | 20 | # Try to renew the certificate every day. 21 | echo "0 0 * * * certbot renew --quiet" | crontab - 22 | 23 | caddy run --config /etc/caddy/Caddyfile.ssl 24 | -------------------------------------------------------------------------------- /cups/cups/Containerfile: -------------------------------------------------------------------------------- 1 | FROM debian:testing-slim 2 | 3 | # Install Packages (basic tools, cups, basic drivers, HP drivers) 4 | RUN apt-get update \ 5 | && apt-get install -y \ 6 | sudo \ 7 | whois \ 8 | usbutils \ 9 | cups \ 10 | cups-client \ 11 | cups-bsd \ 12 | cups-filters \ 13 | foomatic-db-compressed-ppds \ 14 | printer-driver-all \ 15 | openprinting-ppds \ 16 | hpijs-ppds \ 17 | hp-ppd \ 18 | hplip \ 19 | smbclient \ 20 | printer-driver-cups-pdf \ 21 | hplip \ 22 | printer-driver-gutenprint \ 23 | avahi-daemon \ 24 | && apt-get clean \ 25 | && rm -rf /var/lib/apt/lists/* 26 | 27 | ARG CUPS_ADMIN_USER 28 | ARG CUPS_ADMIN_PASSWORD 29 | RUN useradd \ 30 | --create-home \ 31 | --groups sudo,lp,lpadmin \ 32 | --shell=/bin/bash \ 33 | --password=$(mkpasswd $CUPS_ADMIN_PASSWORD) \ 34 | $CUPS_ADMIN_USER \ 35 | && echo "$CUPS_ADMIN_USER ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers 36 | USER $CUPS_ADMIN_USER 37 | 38 | COPY cupsd.conf /etc/cups/cupsd.conf 39 | RUN sudo chmod -R a+rwx /var/spool \ 40 | && sudo chmod -R a+rwx /etc/cups 41 | 42 | COPY avahi-daemon.conf /etc/avahi/avahi-daemon.conf 43 | 44 | CMD [\ 45 | "/usr/sbin/cupsd", \ 46 | "-f" \ 47 | ] 48 | -------------------------------------------------------------------------------- /cups/cups/cupsd.conf: -------------------------------------------------------------------------------- 1 | # Documentation: 2 | # - https://www.cups.org/doc/man-cupsd.conf.html 3 | 4 | # Logs 5 | LogLevel warn 6 | PageLogFormat 7 | MaxLogSize 0 8 | 9 | # Error policy 10 | ErrorPolicy retry-job 11 | 12 | # Allow remote access 13 | Listen *:631 14 | ServerAlias * 15 | 16 | # Show shared printers on the local network 17 | BrowseWebIF Yes 18 | Browsing Yes 19 | BrowseLocalProtocols all 20 | 21 | # Default authentication type 22 | DefaultAuthType Basic 23 | DefaultEncryption IfRequested 24 | 25 | # Web interface 26 | WebInterface Yes 27 | 28 | # Restrict access to the server 29 | 30 | Allow all 31 | Order allow,deny 32 | 33 | 34 | # Restrict access to the admin pages 35 | 36 | AuthType Default 37 | Require user @SYSTEM 38 | Order allow,deny 39 | Allow all 40 | 41 | 42 | # Set the default printer/job policies 43 | 44 | # Job/subscription privacy 45 | JobPrivateAccess default 46 | JobPrivateValues default 47 | SubscriptionPrivateAccess default 48 | SubscriptionPrivateValues default 49 | 50 | # Job-related operations must be done by the owner or an administrator 51 | 52 | Order deny,allow 53 | 54 | 55 | 56 | Require user @OWNER @SYSTEM 57 | Order deny,allow 58 | 59 | 60 | 61 | AuthType Default 62 | Require user @OWNER @SYSTEM 63 | Order deny,allow 64 | 65 | 66 | # All administration operations require an administrator to authenticate 67 | 68 | AuthType Default 69 | Require user @SYSTEM 70 | Order deny,allow 71 | 72 | 73 | # All printer operations require a printer operator to authenticate 74 | 75 | AuthType Default 76 | Require user @SYSTEM 77 | Order deny,allow 78 | 79 | 80 | # Only the owner or an administrator can cancel or authenticate a job 81 | 82 | Require user @OWNER @SYSTEM 83 | Order deny,allow 84 | 85 | 86 | 87 | AuthType Default 88 | Require user @OWNER @SYSTEM 89 | Order deny,allow 90 | 91 | 92 | 93 | Order deny,allow 94 | 95 | 96 | 97 | # Set the authenticated printer/job policies 98 | 99 | # Job/subscription privacy 100 | JobPrivateAccess default 101 | JobPrivateValues default 102 | SubscriptionPrivateAccess default 103 | SubscriptionPrivateValues default 104 | 105 | # Job-related operations must be done by the owner or an administrator 106 | 107 | AuthType Default 108 | Order deny,allow 109 | 110 | 111 | 112 | AuthType Default 113 | Require user @OWNER @SYSTEM 114 | Order deny,allow 115 | 116 | 117 | # All administration operations require an administrator to authenticate 118 | 119 | AuthType Default 120 | Require user @SYSTEM 121 | Order deny,allow 122 | 123 | 124 | # All printer operations require a printer operator to authenticate 125 | 126 | AuthType Default 127 | Require user @SYSTEM 128 | Order deny,allow 129 | 130 | 131 | # Only the owner or an administrator can cancel or authenticate a job 132 | 133 | AuthType Default 134 | Require user @OWNER @SYSTEM 135 | Order deny,allow 136 | 137 | 138 | 139 | Order deny,allow 140 | 141 | 142 | 143 | # Set the kerberized printer/job policies 144 | 145 | # Job/subscription privacy 146 | JobPrivateAccess default 147 | JobPrivateValues default 148 | SubscriptionPrivateAccess default 149 | SubscriptionPrivateValues default 150 | 151 | # Job-related operations must be done by the owner or an administrator 152 | 153 | AuthType Negotiate 154 | Order deny,allow 155 | 156 | 157 | 158 | AuthType Negotiate 159 | Require user @OWNER @SYSTEM 160 | Order deny,allow 161 | 162 | 163 | # All administration operations require an administrator to authenticate 164 | 165 | AuthType Default 166 | Require user @SYSTEM 167 | Order deny,allow 168 | 169 | 170 | # All printer operations require a printer operator to authenticate 171 | 172 | AuthType Default 173 | Require user @SYSTEM 174 | Order deny,allow 175 | 176 | 177 | # Only the owner or an administrator can cancel or authenticate a job 178 | 179 | AuthType Negotiate 180 | Require user @OWNER @SYSTEM 181 | Order deny,allow 182 | 183 | 184 | 185 | Order deny,allow 186 | 187 | 188 | -------------------------------------------------------------------------------- /cups/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | cups: 5 | container_name: ${CONTAINER_NAME_PREFIX}_cups 6 | build: 7 | context: cups 8 | dockerfile: Containerfile 9 | args: 10 | CUPS_ADMIN_USER: ${CUPS_ADMIN_USER} 11 | CUPS_ADMIN_PASSWORD: ${CUPS_ADMIN_PASSWORD} 12 | environment: 13 | CUPS_ADMIN: ${CUPS_ADMIN_USER} 14 | CUPS_ADMIN_PASS: ${CUPS_ADMIN_PASSWORD} 15 | networks: 16 | - network 17 | 18 | caddy: 19 | container_name: ${CONTAINER_NAME_PREFIX}_caddy 20 | build: 21 | context: caddy 22 | dockerfile: Containerfile 23 | target: ${CONTAINER_TARGET} 24 | environment: 25 | - CERTBOT_DOMAIN=${CERTBOT_DOMAIN} 26 | - CERTBOT_EMAIL=${CERTBOT_EMAIL} 27 | ports: 28 | - '${CONTAINER_CADDY_SERVICE_HTTP_PORT}:80' 29 | - '${CONTAINER_CADDY_SERVICE_HTTPS_PORT}:443' 30 | volumes: 31 | - ./var/www:/var/www 32 | depends_on: 33 | - cups 34 | networks: 35 | - network 36 | 37 | networks: 38 | network: 39 | name: ${CONTAINER_NAME_PREFIX}_network 40 | -------------------------------------------------------------------------------- /cups/podman-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | cups: 5 | container_name: ${CONTAINER_NAME_PREFIX}_cups 6 | build: 7 | context: ./cups 8 | args: 9 | CUPS_ADMIN_USER: ${CUPS_ADMIN_USER} 10 | CUPS_ADMIN_PASSWORD: ${CUPS_ADMIN_PASSWORD} 11 | environment: 12 | CUPS_ADMIN: ${CUPS_ADMIN_USER} 13 | CUPS_ADMIN_PASS: ${CUPS_ADMIN_PASSWORD} 14 | networks: 15 | - network 16 | 17 | caddy: 18 | container_name: ${CONTAINER_NAME_PREFIX}_caddy 19 | build: 20 | context: ./caddy 21 | target: ${CONTAINER_TARGET} 22 | environment: 23 | - CERTBOT_DOMAIN=${CERTBOT_DOMAIN} 24 | - CERTBOT_EMAIL=${CERTBOT_EMAIL} 25 | ports: 26 | - '${CONTAINER_CADDY_SERVICE_HTTP_PORT}:80' 27 | - '${CONTAINER_CADDY_SERVICE_HTTPS_PORT}:443' 28 | volumes: 29 | - ./var/www:/var/www 30 | depends_on: 31 | - cups 32 | networks: 33 | - network 34 | 35 | networks: 36 | network: 37 | name: ${CONTAINER_NAME_PREFIX}_network 38 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | License: MIT -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | disable_error_code = annotation-unchecked 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "beam", 3 | "scripts": {}, 4 | "dependencies": { 5 | "onscan.js": "^1.5.2" 6 | }, 7 | "devDependencies": {}, 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/agritheory/beam.git" 11 | }, 12 | "publishConfig": { 13 | "access": "restricted" 14 | }, 15 | "private": true, 16 | "release": { 17 | "branches": [ 18 | "version-14" 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "colorama" 5 | version = "0.4.6" 6 | description = "Cross-platform colored terminal text." 7 | optional = false 8 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 9 | files = [ 10 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 11 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 12 | ] 13 | 14 | [[package]] 15 | name = "coverage" 16 | version = "7.6.1" 17 | description = "Code coverage measurement for Python" 18 | optional = false 19 | python-versions = ">=3.8" 20 | files = [ 21 | {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, 22 | {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, 23 | {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, 24 | {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, 25 | {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, 26 | {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, 27 | {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, 28 | {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, 29 | {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, 30 | {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, 31 | {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, 32 | {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, 33 | {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, 34 | {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, 35 | {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, 36 | {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, 37 | {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, 38 | {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, 39 | {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, 40 | {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, 41 | {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, 42 | {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, 43 | {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, 44 | {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, 45 | {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, 46 | {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, 47 | {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, 48 | {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, 49 | {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, 50 | {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, 51 | {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, 52 | {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, 53 | {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, 54 | {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, 55 | {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, 56 | {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, 57 | {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, 58 | {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, 59 | {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, 60 | {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, 61 | {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, 62 | {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, 63 | {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, 64 | {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, 65 | {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, 66 | {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, 67 | {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, 68 | {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, 69 | {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, 70 | {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, 71 | {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, 72 | {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, 73 | {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, 74 | {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, 75 | {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, 76 | {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, 77 | {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, 78 | {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, 79 | {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, 80 | {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, 81 | {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, 82 | {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, 83 | {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, 84 | {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, 85 | {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, 86 | {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, 87 | {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, 88 | {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, 89 | {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, 90 | {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, 91 | {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, 92 | {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, 93 | ] 94 | 95 | [package.dependencies] 96 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 97 | 98 | [package.extras] 99 | toml = ["tomli"] 100 | 101 | [[package]] 102 | name = "exceptiongroup" 103 | version = "1.2.2" 104 | description = "Backport of PEP 654 (exception groups)" 105 | optional = false 106 | python-versions = ">=3.7" 107 | files = [ 108 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, 109 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, 110 | ] 111 | 112 | [package.extras] 113 | test = ["pytest (>=6)"] 114 | 115 | [[package]] 116 | name = "iniconfig" 117 | version = "2.0.0" 118 | description = "brain-dead simple config-ini parsing" 119 | optional = false 120 | python-versions = ">=3.7" 121 | files = [ 122 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 123 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 124 | ] 125 | 126 | [[package]] 127 | name = "packaging" 128 | version = "24.1" 129 | description = "Core utilities for Python packages" 130 | optional = false 131 | python-versions = ">=3.8" 132 | files = [ 133 | {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, 134 | {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, 135 | ] 136 | 137 | [[package]] 138 | name = "pluggy" 139 | version = "1.5.0" 140 | description = "plugin and hook calling mechanisms for python" 141 | optional = false 142 | python-versions = ">=3.8" 143 | files = [ 144 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 145 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 146 | ] 147 | 148 | [package.extras] 149 | dev = ["pre-commit", "tox"] 150 | testing = ["pytest", "pytest-benchmark"] 151 | 152 | [[package]] 153 | name = "pytest" 154 | version = "8.3.2" 155 | description = "pytest: simple powerful testing with Python" 156 | optional = false 157 | python-versions = ">=3.8" 158 | files = [ 159 | {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, 160 | {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, 161 | ] 162 | 163 | [package.dependencies] 164 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 165 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 166 | iniconfig = "*" 167 | packaging = "*" 168 | pluggy = ">=1.5,<2" 169 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 170 | 171 | [package.extras] 172 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 173 | 174 | [[package]] 175 | name = "pytest-cov" 176 | version = "5.0.0" 177 | description = "Pytest plugin for measuring coverage." 178 | optional = false 179 | python-versions = ">=3.8" 180 | files = [ 181 | {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, 182 | {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, 183 | ] 184 | 185 | [package.dependencies] 186 | coverage = {version = ">=5.2.1", extras = ["toml"]} 187 | pytest = ">=4.6" 188 | 189 | [package.extras] 190 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] 191 | 192 | [[package]] 193 | name = "pytest-order" 194 | version = "1.2.1" 195 | description = "pytest plugin to run your tests in a specific order" 196 | optional = false 197 | python-versions = ">=3.6" 198 | files = [ 199 | {file = "pytest-order-1.2.1.tar.gz", hash = "sha256:4451bd8821ba4fa2109455a2fcc882af60ef8e53e09d244d67674be08f56eac3"}, 200 | {file = "pytest_order-1.2.1-py3-none-any.whl", hash = "sha256:c3082fc73f9ddcf13e4a22dda9bbcc2f39865bf537438a1d50fa241e028dd743"}, 201 | ] 202 | 203 | [package.dependencies] 204 | pytest = {version = ">=6.2.4", markers = "python_version >= \"3.10\""} 205 | 206 | [[package]] 207 | name = "python-barcode" 208 | version = "0.15.1" 209 | description = "Create standard barcodes with Python. No external modules needed. (optional Pillow support included)." 210 | optional = false 211 | python-versions = "*" 212 | files = [ 213 | {file = "python-barcode-0.15.1.tar.gz", hash = "sha256:3b1825fbdb11e597466dff4286b4ea9b1e86a57717b59e563ae679726fc854de"}, 214 | {file = "python_barcode-0.15.1-py3-none-any.whl", hash = "sha256:057636fba37369c22852410c8535b36adfbeb965ddfd4e5b6924455d692e0886"}, 215 | ] 216 | 217 | [package.extras] 218 | images = ["pillow"] 219 | 220 | [[package]] 221 | name = "tomli" 222 | version = "2.0.1" 223 | description = "A lil' TOML parser" 224 | optional = false 225 | python-versions = ">=3.7" 226 | files = [ 227 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 228 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 229 | ] 230 | 231 | [[package]] 232 | name = "zebra-zpl" 233 | version = "0.1.0" 234 | description = "Python library to generate usable and printable ZPL2 code" 235 | optional = false 236 | python-versions = "*" 237 | files = [] 238 | develop = false 239 | 240 | [package.source] 241 | type = "git" 242 | url = "https://github.com/mtking2/py-zebra-zpl.git" 243 | reference = "HEAD" 244 | resolved_reference = "45ffc60638814df575d9fe11c7504b1a533e4ecb" 245 | 246 | [metadata] 247 | lock-version = "2.0" 248 | python-versions = ">=3.10" 249 | content-hash = "12be1441efa3267b06c916360cc5d6b3f0a9588850b760b5946904e6ac3cbdbe" 250 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "beam" 3 | version = "14.8.7" 4 | authors = ["AgriTheory "] 5 | description = "Barcode Scanning for ERPNext" 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = ">=3.10" 10 | python-barcode = "^0.15.1" 11 | zebra-zpl = {git = "https://github.com/mtking2/py-zebra-zpl.git"} 12 | 13 | [tool.poetry.group.dev.dependencies] 14 | pytest = "^8.3.2" 15 | pytest-order = "^1.2.1" 16 | pytest-cov = "^5.0.0" 17 | 18 | [build-system] 19 | requires = ["poetry-core"] 20 | build-backend = "poetry.core.masonry.api" 21 | 22 | [tool.pytest.ini_options] 23 | addopts = "--cov=beam --cov-report term-missing" 24 | 25 | [tool.black] 26 | line-length = 99 27 | 28 | [tool.isort] 29 | line_length = 99 30 | multi_line_output = 3 31 | include_trailing_comma = true 32 | force_grid_wrap = 0 33 | use_parentheses = true 34 | ensure_newline_before_comments = true 35 | indent = "\t" 36 | 37 | [tool.semantic_release] 38 | version_toml = ["pyproject.toml:tool.poetry.version"] 39 | 40 | [tool.semantic_release.branches.version] 41 | match = "version-14" 42 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | # TODO: Remove this file when bench >=v5.11.0 is adopted / v15.0.0 is released 4 | name = "beam" 5 | 6 | setup() 7 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | onscan.js@^1.5.2: 6 | version "1.5.2" 7 | resolved "https://registry.yarnpkg.com/onscan.js/-/onscan.js-1.5.2.tgz#14ed636e5f4c3f0a78bacbf9a505dad3140ee341" 8 | integrity sha512-9oGYy2gXYRjvXO9GYqqVca0VuCTAmWhbmX3egBSBP13rXiMNb+dKPJzKFEeECGqPBpf0m40Zoo+GUQ7eCackdw== 9 | --------------------------------------------------------------------------------