+
+
+This feature computes the what Items are needed and where they are available.
+
+### Demand Map
+
+Demand increases based on the following factors:
+- When a Sales Order is submitted
+- When a Work Order is submitted
+
+Demand decreases based on the following factors:
+- When a Sales Order is either:
+ - fulfilled (via a Sales Invoice or a Delivery Note)
+ - cancelled
+ - closed
+ - put on hold
+- When a Work Order is either:
+ - completed (via a Stock Entry)
+ - cancelled
+ - closed
+ - stopped
+
+
diff --git a/beam/docs/form.md b/beam/docs/form.md
index 52ff652d..d6062ff6 100644
--- a/beam/docs/form.md
+++ b/beam/docs/form.md
@@ -3,6 +3,11 @@ For license information, please see license.txt-->
# Form
+
+ Rohan Bansal, Heather Kusmierz, and Tyler Matteson 2025-05-28
+
+
+
The result of scanning a barcode in the form depends on several factors:
- Is the barcode recognized?
@@ -14,7 +19,6 @@ For example, when an Item is scanned while viewing a Delivery Note record, it wi
|-----------------|-----------------------|--------|--------|
|Item|Delivery Note|add_or_increment|item_code|
-Beam uses a [decision matrix](./matrix.md) to decide what action to take based on what kind of doctype has been scanned.
+BEAM uses a [decision matrix](./matrix.md) to decide what action to take based on what kind of doctype has been scanned.
Custom actions and client side functions can be added by using [hooks](./hooks.md).
-
diff --git a/beam/docs/handling_unit.md b/beam/docs/handling_unit.md
index d13521c3..8d572804 100644
--- a/beam/docs/handling_unit.md
+++ b/beam/docs/handling_unit.md
@@ -3,16 +3,21 @@ For license information, please see license.txt-->
# Handling Unit
+
+ Rohan Bansal, github-actions, Heather Kusmierz, Tyler Matteson, and Francisco Roldán 2025-05-28
+
+
+
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.
-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.
+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.

## Listviews
Generally scanning a Handling Unit in a list view will filter to show all the transactions of the doctype with the appropriate Handling Unit.
-## Purchase Receipt
+## Purchase Receipt
For Purchase Receipts, Handling Units are generated and cannot be supplied by the user.
| Item | Warehouse | Handling Unit | Quantity |
@@ -76,13 +81,29 @@ When material is transferred from one warehouse to another, it will generate a n
| Cocoplum | Work In Progress | 456 | 20 Ea |
-When cancelling a Stock Entry, the user will be given an option to re-combine or let handling units remain tracked separately.
+#### Cancelling Material Transfer Entries
+
+When cancelling a Material Transfer Stock Entry (including Send to Subcontractor and Material Transfer for Manufacture), a dialog appears asking whether to recombine handling units or keep them tracked separately.

+The dialog shows each source handling unit along with its corresponding target handling unit that was created during the transfer. By default, all rows are pre-selected for recombination (the recommended action).
+
+**Recombine (Default):** When rows are selected and "Cancel and Recombine" is clicked:
+- The source and target handling units are merged back together
+- The original handling unit retains its full quantity as if the transfer never happened
+- The target handling unit is removed from inventory
+- This is the typical choice when correcting errors or undoing temporary transfers
+
+**Keep Separate:** When rows are unchecked before clicking "Cancel and Recombine":
+- Both handling units remain in the system with their respective quantities
+- Stock ledger entries are created to restore the quantities in both warehouses
+- The handling units continue to be tracked independently
+- Useful when you want to maintain the split for future reference or traceability
+
### Repack and Manufacture
-In the case of a Repack, Material Issue or Material Consumption for Manufacture, a new Handling Unit is generated for the new quantities.
+In the case of a Repack, Material Issue or Material Consumption for Manufacture, a new Handling Unit is generated for the new quantities.
| Item | Warehouse | Handling Unit | Quantity |
| ---------------- | ------------------ | -------------- | --------------:|
@@ -98,6 +119,22 @@ In a case where less than the total quantity associated with a Handling Unit is
| Cocoplum Puree | Work In Progress | 012 | 1 liter |
| Cocoplum | Scrap | | 1 Ea |
+#### Cancelling Repack and Manufacture Entries
+
+Similar to Material Transfer entries, when cancelling a Repack or Manufacture Stock Entry, a dialog appears to choose the recombine behavior. The dialog shows each consumed (source) handling unit paired with its corresponding produced (target) handling unit. All rows are pre-selected for recombination by default.
+
+**Recombine (Default):** When rows are selected:
+- The consumed handling unit is restored to its original quantity
+- The produced handling unit is removed from inventory
+- The transformation is completely reversed
+- Best for correcting data entry errors or voiding incorrect manufacturing entries
+
+**Keep Separate:** When rows are unchecked:
+- The consumed handling unit receives its quantity back
+- The produced handling unit also retains its quantity
+- Both handling units coexist in inventory
+- Useful for maintaining audit trails when a production run needs to be reversed but you want to preserve the separate handling unit records for compliance or tracking purposes
+
#### BOM Scrap Item
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.
@@ -117,7 +154,7 @@ In both these cases, there is no offsetting movement or creation of items.
| Cocoplum | Work In Progress | 123 | -20 Ea |
### Material Receipt
-In the case of Material Receipt, a new Handling Unit is generated for each item.
+In the case of Material Receipt, a new Handling Unit is generated for each item.
| Item | Warehouse | Handling Unit | Quantity |
| ---------------- | ------------------ | -------------- | --------------:|
diff --git a/beam/docs/hooks.md b/beam/docs/hooks.md
index 04fb9f54..66a2963e 100644
--- a/beam/docs/hooks.md
+++ b/beam/docs/hooks.md
@@ -1,9 +1,14 @@
-# Extending Beam With Custom Hooks
+# Extending BEAM With Custom Hooks
-Beam can be extended by adding configurations to your application's `hooks.py`.
+
+ Rohan Bansal, Heather Kusmierz, and Tyler Matteson 2025-05-28
+
+
+
+BEAM can be extended by adding configurations to your application's `hooks.py`.
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.
@@ -40,7 +45,7 @@ beam_frm = {
}
}
```
-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.
+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.
```python
# hooks.py
@@ -49,4 +54,4 @@ beam_client = {
"show_message": "custom_app.show_message"
}
-```
\ No newline at end of file
+```
diff --git a/beam/docs/hu_traceability_report.md b/beam/docs/hu_traceability_report.md
index 2752d6e5..12080b4e 100644
--- a/beam/docs/hu_traceability_report.md
+++ b/beam/docs/hu_traceability_report.md
@@ -3,6 +3,11 @@ For license information, please see license.txt-->
# Handling Unit Traceability Report
+
+ Rohan Bansal, Heather Kusmierz, and Tyler Matteson 2025-02-14
+
+
+
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.

diff --git a/beam/docs/index.md b/beam/docs/index.md
index 89dbbc60..c2a7d6db 100644
--- a/beam/docs/index.md
+++ b/beam/docs/index.md
@@ -1,13 +1,17 @@
-# Beam
+# BEAM
-Beam is a general purpose 2D barcode scanning application for ERPNext.
+
+ Rohan Bansal, Heather Kusmierz, Tyler Matteson, and Francisco Roldán 2025-05-28
+
+
+BEAM is a general purpose barcode scanning application for ERPNext.
## What does this application do?
-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.
+BEAM allows a user to scan a 2D or QR 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.
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.
@@ -23,22 +27,42 @@ If the user scans an Item in a Delivery Note, it will populate everything it kno
Read more about [how scanning in form views works](./form.md).
-## Beam Settings
+## BEAM Settings
-Beam's version 15 introduced a new Beam Settings document to allow users to opt in or out of features in the app. Settings are unique on a per-company basis and are automatically generated (with default options) during certain related transactions if a Beam Settings document doesn't already exist for the company. Related transactions include submission of a Purchase Receipt, Purchase Invoice, or Stock Entry.
+Version 15 introduced a new BEAM Settings document to allow users to opt in or out of features in the app. Settings are unique on a per-company basis and are automatically generated (with default options) during certain related transactions if a BEAM Settings document doesn't already exist for the company. Related transactions include submission of a Purchase Receipt, Purchase Invoice, or Stock Entry.
-
+
Settings options include:
- **Company:** the company in ERPNext to apply the given settings to. One Beam Settings document may exist for each company in the system
+- **Barcode Font Size:** (default 12) the font size to use when printing barcodes
- **Enable Handling Units:** (default checked) enables the generation of Handling Units (see What is a Handling Unit section for more information)
+- **Ignore Drop Shipped Items in Demand:** (default unchecked) if checked, calculated demand from Sales Orders will ignore any items marked to be shipped by the supplier (drop shipped)
+
+### QR Code Settings
+
+- **QR Scale:** (default 8) the module size in pixels used when generating QR code images — larger values produce a bigger image
+- **QR Border:** (default 4) the quiet zone border size in modules surrounding the QR code
+- **QR Error Correct:** (default M) the error correction level encoded into QR codes; options are L (7%), M (15%), Q (25%), and H (30%) — higher levels allow the code to remain scannable even if partially damaged, at the cost of a denser image
+
+### Barcode Generation
+
+The Barcode Generation section controls which document types receive an automatically generated Code128 barcode when saved. Any document type that has a Barcodes table (using the Item Barcode child doctype) is listed here. Checked items have auto-generation **enabled**; unchecked items are shown with a strikethrough and will not have barcodes generated on save.
+By default, **Item** and **Warehouse** are enabled. If a Code128 barcode already exists on a document, a new one will never be generated regardless of this setting. If you customize another doctype by adding a Item Barcode table, automatic generation can be configured here but still requires a `doc_event` hook to trigger, which can be configured in your app's `hooks.py` or in a Server Script.
+```python
+"Asset": {
+ "validate": [
+ "beam.beam.barcodes.create_beam_barcode",
+ ]
+},
+```
## What is a Handling Unit?
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.
-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.
+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.
A Handling Unit is generated when materials are received or created in the manufacturing process.
@@ -46,7 +70,7 @@ Read more [about Handling Units here](./handling_unit.md).
## Installation and Customization
-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:
+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:
- [Installation](https://github.com/agritheory/beam)
- [Customization](./hooks.md)
@@ -59,7 +83,7 @@ Warehouses may also have unique barcodes associated with them. The user can navi
## Print Server Integration
-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).
+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).
### Zebra Printing
diff --git a/beam/docs/listview.md b/beam/docs/listview.md
index b3fe3a31..1dc92136 100644
--- a/beam/docs/listview.md
+++ b/beam/docs/listview.md
@@ -3,6 +3,11 @@ For license information, please see license.txt-->
# Listview
+
+ Rohan Bansal, Heather Kusmierz, and Tyler Matteson 2025-05-28
+
+
+
The result of scanning a barcode in the listview depends on several factors:
- Is the barcode recognized?
@@ -22,7 +27,6 @@ Another example: If an Item is scanned while viewing the Purchase Receipt list,
|Item|Purchase Receipt|filter|item_code|
-Beam uses a [decision matrix](./matrix.md) to decide what action to take based on what kind of doctype has been scanned.
+BEAM uses a [decision matrix](./matrix.md) to decide what action to take based on what kind of doctype has been scanned.
Custom actions and client side functions can be added by using [hooks](./hooks.md)
-
diff --git a/beam/docs/matrix.md b/beam/docs/matrix.md
index 6133f078..5fe860e2 100644
--- a/beam/docs/matrix.md
+++ b/beam/docs/matrix.md
@@ -2,6 +2,11 @@
For license information, please see license.txt-->
# Listview Actions
+
+
+ Rohan Bansal and Tyler Matteson 2025-05-28
+
+
| Scanned Doctype | Listview | Action | Target |
|-----------------|-----------------------|--------|--------|
|Handling Unit|Delivery Note|route|Delivery Note|
@@ -36,7 +41,7 @@ For license information, please see license.txt-->
|Warehouse|Stock Reconciliation|filter|warehouse|
|Warehouse|Warehouse|route|Warehouse|
- ---
+ ---
# Form Actions
| Scanned Doctype | Form | Action | Target |
diff --git a/beam/docs/print_server.md b/beam/docs/print_server.md
index 28bf6fed..542bae0c 100644
--- a/beam/docs/print_server.md
+++ b/beam/docs/print_server.md
@@ -3,6 +3,11 @@ For license information, please see license.txt-->
# Print Server
+
+ Rohan Bansal, Heather Kusmierz, and Tyler Matteson 2025-02-14
+
+
+
There are several steps to get a print server connected in ERPNext.
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:
@@ -15,6 +20,8 @@ There are several steps to get a print server connected in ERPNext.

+The **Printer Name** field is an autocomplete that queries the configured CUPS server and displays available printers by their CUPS identifier, with the make/model and location shown as secondary text. Selecting a printer automatically fills in the **Printer Location** field from CUPS. The location can be edited freely — saving the record pushes the updated value back to CUPS, keeping the two in sync. The **Printer Type** field (`General Purpose` or `Label / RAW`) can be used to distinguish IPP or PDF printers from ZPL/raw label printers.
+
---
A convenient Print Handling Unit button on relevant doctypes enables the user to print new Handling Unit labels directly from the ERPNext user interface.
diff --git a/beam/docs/testing.md b/beam/docs/testing.md
index 04d877a2..9bf47c80 100644
--- a/beam/docs/testing.md
+++ b/beam/docs/testing.md
@@ -3,6 +3,11 @@ For license information, please see license.txt-->
# Testing
+
+ Rohan Bansal, Heather Kusmierz, and Tyler Matteson 2025-02-14
+
+
+
## Simulating a Scanner
Open the browser console. This assumes a barcode of `'9968934975826708157'` which must be sent as a string.
diff --git a/beam/docs/zebra_printing.md b/beam/docs/zebra_printing.md
index 9334a993..0bb7d4e9 100644
--- a/beam/docs/zebra_printing.md
+++ b/beam/docs/zebra_printing.md
@@ -3,22 +3,27 @@ For license information, please see license.txt-->
# Zebra Printing
+
+ Rohan Bansal and Tyler Matteson 2025-02-14
+
+
+
To create a Zebra print format, you need the following documents:
- 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.
- 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.
### ZPL Code Generation
-Currently, only three types of printable ZPL data can be generated with utilities within Beam:
+Currently, only three types of printable ZPL data can be generated with utilities within BEAM:
- `Text`
- `Barcode`
- `Label`
-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.
+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.
**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).
-In addition, Beam exposes the following Jinja functions to be used within a Print Format:
+In addition, BEAM exposes the following Jinja functions to be used within a Print Format:
---
@@ -135,21 +140,34 @@ Additional arguments can be passed to the function to customize the text. Please
#### `labelary_api`
-Generate an encoded Zebra printing label via the free Labelary API. It takes the following arguments:
+Generate an encoded Zebra printing label preview via the free Labelary API. Converts ZPL code to a PNG image for preview purposes. It takes the following arguments:
- `doc`: The document to be printed. Required.
- `print_format`: The ZPL Print Format to be used for generating the label. Required.
- `settings`: Additional settings to be passed to the Labelary API. Allows setting up the following parameters:
- - `dpmm`: The desired print density, in dots per millimeter. Defaults to 8.
+ - `dpmm`: The desired print density, in dots per millimeter. Defaults to 8 (≈203 DPI). Use 12 for 300 DPI printers.
- `width`: The desired label width, in inches. Defaults to 6.
- `height`: The desired label height, in inches. Defaults to 4.
- `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.
-##### Example
+**Important:** The `width` and `height` settings **MUST match the label dimensions used in your ZPL format**, otherwise the image will appear stretched or compressed. The `dpmm` setting should also match your printer's DPI.
+
+##### Example: 6x4" label at 203 DPI
```jinja
-
+
```
+##### Example: 4x6" label at 300 DPI
+```jinja
+
+```
+
+##### DPI Reference
+| Printer Type | DPI | DPMM |
+|---|---|---|
+| Standard | 203 | 8 |
+| High Resolution | 300 | 12 |
+
---
#### `get_handling_unit`
@@ -182,3 +200,187 @@ Add text, barcodes, and other printable elements to a ZPL label. It takes the fo
{% add_to_label(label, barcode) %}
{{ label.dump_contents() }}
```
+
+---
+
+## ZPL Label Layout Tools
+
+The ZPL Layout Tools are designed to accelerate the process of creating ZPL label templates by automatically extracting text coordinates from PDF shipping label samples and generating production-ready ZPL templates with correct coordinates.
+
+### Overview
+
+Instead of manually measuring and calculating ZPL dot coordinates for every label element, you can:
+
+1. Run the layout analysis tool against a sample PDF label
+2. Get an automatically generated ZPL template with all coordinates mapped
+3. Customize as needed for your specific document fields
+4. Integrate into BEAM print formats
+
+### Command Line Tool
+
+The layout analysis tool is available as a standalone command-line utility at `beam/beam/zpl_layout.py`.
+
+#### Usage
+
+```bash
+# Activate the virtual environment
+source /path/to/env/bin/activate
+cd /path/to/beam
+
+# Basic usage (assumes portrait PDF, 6x4" landscape output @ 300 DPI)
+python beam/beam/zpl_layout.py /path/to/label.pdf
+
+# Specify custom label dimensions
+python beam/beam/zpl_layout.py /path/to/label.pdf --width 4 --height 6 --dpi 203
+
+# Disable rotation (for already-landscape PDFs)
+python beam/beam/zpl_layout.py /path/to/label.pdf --no-rotate
+
+# Custom output directory
+python beam/beam/zpl_layout.py /path/to/label.pdf --output ./my_templates/
+```
+
+#### Options
+
+- `pdf`: Path to the PDF file to analyze (required)
+- `--output, -o`: Output directory (default: creates `output/` directory next to PDF)
+- `--dpi`: Target printer DPI - 203 or 300 (default: 300)
+- `--width`: Label width in inches (default: 6)
+- `--height`: Label height in inches (default: 4)
+- `--no-rotate`: Do not rotate portrait PDF to landscape
+
+### Output Files
+
+For each PDF processed, the tool generates three files in the output directory:
+
+#### 1. `{label_name}.zpl` - Production ZPL Template
+
+A Jinja2-compatible ZPL template with:
+- All text coordinates automatically mapped
+- Sections organized (addresses, shipping info, product details, barcodes)
+- Variable placeholders (e.g., `{{ doc.ship_to_name }}`) ready for customization
+- Comments indicating each section and coordinate values
+
+Example:
+```jinja
+{# Shipping Label - 6.0x4.0" @ 300 DPI #}
+{% set label = zebra_zpl_label(width=1800.0, length=1200.0, dpi=300) -%}
+
+^XA {# Start Format #}
+^PW1800.0 {# Print Width: 1800.0 dots #}
+^LL1200.0 {# Label Length: 1200.0 dots #}
+
+{# === ADDRESS SECTION === #}
+{# Ship From (Left Side) #}
+^FO50,150^A0N,35,35^FDShip From:^FS
+^FO50,200^A0N,28,28^FB700,5,0,L,0^FD{{ doc.ship_from_name }}^FS
+^FO50,250^A0N,28,28^FB700,5,0,L,0^FD{{ doc.ship_from_address }}^FS
+
+...
+
+^XZ {# End Format #}
+```
+
+#### 2. `{label_name}_analysis.json` - Coordinate Data
+
+JSON file containing detailed extraction results:
+- Label dimensions in dots and DPI
+- Text blocks grouped by section (main_addresses, shipping_info, product_details, etc.)
+- Each block includes:
+ - Text content
+ - ZPL X,Y coordinates (in dots)
+ - Barcode detection flag
+
+Use this for reference or further customization.
+
+#### 3. `{label_name}_layout_map.txt` - ASCII Visual Map
+
+ASCII art representation of the label layout showing:
+- `·` for regular text blocks
+- `â–ˆ` for detected barcodes
+- Borders indicating label dimensions
+
+Useful for visually verifying that coordinates were extracted correctly.
+
+### Integration into BEAM Print Formats
+
+Once you have a generated ZPL template:
+
+1. **Copy the template** into a new BEAM Print Format (create via Settings > Print Format)
+2. **Replace variable placeholders** with actual document field references:
+ - `{{ doc.ship_from_name }}` → `{{ doc.supplier_name }}` (or your actual field)
+ - `{{ doc.po_number }}` → `{{ doc.purchase_order_number }}`
+ - etc.
+3. **Test in Labelary viewer** at https://labelary.com/viewer.html
+ - Copy the ZPL code (with variables replaced by test data)
+ - Set label size to match your printer
+ - Verify layout and positioning
+4. **Adjust coordinates as needed** based on actual print results
+
+### Key Features
+
+- **Automatic Barcode Detection**: Identifies GS1 Application Identifiers (e.g., `(420)`) and long numeric sequences
+- **Rotation Support**: Automatically converts portrait PDFs (4"×6") to landscape (6"×4")
+- **Multi-DPI Support**: Works with 203 DPI and 300 DPI printers
+- **Section Grouping**: Intelligently organizes extracted text into logical regions
+- **Visual Feedback**: ASCII layout map shows element positions for verification
+
+### Coordinate System
+
+The tool converts between different coordinate systems:
+
+| System | Origin | Y-Axis | Units | Example |
+|--------|--------|--------|-------|---------|
+| PDF | Bottom-left | Increases upward | Points | (x0, y0) in pdfplumber |
+| ZPL | Top-left | Increases downward | Dots | ^FO{x},{y} in ZPL |
+
+Conversion formula: `zpl_dots = pdf_points × (target_dpi / 72)`
+
+### DPI/DPMM Reference
+
+When using the `labelary_api` helper or generating ZPL templates, ensure label dimensions match across all components:
+
+| DPI | DPMM | Printer Type | Example |
+|-----|------|--------------|---------|
+| 203 | 8 | Standard Zebra | Most common thermal printers |
+| 300 | 12 | High Resolution | Better quality labels |
+
+**Critical:** Always pass the correct `dpmm` value to `labelary_api` to avoid image stretching. If your ZPL template is 6x4" at 300 DPI but you pass `dpmm: 8`, the preview will appear stretched horizontally.
+
+Example configurations:
+- 6x4" label at 203 DPI: `labelary_api(doc, 'Format Name', {'width': 6, 'height': 4, 'dpmm': 8})`
+- 4x6" label at 300 DPI: `labelary_api(doc, 'Format Name', {'width': 4, 'height': 6, 'dpmm': 12})`
+
+### Troubleshooting
+
+**Coordinates seem incorrect:**
+- Verify the PDF orientation (portrait vs. landscape)
+- Try with `--no-rotate` flag if PDF is already landscape
+- Check that DPI matches your printer specification
+
+**Text not grouped correctly:**
+- The section boundaries may need adjustment for non-standard label layouts
+- Use the JSON analysis file to see exactly where text was detected
+- Consider manually adjusting section coordinates in the generated template
+
+**Missing elements:**
+- Some PDF elements (images, lines) may not be extracted
+- pdfplumber extracts text only; complex graphics may need manual addition
+- Review the layout map to identify missing elements
+
+### Example: Processing Trading Partner Labels
+
+The `label_spec/` folder contains sample PDFs from multiple trading partners. To generate templates for all:
+
+```bash
+cd /path/to/beam
+source /path/to/env/bin/activate
+
+# Pure Hockey (6x4 with rotation)
+python beam/beam/zpl_layout.py label_spec/Pure\ Hockey\ -\ ASN\ label/*.pdf
+
+# Mindware (4x6 already landscape)
+python beam/beam/zpl_layout.py "label_spec/Mindware - Oriental Trading Co - Carton label/*.pdf" --width 4 --height 6 --no-rotate
+```
+
+Templates are automatically saved to `label_spec/{partner}/output/` for easy access.
diff --git a/beam/hooks.py b/beam/hooks.py
index 53d939be..b00d7d1e 100644
--- a/beam/hooks.py
+++ b/beam/hooks.py
@@ -8,6 +8,7 @@
app_description = "Barcode Scanning for ERPNext"
app_email = "support@agritheory.dev"
app_license = "MIT"
+required_apps = ["erpnext"]
# Includes in
# ------------------
@@ -18,7 +19,7 @@
# include js, css files in header of web template
# web_include_css = "/assets/beam/css/beam.css"
-# web_include_js = "/assets/beam/js/beam.js"
+web_include_js = ["beam-web.bundle.js"]
# include custom scss in every website theme (without file extension ".scss")
# website_theme_scss = "beam/public/scss/website"
@@ -31,7 +32,10 @@
# page_js = {"page" : "public/js/file.js"}
# include js in doctype views
-doctype_js = {"Stock Entry": "public/js/stock_entry_custom.js"}
+doctype_js = {
+ "Network Printer Settings": "public/js/network_printer_settings_custom.js",
+ "Stock Entry": "public/js/stock_entry_custom.js",
+}
# doctype_list_js = {"doctype" : "public/js/doctype_list.js"}
# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"}
@@ -44,7 +48,7 @@
# website user home page (by Role)
# role_home_page = {
-# "Role": "home_page"
+# "BEAM Mobile User": "/beam/"
# }
# Generators
@@ -60,6 +64,7 @@
"methods": [
"beam.beam.barcodes.add_to_label",
"beam.beam.barcodes.barcode128",
+ "beam.beam.barcodes.get_qr_code",
"beam.beam.barcodes.formatted_zpl_barcode",
"beam.beam.barcodes.formatted_zpl_label",
"beam.beam.barcodes.formatted_zpl_text",
@@ -68,6 +73,7 @@
"beam.beam.barcodes.zebra_zpl_text",
"beam.beam.printing.labelary_api",
"beam.beam.scan.get_handling_unit",
+ "beam.beam.scan.get_serial_no",
],
}
@@ -110,8 +116,11 @@
# ---------------
# Override standard doctype classes
override_doctype_class = {
+ "Sales Order": "beam.beam.overrides.sales_order.BEAMSalesOrder",
+ "Network Printer Settings": "beam.beam.overrides.network_printer_settings.BEAMNetworkPrinterSettings",
"Stock Entry": "beam.beam.overrides.stock_entry.BEAMStockEntry",
"Subcontracting Receipt": "beam.beam.overrides.subcontracting_receipt.BEAMSubcontractingReceipt",
+ "Work Order": "beam.beam.overrides.work_order.BEAMWorkOrder",
}
@@ -120,55 +129,68 @@
# Hook on document methods and events
doc_events = {
- "Item": {
- "validate": [
- "beam.beam.barcodes.create_beam_barcode",
- ]
- },
- "Warehouse": {
- "validate": [
- "beam.beam.barcodes.create_beam_barcode",
- ]
- },
- "Purchase Receipt": {
- "before_submit": [
- "beam.beam.handling_unit.generate_handling_units",
+ "Inventory Dimension": {
+ "after_insert": [
+ "beam.beam.overrides.inventory_dimension.reset_demand_map",
+ "beam.beam.overrides.inventory_dimension.reset_receiving_map",
],
- "validate": [
- # "beam.beam.handling_unit.validate_handling_unit_overconsumption",
+ "on_trash": [
+ "beam.beam.overrides.inventory_dimension.reset_demand_map",
+ "beam.beam.overrides.inventory_dimension.reset_receiving_map",
],
},
- "Purchase Invoice": {
- "before_submit": [
- "beam.beam.handling_unit.generate_handling_units",
- ],
+ ("Item", "Warehouse", "User"): {
+ "validate": ["beam.beam.barcodes.create_beam_barcode"],
+ },
+ # (
+ # "Purchase Receipt",
+ # "Stock Entry",
+ # "Sales Invoice",
+ # "Delivery Note",
+ # ): {"validate": ["beam.beam.handling_unit.validate_handling_unit_overconsumption"]},
+ ("Delivery Note", "Purchase Receipt", "Sales Invoice", "Stock Entry", "Stock Reconciliation",): {
+ "on_submit": ["beam.beam.demand.demand.modify_allocations"],
+ "on_cancel": ["beam.beam.demand.demand.modify_allocations"],
+ },
+ ("Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"): {
+ "before_submit": ["beam.beam.handling_unit.generate_handling_units"],
},
"Stock Entry": {
- "validate": [
- # "beam.beam.handling_unit.validate_handling_unit_overconsumption",
- ],
"before_submit": [
"beam.beam.handling_unit.generate_handling_units",
"beam.beam.overrides.stock_entry.validate_items_with_handling_unit",
],
},
- "Sales Invoice": {
- "validate": [
- # "beam.beam.handling_unit.validate_handling_unit_overconsumption",
- ],
+ ("Sales Order", "Work Order"): {
+ "on_submit": ["beam.beam.demand.demand.modify_demand"],
+ "on_cancel": ["beam.beam.demand.demand.modify_demand"],
},
- "Delivery Note": {
- "validate": [
- # "beam.beam.handling_unit.validate_handling_unit_overconsumption",
+ "Purchase Order": {
+ "on_submit": ["beam.beam.demand.receiving.modify_receiving"],
+ "on_cancel": ["beam.beam.demand.receiving.modify_receiving"],
+ },
+ "Purchase Invoice": {
+ "on_submit": [
+ "beam.beam.demand.receiving.modify_receiving",
+ "beam.beam.demand.demand.modify_allocations",
+ ],
+ "on_cancel": [
+ "beam.beam.demand.receiving.modify_receiving",
+ "beam.beam.demand.demand.modify_allocations",
],
},
- "Subcontracting Receipt": {
- "before_submit": [
- "beam.beam.handling_unit.generate_handling_units",
+ "Company": {
+ "after_insert": [
+ "beam.beam.overrides.company.create_company_beam_settings",
],
},
}
+# Types
+# ---------------
+
+export_python_type_annotations = True
+
# Scheduled Tasks
# ---------------
@@ -197,11 +219,10 @@
# Overriding Methods
# ------------------------------
-#
-# override_whitelisted_methods = {
-# "frappe.desk.doctype.event.event.get_events": "beam.event.get_events"
-# }
-#
+
+# override_whitelisted_methods = {"demand": "beam.beam..graphql_server"}
+
+
# each overriding function accepts a `data` argument;
# generated from the base implementation of the doctype dashboard,
# along with any modifications made in other Frappe apps
@@ -255,6 +276,388 @@
# Authentication and authorization
# --------------------------------
-# auth_hooks = [
-# "beam.auth.validate"
-# ]
+auth_hooks = ["beam.beam.boot.redirect_to_beam"]
+
+demand = {
+ "Delivery Note": {
+ "on_submit": [
+ {
+ "warehouse_field": "warehouse",
+ "quantity_field": "stock_qty",
+ "demand_effect": "decrease",
+ "allocation_effect": "decrease",
+ }
+ ],
+ "on_cancel": [
+ {
+ "warehouse_field": "warehouse",
+ "quantity_field": "stock_qty",
+ "demand_effect": "increase",
+ "allocation_effect": "increase",
+ }
+ ],
+ },
+ "Purchase Invoice": {
+ "on_submit": [
+ {
+ "warehouse_field": "warehouse",
+ "quantity_field": "stock_qty",
+ "demand_effect": "increase",
+ "allocation_effect": "decrease",
+ "conditions": {"update_stock": True, "is_return": False},
+ },
+ {
+ "warehouse_field": "warehouse",
+ "quantity_field": "stock_qty",
+ "demand_effect": "decrease",
+ "allocation_effect": "increase",
+ "conditions": {"update_stock": True, "is_return": True},
+ },
+ ],
+ "on_cancel": [
+ {
+ "warehouse_field": "warehouse",
+ "quantity_field": "stock_qty",
+ "demand_effect": "decrease",
+ "allocation_effect": "increase",
+ "conditions": {"update_stock": True, "is_return": False},
+ },
+ {
+ "warehouse_field": "warehouse",
+ "quantity_field": "stock_qty",
+ "demand_effect": "increase",
+ "allocation_effect": "decrease",
+ "conditions": {"update_stock": True, "is_return": True},
+ },
+ ],
+ },
+ "Purchase Receipt": {
+ "on_submit": [
+ {
+ "warehouse_field": "warehouse",
+ "quantity_field": "stock_qty",
+ "demand_effect": "decrease",
+ "allocation_effect": "increase",
+ }
+ ],
+ "on_cancel": [
+ {
+ "warehouse_field": "warehouse",
+ "quantity_field": "stock_qty",
+ "demand_effect": "increase",
+ "allocation_effect": "decrease",
+ }
+ ],
+ },
+ "Sales Invoice": {
+ "on_submit": [
+ {
+ "warehouse_field": "warehouse",
+ "quantity_field": "stock_qty",
+ "demand_effect": "decrease",
+ "allocation_effect": "increase",
+ "conditions": {"update_stock": True, "is_return": False},
+ },
+ {
+ "warehouse_field": "warehouse",
+ "quantity_field": "stock_qty",
+ "demand_effect": "increase",
+ "allocation_effect": "decrease",
+ "conditions": {"update_stock": True, "is_return": True},
+ },
+ ],
+ "on_cancel": [
+ {
+ "warehouse_field": "warehouse",
+ "quantity_field": "stock_qty",
+ "demand_effect": "increase",
+ "allocation_effect": "decrease",
+ "conditions": {"update_stock": True, "is_return": False},
+ },
+ {
+ "warehouse_field": "warehouse",
+ "quantity_field": "stock_qty",
+ "demand_effect": "decrease",
+ "allocation_effect": "increase",
+ "conditions": {"update_stock": True, "is_return": True},
+ },
+ ],
+ },
+ "Stock Entry": {
+ "on_submit": [
+ {
+ "warehouse_field": "s_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "decrease",
+ "conditions": {"purpose": "Material Transfer for Manufacture"},
+ },
+ {
+ "warehouse_field": "t_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "increase",
+ "conditions": {"purpose": "Material Transfer for Manufacture"},
+ },
+ {
+ "warehouse_field": "s_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "decrease",
+ "conditions": {"purpose": "Material Issue"},
+ },
+ {
+ "warehouse_field": "t_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "increase",
+ "conditions": {"purpose": "Material Receipt"},
+ },
+ {
+ "warehouse_field": "s_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "decrease",
+ "conditions": {"purpose": "Material Transfer"},
+ },
+ {
+ "warehouse_field": "t_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "increase",
+ "conditions": {"purpose": "Material Transfer"},
+ },
+ {
+ "warehouse_field": "s_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "decrease",
+ "conditions": {"purpose": "Manufacture"},
+ },
+ {
+ "warehouse_field": "t_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "increase",
+ "conditions": {"purpose": "Manufacture"},
+ },
+ {
+ "warehouse_field": "s_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "decrease",
+ "conditions": {"purpose": "Repack"},
+ },
+ {
+ "warehouse_field": "t_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "increase",
+ "conditions": {"purpose": "Repack"},
+ },
+ {
+ "warehouse_field": "s_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "decrease",
+ "conditions": {"purpose": "Send to Subcontractor"},
+ },
+ {
+ "warehouse_field": "t_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "increase",
+ "conditions": {"purpose": "Send to Subcontractor"},
+ },
+ ],
+ "on_cancel": [
+ {
+ "warehouse_field": "s_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "increase",
+ "conditions": {"purpose": "Material Transfer for Manufacture"},
+ },
+ {
+ "warehouse_field": "t_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "decrease",
+ "conditions": {"purpose": "Material Transfer for Manufacture"},
+ },
+ {
+ "warehouse_field": "s_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "increase",
+ "conditions": {"purpose": "Material Issue"},
+ },
+ {
+ "warehouse_field": "s_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "decrease",
+ "conditions": {"purpose": "Material Receipt"},
+ },
+ {
+ "warehouse_field": "s_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "increase",
+ "conditions": {"purpose": "Material Transfer"},
+ },
+ {
+ "warehouse_field": "t_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "decrease",
+ "conditions": {"purpose": "Material Transfer"},
+ },
+ {
+ "warehouse_field": "s_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "increase",
+ "conditions": {"purpose": "Manufacture"},
+ },
+ {
+ "warehouse_field": "t_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "decrease",
+ "conditions": {"purpose": "Manufacture"},
+ },
+ {
+ "warehouse_field": "s_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "increase",
+ "conditions": {"purpose": "Repack"},
+ },
+ {
+ "warehouse_field": "t_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "decrease",
+ "conditions": {"purpose": "Repack"},
+ },
+ {
+ "warehouse_field": "s_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "increase",
+ "conditions": {"purpose": "Send to Subcontractor"},
+ },
+ {
+ "warehouse_field": "t_warehouse",
+ "quantity_field": "transfer_qty",
+ "allocation_effect": "decrease",
+ "conditions": {"purpose": "Send to Subcontractor"},
+ },
+ ],
+ },
+ "Stock Reconciliation": {
+ "on_submit": [
+ {
+ "warehouse_field": "warehouse",
+ "quantity_field": "qty",
+ "allocation_effect": "adjustment",
+ }
+ ],
+ "on_cancel": [
+ {
+ "warehouse_field": "warehouse",
+ "quantity_field": "qty",
+ "allocation_effect": "adjustment",
+ }
+ ],
+ },
+}
+
+
+beam_mobile = {
+ "components": {
+ "DeliveryNote": "./beam/beam/www/beam/pages/DeliveryNote.vue",
+ "Demand": "./beam/beam/www/beam/pages/Demand.vue",
+ "Home": "./beam/beam/www/beam/pages/Home.vue",
+ "JobCard": "./beam/beam/www/beam/pages/JobCard.vue",
+ "Manufacture": "./beam/beam/www/beam/pages/Manufacture.vue",
+ "Move": "./beam/beam/www/beam/pages/Move.vue",
+ "Operation": "./beam/beam/www/beam/pages/Operation.vue",
+ "PurchaseReceipt": "./beam/beam/www/beam/pages/PurchaseReceipt.vue",
+ "Receive": "./beam/beam/www/beam/pages/Receive.vue",
+ "Repack": "./beam/beam/www/beam/pages/Repack.vue",
+ "Ship": "./beam/beam/www/beam/pages/Ship.vue",
+ "WorkOrder": "./beam/beam/www/beam/pages/WorkOrder.vue",
+ "Workstation": "./beam/beam/www/beam/pages/Workstation.vue",
+ "404": "./beam/beam/www/beam/pages/404.vue",
+ },
+ "routes": [
+ {
+ "path": "/",
+ "name": "home",
+ "component": "Home",
+ "meta": {"requiresAuth": True, "doctype": None, "view": "list"},
+ },
+ {
+ "path": "/workstation",
+ "name": "workstation",
+ "component": "Workstation",
+ "meta": {"requiresAuth": True, "doctype": "Workstation", "view": "list"},
+ },
+ {
+ "path": "/work_order/:id/",
+ "name": "work_order",
+ "component": "WorkOrder",
+ "meta": {"requiresAuth": True, "doctype": "Work Order", "view": "form"},
+ },
+ {
+ "path": "/job_card/:id/",
+ "name": "job_card",
+ "component": "JobCard",
+ "meta": {"requiresAuth": True, "doctype": "Work Order", "view": "form"},
+ },
+ {
+ "path": "/work_order/:id/operation/:operationId",
+ "name": "operation",
+ "component": "Operation",
+ "meta": {"requiresAuth": True, "doctype": "Work Order", "view": "form"},
+ },
+ {
+ "path": "/receive",
+ "name": "receive",
+ "component": "Receive",
+ "meta": {"requiresAuth": True, "doctype": "Purchase Receipt", "view": "list"},
+ },
+ {
+ "path": "/purchase-receipt",
+ "name": "purchase-receipt",
+ "component": "PurchaseReceipt",
+ "meta": {"requiresAuth": True, "doctype": "Purchase Receipt", "view": "form"},
+ },
+ {
+ "path": "/purchase-receipt/:id",
+ "name": "purchase-receipt",
+ "component": "PurchaseReceipt",
+ "meta": {"requiresAuth": True, "doctype": "Purchase Receipt", "view": "form"},
+ },
+ {
+ "path": "/ship",
+ "name": "ship",
+ "component": "Ship",
+ "meta": {"requiresAuth": True, "doctype": "Delivery Note", "view": "list"},
+ },
+ {
+ "path": "/delivery-note",
+ "name": "delivery-note",
+ "component": "DeliveryNote",
+ "meta": {"requiresAuth": True, "doctype": "Delivery Note", "view": "form"},
+ },
+ {
+ "path": "/demand",
+ "name": "demand",
+ "component": "Demand",
+ "meta": {"requiresAuth": True, "doctype": "Stock Entry", "view": "list"},
+ },
+ {
+ "path": "/move",
+ "name": "move",
+ "component": "Move",
+ "meta": {"requiresAuth": True, "doctype": "Stock Entry", "view": "form"},
+ },
+ {
+ "path": "/manufacture",
+ "name": "manufacture",
+ "component": "Manufacture",
+ "meta": {"requiresAuth": True, "doctype": "Work Order", "view": "list"},
+ },
+ {
+ "path": "/repack",
+ "name": "repack",
+ "component": "Repack",
+ "meta": {"requiresAuth": True, "doctype": "Stock Entry", "view": "form"},
+ },
+ {
+ "path": "/:catchAll(.*)*",
+ "name": "404",
+ "component": "404",
+ },
+ ],
+}
diff --git a/beam/install.py b/beam/install.py
index 7ee4788b..e76a11ee 100644
--- a/beam/install.py
+++ b/beam/install.py
@@ -1,21 +1,33 @@
# Copyright (c) 2025, AgriTheory and contributors
# For license information, please see license.txt
+import pathlib
+
import frappe
+from frappe.utils import get_site_path
+from beam.beam.demand.demand import build_demand_allocation_map
+from beam.beam.demand.receiving import reset_build_receiving_map
from beam.beam.scan.config import get_scan_doctypes
-from beam.customize import load_customizations
+from beam.patches.v15.setup_beam_mobile_settings import execute
+
+
+def create_beam_mobile_user_role():
+ if not frappe.db.exists("Role", "BEAM Mobile User"):
+ role = frappe.get_doc(
+ {"doctype": "Role", "role_name": "BEAM Mobile User", "desk_access": 0, "home_page": "/beam"}
+ )
+ role.insert(ignore_permissions=True)
def after_install():
- load_customizations()
print("Setting up Handling Unit Inventory Dimension")
if frappe.db.exists("Inventory Dimension", "Handling Unit"):
return
huid = frappe.new_doc("Inventory Dimension")
huid.dimension_name = "Handling Unit"
huid.reference_document = "Handling Unit"
- huid.apply_to_all_doctypes = 1
+ huid.apply_to_all_doctypes = True
huid.save()
# re-label
@@ -29,17 +41,24 @@ def after_install():
if custom_field.dt == "Purchase Invoice Item":
frappe.set_value("Custom Field", custom_field, "label", "Handling Unit")
else:
- frappe.set_value("Custom Field", custom_field, "read_only", 1)
- frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1)
+ frappe.set_value("Custom Field", custom_field, "read_only", True)
+ frappe.set_value("Custom Field", custom_field["name"], "no_copy", True)
frm_doctypes = get_scan_doctypes()["frm"]
for custom_field in frappe.get_all("Custom Field", {"label": "Handling Unit"}, ["name", "dt"]):
- frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1)
+ frappe.set_value("Custom Field", custom_field["name"], "no_copy", True)
if (
custom_field["dt"] not in frm_doctypes
and custom_field["dt"].replace(" Item", "").replace(" Detail", "") not in frm_doctypes
):
- frappe.set_value("Custom Field", custom_field["name"], "read_only", 1)
- frappe.set_value("Custom Field", custom_field["name"], "no_copy", 1)
+ frappe.set_value("Custom Field", custom_field["name"], "read_only", True)
+ frappe.set_value("Custom Field", custom_field["name"], "no_copy", True)
+
+ print("Setting up demand database")
+ pathlib.Path(f"{get_site_path()}/demand.db").unlink(missing_ok=True)
+ build_demand_allocation_map()
+ reset_build_receiving_map()
+ create_beam_mobile_user_role()
+ execute()
diff --git a/beam/patches.txt b/beam/patches.txt
index e69de29b..ceaf3949 100644
--- a/beam/patches.txt
+++ b/beam/patches.txt
@@ -0,0 +1,2 @@
+beam.patches.v15.create_beam_mobile_user_role # 11/01/24 Francisco Roldan
+beam.patches.v15.setup_beam_mobile_settings # 11/01/24 Tyler Matteson
\ No newline at end of file
diff --git a/beam/patches/.gitkeep b/beam/patches/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/beam/patches/v15/create_beam_mobile_user_role.py b/beam/patches/v15/create_beam_mobile_user_role.py
new file mode 100644
index 00000000..59b7c148
--- /dev/null
+++ b/beam/patches/v15/create_beam_mobile_user_role.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2024, AgriTheory and contributors
+# For license information, please see license.txt
+
+from beam.install import create_beam_mobile_user_role
+
+
+def execute():
+ create_beam_mobile_user_role()
diff --git a/beam/patches/v15/setup_beam_mobile_settings.py b/beam/patches/v15/setup_beam_mobile_settings.py
new file mode 100644
index 00000000..86267b0e
--- /dev/null
+++ b/beam/patches/v15/setup_beam_mobile_settings.py
@@ -0,0 +1,39 @@
+# Copyright (c) 2024, AgriTheory and contributors
+# For license information, please see license.txt
+
+import frappe
+from erpnext import get_default_company
+
+from beam.beam.doctype.beam_settings.beam_settings import create_beam_settings
+
+
+def execute(company=None):
+ frappe.reload_doc("beam", "doctype", "beam_settings")
+
+ default_config = [
+ {
+ "label": "Manufacture",
+ "route": "#/manufacture",
+ "dt": "Stock Entry",
+ "component": "Manufacture",
+ },
+ {"label": "Demand", "route": "#/demand", "dt": "Stock Entry", "component": "Demand"},
+ {"label": "Move", "route": "#/move", "dt": "Stock Entry", "component": "Demand"},
+ {"label": "Receive", "route": "#/receive", "dt": "Purchase Receipt", "component": "Receive"},
+ {"label": "Ship", "route": "#/ship", "dt": "Delivery Note", "component": "Ship"},
+ {"label": "Repack", "route": "#/repack", "dt": "Stock Entry", "component": "Repack"},
+ ]
+
+ beam_configs = frappe.get_all("BEAM Settings", pluck="name")
+ if not beam_configs:
+ company = get_default_company() or company
+ if not company:
+ return
+ beam_configs = [create_beam_settings(company)]
+ for company in beam_configs:
+ doc = frappe.get_doc("BEAM Settings", company)
+ if len(doc.routes) > 0:
+ return
+ for row in default_config:
+ doc.append("routes", row)
+ doc.save()
diff --git a/beam/public/js/beam-web.bundle.js b/beam/public/js/beam-web.bundle.js
new file mode 100644
index 00000000..0004b12b
--- /dev/null
+++ b/beam/public/js/beam-web.bundle.js
@@ -0,0 +1,25 @@
+// Copyright (c) 2024, AgriTheory and contributors
+// For license information, please see license.txt
+
+import './scan/scan.js'
+
+// specifically remove Frappe website theming for the Beam page
+if (window.location.pathname === '/beam') {
+ const stylesheets = document.querySelectorAll('link[rel=stylesheet]')
+ for (const stylesheet of stylesheets) {
+ if (stylesheet.href.includes('assets/frappe/dist/css/website.bundle')) {
+ stylesheet.parentNode.removeChild(stylesheet)
+ }
+ }
+}
+
+// remove redirect-to query parameter on login page for mobile users
+document.addEventListener('DOMContentLoaded', function () {
+ if (window.location.pathname === '/login') {
+ const url = new URL(window.location.href)
+ if (url.searchParams.has('redirect-to')) {
+ url.searchParams.delete('redirect-to')
+ window.history.replaceState({}, document.title, url.toString())
+ }
+ }
+})
diff --git a/beam/public/js/network_printer_settings_custom.js b/beam/public/js/network_printer_settings_custom.js
new file mode 100644
index 00000000..e98acf69
--- /dev/null
+++ b/beam/public/js/network_printer_settings_custom.js
@@ -0,0 +1,52 @@
+// Copyright (c) 2025, AgriTheory and contributors
+// For license information, please see license.txt
+
+let printers_cache = []
+let pending_printer_name = null
+
+function set_location_from_cache(frm, printer_name) {
+ const match = printers_cache.find(p => p.value === printer_name)
+ frm.set_value('printer_location', match ? match.location || '' : '')
+}
+
+frappe.ui.form.on('Network Printer Settings', {
+ after_save(frm) {
+ // Refresh cache from CUPS so subsequent printer_name changes
+ // reflect the just-saved location, not stale pre-load data.
+ printers_cache = []
+ frm.trigger('connect_print_server')
+ },
+ connect_print_server(frm) {
+ if (frm.doc.server_ip && frm.doc.port) {
+ frappe.call({
+ doc: frm.doc,
+ method: 'get_printers_list',
+ args: {
+ ip: frm.doc.server_ip,
+ port: frm.doc.port,
+ },
+ callback(data) {
+ printers_cache = data.message || []
+ frm.fields_dict.printer_name.set_data(printers_cache)
+ // Resolve any pending printer_name lookup that fired before cache was ready
+ if (pending_printer_name) {
+ set_location_from_cache(frm, pending_printer_name)
+ pending_printer_name = null
+ }
+ },
+ })
+ }
+ },
+ printer_name(frm) {
+ if (!frm.doc.printer_name) {
+ return
+ }
+ if (!printers_cache.length) {
+ // Cache not populated yet — queue the lookup and trigger a fetch
+ pending_printer_name = frm.doc.printer_name
+ frm.trigger('connect_print_server')
+ return
+ }
+ set_location_from_cache(frm, frm.doc.printer_name)
+ },
+})
diff --git a/beam/public/js/print/print.js b/beam/public/js/print/print.js
index 70a54a49..238ffb54 100644
--- a/beam/public/js/print/print.js
+++ b/beam/public/js/print/print.js
@@ -40,47 +40,49 @@ function custom_print_button(frm) {
if (frm.doc.docstatus != 1) {
return
}
- frappe.db.get_value('BEAM Settings', { company: frm.doc.company }, 'enable_handling_units', r => {
- if (r && r.enable_handling_units) {
- frm.add_custom_button(__(' Print Handling Unit'), () => {
- let d = new frappe.ui.Dialog({
- title: __('Select Printer Setting'),
- fields: [
- {
- label: __('Printer Setting'),
- fieldname: 'printer_setting',
- fieldtype: 'Link',
- options: 'Network Printer Settings',
- },
- {
- label: __('Printer Format'),
- fieldname: 'print_format',
- fieldtype: 'Link',
- options: 'Print Format',
- get_query: function () {
- return {
- filters: { doc_type: 'Handling Unit' },
- }
- },
- },
- ],
- primary_action_label: 'Select',
- primary_action(selection) {
- d.hide()
- frappe.call({
- method: 'beam.beam.printing.print_handling_units',
- args: {
- doctype: frm.doc.doctype,
- name: frm.doc.name,
- printer_setting: selection.printer_setting,
- print_format: selection.print_format,
- doc: frm.doc,
- },
- })
+ const beam_settings = frappe.boot.beam?.settings?.[frm.doc.company]
+ if (!beam_settings?.enable_handling_units) {
+ return
+ }
+ frm.add_custom_button(__(' Print Handling Unit'), () => {
+ let d = new frappe.ui.Dialog({
+ title: __('Select Printer Setting'),
+ fields: [
+ {
+ label: __('Printer Setting'),
+ fieldname: 'printer_setting',
+ fieldtype: 'Link',
+ options: 'Network Printer Settings',
+ default: frappe.defaults.get_user_default('Network Printer Settings'),
+ },
+ {
+ label: __('Print Format'),
+ fieldname: 'print_format',
+ fieldtype: 'Link',
+ options: 'Print Format',
+ default: frappe.boot.beam?.default_hu_print_format,
+ get_query: function () {
+ return {
+ filters: { doc_type: 'Handling Unit' },
+ }
+ },
+ },
+ ],
+ primary_action_label: 'Select',
+ primary_action(selection) {
+ d.hide()
+ frappe.call({
+ method: 'beam.beam.printing.print_handling_units',
+ args: {
+ doctype: frm.doc.doctype,
+ name: frm.doc.name,
+ printer_setting: selection.printer_setting,
+ print_format: selection.print_format,
+ doc: frm.doc,
},
})
- d.show()
- })
- }
+ },
+ })
+ d.show()
})
}
diff --git a/beam/public/js/scan/scan.js b/beam/public/js/scan/scan.js
index c987ed43..3d33c7f8 100644
--- a/beam/public/js/scan/scan.js
+++ b/beam/public/js/scan/scan.js
@@ -3,17 +3,23 @@
import onScan from 'onscan.js'
+const isLoginPath = window.location.pathname === '/login'
+
function waitForElement(selector) {
return new Promise(resolve => {
- if (document.querySelector(selector)) {
- return resolve(document.querySelector(selector))
- }
- const observer = new MutationObserver(mutations => {
- if (document.querySelector(selector)) {
- resolve(document.querySelector(selector))
+ if (isLoginPath) return resolve(document.body)
+
+ const element = document.querySelector(selector)
+ if (element) return resolve(element)
+
+ const observer = new MutationObserver(() => {
+ const element = document.querySelector(selector)
+ if (element) {
+ resolve(element)
observer.disconnect()
}
})
+
observer.observe(document.body, {
childList: true,
subtree: true,
@@ -21,10 +27,18 @@ function waitForElement(selector) {
})
}
+function initScanHandler() {
+ if (typeof ScanHandler === 'undefined') return
+ new ScanHandler()
+}
+
waitForElement('[data-route]').then(element => {
- let observer = new MutationObserver(() => {
- new ScanHandler()
+ initScanHandler()
+
+ const observer = new MutationObserver(() => {
+ initScanHandler()
})
+
const config = { attributes: true, childList: false, characterData: true }
observer.observe(element, config)
})
@@ -66,18 +80,24 @@ class ScanHandler {
}
async get_scanned_context(sCode, iQty) {
return new Promise(resolve => {
- const context = this.reduceContext()
- frappe.xcall('beam.beam.scan.scan', { barcode: sCode, context: context, current_qty: iQty }).then(r => {
- if (r && r.length) {
- if (Object.keys(frappe.boot.beam.client).includes(r[0].action)) {
- let path = frappe.boot.beam.client[r[0].action][0]
- resolve(path.split('.').reduce((o, i) => o[i], window)(r)) // calls (first) custom built callback registered in hooks
- } else {
- resolve(this[String(r[0].action)](r)) // TODO: this only calls the first function
+ if (isLoginPath) {
+ frappe.xcall('beam.beam.scan.user_login.scan_login', { barcode: sCode }).then(r => {
+ if (r.success) window.location.href = '/beam'
+ })
+ } else {
+ const context = this.reduceContext()
+ frappe.xcall('beam.beam.scan.scan', { barcode: sCode, context: context, current_qty: iQty }).then(r => {
+ if (r && r.length) {
+ if (Object.keys(frappe.boot.beam.client).includes(r[0].action)) {
+ let path = frappe.boot.beam.client[r[0].action][0]
+ resolve(path.split('.').reduce((o, i) => o[i], window)(r)) // calls (first) custom built callback registered in hooks
+ } else {
+ resolve(this[String(r[0].action)](r)) // TODO: this only calls the first function
+ }
}
- }
- // TODO: else error
- })
+ // TODO: else error
+ })
+ }
})
}
route(barcode_context) {
@@ -188,6 +208,36 @@ class ScanHandler {
frappe.model.set_value(row.doctype, row.name, 's_warehouse', barcode_context.target)
frappe.model.set_value(row.doctype, row.name, 't_warehouse', barcode_context.target)
}
+ } else if (
+ barcode_context.doctype == 'Stock Reconciliation Item' ||
+ barcode_context.doctype == 'Stock Reconciliation'
+ ) {
+ cur_frm.set_value('set_warehouse', barcode_context.target)
+ cur_frm.set_value('purpose', 'Stock Reconciliation')
+ frappe.call({
+ method: 'erpnext.stock.doctype.stock_reconciliation.stock_reconciliation.get_items',
+ args: {
+ warehouse: barcode_context.target,
+ posting_date: cur_frm.doc.posting_date,
+ posting_time: cur_frm.doc.posting_time,
+ company: cur_frm.doc.company,
+ },
+ callback: function (r) {
+ if (r.exc || !r.message || !r.message.length) return
+
+ cur_frm.clear_table('items')
+
+ r.message.forEach(row => {
+ let item = cur_frm.add_child('items')
+ $.extend(item, row)
+
+ item.qty = item.qty || 0
+ item.valuation_rate = item.valuation_rate || 0
+ item.use_serial_batch_fields = cint(frappe.user_defaults?.use_serial_batch_fields)
+ })
+ cur_frm.refresh_field('items')
+ },
+ })
}
}
add_or_increment(barcode_context) {
diff --git a/beam/public/js/stock_entry_custom.js b/beam/public/js/stock_entry_custom.js
index 8b6d6531..09f68642 100644
--- a/beam/public/js/stock_entry_custom.js
+++ b/beam/public/js/stock_entry_custom.js
@@ -23,8 +23,8 @@ frappe.ui.form.on('Stock Entry', {
async function show_handling_unit_recombine_dialog(frm) {
const data = await get_handling_units(frm)
- if (!data) {
- return resolve({})
+ if (!data || !data.length) {
+ return []
}
let fields = [
{
@@ -35,6 +35,14 @@ async function show_handling_unit_recombine_dialog(frm) {
disabled: 0,
hidden: 1,
},
+ {
+ fieldtype: 'Data',
+ fieldname: 'target_row_name',
+ in_list_view: 0,
+ read_only: 1,
+ disabled: 0,
+ hidden: 1,
+ },
{
fieldtype: 'Link',
fieldname: 'item_code',
@@ -43,6 +51,7 @@ async function show_handling_unit_recombine_dialog(frm) {
read_only: 1,
disabled: 0,
label: __('Item Code'),
+ columns: 2,
},
{
fieldtype: 'Data',
@@ -57,6 +66,7 @@ async function show_handling_unit_recombine_dialog(frm) {
label: __('Handling Unit'),
in_list_view: 1,
read_only: 1,
+ columns: 2,
},
{
fieldtype: 'Float',
@@ -64,6 +74,7 @@ async function show_handling_unit_recombine_dialog(frm) {
label: __('Remaining Qty'),
in_list_view: 1,
read_only: 1,
+ columns: 1,
},
{
fieldtype: 'Data',
@@ -71,6 +82,7 @@ async function show_handling_unit_recombine_dialog(frm) {
label: __('Handling Unit to recombine'),
in_list_view: 1,
read_only: 1,
+ columns: 2,
},
{
fieldtype: 'Float',
@@ -78,6 +90,7 @@ async function show_handling_unit_recombine_dialog(frm) {
label: __('Transferred Qty'),
in_list_view: 1,
read_only: 1,
+ columns: 1,
},
]
@@ -88,10 +101,8 @@ async function show_handling_unit_recombine_dialog(frm) {
{
fieldname: 'handling_units',
fieldtype: 'Table',
- in_place_edit: false,
- editable_grid: false,
cannot_add_rows: true,
- cannot_delete_rows: true,
+ cannot_delete_rows: false,
reqd: 1,
data: data,
get_data: () => {
@@ -104,9 +115,14 @@ async function show_handling_unit_recombine_dialog(frm) {
},
],
primary_action: () => {
- let to_recombine = dialog.fields_dict.handling_units.grid.get_selected_children().map(row => {
- return row.row_name
- })
+ let selected = dialog.fields_dict.handling_units.grid.get_selected_children()
+ let to_recombine = []
+ for (let row of selected) {
+ to_recombine.push(row.row_name)
+ if (row.target_row_name) {
+ to_recombine.push(row.target_row_name)
+ }
+ }
dialog.hide()
return resolve(to_recombine)
},
@@ -114,14 +130,39 @@ async function show_handling_unit_recombine_dialog(frm) {
size: 'extra-large',
})
dialog.show()
+ // Pre-check all rows so recombine is the default behavior
+ setTimeout(() => {
+ const grid = dialog.fields_dict.handling_units.grid
+ // Enable and check all rows
+ if (grid.wrapper) {
+ grid.wrapper.find('.grid-row-check').prop('disabled', false).prop('checked', true)
+ // Hide the Delete button
+ grid.wrapper.find('.grid-remove-rows').hide()
+ }
+ grid.grid_rows?.forEach(row => {
+ if (row.doc) {
+ row.doc.__checked = 1
+ if (row.row) {
+ row.row.find('.grid-row-check').prop('disabled', false).prop('checked', true)
+ }
+ }
+ })
+ grid.refresh()
+ }, 200)
dialog.get_close_btn()
})
}
async function get_handling_units(frm) {
let handling_units = []
+ const transfer_types = ['Material Transfer', 'Send to Subcontractor', 'Material Transfer for Manufacture']
+
for (const row of frm.doc.items) {
- if (row.handling_unit && row.to_handling_unit) {
+ if (!row.handling_unit) continue
+
+ if (transfer_types.includes(frm.doc.purpose)) {
+ // Material Transfer types: source and destination HU are on the same row
+ if (!row.to_handling_unit) continue
let remaining_qty = await get_handling_unit_stock_qty(frm.doc.name, row.handling_unit, row.s_warehouse)
handling_units.push({
row_name: row.name,
@@ -132,8 +173,25 @@ async function get_handling_units(frm) {
remaining_qty: remaining_qty,
transferred_qty: row.qty,
})
+ } else {
+ // Repack/Manufacture/etc: source and target HUs are on separate rows
+ // Only show source rows (those with s_warehouse); pair with matching target row
+ if (!row.s_warehouse) continue
+ let target_row = frm.doc.items.find(r => r.t_warehouse && r.handling_unit && r.item_code === row.item_code)
+ let remaining_qty = await get_handling_unit_stock_qty(frm.doc.name, row.handling_unit, row.s_warehouse)
+ handling_units.push({
+ row_name: row.name,
+ target_row_name: target_row?.name || '',
+ item_code: row.item_code,
+ item_name: row.item_name,
+ handling_unit: row.handling_unit,
+ to_handling_unit: target_row?.handling_unit || '',
+ remaining_qty: remaining_qty,
+ transferred_qty: row.transfer_qty || row.qty,
+ })
}
}
+
return handling_units
}
async function get_handling_unit_stock_qty(name, handling_unit, s_warehouse) {
@@ -147,6 +205,10 @@ async function get_handling_unit_stock_qty(name, handling_unit, s_warehouse) {
//re combine
async function set_recombine_handling_units(frm) {
+ // const beam_settings = frappe.boot.beam?.settings?.[frm.doc.company]
+ // if (!beam_settings?.enable_handling_units) {
+ // return
+ // }
let to_recombine = await show_handling_unit_recombine_dialog(frm)
await frappe.xcall('beam.beam.overrides.stock_entry.set_rows_to_recombine', {
docname: frm.doc.name,
diff --git a/beam/tests/conftest.py b/beam/tests/conftest.py
index f3f7866a..c9fd82f1 100644
--- a/beam/tests/conftest.py
+++ b/beam/tests/conftest.py
@@ -3,12 +3,14 @@
import json
from pathlib import Path
-from unittest.mock import MagicMock
import frappe
import pytest
from frappe.utils import get_bench_path
+from beam.beam.demand.demand import build_demand_allocation_map
+from beam.beam.demand.receiving import reset_build_receiving_map
+
def _get_logger(*args, **kwargs):
from frappe.utils.logger import get_logger
@@ -39,7 +41,9 @@ def db_instance():
if (sites / "common_site_config.json").is_file():
currentsite = json.loads((sites / "common_site_config.json").read_text()).get("default_site")
- frappe.init(site=currentsite, sites_path=sites)
+ frappe.init(site=currentsite, sites_path=sites, force=True)
frappe.connect()
- frappe.db.commit = MagicMock()
+
+ build_demand_allocation_map()
+ reset_build_receiving_map()
yield frappe.db
diff --git a/beam/tests/fixtures.py b/beam/tests/fixtures.py
index 12e924ea..2a8f7a96 100644
--- a/beam/tests/fixtures.py
+++ b/beam/tests/fixtures.py
@@ -63,6 +63,8 @@
("Refrigerator Station", "200"),
("Oven Station", "20"),
("Mixer Station", "10"),
+ ("Receiving", "100"),
+ ("Shipping", "100"),
]
operations = [
@@ -368,6 +370,19 @@
"default_warehouse": "Kitchen - APC",
"supplier": "Freedom Provisions",
},
+ {
+ "item_code": "Whipped Cream Canister",
+ "uom": "Nos",
+ "item_group": "Bakery Supplies",
+ "default_warehouse": "Storeroom - APC",
+ "description": "Pressurized whipped cream canister for serving pies; also sold retail.",
+ "item_price": 2.75,
+ "supplier": "Unity Bakery Supply",
+ "is_sales_item": 1,
+ "is_purchase_item": 1,
+ "has_serial_no": 1,
+ "serial_no_series": "WCC-.#####",
+ },
]
boms = [
@@ -697,3 +712,159 @@
"TransAmerica Bank Cafeteria",
"Whole Harvest Grocery Group",
]
+
+
+employees = [
+ {
+ "name": "Tristan Hawkins",
+ "gender": "Male",
+ "date_of_birth": "2002-12-09",
+ "date_of_joining": "2018-01-01",
+ "address": {
+ "address_line1": "1156 Mountview Canyon",
+ "city": "Lakewood",
+ "state": "ME",
+ "postal_code": "02311",
+ },
+ "phone": "(704) 885-0542",
+ "roles": ["Stock Manager", "Item Manager"],
+ # "department": "Operations",
+ "designation": "Bakery Manager",
+ },
+ {
+ "name": "Deane Solomon",
+ "gender": "Female",
+ "date_of_birth": "1987-10-08",
+ "date_of_joining": "2018-01-01",
+ "address": {
+ "address_line1": "590 Avenue Of The Palms Hills",
+ "city": "San Jacinto",
+ "state": "MA",
+ "postal_code": "28260",
+ },
+ "phone": "(658) 583-5499",
+ "roles": ["Stock User", "BEAM Mobile User"],
+ "reports_to": "Tristan Hawkins",
+ # "department": "Operations",
+ "designation": "Baker",
+ },
+ {
+ "name": "Scott Larson",
+ "gender": "Male",
+ "date_of_birth": "1993-01-23",
+ "date_of_joining": "2018-01-01",
+ "address": {
+ "address_line1": "135 Locksley Route",
+ "city": "Sikeston",
+ "state": "CT",
+ "postal_code": "89972",
+ },
+ "phone": "(962) 762-5895",
+ "roles": ["Stock User", "BEAM Mobile User"],
+ "reports_to": "Tristan Hawkins",
+ # "department": "Operations",
+ "designation": "Baker",
+ },
+ {
+ "name": "Almeta Nolan",
+ "gender": "Female",
+ "date_of_birth": "1995-08-28",
+ "date_of_joining": "2018-01-01",
+ "address": {
+ "address_line1": "78 Payson Terrace",
+ "city": "Bedford",
+ "state": "MA",
+ "postal_code": "10796",
+ },
+ "phone": "(366) 357-8223",
+ "roles": ["Stock User", "BEAM Mobile User"],
+ "reports_to": "Tristan Hawkins",
+ # "department": "Operations",
+ "designation": "Bakery Manager",
+ },
+ {
+ "name": "Denise Wilkins",
+ "gender": "Female",
+ "date_of_birth": "1973-11-28",
+ "date_of_joining": "2018-01-01",
+ "address": {
+ "address_line1": "721 Mason Court",
+ "city": "Colonial Heights",
+ "state": "ME",
+ "postal_code": "53756",
+ },
+ "phone": "(930) 920-4520",
+ "roles": ["Stock User", "BEAM Mobile User"],
+ "reports_to": "Tristan Hawkins",
+ # "department": "Operations",
+ "designation": "Baker",
+ },
+ {
+ "name": "Neta Estrada",
+ "gender": "Female",
+ "date_of_birth": "1982-11-09",
+ "date_of_joining": "2020-01-15",
+ "address": {
+ "address_line1": "665 Gorgas Alley",
+ "city": "Whittier",
+ "state": "NH",
+ "postal_code": "85689",
+ },
+ "phone": "(054) 893-8970",
+ "roles": ["Stock User", "BEAM Mobile User"],
+ "reports_to": "Tristan Hawkins",
+ # "department": "Operations",
+ "designation": "Baker",
+ },
+ {
+ "name": "Issac Benson",
+ "gender": "Male",
+ "date_of_birth": "1975-09-19",
+ "date_of_joining": "2023-08-08",
+ "address": {
+ "address_line1": "78 Martha Street",
+ "city": "McKinney",
+ "state": "NH",
+ "postal_code": "47856",
+ },
+ "phone": "(814) 677-9322",
+ "roles": ["Stock User", "BEAM Mobile User"],
+ "reports_to": "Tristan Hawkins",
+ # "department": "Operations",
+ "designation": "Baker",
+ },
+ {
+ "name": "Tracey Faulkner",
+ "gender": "Female",
+ "date_of_birth": "1993-01-09",
+ "date_of_joining": "2022-12-14",
+ "address": {
+ "address_line1": "1079 Woodside Pine",
+ "city": "Belle Glade",
+ "state": "NH",
+ "postal_code": "97865",
+ },
+ "phone": "(133) 195-7828",
+ "roles": ["Stock User", "BEAM Mobile User"],
+ "reports_to": "Tristan Hawkins",
+ # "department": "Operations",
+ "designation": "Baker",
+ },
+ {
+ "name": "Phoebe Hickman",
+ "gender": "Female",
+ "date_of_birth": "1999-12-06",
+ "date_of_joining": "2022-01-16",
+ "address": {
+ "address_line1": "188 Dorcas Cove",
+ "city": "Royal Palm Beach",
+ "state": "NH",
+ "postal_code": "71202",
+ },
+ "phone": "(041) 000-2569",
+ "roles": ["Stock User", "BEAM Mobile User"],
+ "reports_to": "Tristan Hawkins",
+ # "department": "Operations",
+ "designation": "Baker",
+ },
+]
diff --git a/beam/tests/mobile/conftest.py b/beam/tests/mobile/conftest.py
new file mode 100644
index 00000000..20ef8000
--- /dev/null
+++ b/beam/tests/mobile/conftest.py
@@ -0,0 +1,54 @@
+# Copyright (c) 2024, AgriTheory and contributors
+# For license information, please see license.txt
+
+import frappe
+import pytest
+
+
+@pytest.fixture(scope="session")
+def browser_context_args(browser_context_args):
+ # emulate an Android barcode scanner
+ return {
+ **browser_context_args,
+ "viewport": {
+ "width": 400,
+ "height": 900,
+ },
+ }
+
+
+@pytest.fixture(autouse=True)
+def setup(page):
+ # delete all existing draft Purchase Receipts
+ delete_draft_records(["Purchase Receipt", "Stock Entry"])
+
+ page.set_default_timeout(5000)
+
+ base_url = frappe.utils.get_url()
+ page.goto(base_url)
+
+ # visiting the home page redirects to login page
+ page.get_by_role("textbox", name="Email").fill("support@agritheory.dev")
+ page.get_by_role("textbox", name="Password").fill("admin")
+ page.get_by_role("button", name="Login").click() # this will redirect to `/beam`
+ yield
+
+ # delete all Purchase Receipts created during the test
+ receipts = frappe.get_all(
+ "Purchase Receipt", filters={"docstatus": ["in", [1, 2]]}, fields=["name", "docstatus"]
+ )
+ for receipt in receipts:
+ receipt_doc = frappe.get_doc("Purchase Receipt", receipt.name)
+ if receipt.docstatus == 1:
+ receipt_doc.cancel()
+ # only delete if the document is in draft state, since cancelled documents are
+ # linked to SLEs, which can't be deleted
+ elif receipt.docstatus == 0:
+ receipt_doc.delete()
+
+
+def delete_draft_records(doctypes: list[str]):
+ for doctype in doctypes:
+ records = frappe.get_all(doctype, filters={"docstatus": 0}, pluck="name")
+ for record in records:
+ frappe.delete_doc(doctype, record, force=True)
diff --git a/beam/tests/mobile/test_manufacture.py b/beam/tests/mobile/test_manufacture.py
new file mode 100644
index 00000000..12b1d171
--- /dev/null
+++ b/beam/tests/mobile/test_manufacture.py
@@ -0,0 +1,112 @@
+# Copyright (c) 2024, AgriTheory and contributors
+# For license information, please see license.txt
+
+# To test locally:
+# active the virtual environment
+# bench start, and then run:
+# pytest ./beam/tests/mobile/test_manufacture.py --browser firefox --headed --disable-warnings
+
+import re
+
+import frappe
+import pytest
+from playwright.sync_api import expect
+
+from beam.tests.test_utils import use_current_db_transaction
+
+
+@pytest.mark.order(1)
+def test_complete_partial_stock_entry(page):
+ """
+ This test needs to disable handling units on Beam Settings and
+ populate the item in Stock Entry, otherwise we will obtain the error:
+
+ 'frappe.exceptions.ValidationError: Row #1: Handling Unit is missing for item Butter'
+ or
+ 'erpnext.stock.stock_ledger.NegativeStockError: 1.0 units of
+ Item Butter needed in Warehouse Refrigerator - APC to complete this transaction.'
+ """
+
+ frappe.db.set_value("BEAM Settings", "Ambrosia Pie Company", "enable_handling_units", 0)
+ frappe.db.commit()
+
+ butter = frappe.new_doc("Stock Entry")
+ butter.stock_entry_type = butter.purpose = "Material Receipt"
+ butter.append(
+ "items",
+ {
+ "item_code": "Butter",
+ "qty": 5, # intentionally to help with demand tests
+ "t_warehouse": "Refrigerator - APC",
+ "uom": "Pound",
+ "basic_rate": 4.50,
+ "expense_account": "5119 - Stock Adjustment - APC",
+ },
+ )
+
+ butter.save()
+ butter.submit()
+ frappe.db.commit()
+
+ # navigate in the following order: Home -> Manufacture -> Work Order
+ page.get_by_text("Manufacture").click()
+ page.locator("css=.beam_list-item").first.click()
+
+ # get the selected Work Order
+ order_id = page.url.split("/")[-1]
+ assert order_id
+
+ # ensure there are no existing Stock Entries against this Work Order
+ entry = frappe.db.exists(
+ "Stock Entry",
+ {"docstatus": 0, "work_order": order_id},
+ )
+ assert not entry
+
+ # find the first item in the list
+ item = page.locator("css=.box .beam_list-item").first
+ item_code, *others = item.inner_text().split("\n")
+ item_count = page.locator("css=.box .beam_item-count").first
+ expect(item_count).to_have_text(re.compile("0/"))
+
+ assert item_code == "Butter"
+
+ # ensure that the item has barcodes
+ barcodes = frappe.get_all(
+ "Item Barcode", filters={"parenttype": "Item", "parent": item_code}, pluck="barcode"
+ )
+ assert len(barcodes) > 0
+
+ # scan barcode and expect increment by 1
+ with page.expect_request(
+ lambda request: request.headers.get("x-frappe-cmd") == "beam.beam.scan.scan"
+ ):
+ page.evaluate("barcode => scanner.simulate(window, barcode)", barcodes[0])
+ expect(item_count).to_have_text(re.compile("1/"))
+
+ # check that a draft Stock Entry is created
+ page.get_by_text("SAVE", exact=True).click()
+ page.wait_for_timeout(1000)
+ with use_current_db_transaction():
+ entries = frappe.get_all(
+ "Stock Entry",
+ filters={"work_order": order_id},
+ fields=["docstatus"],
+ )
+ assert len(entries) >= 1
+ assert entries[0]["docstatus"] == 0
+
+ # check that the draft Purchase Receipt is submitted
+ page.get_by_text("TRANSFER", exact=True).click()
+ page.wait_for_timeout(1000)
+ with use_current_db_transaction():
+ receipts = frappe.get_all(
+ "Stock Entry",
+ filters={"work_order": order_id},
+ fields=["docstatus"],
+ )
+ assert len(receipts) >= 1
+ assert receipts[0]["docstatus"] == 1
+
+ frappe.db.set_value("BEAM Settings", "Ambrosia Pie Company", "enable_handling_units", 1)
+ frappe.db.commit()
diff --git a/beam/tests/mobile/test_mobile.py b/beam/tests/mobile/test_mobile.py
new file mode 100644
index 00000000..de57efa8
--- /dev/null
+++ b/beam/tests/mobile/test_mobile.py
@@ -0,0 +1,43 @@
+# Copyright (c) 2024, AgriTheory and contributors
+# For license information, please see license.txt
+
+# To test locally:
+# active the virtual environment
+# bench start, and then run:
+# pytest ./beam/tests/mobile/test_mobile.py --browser firefox --headed --disable-warnings
+
+import re
+
+import frappe
+import pytest
+from playwright.sync_api import expect
+
+# NOTE: any navigation tests should be done using `expect(page).to_have_url` since
+# `page.expect_navigation()` since the latter won't work with Beam's hash-based routes
+
+
+@pytest.mark.order(6)
+@pytest.mark.parametrize("route", ["Ship"])
+def test_scan_item_barcode(page, route):
+ # navigate in the following order: Home -> List -> Form
+ page.get_by_text(route).click()
+ page.locator("css=.beam_list-item").first.click()
+
+ # find the first item in the list
+ item = page.locator("css=.box .beam_list-item").first
+ item_name, *others = item.inner_text().split("\n")
+ item_count = page.locator("css=.box .beam_item-count").first
+ expect(item_count).to_have_text(re.compile("0/"))
+
+ # ensure that the item has barcodes
+ barcodes = frappe.get_all(
+ "Item Barcode", filters={"parenttype": "Item", "parent": item_name}, pluck="barcode"
+ )
+ assert len(barcodes) > 0
+
+ # scan barcode and expect increment by 1
+ with page.expect_request(
+ lambda request: request.headers.get("x-frappe-cmd") == "beam.beam.scan.scan"
+ ):
+ page.evaluate("barcode => scanner.simulate(window, barcode)", barcodes[0])
+ expect(item_count).to_have_text(re.compile("1/"))
diff --git a/beam/tests/mobile/test_receive.py b/beam/tests/mobile/test_receive.py
new file mode 100644
index 00000000..fac92732
--- /dev/null
+++ b/beam/tests/mobile/test_receive.py
@@ -0,0 +1,266 @@
+# Copyright (c) 2024, AgriTheory and contributors
+# For license information, please see license.txt
+
+# To test locally:
+# active the virtual environment
+# bench start, and then run:
+# pytest ./beam/tests/mobile/test_receive.py --browser firefox --headed --disable-warnings
+
+import re
+from urllib.parse import urlparse
+
+import frappe
+import pytest
+from playwright.sync_api import expect
+
+from beam.tests.test_utils import use_current_db_transaction
+
+# NOTE: any navigation tests should be done using `expect(page).to_have_url` since
+# `page.expect_navigation()` won't work with Beam's hash-based routes
+
+
+@pytest.mark.order(2)
+def test_scan_invalid_barcode(page):
+ page.get_by_text("Receive").click()
+ page.locator("css=.beam_list-item").first.click()
+
+ # get the selected Purchase Order
+ parsed_url = urlparse(page.url.replace("#", ""))
+ path_parts = [p for p in parsed_url.path.split("/") if p]
+ order_id = path_parts[-1] if path_parts else None
+ assert order_id
+
+ # find all items in the list
+ all_item_counts = page.locator("css=.box .beam_item-count")
+
+ # get all item counts before scanning invalid barcode
+ initial_counts = []
+ for i in range(all_item_counts.count()):
+ count_text = all_item_counts.nth(i).inner_text()
+ initial_counts.append(count_text)
+
+ # ensure all items start with 0 count
+ for count in initial_counts:
+ assert count.startswith("0/"), f"Expected item to start with 0/, but got: {count}"
+
+ # verify there are no existing Purchase Receipts created by test user
+ with use_current_db_transaction():
+ existing_receipts = frappe.get_all(
+ "Purchase Receipt", filters={"owner": "support@agritheory.dev", "docstatus": 0}, fields=["name"]
+ )
+ assert (
+ len(existing_receipts) == 0
+ ), f"Found existing draft Purchase Receipts created by test user: {existing_receipts}"
+
+ # scan an invalid barcode that doesn't exist
+ invalid_barcode = "INVALID_BARCODE_12345"
+ page.evaluate("barcode => scanner.simulate(window, barcode)", invalid_barcode)
+
+ page.wait_for_timeout(500)
+
+ # verify ALL item counts remain unchanged (all should still start with "0/")
+ initial_counts = []
+ for i in range(all_item_counts.count()):
+ count_text = all_item_counts.nth(i).inner_text()
+ initial_counts.append(count_text)
+
+ # ensure all items start with 0 count
+ for count in initial_counts:
+ assert count.startswith("0/"), f"Expected item to start with 0/, but got: {count}"
+
+ # verify no draft Purchase Receipt was created by test user
+ with use_current_db_transaction():
+ new_receipts = frappe.get_all(
+ "Purchase Receipt", filters={"owner": "support@agritheory.dev", "docstatus": 0}, fields=["name"]
+ )
+ assert (
+ len(new_receipts) == 0
+ ), f"Invalid barcode scan should not create any Purchase Receipts, but found: {new_receipts}"
+
+
+@pytest.mark.order(3)
+def test_receive_without_scanning(page):
+ """Test trying to receive without scanning any items"""
+ # navigate to a Purchase Order
+ page.get_by_text("Receive").click()
+ page.locator("css=.beam_list-item").first.click()
+
+ # get the selected Purchase Order
+ parsed_url = urlparse(page.url.replace("#", ""))
+ path_parts = [p for p in parsed_url.path.split("/") if p]
+ order_id = path_parts[-1] if path_parts else None
+ assert order_id
+
+ item = page.locator("css=.box .beam_list-item").first
+ item_code, *others = item.inner_text().split("\n")
+
+ # find all items in the list
+ all_item_counts = page.locator("css=.box .beam_item-count")
+ initial_counts = []
+ for i in range(all_item_counts.count()):
+ count_text = all_item_counts.nth(i).inner_text()
+ initial_counts.append(count_text)
+
+ # ensure all items start with 0 count
+ for count in initial_counts:
+ assert count.startswith("0/"), f"Expected item to start with 0/, but got: {count}"
+
+ # count existing Purchase Receipts before attempting to save
+ with use_current_db_transaction():
+ existing_receipts = frappe.get_all(
+ "Purchase Receipt Item",
+ filters={"purchase_order": order_id, "item_code": item_code, "owner": "support@agritheory.dev"},
+ fields=["docstatus", "received_qty"],
+ )
+ initial_count = len(existing_receipts)
+
+ # try to click SAVE without scanning anything
+ save_button = page.get_by_text("SAVE", exact=True)
+ save_button.click()
+ page.wait_for_timeout(1000)
+
+ # verify no new draft Purchase Receipt was created
+ with use_current_db_transaction():
+ new_receipts = frappe.get_all(
+ "Purchase Receipt Item",
+ filters={"purchase_order": order_id, "item_code": item_code, "owner": "support@agritheory.dev"},
+ fields=["docstatus", "received_qty"],
+ )
+ final_count = len(new_receipts)
+ assert (
+ final_count == initial_count
+ ), f"Expected no new receipts, but count changed from {initial_count} to {final_count}"
+
+
+@pytest.mark.order(4)
+def test_complete_partial_receipt(page):
+ # navigate in the following order: Home -> Receive -> Purchase Order
+ page.get_by_text("Receive").click()
+ page.locator("css=.beam_list-item").first.click()
+
+ # get the selected Purchase Order
+ # NOTE: URL format changed: the id lives in the path after the hash (e.g. #/purchase-receipt/PUR-ORD-...)
+ # this PR changed the URL format:
+ # https://github.com/agritheory/beam/pull/274
+ parsed_url = urlparse(page.url.replace("#", ""))
+ path_parts = [p for p in parsed_url.path.split("/") if p]
+ order_id = path_parts[-1] if path_parts else None
+
+ assert order_id
+
+ # find the first item in the list
+ item = page.locator("css=.box .beam_list-item").first
+ item_code, *others = item.inner_text().split("\n")
+ item_count = page.locator("css=.box .beam_item-count").first
+ expect(item_count).to_have_text(re.compile("0/"))
+
+ assert item_code == "Cloudberry"
+
+ # Refresh transaction to see setup data
+ with use_current_db_transaction():
+ # ensure that the item has barcodes
+ barcodes = frappe.get_all(
+ "Item Barcode", filters={"parenttype": "Item", "parent": item_code}, pluck="barcode"
+ )
+ assert len(barcodes) > 0
+
+ # scan barcode and expect increment by 1
+ with page.expect_request(
+ lambda request: request.headers.get("x-frappe-cmd") == "beam.beam.scan.scan"
+ ):
+ page.evaluate("barcode => scanner.simulate(window, barcode)", barcodes[0])
+ expect(item_count).to_have_text(re.compile("1/"))
+
+ # ensure there are no existing Purchase Receipts against this Purchase Order
+ receipt = frappe.db.exists(
+ "Purchase Receipt Item",
+ {
+ "docstatus": 0,
+ "purchase_order": order_id,
+ "item_code": item_code,
+ "owner": "support@agritheory.dev",
+ },
+ )
+ assert not receipt
+
+ # check that a draft Purchase Receipt is created
+ page.get_by_text("SAVE", exact=True).click()
+ page.wait_for_timeout(1000)
+ with use_current_db_transaction():
+ receipts = frappe.get_all(
+ "Purchase Receipt Item",
+ filters={"purchase_order": order_id, "item_code": item_code},
+ fields=["docstatus", "received_qty", "creation", "parent"],
+ order_by="creation desc",
+ )
+ assert len(receipts) >= 1
+ assert receipts[0]["docstatus"] == 0
+ assert receipts[0]["received_qty"] == 1
+
+ # check that the draft Purchase Receipt is submitted
+ page.get_by_text("RECEIVE", exact=True).click()
+ page.wait_for_timeout(1500)
+ with use_current_db_transaction():
+ receipts = frappe.get_all(
+ "Purchase Receipt Item",
+ filters={"purchase_order": order_id, "item_code": item_code},
+ fields=["docstatus", "received_qty", "creation"],
+ order_by="creation desc",
+ limit=1,
+ )
+ assert len(receipts) == 1
+ assert receipts[0]["docstatus"] == 1
+ assert receipts[0]["received_qty"] == 1
+
+
+@pytest.mark.order(5)
+def test_rapid_barcode_scanning(page):
+ """Test scanning multiple barcodes quickly"""
+ # navigate to a Purchase Order
+ page.get_by_text("Receive").click()
+ page.locator("css=.beam_list-item").first.click()
+
+ # get the selected Purchase Order
+ parsed_url = urlparse(page.url.replace("#", ""))
+ path_parts = [p for p in parsed_url.path.split("/") if p]
+ order_id = path_parts[-1] if path_parts else None
+ assert order_id
+
+ # find the first item in the list
+ item = page.locator("css=.box .beam_list-item").first
+ item_code, *others = item.inner_text().split("\n")
+ item_count = page.locator("css=.box .beam_item-count").first
+ expect(item_count).to_have_text(re.compile("0/"))
+
+ # get barcode for the item
+ with use_current_db_transaction():
+ barcodes = frappe.get_all(
+ "Item Barcode", filters={"parenttype": "Item", "parent": item_code}, pluck="barcode"
+ )
+ assert len(barcodes) > 0
+
+ # scan the same barcode multiple times quickly
+ scan_count = 10
+ for _ in range(scan_count):
+ page.evaluate("barcode => scanner.simulate(window, barcode)", barcodes[0])
+ # very short delay between scans to simulate rapid scanning
+ page.wait_for_timeout(100)
+
+ page.wait_for_timeout(1000)
+
+ expect(item_count).to_have_text(re.compile(f"{scan_count}/"))
+
+ page.get_by_text("SAVE", exact=True).click()
+ page.wait_for_timeout(1000)
+
+ with use_current_db_transaction():
+ receipts = frappe.get_all(
+ "Purchase Receipt Item",
+ filters={"purchase_order": order_id, "item_code": item_code},
+ fields=["docstatus", "received_qty"],
+ order_by="creation desc",
+ limit=1,
+ )
+ assert len(receipts) == 1
+ assert receipts[0]["docstatus"] == 0
+ assert receipts[0]["received_qty"] == scan_count
diff --git a/beam/tests/mobile/test_repack.py b/beam/tests/mobile/test_repack.py
new file mode 100644
index 00000000..25f66033
--- /dev/null
+++ b/beam/tests/mobile/test_repack.py
@@ -0,0 +1,303 @@
+# Copyright (c) 2024, AgriTheory and contributors
+# For license information, please see license.txt
+
+import re
+
+import frappe
+import pytest
+from playwright.sync_api import expect
+
+from beam.tests.test_utils import use_current_db_transaction
+
+
+def fill_warehouse_dropdown(page, label: str, value: str):
+ wrapper = page.locator(".input-wrapper", has=page.locator("label", has_text=label))
+ inp = wrapper.locator("input")
+ inp.click()
+ inp.fill(value)
+ page.wait_for_timeout(300)
+ result = wrapper.locator("li.autocomplete-result", has_text=value).first
+ result.wait_for(state="visible")
+ result.click()
+
+
+@pytest.fixture(autouse=True, scope="session")
+def disable_handling_unit_for_tests():
+ """Disable handling unit validation for all items during tests."""
+ items = frappe.get_all("Item", filters={"enable_handling_unit": 1}, pluck="name")
+ for item in items:
+ frappe.db.set_value("Item", item, "enable_handling_unit", 0)
+ frappe.db.commit()
+ yield
+ for item in items:
+ frappe.db.set_value("Item", item, "enable_handling_unit", 1)
+ frappe.db.commit()
+
+
+@pytest.mark.order(8)
+def test_repack_items_manually(page):
+ page.get_by_text("Repack").click()
+ expect(page).to_have_url(re.compile(r"#/repack"), timeout=15000)
+
+ with use_current_db_transaction():
+ source_barcode = frappe.get_all(
+ "Item Barcode",
+ filters={"parent": "Butter"},
+ pluck="barcode",
+ limit=1,
+ )
+ assert source_barcode, "Butter must have a barcode"
+
+ finished_barcode = frappe.get_all(
+ "Item Barcode",
+ filters={"parent": "Ambrosia Pie"},
+ pluck="barcode",
+ limit=1,
+ )
+ assert finished_barcode, "Ambrosia Pie must have a barcode"
+
+ source_wh = "Refrigerator - APC"
+ target_wh = "Baked Goods - APC"
+
+ qty_input = page.locator("input.aform_input-field[type='number']")
+ expect(qty_input).to_have_value("0")
+
+ with page.expect_request(
+ lambda request: request.headers.get("x-frappe-cmd") == "beam.beam.scan.scan"
+ ):
+ page.evaluate("barcode => scanner.simulate(window, barcode)", source_barcode[0])
+
+ page.wait_for_timeout(800)
+
+ expect(qty_input).to_have_value("1")
+
+ page.get_by_role("button", name="+").click()
+
+ expect(qty_input).to_have_value("2")
+
+ page.get_by_role("button", name="-").click()
+
+ expect(qty_input).to_have_value("1")
+
+ fill_warehouse_dropdown(page, "Source Warehouse", source_wh)
+ page.get_by_role("button", name="ADD", exact=True).click()
+
+ expect(page.locator("css=.beam_list-item").first).to_be_visible()
+
+ with page.expect_request(
+ lambda request: request.headers.get("x-frappe-cmd") == "beam.beam.scan.scan"
+ ):
+ page.evaluate("barcode => scanner.simulate(window, barcode)", finished_barcode[0])
+
+ page.wait_for_timeout(800)
+
+ page.get_by_role("button", name="+").click()
+ fill_warehouse_dropdown(page, "Target Warehouse", target_wh)
+ page.get_by_role("button", name="ADD", exact=True).click()
+
+ expect(page.locator("css=.beam_list-item").nth(1)).to_be_visible()
+
+ page.get_by_role("button", name="SAVE", exact=True).click()
+ page.wait_for_timeout(1500)
+
+ with use_current_db_transaction():
+ entries = frappe.get_all(
+ "Stock Entry",
+ filters={"stock_entry_type": "Repack", "docstatus": 0},
+ fields=["name"],
+ order_by="creation desc",
+ limit=1,
+ )
+ assert entries, "Expected draft Stock Entry to be created"
+ stock_entry_name = entries[0]["name"]
+
+ page.get_by_role("button", name="REPACK", exact=True).click()
+ page.wait_for_timeout(1500)
+
+ with use_current_db_transaction():
+ submitted = frappe.get_all(
+ "Stock Entry",
+ filters={"name": stock_entry_name, "docstatus": 1},
+ fields=["name"],
+ )
+
+ assert submitted, f"Expected Stock Entry {stock_entry_name} to be submitted"
+
+
+@pytest.mark.order(9)
+def test_repack_using_bom(page):
+ page.get_by_text("Repack").click()
+ page.wait_for_load_state("networkidle")
+ assert "/repack" in page.url
+
+ bom_name = "BOM-Gooseberry Pie Filling-001"
+ target_wh = "Refrigerator - APC"
+
+ with use_current_db_transaction():
+ finished_barcode = frappe.get_all(
+ "Item Barcode",
+ filters={"parent": "Gooseberry Pie"},
+ pluck="barcode",
+ limit=1,
+ )
+ assert finished_barcode, "Gooseberry Pie must have a barcode"
+
+ with page.expect_request(
+ lambda request: request.headers.get("x-frappe-cmd") == "beam.beam.scan.scan"
+ ):
+ page.evaluate("barcode => scanner.simulate(window, barcode)", finished_barcode[0])
+
+ page.wait_for_timeout(800)
+
+ page.get_by_role("button", name="+").click()
+ page.wait_for_timeout(300)
+
+ fill_warehouse_dropdown(page, "BOM (Optional)", bom_name)
+ page.wait_for_timeout(1000)
+
+ fill_warehouse_dropdown(page, "Target Warehouse", target_wh)
+
+ page.get_by_role("button", name="ADD", exact=True).click()
+ page.wait_for_timeout(1000)
+
+ page.get_by_role("button", name="SAVE", exact=True).click()
+ page.wait_for_timeout(1500)
+
+ with use_current_db_transaction():
+ entries = frappe.get_all(
+ "Stock Entry",
+ filters={"stock_entry_type": "Repack", "docstatus": 0},
+ fields=["name"],
+ order_by="creation desc",
+ limit=1,
+ )
+ assert entries, "Expected a draft Stock Entry to be created from BOM repack"
+
+
+@pytest.mark.order(10)
+def test_scan_item_for_repack(page):
+ page.get_by_text("Repack").click()
+ page.wait_for_load_state("networkidle")
+ assert "/repack" in page.url
+
+ with use_current_db_transaction():
+ item_barcodes = frappe.get_all(
+ "Item Barcode",
+ filters={"parenttype": "Item"},
+ fields=["parent", "barcode"],
+ limit=1,
+ )
+ assert item_barcodes, "No Item barcodes found in test data"
+ item_code = item_barcodes[0]["parent"]
+ barcode = item_barcodes[0]["barcode"]
+
+ with page.expect_request(
+ lambda request: request.headers.get("x-frappe-cmd") == "beam.beam.scan.scan"
+ ):
+ page.evaluate("barcode => scanner.simulate(window, barcode)", barcode)
+ page.wait_for_timeout(800)
+
+ item_wrapper = page.locator(
+ ".input-wrapper", has=page.locator("label", has_text="Item to Repack")
+ )
+ expect(item_wrapper.locator("input")).to_have_value(item_code)
+
+ qty_input = page.locator("input.aform_input-field[type='number']")
+ expect(qty_input).to_have_value("1")
+
+ with page.expect_request(
+ lambda request: request.headers.get("x-frappe-cmd") == "beam.beam.scan.scan"
+ ):
+ page.evaluate("barcode => scanner.simulate(window, barcode)", barcode)
+ page.wait_for_timeout(800)
+
+ expect(qty_input).to_have_value("2")
+
+
+@pytest.mark.order(11)
+def test_clear_repack_form(page):
+ page.get_by_text("Repack").click()
+ page.wait_for_load_state("networkidle")
+ assert "/repack" in page.url
+
+ with use_current_db_transaction():
+ item_barcodes = frappe.get_all(
+ "Item Barcode",
+ filters={"parenttype": "Item"},
+ fields=["parent", "barcode"],
+ limit=1,
+ )
+ assert item_barcodes, "No Item barcodes found in test data"
+ barcode = item_barcodes[0]["barcode"]
+
+ warehouse = frappe.get_all(
+ "Warehouse",
+ filters={"is_group": 0, "company": "Ambrosia Pie Company"},
+ pluck="name",
+ limit=1,
+ )[0]
+
+ with page.expect_request(
+ lambda request: request.headers.get("x-frappe-cmd") == "beam.beam.scan.scan"
+ ):
+ page.evaluate("barcode => scanner.simulate(window, barcode)", barcode)
+ page.wait_for_timeout(800)
+
+ page.get_by_role("button", name="+").click()
+ fill_warehouse_dropdown(page, "Source Warehouse", warehouse)
+
+ page.get_by_role("button", name="ADD", exact=True).click()
+ page.wait_for_timeout(500)
+
+ expect(page.locator("css=.beam_list-item").first).to_be_visible()
+ expect(page.get_by_role("button", name="CLEAN", exact=True)).to_be_visible()
+
+ page.get_by_role("button", name="CLEAN", exact=True).click()
+ page.wait_for_timeout(500)
+
+ expect(page.get_by_text("Scan Items, Select Warehouses, and Set Qty to Begin")).to_be_visible()
+
+ expect(page.get_by_role("button", name="CLEAN", exact=True)).to_be_hidden()
+
+
+@pytest.mark.order(12)
+def test_repack_validation_single_warehouse_direction(page):
+ page.get_by_text("Repack").click()
+ page.wait_for_load_state("networkidle")
+ assert "/repack" in page.url
+
+ with use_current_db_transaction():
+ item_barcodes = frappe.get_all(
+ "Item Barcode",
+ filters={"parenttype": "Item"},
+ fields=["parent", "barcode"],
+ limit=1,
+ )
+ assert item_barcodes, "No Item barcodes found in test data"
+ barcode = item_barcodes[0]["barcode"]
+
+ warehouses = frappe.get_all(
+ "Warehouse",
+ filters={"is_group": 0, "company": "Ambrosia Pie Company"},
+ pluck="name",
+ limit=2,
+ )
+ assert len(warehouses) >= 2, "Need at least 2 warehouses for this test"
+
+ with page.expect_request(
+ lambda request: request.headers.get("x-frappe-cmd") == "beam.beam.scan.scan"
+ ):
+ page.evaluate("barcode => scanner.simulate(window, barcode)", barcode)
+ page.wait_for_timeout(800)
+
+ page.get_by_role("button", name="+").click()
+
+ fill_warehouse_dropdown(page, "Source Warehouse", warehouses[0])
+ fill_warehouse_dropdown(page, "Target Warehouse", warehouses[1])
+
+ page.get_by_role("button", name="ADD", exact=True).click()
+ page.wait_for_timeout(500)
+
+ expect(page.get_by_text("Please select only source or target warehouse")).to_be_visible()
+
+ expect(page.get_by_text("Scan Items, Select Warehouses, and Set Qty to Begin")).to_be_visible()
diff --git a/beam/tests/setup.py b/beam/tests/setup.py
index 547b0105..c17f8369 100644
--- a/beam/tests/setup.py
+++ b/beam/tests/setup.py
@@ -5,14 +5,27 @@
from itertools import groupby
import frappe
+from erpnext.buying.doctype.purchase_order.purchase_order import (
+ make_purchase_invoice,
+ make_purchase_receipt,
+)
from erpnext.manufacturing.doctype.production_plan.production_plan import (
get_items_for_material_requests,
)
+from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse
from erpnext.setup.utils import enable_all_roles_and_domains, set_defaults_for_tests
from erpnext.stock.get_item_details import get_item_details
from frappe.desk.page.setup_wizard.setup_wizard import setup_complete
-from beam.tests.fixtures import boms, customers, items, operations, suppliers, workstations
+from beam.tests.fixtures import (
+ boms,
+ customers,
+ employees,
+ items,
+ operations,
+ suppliers,
+ workstations,
+)
def before_test():
@@ -40,11 +53,10 @@ def before_test():
enable_all_roles_and_domains()
set_defaults_for_tests()
frappe.db.commit()
- create_test_data()
- for modu in frappe.get_all("Module Onboarding"):
- frappe.db.set_value("Module Onboarding", modu, "is_complete", 1)
+ for module in frappe.get_all("Module Onboarding"):
+ frappe.db.set_value("Module Onboarding", module, "is_complete", True)
frappe.set_value("Website Settings", "Website Settings", "home_page", "login")
- frappe.db.commit()
+ create_test_data()
def create_test_data():
@@ -71,17 +83,19 @@ def create_test_data():
company_address.city = "Chelsea"
company_address.state = "MA"
company_address.pincode = "89077"
- company_address.is_your_company_address = 1
+ company_address.is_your_company_address = True
company_address.append("links", {"link_doctype": "Company", "link_name": settings.company})
company_address.save()
frappe.set_value("Company", settings.company, "tax_id", "04-1871930")
create_warehouses(settings)
setup_manufacturing_settings(settings)
create_workstations()
+ setup_beam_settings(settings)
create_operations()
create_item_groups(settings)
create_suppliers(settings)
create_customers(settings)
+ create_employees(settings)
create_items(settings)
create_boms(settings)
prod_plan_from_doc = "Sales Order"
@@ -143,11 +157,11 @@ def create_customers(settings):
def setup_manufacturing_settings(settings):
mfg_settings = frappe.get_doc("Manufacturing Settings", "Manufacturing Settings")
- mfg_settings.material_consumption = 1
+ mfg_settings.material_consumption = True
mfg_settings.default_wip_warehouse = "Kitchen - APC"
mfg_settings.default_fg_warehouse = "Baked Goods - APC"
mfg_settings.overproduction_percentage_for_work_order = 5.00
- mfg_settings.job_Card_excess_transfer = 1
+ mfg_settings.job_card_excess_transfer = True
mfg_settings.save()
if frappe.db.exists("Account", {"account_name": "Work In Progress", "company": settings.company}):
@@ -177,6 +191,36 @@ def setup_manufacturing_settings(settings):
frappe.set_value("Warehouse", "Kitchen - APC", "account", wip.name)
+def setup_beam_settings(settings):
+ if frappe.db.exists("BEAM Settings", settings.company):
+ beams = frappe.get_doc("BEAM Settings", settings.company)
+ else:
+ beams = frappe.new_doc("BEAM Settings")
+ beams.company = settings.company
+ beams.enable_demand = True
+ beams.enable_handling_units = True
+ beams.receiving_workstation = "Receiving"
+ beams.shipping_workstation = "Shipping"
+ beams.set("warehouse_types", [{"warehouse_type": "Quarantine"}])
+ beams.set(
+ "routes",
+ [
+ {
+ "label": "Manufacture",
+ "route": "#/manufacture",
+ "dt": "Stock Entry",
+ "component": "Manufacture",
+ },
+ {"label": "Demand", "route": "#/demand", "dt": "Stock Entry", "component": "Demand"},
+ {"label": "Move", "route": "#/move", "dt": "Stock Entry", "component": "Demand"},
+ {"label": "Receive", "route": "#/receive", "dt": "Purchase Receipt", "component": "Receive"},
+ {"label": "Ship", "route": "#/ship", "dt": "Delivery Note", "component": "Ship"},
+ {"label": "Repack", "route": "#/repack", "dt": "Stock Entry", "component": "Repack"},
+ ],
+ )
+ beams.save()
+
+
def create_workstations():
for ws in workstations:
if frappe.db.exists("Workstation", ws[0]):
@@ -219,21 +263,21 @@ def create_items(settings):
if not frappe.db.exists("Price List", "Bakery Buying"):
pl = frappe.new_doc("Price List")
pl.price_list_name = "Bakery Buying"
- pl.buying = 1
+ pl.buying = True
pl.append("countries", {"country": "United States"})
pl.save()
if not frappe.db.exists("Price List", "Bakery Wholesale"):
pl = frappe.new_doc("Price List")
pl.price_list_name = "Bakery Wholesale"
- pl.selling = 1
+ pl.selling = True
pl.append("countries", {"country": "United States"})
pl.save()
if not frappe.db.exists("Pricing Rule", "Bakery Retail"):
pr = frappe.new_doc("Pricing Rule")
pr.title = "Bakery Retail"
- pr.selling = 1
+ pr.selling = True
pr.apply_on = "Item Group"
pr.company = settings.company
pr.margin_type = "Percentage"
@@ -251,16 +295,23 @@ def create_items(settings):
i.item_group = item.get("item_group")
i.stock_uom = item.get("uom")
i.description = item.get("description")
- i.maintain_stock = 1
- i.enable_handling_unit = 0 if i.item_code in ("Water", "Ice Water") else 1
- i.include_item_in_manufacturing = 1
+ i.maintain_stock = True
+ i.enable_handling_unit = i.item_code not in ("Water", "Ice Water")
+ i.include_item_in_manufacturing = True
i.default_warehouse = settings.get("warehouse")
i.default_material_request_type = (
"Purchase" if item.get("item_group") in ("Bakery Supplies", "Ingredients") else "Manufacture"
)
i.valuation_method = "FIFO"
- i.is_purchase_item = 1 if item.get("item_group") in ("Bakery Supplies", "Ingredients") else 0
- i.is_sales_item = 1 if item.get("item_group") == "Baked Goods" else 0
+ i.is_purchase_item = (
+ 1
+ if item.get("item_group") in ("Bakery Supplies", "Ingredients")
+ or item.get("is_purchase_item", 0)
+ else 0
+ )
+ i.is_sales_item = (
+ 1 if item.get("item_group") == "Baked Goods" or item.get("is_sales_item", 0) else 0
+ )
i.append(
"item_defaults",
{"company": settings.company, "default_warehouse": item.get("default_warehouse")},
@@ -270,13 +321,20 @@ def create_items(settings):
if i.item_code == "Parchment Paper":
i.append("uoms", {"uom": "Box", "conversion_factor": 100})
i.purchase_uom = "Box"
+ if i.item_code in ("Water", "Ice Water"):
+ i.append("uoms", {"uom": "Gallon Liquid (US)", "conversion_factor": 15.142})
+ i.purchase_uom = "Gallon Liquid (US)"
+ i.valuation_rate = 0.01 if i.item_code == "Water" else 0.02
+
+ i.has_serial_no = item.get("has_serial_no", 0) or 0
+ i.serial_no_series = item.get("serial_no_series", "") or ""
i.save()
if item.get("item_price"):
ip = frappe.new_doc("Item Price")
ip.item_code = i.item_code
ip.uom = i.stock_uom
ip.price_list = "Bakery Wholesale" if i.is_sales_item else "Bakery Buying"
- ip.buying = 1
+ ip.buying = True
ip.valid_from = "2018-1-1"
ip.price_list_rate = item.get("item_price")
ip.save()
@@ -287,20 +345,22 @@ def create_items(settings):
"items",
{
"item_code": "Water",
- "qty": 10000000,
+ "qty": 9, # intentionally to help with demand tests
"t_warehouse": "Refrigerator - APC",
- "basic_rate": 0.0,
- "allow_zero_valuation_rate": 1,
+ "uom": "Cup",
+ "basic_rate": 0.15,
+ "expense_account": "5111 - Cost of Goods Sold - APC",
},
)
water.append(
"items",
{
"item_code": "Ice Water",
- "qty": 10000000,
+ "qty": 11, # intentionally to help with demand tests
+ "uom": "Cup",
"t_warehouse": "Refrigerator - APC",
- "basic_rate": 0.0,
- "allow_zero_valuation_rate": 1,
+ "basic_rate": 0.30,
+ "expense_account": "5111 - Cost of Goods Sold - APC",
},
)
water.save()
@@ -323,6 +383,90 @@ def create_warehouses(settings):
wh.parent_warehouse = root_wh
wh.company = settings.company
wh.save()
+ create_quarantine_warehouse(settings, parent_wh=root_wh)
+
+
+# TODO: replace with test utils functionality
+def create_quarantine_warehouse(
+ settings,
+ wh_name="Quarantined, Scrap and Rejected Items",
+ account_name=None,
+ parent_account=None,
+ account_number="1430",
+ parent_wh=None,
+ is_default_scrap_wh=True,
+):
+ if not account_name:
+ if not parent_account:
+ # If one possible parent account in system, use it, if zero or 2+, account is standalone
+ parent_accts = frappe.get_all(
+ "Account",
+ {
+ "company": settings.company,
+ "root_type": "Asset",
+ "account_type": "Stock",
+ "is_group": 1,
+ },
+ "name",
+ pluck="name",
+ )
+ parent_account = parent_accts[0] if len(parent_accts) == 1 else ""
+
+ if not frappe.db.exists(
+ "Account",
+ {
+ "name": wh_name,
+ "company": settings.company,
+ "root_type": "Asset",
+ "account_type": "Stock",
+ },
+ ):
+ a = frappe.new_doc("Account")
+ a.name = a.account_name = wh_name
+ a.account_number = account_number
+ a.is_group = 0
+ a.company = settings.company
+ a.root_type = "Asset"
+ a.report_type = "Balance Sheet"
+ a.account_currency = frappe.get_value("Company", settings.company, "default_currency")
+ a.parent_account = parent_account
+ a.account_type = "Stock"
+ a.save()
+ account_name = a.name
+
+ if not parent_wh:
+ parent_wh = frappe.get_value("Warehouse", {"company": settings.company, "is_group": 1})
+
+ wh_type = "Quarantine"
+ if not frappe.db.exists("Warehouse Type", wh_type):
+ wht = frappe.new_doc("Warehouse Type")
+ wht.name = wh_type
+ wht.save()
+
+ if not frappe.db.exists(
+ "Warehouse",
+ {
+ "warehouse_name": wh_name,
+ "company": settings.company,
+ "is_rejected_warehouse": 1,
+ "account": account_name,
+ },
+ ):
+ wh = frappe.new_doc("Warehouse")
+ wh.warehouse_name = wh_name
+ wh.company = settings.company
+ wh.is_group = 0
+ wh.parent_warehouse = parent_wh
+ wh.is_rejected_warehouse = 1
+ wh.account = account_name
+ wh.warehouse_type = wh_type
+ wh.save()
+ wh_name = wh.name
+
+ if is_default_scrap_wh:
+ ms = frappe.get_doc("Manufacturing Settings")
+ ms.default_scrap_warehouse = wh_name
+ ms.save()
def create_boms(settings):
@@ -337,7 +481,7 @@ def create_boms(settings):
b.rm_cost_as_per = "Price List"
b.buying_price_list = "Bakery Buying"
b.currency = "USD"
- b.with_operations = 1
+ b.with_operations = True
for item in bom.get("items"):
b.append("items", {**item, "stock_uom": item.get("uom")})
b.items[-1].bom_no = frappe.get_value("BOM", {"item": item.get("item_code")})
@@ -370,7 +514,7 @@ def create_sales_order(settings):
"items",
{
"item_code": "Double Plum Pie",
- "delivery_date": so.transaction_date,
+ "delivery_date": so.transaction_date + datetime.timedelta(days=1),
"qty": 40,
"warehouse": "Baked Goods - APC",
},
@@ -379,7 +523,7 @@ def create_sales_order(settings):
"items",
{
"item_code": "Gooseberry Pie",
- "delivery_date": so.transaction_date,
+ "delivery_date": so.transaction_date + datetime.timedelta(days=2),
"qty": 10,
"warehouse": "Baked Goods - APC",
},
@@ -388,7 +532,7 @@ def create_sales_order(settings):
"items",
{
"item_code": "Kaduka Key Lime Pie",
- "delivery_date": so.transaction_date,
+ "delivery_date": so.transaction_date + datetime.timedelta(days=3),
"qty": 10,
"warehouse": "Baked Goods - APC",
},
@@ -415,7 +559,7 @@ def create_material_request(settings):
"items",
{
"item_code": "Double Plum Pie",
- "schedule_date": mr.schedule_date,
+ "schedule_date": mr.schedule_date + datetime.timedelta(days=1),
"qty": 40,
"warehouse": "Baked Goods - APC",
},
@@ -424,7 +568,7 @@ def create_material_request(settings):
"items",
{
"item_code": "Gooseberry Pie",
- "schedule_date": mr.schedule_date,
+ "schedule_date": mr.schedule_date + datetime.timedelta(days=2),
"qty": 10,
"warehouse": "Baked Goods - APC",
},
@@ -433,7 +577,7 @@ def create_material_request(settings):
"items",
{
"item_code": "Kaduka Key Lime Pie",
- "schedule_date": mr.schedule_date,
+ "schedule_date": mr.schedule_date + datetime.timedelta(days=3),
"qty": 10,
"warehouse": "Baked Goods - APC",
},
@@ -446,7 +590,7 @@ def create_production_plan(settings, prod_plan_from_doc):
pp = frappe.new_doc("Production Plan")
pp.posting_date = settings.day
pp.company = settings.company
- pp.combine_sub_items = 1
+ pp.combine_sub_items = True
if prod_plan_from_doc == "Sales Order":
pp.get_items_from = "Sales Order"
pp.append(
@@ -465,12 +609,20 @@ def create_production_plan(settings, prod_plan_from_doc):
},
)
pp.get_mr_items()
- for item in pp.po_items:
- item.planned_start_date = settings.day
+
+ pp.po_items = sorted(pp.po_items, key=lambda x: x.get("item_code"))
+
+ for idx, item in enumerate(pp.po_items):
+ item.planned_start_date = settings.day + datetime.timedelta(days=idx)
+
pp.get_sub_assembly_items()
+ start_time = datetime.datetime(settings.day.year, settings.day.month, settings.day.day, 0, 0)
for item in pp.sub_assembly_items:
- item.schedule_date = settings.day
+ item.schedule_date = start_time
+ time = frappe.get_value("BOM Operation", {"parent": item.bom_no}, "sum(time_in_mins) AS time")
+ start_time += datetime.timedelta(minutes=time + 2)
pp.for_warehouse = "Storeroom - APC"
+ pp.sub_assembly_items = sorted(pp.sub_assembly_items, key=lambda x: x.get("production_item"))
raw_materials = get_items_for_material_requests(
pp.as_dict(), warehouses=None, get_parent_warehouse_data=None
)
@@ -501,54 +653,83 @@ def create_production_plan(settings, prod_plan_from_doc):
sorted((m for m in mr.items if m.supplier), key=lambda d: d.supplier),
lambda x: x.get("supplier"),
):
- items = list(_items)
if supplier == "No Supplier":
- # make a stock entry here?
continue
- if supplier == "Freedom Provisions":
- pr = frappe.new_doc("Purchase Invoice")
- pr.update_stock = 1
- else:
- pr = frappe.new_doc("Purchase Receipt")
- pr.company = settings.company
- pr.supplier = supplier
- pr.posting_date = settings.day
- pr.set_posting_time = 1
- pr.buying_price_list = "Bakery Buying"
+ items = list(_items)
+ po = frappe.new_doc("Purchase Order")
+ po.company = settings.company
+ po.supplier = supplier
+ po.transaction_date = po.schedule_date = settings.day
+ po.buying_price_list = "Bakery Buying"
for item in items:
item_details = get_item_details(
{
"item_code": item.item_code,
"qty": item.qty,
- "supplier": pr.supplier,
- "company": pr.company,
- "doctype": pr.doctype,
- "currency": pr.currency,
- "buying_price_list": pr.buying_price_list,
+ "supplier": po.supplier,
+ "company": po.company,
+ "doctype": po.doctype,
+ "currency": po.currency,
+ "buying_price_list": po.buying_price_list,
}
)
- pr.append("items", {**item_details})
+ po.append("items", {**item_details})
+ po.save()
+ po.submit()
+
+ if supplier == "Freedom Provisions":
+ pr = make_purchase_invoice(po.name)
+ pr.update_stock = True
+ else:
+ pr = make_purchase_receipt(po.name)
+
+ pr.set_posting_time = True
+ pr.posting_date = settings.day
pr.save()
# pr.submit() # don't submit - needed to test handling unit generation
- pp.make_work_order()
- wos = frappe.get_all("Work Order", {"production_plan": pp.name})
+ wo_list, po_list = [], []
+ subcontracted_po = {}
+ default_warehouses = get_default_warehouse()
+ pp.make_work_order_for_subassembly_items(wo_list, subcontracted_po, default_warehouses)
+ pp.make_work_order_for_finished_goods(wo_list, default_warehouses)
+ wos = frappe.get_all("Work Order", {"production_plan": pp.name}, order_by="name ASC")
+ start_time = datetime.datetime(settings.day.year, settings.day.month, settings.day.day, 0, 0)
for wo in wos:
wo = frappe.get_doc("Work Order", wo)
wo.wip_warehouse = "Kitchen - APC"
+ wo.actual_start_date = wo.planned_start_date = start_time
+ wo.required_items = sorted(wo.required_items, key=lambda x: x.get("item_code"))
+ for idx, w in enumerate(wo.required_items, start=1):
+ w.idx = idx
wo.save()
wo.submit()
- job_cards = frappe.get_all("Job Card", {"work_order": wo.name})
- for job_card in job_cards:
- job_card = frappe.get_doc("Job Card", job_card)
+ frappe.db.set_value("Work Order", wo.name, "creation", start_time)
+ # Get job cards and sort by sequence_id to process in order
+ job_cards = frappe.get_all(
+ "Job Card", {"work_order": wo.name}, ["name", "sequence_id"], order_by="sequence_id asc"
+ )
+ for jc in job_cards:
+ job_card = frappe.get_doc("Job Card", jc.name)
+ batch_size, total_operation_time = frappe.get_value(
+ "Operation", job_card.operation, ["batch_size", "total_operation_time"]
+ )
+ time_in_mins = (total_operation_time / batch_size) * wo.qty
job_card.append(
"time_logs",
{
- "completed_qty": wo.qty,
+ # "completed_qty": wo.qty,
+ "from_time": start_time,
+ "to_time": start_time + datetime.timedelta(minutes=time_in_mins),
+ "time_in_mins": time_in_mins,
+ "remaining_time_in_mins": time_in_mins,
},
)
+ # Complete the job card
+ job_card.total_completed_qty = wo.qty
job_card.save()
- job_card.submit()
+ start_time = job_card.time_logs[0].to_time + datetime.timedelta(minutes=2)
+ # job_card.submit() # TODO: don't submit for demand tests
def create_purchase_receipt_for_received_qty_test(settings):
@@ -556,7 +737,7 @@ def create_purchase_receipt_for_received_qty_test(settings):
pr.company = settings.company
pr.supplier = "Freedom Provisions"
pr.posting_date = settings.day
- pr.set_posting_time = 1
+ pr.set_posting_time = True
pr.buying_price_list = "Bakery Buying"
item = frappe.get_doc("Item", "Gooseberry")
pr.append(
@@ -592,3 +773,44 @@ def create_network_printer_settings(settings):
nps.port = ps["port"]
nps.printer_name = ps["name"]
nps.save()
+
+
+def create_employees(settings, only_create=None):
+ for employee in employees:
+ if only_create and employee.get("employee_name") not in only_create:
+ continue
+
+ if frappe.db.exists("Employee", {"employee_name": employee.get("employee_name")}):
+ continue
+
+ if not frappe.db.exists("Designation", employee.get("designation")):
+ desg = frappe.new_doc("Designation")
+ desg.designation_name = employee.get("designation")
+ desg.save()
+
+ empl = frappe.new_doc("Employee")
+ name = employee.pop("name")
+ empl.first_name = name.split(" ")[0]
+ empl.last_name = name.split(" ")[1]
+ empl.update(employee)
+ empl.reports_to = None
+ if settings.company:
+ empl.company = settings.company
+ empl.save()
+
+ user = frappe.new_doc("User")
+ user.email = f"{empl.first_name[0].lower()}{empl.last_name.lower()}@cfc.co"
+ user.first_name = empl.first_name
+ user.last_name = empl.last_name
+ user.send_welcome_email = 0
+ user.enabled = 1
+ user.language = settings.language
+ user.time_zone = settings.time_zone
+ for r in employee.get("roles", []):
+ user.append("roles", {"role": r})
+
+ user.save()
+ empl.user_id = user.email
+ if employee.get("reports_to"):
+ empl.reports_to = frappe.get_value("Employee", {"employee_name": employee.get("reports_to")})
+ empl.save()
diff --git a/beam/tests/test_barcode_auto_generate.py b/beam/tests/test_barcode_auto_generate.py
new file mode 100644
index 00000000..1115a780
--- /dev/null
+++ b/beam/tests/test_barcode_auto_generate.py
@@ -0,0 +1,74 @@
+# Copyright (c) 2025, AgriTheory and contributors
+# For license information, please see license.txt
+
+import frappe
+import pytest
+
+from beam.beam.barcodes import create_beam_barcode
+from beam.beam.doctype.beam_settings.beam_settings import get_doctypes_with_item_barcodes
+
+
+def test_get_doctypes_with_item_barcodes():
+ doctypes = get_doctypes_with_item_barcodes()
+ assert isinstance(doctypes, list)
+ assert "Item" in doctypes
+ assert "Warehouse" in doctypes
+ # all returned values must be real doctypes
+ for dt in doctypes:
+ assert frappe.db.exists("DocType", dt), f"Stale DocField reference: '{dt}' does not exist"
+
+
+def _make_item(item_code):
+ if frappe.db.exists("Item", item_code):
+ item = frappe.get_doc("Item", item_code)
+ item.barcodes = []
+ return item
+ item = frappe.new_doc("Item")
+ item.item_code = item_code
+ item.item_name = item_code
+ item.item_group = "All Item Groups"
+ item.stock_uom = "Nos"
+ item.is_stock_item = 1
+ return item
+
+
+@pytest.fixture()
+def beam_settings():
+ company = frappe.defaults.get_defaults().get("company")
+ settings = frappe.get_doc("BEAM Settings", {"company": company})
+ original = settings.auto_barcode_doctypes
+ yield settings
+ settings.auto_barcode_doctypes = original
+ settings.save()
+
+
+def test_barcode_generated_when_doctype_allowed(beam_settings):
+ beam_settings.auto_barcode_doctypes = '["Item", "Warehouse"]'
+ beam_settings.save()
+
+ item = _make_item("_Test Barcode Allow Item")
+ create_beam_barcode(item)
+
+ assert any(b.barcode_type == "Code128" for b in item.barcodes)
+
+
+def test_barcode_not_generated_when_doctype_not_allowed(beam_settings):
+ beam_settings.auto_barcode_doctypes = '["Warehouse"]'
+ beam_settings.save()
+
+ item = _make_item("_Test Barcode Disallow Item")
+ create_beam_barcode(item)
+
+ assert not any(b.barcode_type == "Code128" for b in item.barcodes)
+
+
+def test_barcode_not_duplicated_when_code128_exists(beam_settings):
+ beam_settings.auto_barcode_doctypes = '["Item", "Warehouse"]'
+ beam_settings.save()
+
+ item = _make_item("_Test Barcode Dedup Item")
+ item.append("barcodes", {"barcode": "12345678901234567890", "barcode_type": "Code128"})
+ create_beam_barcode(item)
+
+ code128_barcodes = [b for b in item.barcodes if b.barcode_type == "Code128"]
+ assert len(code128_barcodes) == 1
diff --git a/beam/tests/test_demand.py b/beam/tests/test_demand.py
new file mode 100644
index 00000000..10aed730
--- /dev/null
+++ b/beam/tests/test_demand.py
@@ -0,0 +1,427 @@
+# Copyright (c) 2024, AgriTheory and contributors
+# For license information, please see license.txt
+
+import random
+from datetime import datetime
+from pathlib import Path
+
+import frappe
+import pytest
+from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
+from frappe.utils import add_days, get_site_path, today
+
+from beam.beam.demand.demand import (
+ build_demand_allocation_map,
+ get_demand,
+ get_manufacturing_demand,
+ get_sales_demand,
+)
+from beam.tests.fixtures import customers
+
+# TODO:
+# configure rejected warehouse and make sure its under test for demand
+# debug allocation issues when
+# add filters to Demand Map: manufactured items, purchased items, finished goods
+
+current_year = datetime.now().year
+
+
+@pytest.mark.order(1)
+def test_opening_demand():
+ # destroy and reset
+ demand_db_path = Path(f"{get_site_path()}/demand.db").resolve()
+ if demand_db_path.exists():
+ demand_db_path.unlink(missing_ok=True)
+
+ sales_demand = get_sales_demand()
+ assert sales_demand[0].item_code == "Ambrosia Pie"
+ assert sales_demand[1].item_code == "Double Plum Pie"
+ assert sales_demand[2].item_code == "Gooseberry Pie"
+ assert sales_demand[3].item_code == "Kaduka Key Lime Pie"
+
+ wos = frappe.get_all("Work Order", ["production_item"], order_by="name ASC")
+
+ assert wos[0].get("production_item") == "Ambrosia Pie Filling"
+ assert wos[1].get("production_item") == "Double Plum Pie Filling"
+ assert wos[2].get("production_item") == "Gooseberry Pie Filling"
+ assert wos[3].get("production_item") == "Kaduka Key Lime Pie Filling"
+ assert wos[4].get("production_item") == "Pie Crust"
+ assert wos[5].get("production_item") == "Ambrosia Pie"
+ assert wos[6].get("production_item") == "Double Plum Pie"
+ assert wos[7].get("production_item") == "Gooseberry Pie"
+ assert wos[8].get("production_item") == "Kaduka Key Lime Pie"
+
+ # [print(f"assert wos[{idx}].get('production_item') == '{m.get('production_item')}'") for idx, m in enumerate(wos)]
+
+ manufacturing_demand = get_manufacturing_demand()
+ # [
+ # print(
+ # f"assert manufacturing_demand[{idx}].get('parent') == '{m.get('parent')}'" + '\n' +
+ # f"assert manufacturing_demand[{idx}].get('item_code') == '{m.get('item_code')}'"
+ # )
+ # for idx, m in enumerate(manufacturing_demand)
+ # ]
+
+ # assert frappe.get_value('Work Order', manufacturing_demand[0].get('parent'), 'production_item') == 'Kaduka Key Lime Pie Filling'
+ assert manufacturing_demand[0].get("parent") == f"MFG-WO-{current_year}-00001"
+ assert manufacturing_demand[0].get("item_code") == "Butter"
+ assert manufacturing_demand[1].get("parent") == f"MFG-WO-{current_year}-00001"
+ assert manufacturing_demand[1].get("item_code") == "Cloudberry"
+ assert manufacturing_demand[2].get("parent") == f"MFG-WO-{current_year}-00001"
+ assert manufacturing_demand[2].get("item_code") == "Cornstarch"
+ assert manufacturing_demand[3].get("parent") == f"MFG-WO-{current_year}-00001"
+ assert manufacturing_demand[3].get("item_code") == "Hairless Rambutan"
+ assert manufacturing_demand[4].get("parent") == f"MFG-WO-{current_year}-00001"
+ assert manufacturing_demand[4].get("item_code") == "Sugar"
+ assert manufacturing_demand[5].get("parent") == f"MFG-WO-{current_year}-00001"
+ assert manufacturing_demand[5].get("item_code") == "Tayberry"
+ assert manufacturing_demand[6].get("parent") == f"MFG-WO-{current_year}-00001"
+ assert manufacturing_demand[6].get("item_code") == "Water"
+ assert manufacturing_demand[7].get("parent") == f"MFG-WO-{current_year}-00002"
+ assert manufacturing_demand[7].get("item_code") == "Butter"
+ assert manufacturing_demand[8].get("parent") == f"MFG-WO-{current_year}-00002"
+ assert manufacturing_demand[8].get("item_code") == "Cocoplum"
+ assert manufacturing_demand[9].get("parent") == f"MFG-WO-{current_year}-00002"
+ assert manufacturing_demand[9].get("item_code") == "Cornstarch"
+ assert manufacturing_demand[10].get("parent") == f"MFG-WO-{current_year}-00002"
+ assert manufacturing_demand[10].get("item_code") == "Damson Plum"
+ assert manufacturing_demand[11].get("parent") == f"MFG-WO-{current_year}-00002"
+ assert manufacturing_demand[11].get("item_code") == "Sugar"
+ assert manufacturing_demand[12].get("parent") == f"MFG-WO-{current_year}-00002"
+ assert manufacturing_demand[12].get("item_code") == "Water"
+ assert manufacturing_demand[13].get("parent") == f"MFG-WO-{current_year}-00003"
+ assert manufacturing_demand[13].get("item_code") == "Butter"
+ assert manufacturing_demand[14].get("parent") == f"MFG-WO-{current_year}-00003"
+ assert manufacturing_demand[14].get("item_code") == "Cornstarch"
+ assert manufacturing_demand[15].get("parent") == f"MFG-WO-{current_year}-00003"
+ assert manufacturing_demand[15].get("item_code") == "Gooseberry"
+ assert manufacturing_demand[16].get("parent") == f"MFG-WO-{current_year}-00003"
+ assert manufacturing_demand[16].get("item_code") == "Sugar"
+ assert manufacturing_demand[17].get("parent") == f"MFG-WO-{current_year}-00003"
+ assert manufacturing_demand[17].get("item_code") == "Water"
+ assert manufacturing_demand[18].get("parent") == f"MFG-WO-{current_year}-00004"
+ assert manufacturing_demand[18].get("item_code") == "Butter"
+ assert manufacturing_demand[19].get("parent") == f"MFG-WO-{current_year}-00004"
+ assert manufacturing_demand[19].get("item_code") == "Cornstarch"
+ assert manufacturing_demand[20].get("parent") == f"MFG-WO-{current_year}-00004"
+ assert manufacturing_demand[20].get("item_code") == "Kaduka Lime"
+ assert manufacturing_demand[21].get("parent") == f"MFG-WO-{current_year}-00004"
+ assert manufacturing_demand[21].get("item_code") == "Limequat"
+ assert manufacturing_demand[22].get("parent") == f"MFG-WO-{current_year}-00004"
+ assert manufacturing_demand[22].get("item_code") == "Sugar"
+ assert manufacturing_demand[23].get("parent") == f"MFG-WO-{current_year}-00004"
+ assert manufacturing_demand[23].get("item_code") == "Water"
+ assert manufacturing_demand[24].get("parent") == f"MFG-WO-{current_year}-00005"
+ assert manufacturing_demand[24].get("item_code") == "Butter"
+ assert manufacturing_demand[25].get("parent") == f"MFG-WO-{current_year}-00005"
+ assert manufacturing_demand[25].get("item_code") == "Flour"
+ assert manufacturing_demand[26].get("parent") == f"MFG-WO-{current_year}-00005"
+ assert manufacturing_demand[26].get("item_code") == "Ice Water"
+ assert manufacturing_demand[27].get("parent") == f"MFG-WO-{current_year}-00005"
+ assert manufacturing_demand[27].get("item_code") == "Parchment Paper"
+ assert manufacturing_demand[28].get("parent") == f"MFG-WO-{current_year}-00005"
+ assert manufacturing_demand[28].get("item_code") == "Pie Tin"
+ assert manufacturing_demand[29].get("parent") == f"MFG-WO-{current_year}-00005"
+ assert manufacturing_demand[29].get("item_code") == "Salt"
+ assert manufacturing_demand[30].get("parent") == f"MFG-WO-{current_year}-00006"
+ assert manufacturing_demand[30].get("item_code") == "Ambrosia Pie Filling"
+ assert manufacturing_demand[31].get("parent") == f"MFG-WO-{current_year}-00006"
+ assert manufacturing_demand[31].get("item_code") == "Pie Box"
+ assert manufacturing_demand[32].get("parent") == f"MFG-WO-{current_year}-00006"
+ assert manufacturing_demand[32].get("item_code") == "Pie Crust"
+ assert manufacturing_demand[33].get("parent") == f"MFG-WO-{current_year}-00007"
+ assert manufacturing_demand[33].get("item_code") == "Double Plum Pie Filling"
+ assert manufacturing_demand[34].get("parent") == f"MFG-WO-{current_year}-00007"
+ assert manufacturing_demand[34].get("item_code") == "Pie Box"
+ assert manufacturing_demand[35].get("parent") == f"MFG-WO-{current_year}-00007"
+ assert manufacturing_demand[35].get("item_code") == "Pie Crust"
+ assert manufacturing_demand[36].get("parent") == f"MFG-WO-{current_year}-00008"
+ assert manufacturing_demand[36].get("item_code") == "Gooseberry Pie Filling"
+ assert manufacturing_demand[37].get("parent") == f"MFG-WO-{current_year}-00008"
+ assert manufacturing_demand[37].get("item_code") == "Pie Box"
+ assert manufacturing_demand[38].get("parent") == f"MFG-WO-{current_year}-00008"
+ assert manufacturing_demand[38].get("item_code") == "Pie Crust"
+ assert manufacturing_demand[39].get("parent") == f"MFG-WO-{current_year}-00009"
+ assert manufacturing_demand[39].get("item_code") == "Kaduka Key Lime Pie Filling"
+ assert manufacturing_demand[40].get("parent") == f"MFG-WO-{current_year}-00009"
+ assert manufacturing_demand[40].get("item_code") == "Pie Box"
+ assert manufacturing_demand[41].get("parent") == f"MFG-WO-{current_year}-00009"
+ assert manufacturing_demand[41].get("item_code") == "Pie Crust"
+
+ build_demand_allocation_map()
+
+ # get demand assert that correct quantities and allocations exist
+ water = get_demand(filters={"item_code": "Water"})
+ assert len(water) == 4
+
+ assert water[0].parent == f"MFG-WO-{current_year}-00001"
+ assert water[0].total_required_qty == 10.0
+ assert water[0].net_required_qty == 1.0
+ assert water[0].allocated_qty == 9.0
+ assert water[0].warehouse == "Refrigerator - APC"
+
+ assert water[1].parent == f"MFG-WO-{current_year}-00002"
+ assert water[1].total_required_qty == 10.0
+ assert water[1].net_required_qty == 10.0
+ assert water[1].allocated_qty == 0.0
+ assert water[1].warehouse == "Kitchen - APC"
+
+ assert water[2].parent == f"MFG-WO-{current_year}-00003"
+ assert water[2].total_required_qty == 2.5
+ assert water[2].net_required_qty == 2.5
+ assert water[2].allocated_qty == 0.0
+ assert water[2].warehouse == "Kitchen - APC"
+
+ assert water[3].parent == f"MFG-WO-{current_year}-00004"
+ assert water[3].total_required_qty == 2.5
+ assert water[3].net_required_qty == 2.5
+ assert water[3].allocated_qty == 0.0
+ assert water[3].warehouse == "Kitchen - APC"
+
+ ice_water = get_demand(filters={"item_code": "Ice Water"})
+ assert len(ice_water) == 1
+
+ assert ice_water[0].parent == f"MFG-WO-{current_year}-00005"
+ assert ice_water[0].total_required_qty == 50
+ assert ice_water[0].net_required_qty == 39.0
+ assert ice_water[0].allocated_qty == 11.0
+ assert ice_water[0].warehouse == "Refrigerator - APC"
+
+
+@pytest.mark.order(2)
+def test_insufficient_total_demand_scenario():
+ # test multiple allocations
+ se = frappe.new_doc("Stock Entry")
+ se.stock_entry_type = se.purpose = "Material Receipt"
+ se.append(
+ "items",
+ {
+ "item_code": "Water",
+ "qty": 7,
+ "t_warehouse": "Refrigerator - APC",
+ "uom": "Cup",
+ "basic_rate": 0.15,
+ "expense_account": "5111 - Cost of Goods Sold - APC",
+ },
+ )
+ se.append(
+ "items",
+ {
+ "item_code": "Ice Water",
+ "qty": 100,
+ "uom": "Cup",
+ "t_warehouse": "Refrigerator - APC",
+ "basic_rate": 0.30,
+ "expense_account": "5111 - Cost of Goods Sold - APC",
+ },
+ )
+ se.save()
+ se.submit()
+ water = get_demand(filters={"item_code": "Water"})
+ assert len(water) == 4
+
+ assert water[0].parent == f"MFG-WO-{current_year}-00001"
+ assert water[0].total_required_qty == 10.0
+ assert water[0].net_required_qty == 0.0
+ assert water[0].allocated_qty == 10.0
+ assert water[0].warehouse == "Refrigerator - APC"
+
+ assert water[1].parent == f"MFG-WO-{current_year}-00002"
+ assert water[1].total_required_qty == 10.0
+ assert water[1].net_required_qty == 10.0
+ assert water[1].allocated_qty == 0.0
+ assert water[1].warehouse == "Kitchen - APC"
+
+ assert water[2].parent == f"MFG-WO-{current_year}-00003"
+ assert water[2].total_required_qty == 2.5
+ assert water[2].net_required_qty == 2.5
+ assert water[2].allocated_qty == 0.0
+ assert water[2].warehouse == "Kitchen - APC"
+
+ assert water[3].parent == f"MFG-WO-{current_year}-00004"
+ assert water[3].total_required_qty == 2.5
+ assert water[3].net_required_qty == 2.5
+ assert water[3].allocated_qty == 0.0
+ assert water[3].warehouse == "Kitchen - APC"
+
+ # assert partial allocations
+ ice_water = get_demand(filters={"item_code": "Ice Water"})
+ assert len(ice_water) == 1
+
+ assert ice_water[0].total_required_qty == 50
+ assert ice_water[0].net_required_qty == 0
+ assert ice_water[0].allocated_qty == 50
+ assert ice_water[0].warehouse == "Refrigerator - APC"
+ assert ice_water[0].parent == f"MFG-WO-{current_year}-00005"
+
+
+@pytest.mark.order(31) # run after other tests
+def test_demand_removal_on_order_cancel():
+ # Force rebuild demand allocation map to ensure it's up to date
+ build_demand_allocation_map()
+
+ pie = get_demand(filters={"item_code": "Ambrosia Pie"})
+ assert len(pie) == 1
+
+ so = frappe.new_doc("Sales Order")
+ so.customer = random.choice(customers)
+ so.selling_price_list = "Bakery Wholesale"
+ so.append(
+ "items",
+ {
+ "item_code": "Ambrosia Pie",
+ "delivery_date": add_days(today(), 7),
+ "qty": 10,
+ "warehouse": "Baked Goods - APC",
+ },
+ )
+ so.save()
+ so.submit()
+
+ pie = get_demand(filters={"item_code": "Ambrosia Pie"})
+ assert len(pie) == 2
+
+ so.cancel()
+ so.delete()
+
+ pie = get_demand(filters={"item_code": "Ambrosia Pie"})
+ assert len(pie) == 1
+
+
+@pytest.mark.order(32) # run after other tests
+def test_allocation_creation_on_delivery():
+ se = frappe.new_doc("Stock Entry")
+ se.stock_entry_type = se.purpose = "Material Receipt"
+ se.append(
+ "items",
+ {
+ "item_code": "Ambrosia Pie",
+ "qty": 40,
+ "t_warehouse": "Baked Goods - APC",
+ "basic_rate": frappe.get_value("Item Price", {"item_code": "Ambrosia Pie"}, "price_list_rate"),
+ },
+ )
+ se.save()
+ se.submit()
+
+ # assert partial allocations
+ pie = get_demand(filters={"item_code": "Ambrosia Pie"})
+ assert len(pie) == 1
+
+ assert pie[0].total_required_qty == 40
+ assert pie[0].net_required_qty == 0
+ assert pie[0].allocated_qty == 40
+ assert pie[0].warehouse == "Baked Goods - APC"
+ assert pie[0].parent == f"SAL-ORD-{current_year}-00001"
+
+ dn = make_delivery_note(f"SAL-ORD-{current_year}-00001")
+ for item in dn.items[:]:
+ if item.item_code == "Ambrosia Pie":
+ item.qty = 5
+ else:
+ dn.remove(item)
+ dn.save()
+ dn.submit()
+
+ # assert partial allocations
+ pie = get_demand(filters={"item_code": "Ambrosia Pie"})
+ assert len(pie) == 1
+
+ assert pie[0].total_required_qty == 35
+ assert pie[0].net_required_qty == 0
+ assert pie[0].allocated_qty == 35
+ assert pie[0].warehouse == "Baked Goods - APC"
+ assert pie[0].parent == f"SAL-ORD-{current_year}-00001"
+
+
+@pytest.mark.order(33) # run after other tests
+def test_allocation_reversal_on_delivery_cancel():
+ dn = frappe.get_doc("Delivery Note", f"MAT-DN-{current_year}-00001")
+ dn.cancel()
+
+ pie = get_demand(filters={"item_code": "Ambrosia Pie"})
+ assert len(pie) == 1
+
+ # demand + allocation from stock entry
+ assert pie[0].total_required_qty == 40
+ assert pie[0].net_required_qty == 0
+ assert pie[0].allocated_qty == 40
+ assert pie[0].warehouse == "Baked Goods - APC"
+ assert pie[0].parent == f"SAL-ORD-{current_year}-00001"
+
+
+@pytest.mark.order(13)
+def test_allocation_from_purchasing():
+ # Rebuild demand allocation map to ensure purchase receipts with handling units are reflected
+ build_demand_allocation_map()
+
+ receipts = frappe.get_all(
+ "Purchase Receipt", ["name", "'Purchase Receipt' AS doctype"]
+ ) + frappe.get_all("Purchase Invoice", ["name", "'Purchase Invoice' AS doctype"])
+
+ for pr in receipts:
+ doc = frappe.get_doc(pr.doctype, pr.name)
+ for item in doc.items:
+ if item.handling_unit: # flag for inventoriable item
+ # TODO: this should be improved with greater specificity, but detecting that
+ # creating inventory leads to modification of the demand db is OK for now
+ d = get_demand(filters={"item_code": item.item_code})
+ assert len(d) > 0
+
+
+@pytest.mark.order(35) # run after other tests
+def test_ignore_drop_shipped_items_in_demand():
+ # Create temporary drop shipped item
+ supplier = "Freedom Provisions"
+ i = frappe.new_doc("Item")
+ i.item_code = i.item_name = "Pie Servingware"
+ i.item_group = "Products"
+ i.is_stock_item = 1
+ i.stock_uom = "Nos"
+ i.is_purchase_item = 1
+ i.is_sales_item = 1
+ i.delivered_by_supplier = 1
+ i.append("supplier_items", {"supplier": supplier})
+ i.save()
+
+ d = get_sales_demand(item_code=i.item_code)
+ assert len(d) == 0
+
+ # create sales order with drop shipped item
+ so = frappe.new_doc("Sales Order")
+ so.customer = random.choice(customers)
+ so.selling_price_list = "Standard Selling"
+ so.append(
+ "items",
+ {
+ "item_code": i.item_code,
+ "delivery_date": add_days(today(), 7),
+ "qty": 10,
+ "rate": 2,
+ "delivered_by_supplier": 1,
+ "supplier": supplier,
+ },
+ )
+ so.save()
+ so.submit()
+
+ bs = frappe.get_doc("BEAM Settings", {"company": so.company})
+ assert bs.ignore_drop_shipped_items == 0
+
+ d = get_sales_demand(item_code=i.item_code)
+ assert len(d) == 1
+ assert d[0].get("total_required_qty") == 10
+
+ # update settings to ignore drop shipped items
+ bs.ignore_drop_shipped_items = 1
+ bs.save()
+
+ d = get_sales_demand(item_code=i.item_code)
+ assert len(d) == 0
+
+ # reset settings and cancel/delete Sales Order
+ bs.ignore_drop_shipped_items = 0
+ bs.save()
+
+ so.cancel()
+ so.delete()
diff --git a/beam/tests/test_handling_unit.py b/beam/tests/test_handling_unit.py
index 0b27ebef..c02386f9 100644
--- a/beam/tests/test_handling_unit.py
+++ b/beam/tests/test_handling_unit.py
@@ -22,6 +22,85 @@ def submit_all_purchase_receipts():
pr.submit()
+@pytest.mark.order(10)
+def test_enable_handling_units_setting():
+ """Test that enable_handling_units setting controls whether handling units are assigned to SLEs"""
+ company = frappe.defaults.get_defaults().get("company")
+
+ # Test with enable_handling_units = False (default)
+ beam_settings = frappe.get_doc("BEAM Settings", {"company": company})
+ original_value = beam_settings.enable_handling_units
+ beam_settings.enable_handling_units = 0
+ beam_settings.save()
+
+ try:
+ se_disabled = frappe.new_doc("Stock Entry")
+ se_disabled.stock_entry_type = se_disabled.purpose = "Material Receipt"
+ se_disabled.company = company
+ se_disabled.append(
+ "items",
+ {
+ "item_code": "Ambrosia Pie",
+ "qty": 10,
+ "t_warehouse": "Baked Goods - APC",
+ "basic_rate": frappe.get_value("Item Price", {"item_code": "Ambrosia Pie"}, "price_list_rate"),
+ },
+ )
+ se_disabled.save()
+ se_disabled.submit()
+
+ # When disabled, handling_unit should NOT be generated
+ item_row = se_disabled.items[0]
+ assert (
+ not item_row.handling_unit
+ ), f"Item row should not have handling_unit when setting is disabled, but got: {item_row.handling_unit}"
+
+ # Check SLE - handling_unit should also NOT be set
+ sle_disabled = frappe.get_doc("Stock Ledger Entry", {"voucher_detail_no": item_row.name})
+ assert (
+ not sle_disabled.handling_unit or sle_disabled.handling_unit == ""
+ ), f"SLE should not have handling_unit when enable_handling_units is disabled, but got: {sle_disabled.handling_unit}"
+
+ # Now test with enable_handling_units = True
+ beam_settings.enable_handling_units = 1
+ beam_settings.save()
+
+ se_enabled = frappe.new_doc("Stock Entry")
+ se_enabled.stock_entry_type = se_enabled.purpose = "Material Receipt"
+ se_enabled.company = company
+ se_enabled.append(
+ "items",
+ {
+ "item_code": "Ambrosia Pie",
+ "qty": 10,
+ "t_warehouse": "Baked Goods - APC",
+ "basic_rate": frappe.get_value("Item Price", {"item_code": "Ambrosia Pie"}, "price_list_rate"),
+ },
+ )
+ se_enabled.save()
+ se_enabled.submit()
+
+ # When enabled, handling_unit should be generated on item row
+ item_row_enabled = se_enabled.items[0]
+ assert (
+ item_row_enabled.handling_unit
+ ), "Item row should have handling_unit when setting is enabled"
+
+ # Check SLE - handling_unit SHOULD be set when enabled
+ sle_enabled = frappe.get_doc("Stock Ledger Entry", {"voucher_detail_no": item_row_enabled.name})
+ assert (
+ sle_enabled.handling_unit
+ ), "SLE should have handling_unit when enable_handling_units is enabled"
+ assert (
+ sle_enabled.handling_unit == item_row_enabled.handling_unit
+ ), f"SLE handling_unit should match item row: {sle_enabled.handling_unit} != {item_row_enabled.handling_unit}"
+
+ finally:
+ # Restore original setting
+ beam_settings.enable_handling_units = original_value
+ beam_settings.save()
+
+
@pytest.mark.order(1)
def test_purchase_receipt_handling_unit_generation():
for pr in frappe.get_all("Purchase Receipt"):
@@ -33,11 +112,11 @@ def test_purchase_receipt_handling_unit_generation():
assert isinstance(row.handling_unit, str)
if row.rejected_qty:
assert row.rejected_qty + row.qty == row.received_qty
- hu = get_handling_unit(row.handling_unit)
- assert hu.stock_qty == row.stock_qty
+ hu = get_handling_unit(row.handling_unit)
+ assert hu.stock_qty == row.stock_qty
-@pytest.mark.order(2)
+@pytest.mark.order(11)
def test_purchase_invoice():
for pi in frappe.get_all("Purchase Invoice"):
pi = frappe.get_doc("Purchase Invoice", pi)
@@ -54,7 +133,7 @@ def test_purchase_invoice():
assert row.handling_unit == None
-@pytest.mark.order(3)
+@pytest.mark.order(13)
def test_stock_entry_material_receipt():
submit_all_purchase_receipts()
se = frappe.new_doc("Stock Entry")
@@ -72,7 +151,7 @@ def test_stock_entry_material_receipt():
"items",
{
"item_code": "Ice Water",
- "qty": 1000000000,
+ "qty": 100,
"t_warehouse": "Refrigerator - APC",
"basic_rate": 0,
"allow_zero_valuation_rate": 1,
@@ -93,7 +172,7 @@ def test_stock_entry_material_receipt():
assert row.handling_unit == sle.handling_unit
-@pytest.mark.order(4)
+@pytest.mark.order(14)
def test_stock_entry_repack():
submit_all_purchase_receipts()
pr_hu = frappe.get_value(
@@ -120,6 +199,18 @@ def test_stock_entry_repack():
"handling_unit": pr_hu["handling_unit"],
},
)
+ scan = frappe.call(
+ "beam.beam.scan.scan",
+ **{
+ "barcode": pr_hu.handling_unit,
+ "context": {"frm": "Stock Entry", "doc": se.as_dict()},
+ "current_qty": 100,
+ },
+ )
+ assert scan[0]["action"] == "add_or_associate"
+ se.items[0].handling_unit = scan[0]["context"].get(
+ "handling_unit"
+ ) # simulates the effect of 'associate'
se.append(
"items",
{
@@ -149,7 +240,7 @@ def test_stock_entry_repack():
assert hu.stock_qty == 100
-@pytest.mark.order(4)
+@pytest.mark.order(15)
def test_stock_entry_material_transfer_for_manufacture():
submit_all_purchase_receipts()
wo = frappe.get_value("Work Order", {"production_item": "Kaduka Key Lime Pie Filling"})
@@ -200,13 +291,23 @@ def test_stock_entry_material_transfer_for_manufacture():
assert row.handling_unit != row.to_handling_unit
-@pytest.mark.order(6)
+@pytest.mark.order(16)
def test_stock_entry_for_manufacture():
submit_all_purchase_receipts()
wo = frappe.get_value("Work Order", {"production_item": "Kaduka Key Lime Pie Filling"})
se_tfm = frappe.get_value(
"Stock Entry", {"work_order": wo, "purpose": "Material Transfer for Manufacture"}
)
+ job_cards = frappe.get_all(
+ "Job Card", {"work_order": wo}, ["name", "sequence_id"], order_by="sequence_id asc"
+ )
+ for jc in job_cards:
+ job_card = frappe.get_doc("Job Card", jc.name)
+ # Complete the job card by setting completed qty equal to qty to manufacture
+ for time_log in job_card.time_logs:
+ time_log.completed_qty = job_card.for_quantity
+ job_card.submit()
+
se = make_stock_entry(wo, "Manufacture", 40)
# simulate scanning
for row in se.get("items"):
@@ -263,7 +364,7 @@ def test_stock_entry_for_manufacture():
assert row.t_warehouse == sle.warehouse # target warehouse
-@pytest.mark.order(7)
+@pytest.mark.order(17)
def test_delivery_note():
se = frappe.new_doc("Stock Entry")
se.stock_entry_type = se.purpose = "Material Receipt"
@@ -306,7 +407,7 @@ def test_delivery_note():
assert hu.item_code == dn.items[0].item_code
-@pytest.mark.order(8)
+@pytest.mark.order(18)
def test_sales_invoice():
se = frappe.new_doc("Stock Entry")
se.stock_entry_type = se.purpose = "Material Receipt"
@@ -350,7 +451,7 @@ def test_sales_invoice():
assert hu.item_code == si.items[0].item_code
-@pytest.mark.order(9)
+@pytest.mark.order(19)
def test_packing_slip():
se = frappe.new_doc("Stock Entry")
se.stock_entry_type = se.purpose = "Material Receipt"
@@ -407,7 +508,7 @@ def test_packing_slip():
assert hu.stock_qty == 0
-@pytest.mark.order(10)
+@pytest.mark.order(20)
def test_stock_entry_material_transfer():
# create clean material receipt to avoid conflicts with Repack test
semr = frappe.new_doc("Stock Entry")
@@ -463,7 +564,11 @@ def test_stock_entry_material_transfer():
"Item", row.item_code, "enable_handling_unit"
):
continue
- sle = frappe.get_doc("Stock Ledger Entry", {"handling_unit": row.handling_unit})
+ # For Material Transfer, there are two SLEs - one for source (negative) and one for target (positive)
+ # Get the source warehouse SLE (the one consuming from the handling unit)
+ sle = frappe.get_doc(
+ "Stock Ledger Entry", {"handling_unit": row.handling_unit, "warehouse": row.s_warehouse}
+ )
hu = get_handling_unit(str(row.handling_unit))
assert row.transfer_qty == abs(sle.actual_qty)
assert hu.stock_qty == 95 # net qty
@@ -496,7 +601,7 @@ def test_stock_entry_material_transfer():
assert row.t_warehouse == tsle.warehouse # target warehouse
-@pytest.mark.order(11)
+@pytest.mark.order(21)
def test_stock_entry_for_send_to_subcontractor():
submit_all_purchase_receipts()
se = frappe.new_doc("Stock Entry")
@@ -557,7 +662,7 @@ def test_stock_entry_for_send_to_subcontractor():
assert hu.qty > 0
-@pytest.mark.order(12)
+@pytest.mark.order(22)
def test_subcontracting_receipt():
for row in frappe.get_all("Subcontracting Order", pluck="name"):
if not frappe.db.exists(
@@ -579,7 +684,7 @@ def test_subcontracting_receipt():
assert hu.stock_qty == row.returned_qty
-@pytest.mark.order(13)
+@pytest.mark.order(23)
@pytest.mark.skip() # Remove when validate_handling_unit_overconsumption is uncommented in hooks.py doc_events
def test_handling_units_overconsumption_in_material_transfer_stock_entry():
# Tests validate_handling_unit_overconsumption Stock Entry incoming code block
@@ -635,7 +740,7 @@ def test_handling_units_overconsumption_in_material_transfer_stock_entry():
)
-@pytest.mark.order(14)
+@pytest.mark.order(24)
@pytest.mark.skip() # Remove when validate_handling_unit_overconsumption is uncommented in hooks.py doc_events
def test_handling_units_overconsumption_in_delivery_note():
# Tests validate_handling_unit_overconsumption Delivery Note code block
@@ -681,3 +786,223 @@ def test_handling_units_overconsumption_in_delivery_note():
f"Row #1: Handling Unit for Ambrosia Pie cannot be more than {hu.stock_qty} {hu.stock_uom}. You have {row_qty:.1f} {row_stock_uom}"
in exc_info.value.args[0]
)
+
+
+@pytest.mark.order(15)
+def test_repack_cancel_without_recombine():
+ """Test cancelling a Repack Stock Entry without recombining handling units"""
+ # Create a material receipt with a known handling unit
+ se_receipt = frappe.new_doc("Stock Entry")
+ se_receipt.stock_entry_type = se_receipt.purpose = "Material Receipt"
+ se_receipt.append(
+ "items",
+ {
+ "item_code": "Parchment Paper",
+ "qty": 100,
+ "t_warehouse": "Storeroom - APC",
+ "basic_rate": frappe.get_value(
+ "Item Price", {"item_code": "Parchment Paper"}, "price_list_rate"
+ ),
+ },
+ )
+ se_receipt.save()
+ se_receipt.submit()
+ source_hu = se_receipt.items[0].handling_unit
+
+ # Create a repack entry
+ se_repack = frappe.new_doc("Stock Entry")
+ se_repack.stock_entry_type = se_repack.purpose = "Repack"
+ se_repack.append(
+ "items",
+ {
+ "item_code": "Parchment Paper",
+ "qty": 1,
+ "uom": "Box",
+ "conversion_factor": 100,
+ "stock_qty": 100,
+ "actual_qty": 100,
+ "transfer_qty": 100,
+ "s_warehouse": "Storeroom - APC",
+ "handling_unit": source_hu,
+ },
+ )
+ se_repack.append(
+ "items",
+ {
+ "item_code": "Parchment Paper",
+ "uom": "Nos",
+ "qty": 100,
+ "actual_qty": 100,
+ "transfer_qty": 100,
+ "t_warehouse": "Storeroom - APC",
+ },
+ )
+ se_repack.save()
+ se_repack.submit()
+
+ source_row = se_repack.items[0]
+ target_row = se_repack.items[1]
+ target_hu = target_row.handling_unit
+
+ # Verify initial state
+ source_hu_doc = get_handling_unit(source_hu)
+ target_hu_doc = get_handling_unit(target_hu)
+ assert source_hu_doc.stock_qty == 0 # consumed
+ assert target_hu_doc.stock_qty == 100 # created
+
+ # Cancel WITHOUT recombine (don't set recombine_on_cancel)
+ se_repack.cancel()
+
+ # After cancel without recombine:
+ # - Source HU should have qty 0 (consumed stays consumed)
+ # - Target HU should still exist with qty 100 (produced stays produced)
+ # This "keep separate" behavior maintains the split in cancelled state
+ source_hu_doc = get_handling_unit(source_hu)
+ target_hu_doc = get_handling_unit(target_hu)
+ assert source_hu_doc.stock_qty == 0 # consumed
+ assert target_hu_doc.stock_qty == 100 # produced
+
+
+@pytest.mark.order(16)
+def test_repack_cancel_with_recombine():
+ """Test cancelling a Repack Stock Entry WITH recombining handling units"""
+ # Create a material receipt with a known handling unit
+ se_receipt = frappe.new_doc("Stock Entry")
+ se_receipt.stock_entry_type = se_receipt.purpose = "Material Receipt"
+ se_receipt.append(
+ "items",
+ {
+ "item_code": "Parchment Paper",
+ "qty": 100,
+ "t_warehouse": "Storeroom - APC",
+ "basic_rate": frappe.get_value(
+ "Item Price", {"item_code": "Parchment Paper"}, "price_list_rate"
+ ),
+ },
+ )
+ se_receipt.save()
+ se_receipt.submit()
+ source_hu = se_receipt.items[0].handling_unit
+
+ # Create a repack entry
+ se_repack = frappe.new_doc("Stock Entry")
+ se_repack.stock_entry_type = se_repack.purpose = "Repack"
+ se_repack.append(
+ "items",
+ {
+ "item_code": "Parchment Paper",
+ "qty": 1,
+ "uom": "Box",
+ "conversion_factor": 100,
+ "stock_qty": 100,
+ "actual_qty": 100,
+ "transfer_qty": 100,
+ "s_warehouse": "Storeroom - APC",
+ "handling_unit": source_hu,
+ },
+ )
+ se_repack.append(
+ "items",
+ {
+ "item_code": "Parchment Paper",
+ "uom": "Nos",
+ "qty": 100,
+ "actual_qty": 100,
+ "transfer_qty": 100,
+ "t_warehouse": "Storeroom - APC",
+ },
+ )
+ se_repack.save()
+ se_repack.submit()
+
+ source_row = se_repack.items[0]
+ target_row = se_repack.items[1]
+ target_hu = target_row.handling_unit
+
+ # Set recombine_on_cancel on BOTH rows (as the frontend does)
+ source_row.db_set("recombine_on_cancel", True)
+ target_row.db_set("recombine_on_cancel", True)
+
+ # Cancel WITH recombine
+ se_repack.reload()
+ se_repack.cancel()
+
+ # After cancel with recombine:
+ # - Source HU should NOT get additional entries (recombine prevents split)
+ # - Target HU should NOT exist (was recombined back)
+ source_hu_doc = get_handling_unit(source_hu)
+ target_hu_doc = get_handling_unit(target_hu)
+
+ # Source HU should have the original quantity (no split entries added)
+ assert source_hu_doc.stock_qty == 100
+ # Target HU should be empty/zero (recombined back to source)
+ assert target_hu_doc is None or target_hu_doc.stock_qty == 0
+
+
+@pytest.mark.order(17)
+def test_material_transfer_cancel_without_recombine():
+ """Test cancelling a Material Transfer Stock Entry without recombining handling units"""
+ # Create a material receipt
+ se_receipt = frappe.new_doc("Stock Entry")
+ se_receipt.stock_entry_type = se_receipt.purpose = "Material Receipt"
+ se_receipt.append(
+ "items",
+ {
+ "item_code": "Parchment Paper",
+ "qty": 100,
+ "t_warehouse": "Storeroom - APC",
+ "basic_rate": frappe.get_value(
+ "Item Price", {"item_code": "Parchment Paper"}, "price_list_rate"
+ ),
+ },
+ )
+ se_receipt.save()
+ se_receipt.submit()
+ source_hu = se_receipt.items[0].handling_unit
+
+ # Create a material transfer
+ se_transfer = frappe.new_doc("Stock Entry")
+ se_transfer.stock_entry_type = se_transfer.purpose = "Material Transfer"
+ se_transfer.company = frappe.defaults.get_defaults().get("company")
+
+ scan = frappe.call(
+ "beam.beam.scan.scan",
+ **{
+ "barcode": str(source_hu),
+ "context": {"frm": "Stock Entry", "doc": se_transfer.as_dict()},
+ "current_qty": 1,
+ },
+ )
+ se_transfer.append(
+ "items",
+ {
+ **scan[0]["context"],
+ "qty": 50,
+ "actual_qty": 50,
+ "transfer_qty": 50,
+ "s_warehouse": "Storeroom - APC",
+ "t_warehouse": "Kitchen - APC",
+ },
+ )
+ se_transfer.save()
+ se_transfer.submit()
+
+ transfer_row = se_transfer.items[0]
+ target_hu = transfer_row.to_handling_unit
+
+ # Verify initial state
+ source_hu_doc = get_handling_unit(source_hu)
+ target_hu_doc = get_handling_unit(target_hu)
+ assert source_hu_doc.stock_qty == 50 # remaining in source
+ assert target_hu_doc.stock_qty == 50 # transferred to target
+
+ # Cancel WITHOUT recombine
+ se_transfer.cancel()
+
+ # After cancel without recombine:
+ # - Source HU should be restored
+ # - Target HU should also be restored (both persist separately)
+ source_hu_doc = get_handling_unit(source_hu)
+ target_hu_doc = get_handling_unit(target_hu)
+ assert source_hu_doc.stock_qty == 50 # restored in source warehouse
+ assert target_hu_doc.stock_qty == 50 # restored in target warehouse
diff --git a/beam/tests/test_hooks_override.py b/beam/tests/test_hooks_override.py
index 7149a91f..1fb76245 100644
--- a/beam/tests/test_hooks_override.py
+++ b/beam/tests/test_hooks_override.py
@@ -43,6 +43,7 @@ def patched_hooks(*args, **kwargs):
monkeymodule.setattr("frappe.get_hooks", patched_hooks)
+@pytest.mark.order(30)
def test_beam_frm_hooks_override(patch_frappe_get_hooks):
item_barcode = frappe.get_value("Item Barcode", {"parent": "Kaduka Key Lime Pie"}, "barcode")
dn = frappe.new_doc("Delivery Note")
diff --git a/beam/tests/test_item_barcode_print_format.py b/beam/tests/test_item_barcode_print_format.py
new file mode 100644
index 00000000..46ce6a9e
--- /dev/null
+++ b/beam/tests/test_item_barcode_print_format.py
@@ -0,0 +1,23 @@
+# Copyright (c) 2025, AgriTheory and contributors
+# Test for barcode generation in Item Barcode print format
+
+import pytest
+
+from beam.beam.barcodes import barcode128
+
+
+@pytest.mark.parametrize("barcode_text", ["123456789012", "ITEM-00001", "987654321098"])
+def test_item_barcode_print_format(barcode_text):
+ # Generate barcode image in print format
+ img_html = barcode128(barcode_text)
+ assert img_html.startswith('')
+ # Optionally, check that the base64 string decodes to PNG
+ import base64
+ import re
+
+ match = re.search(r"data:image/png;base64,([A-Za-z0-9+/=]+)", img_html)
+ assert match, "No base64 PNG found in img tag"
+ png_bytes = base64.b64decode(match.group(1))
+ assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n", "Not a PNG file"
diff --git a/beam/tests/test_printing.py b/beam/tests/test_printing.py
new file mode 100644
index 00000000..e2f6f6a0
--- /dev/null
+++ b/beam/tests/test_printing.py
@@ -0,0 +1,85 @@
+# Copyright (c) 2025, AgriTheory and contributors
+# For license information, please see license.txt
+
+from unittest.mock import Mock, patch
+
+import frappe
+from frappe.exceptions import DoesNotExistError
+
+from beam.beam.printing import print_by_server
+
+
+def test_print_by_server_empty_string_uses_standard():
+ """Empty print_format should default to Standard"""
+ mock_cups = Mock()
+ mock_cups.IPPError = Exception
+ with patch("beam.beam.printing.cups", mock_cups):
+ try:
+ print_by_server(
+ doctype="Item",
+ name="Ambrosia Pie",
+ printer_setting="Kitchen Printer",
+ print_format="",
+ )
+ except DoesNotExistError as e:
+ # Should fail trying to get "Standard" print format
+ assert "Standard" in str(e)
+
+
+def test_print_by_server_none_uses_standard():
+ """None print_format should default to Standard"""
+ mock_cups = Mock()
+ mock_cups.IPPError = Exception
+ with patch("beam.beam.printing.cups", mock_cups):
+ try:
+ print_by_server(
+ doctype="Item",
+ name="Ambrosia Pie",
+ printer_setting="Kitchen Printer",
+ print_format=None,
+ )
+ except DoesNotExistError as e:
+ # Should fail trying to get "Standard" print format
+ assert "Standard" in str(e)
+
+
+def test_print_by_server_explicit_format():
+ """Explicit print_format should be used"""
+ from beam.beam.printing import print_by_server
+
+ mock_cups = Mock()
+ mock_cups.IPPError = Exception
+ with patch("beam.beam.printing.cups", mock_cups):
+ try:
+ print_by_server(
+ doctype="Item",
+ name="Ambrosia Pie",
+ printer_setting="Kitchen Printer",
+ print_format="Item Barcode",
+ )
+ except Exception as e:
+ # Should NOT fail on "Standard" - should use explicit format
+ assert "Standard" not in str(e), "Should use explicit format, not Standard"
+
+
+def test_print_by_server_with_serialized_doc():
+ """Serialized doc should be properly deserialized as full document instance"""
+ # Get a real item doc and serialize it like the frontend would
+ item = frappe.get_doc("Item", "Ambrosia Pie")
+ serialized_doc = frappe.as_json(item.as_dict())
+
+ mock_cups = Mock()
+ mock_cups.IPPError = Exception
+ with patch("beam.beam.printing.cups", mock_cups):
+ try:
+ print_by_server(
+ doctype="Item",
+ name="Ambrosia Pie",
+ printer_setting="Kitchen Printer",
+ print_format="Item Barcode",
+ doc=serialized_doc, # Pass as JSON string
+ )
+ except Exception as e:
+ # Should not fail with AttributeError about 'in_print'
+ assert "in_print" not in str(e)
+ assert not isinstance(e, AttributeError)
diff --git a/beam/tests/test_receiving_demand.py b/beam/tests/test_receiving_demand.py
new file mode 100644
index 00000000..13d290e1
--- /dev/null
+++ b/beam/tests/test_receiving_demand.py
@@ -0,0 +1,89 @@
+# Copyright (c) 2024, AgriTheory and contributors
+# For license information, please see license.txt
+
+# test update with purchase receipt submission
+# test update with purchase receipt cancellation
+# test demand from unfilled qty on blanket order
+# test demand from purchase invoice without PO
+#
+# For license information, please see license.txt
+
+
+from datetime import datetime
+
+import frappe
+import pytest
+
+from beam.beam.demand.receiving import (
+ _get_receiving_demand,
+ get_receiving_demand,
+ reset_build_receiving_map,
+)
+
+current_year = datetime.now().year
+
+
+@pytest.mark.order(2)
+def test_opening_receiving():
+
+ receiving_demand = _get_receiving_demand()
+ assert receiving_demand[0].item_code == "Cloudberry"
+ assert receiving_demand[1].item_code == "Hairless Rambutan"
+ assert receiving_demand[2].item_code == "Tayberry"
+ assert receiving_demand[3].item_code == "Cocoplum"
+ assert receiving_demand[4].item_code == "Damson Plum"
+ assert receiving_demand[5].item_code == "Gooseberry"
+ assert receiving_demand[6].item_code == "Kaduka Lime"
+ assert receiving_demand[7].item_code == "Limequat"
+ assert receiving_demand[8].item_code == "Butter"
+ assert receiving_demand[9].item_code == "Cornstarch"
+ assert receiving_demand[10].item_code == "Flour"
+ assert receiving_demand[11].item_code == "Salt"
+ assert receiving_demand[12].item_code == "Sugar"
+ assert receiving_demand[13].item_code == "Water"
+ assert receiving_demand[14].item_code == "Parchment Paper"
+ assert receiving_demand[15].item_code == "Pie Box"
+ assert receiving_demand[16].item_code == "Pie Tin"
+
+ reset_build_receiving_map()
+
+ water = get_receiving_demand(filters={"item_code": "Water"})
+ assert len(water) == 1
+
+ assert water[0].parent == f"PUR-ORD-{current_year}-00002"
+ assert water[0].stock_qty == 24.999442
+ assert water[0].warehouse == "Kitchen - APC"
+
+
+@pytest.mark.order(31)
+def test_demand_from_purchase_invoice_without_po():
+
+ receiving_demand = get_receiving_demand(filters={"doctype": "Purchase Invoice"})
+ assert len(receiving_demand) == 0
+
+ pi = frappe.new_doc("Purchase Invoice")
+ pi.supplier = "Freedom Provisions"
+ pi.set_posting_time = True
+ pi.buying_price_list = "Bakery Buying"
+ item = frappe.get_doc("Item", "Butter")
+ pi.append(
+ "items",
+ {
+ "item_code": item.item_code,
+ "warehouse": "Refrigerator - APC",
+ "rejected_warehouse": "Storeroom - APC",
+ "received_qty": 0,
+ "qty": 10,
+ "rate": 10,
+ },
+ )
+ pi.save()
+ pi.submit()
+
+ receiving_demand = get_receiving_demand(filters={"doctype": "Purchase Invoice"})
+ assert len(receiving_demand) == 1
+
+ pi.cancel()
+
+ receiving_demand = get_receiving_demand(filters={"doctype": "Purchase Invoice"})
+ assert len(receiving_demand) == 0
diff --git a/beam/tests/test_serial_number.py b/beam/tests/test_serial_number.py
new file mode 100644
index 00000000..a304a8ba
--- /dev/null
+++ b/beam/tests/test_serial_number.py
@@ -0,0 +1,134 @@
+# Copyright (c) 2025, AgriTheory and contributors
+# For license information, please see license.txt
+
+import frappe
+import pytest
+from frappe.utils import today
+
+
+def _make_serials(series="WCC-.#####", qty=1):
+ from frappe.model.naming import make_autoname
+
+ return [make_autoname(series) for _ in range(qty)]
+
+
+@pytest.mark.order(20)
+def test_serial_number_scan():
+ warehouse = "Storeroom - APC"
+ supplier = "Unity Bakery Supply"
+ item_code = "Whipped Cream Canister"
+ serials = _make_serials(qty=3)
+ pr = frappe.get_doc(
+ {
+ "doctype": "Purchase Receipt",
+ "supplier": supplier,
+ "posting_date": today(),
+ "items": [
+ {
+ "item_code": item_code,
+ "qty": 1,
+ "received_qty": 1,
+ "rate": 10,
+ "warehouse": warehouse,
+ "serial_no": serials[0],
+ "use_serial_batch_fields": 1,
+ }
+ ],
+ }
+ )
+ pr.save()
+ pr.submit()
+
+ # Serial No scanning disabled
+ company = frappe.defaults.get_defaults().get("company")
+ settings = frappe.get_doc("BEAM Settings", {"company": company})
+ settings.scan_serial_no = 0
+ settings.save()
+ assert settings.scan_serial_no == 0
+ scan = frappe.call(
+ "beam.beam.scan.scan",
+ **{"barcode": str(serials[0]), "context": {"listview": "Purchase Receipt"}}
+ )
+ assert scan is None
+
+ # Serial No scanning enabled
+ settings.scan_serial_no = 1
+ settings.save()
+
+ assert settings.scan_serial_no == 1
+ scan = frappe.call(
+ "beam.beam.scan.scan",
+ **{"barcode": str(serials[0]), "context": {"listview": "Purchase Receipt"}}
+ )
+ assert scan[0]["action"] == "route"
+ assert scan[0]["doctype"] == "Purchase Receipt"
+ assert scan[0]["field"] == "Purchase Receipt"
+ assert scan[0]["target"] == pr.name
+
+ pi = frappe.get_doc(
+ {
+ "doctype": "Purchase Invoice",
+ "supplier": supplier,
+ "posting_date": today(),
+ "update_stock": 1,
+ "items": [
+ {
+ "item_code": item_code,
+ "qty": 1,
+ "received_qty": 1,
+ "rate": 10,
+ "warehouse": warehouse,
+ "serial_no": serials[1],
+ "use_serial_batch_fields": 1,
+ }
+ ],
+ }
+ )
+ pi.save()
+ pi.submit()
+
+ company = frappe.defaults.get_defaults().get("company")
+ settings = frappe.get_doc("BEAM Settings", {"company": company})
+ settings.scan_serial_no = 1
+ settings.save()
+ scan = frappe.call(
+ "beam.beam.scan.scan",
+ **{"barcode": str(serials[1]), "context": {"listview": "Purchase Invoice"}}
+ )
+ assert scan[0]["action"] == "filter"
+ assert scan[0]["doctype"] == "Purchase Invoice"
+ assert scan[0]["field"] == "name"
+ assert scan[0]["target"] == pi.name
+
+ dn = frappe.get_doc(
+ {
+ "doctype": "Delivery Note",
+ "customer": "Longwoods Sandwich Shop",
+ "posting_date": today(),
+ "items": [
+ {
+ "item_code": item_code,
+ "qty": 1,
+ "received_qty": 1,
+ "rate": 10,
+ "warehouse": warehouse,
+ "serial_no": serials[1],
+ "use_serial_batch_fields": 1,
+ }
+ ],
+ }
+ )
+ dn.save()
+ dn.submit()
+
+ company = frappe.defaults.get_defaults().get("company")
+ settings = frappe.get_doc("BEAM Settings", {"company": company})
+ settings.scan_serial_no = 1
+ settings.save()
+ scan = frappe.call(
+ "beam.beam.scan.scan", **{"barcode": str(serials[1]), "context": {"listview": "Delivery Note"}}
+ )
+ assert scan[0]["action"] == "filter"
+ assert scan[0]["doctype"] == "Delivery Note"
+ assert scan[0]["field"] == "name"
+ assert scan[0]["target"] == dn.name
diff --git a/beam/tests/test_utils.py b/beam/tests/test_utils.py
new file mode 100644
index 00000000..2f4cd88b
--- /dev/null
+++ b/beam/tests/test_utils.py
@@ -0,0 +1,22 @@
+# Copyright (c) 2024, AgriTheory and contributors
+# For license information, please see license.txt
+
+from contextlib import contextmanager
+
+import frappe
+
+
+@contextmanager
+def use_current_db_transaction():
+ """
+ Context manager to refresh the database transaction scope.
+
+ This is needed when testing with Playwright because the browser actions
+ (like clicking SAVE or RECEIVE) commit data to the database, but the pytest
+ context maintains its own transaction scope and can't see the committed data
+ until the transaction is refreshed.
+
+ """
+ frappe.db.rollback()
+ frappe.db.begin()
+ yield
diff --git a/beam/www/beam/Beam.vue b/beam/www/beam/Beam.vue
new file mode 100644
index 00000000..282e9015
--- /dev/null
+++ b/beam/www/beam/Beam.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/beam/www/beam/__init__.py b/beam/www/beam/__init__.py
new file mode 100644
index 00000000..a2ae016a
--- /dev/null
+++ b/beam/www/beam/__init__.py
@@ -0,0 +1,28 @@
+# Copyright (c) 2024, AgriTheory and contributors
+# For license information, please see license.txt
+
+import frappe
+from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
+from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
+from frappe.model.create_new import make_new_doc as frappe_make_new_doc
+
+
+@frappe.whitelist()
+def make_new_doc(doctype, docname=None):
+ if doctype == "Stock Entry":
+ doc = frappe_make_new_doc(doctype)
+ doc.purpose = "Material Transfer"
+ return doc
+ elif doctype == "Purchase Receipt":
+ return make_purchase_receipt(docname).as_dict()
+ elif doctype == "Delivery Note":
+ return make_delivery_note(docname).as_dict()
+
+
+# @frappe.whitelist()
+# def make_mapped_doc(doctype, docname):
+# print(doctype, docname)
+# if doctype in ['Purchase Order']:
+# return
+# elif doctype in ['Sales Order']:
+# print('map sales order')
diff --git a/beam/www/beam/components/ControlButtons.vue b/beam/www/beam/components/ControlButtons.vue
new file mode 100644
index 00000000..11430135
--- /dev/null
+++ b/beam/www/beam/components/ControlButtons.vue
@@ -0,0 +1,48 @@
+
+