Initial commit: full backend - 13 activity doctypes, JWT auth, generic activity API, QR badge generation, workspace

This commit is contained in:
Joseph Njuki
2026-07-31 06:36:30 +03:00
commit eea66ad833
116 changed files with 2753 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
__version__ = "0.0.1"
@@ -0,0 +1,82 @@
import json
import frappe
from agri_seedling.agri_seedling.api.mobile_auth import get_current_field_worker
# doctype -> fieldname that should be auto-stamped with the logged-in worker
ACTIVITY_DOCTYPES = {
"Scouting Report": "worker",
"Order Patch": None,
"Dispatch Record": None,
"Media Mix Batch": "worker",
"Planting Record": "worker",
"Cover Log": "worker",
"Greenhouse Watering Log": "worker",
"Seedling Watering Log": "worker",
"Spray Log Morning": "worker",
"Spray Log Evening": "worker",
"Customer Interaction": None,
"Soaking Record": "worker",
"Tray Recycling Record": "worker",
}
@frappe.whitelist(allow_guest=True)
def submit_activity(doctype, data):
"""Generic activity submission endpoint used by the React Native app
for all 13 field activities. Requires a valid field-worker JWT."""
worker = get_current_field_worker()
if doctype not in ACTIVITY_DOCTYPES:
frappe.throw(f"Unknown or disallowed activity doctype: {doctype}")
if isinstance(data, str):
data = json.loads(data)
data["doctype"] = doctype
worker_field = ACTIVITY_DOCTYPES[doctype]
if worker_field:
data[worker_field] = worker["worker_id"]
doc = frappe.get_doc(data)
doc.insert(ignore_permissions=True)
frappe.db.commit()
return {"name": doc.name, "doctype": doctype, "status": "submitted"}
@frappe.whitelist(allow_guest=True)
def my_activities(doctype, limit=20):
"""Returns the logged-in worker's own recent submissions for a
given activity doctype (field workers can only see their own, per
the row-level permission rule in Sec 9.2 of the manual)."""
worker = get_current_field_worker()
if doctype not in ACTIVITY_DOCTYPES:
frappe.throw(f"Unknown or disallowed activity doctype: {doctype}")
worker_field = ACTIVITY_DOCTYPES[doctype]
filters = {worker_field: worker["worker_id"]} if worker_field else {}
return frappe.get_list(
doctype,
filters=filters,
fields=["name", "creation"],
order_by="creation desc",
limit_page_length=int(limit),
)
@frappe.whitelist(allow_guest=True)
def get_reference_data():
"""Dropdown/reference data the mobile app needs to populate forms
(zones, varieties, customers). Requires a valid field-worker JWT."""
get_current_field_worker()
return {
"greenhouse_zones": frappe.get_all("Greenhouse Zone", fields=["name"]),
"seed_varieties": frappe.get_all("Seed Variety", fields=["name"]),
"customers": frappe.get_all("Customer Profile", fields=["name"]),
}
@@ -0,0 +1,72 @@
import datetime
import frappe
import jwt
from frappe.utils import add_days, get_datetime, nowdate
from frappe.utils.password import get_decrypted_password
def get_jwt_secret():
secret = frappe.conf.get("agri_seedling_jwt_secret")
if not secret:
frappe.throw("JWT secret not configured")
return secret
@frappe.whitelist(allow_guest=True)
def field_worker_login(worker_id, pin):
if not frappe.db.exists("Worker Profile", worker_id):
frappe.throw("Invalid worker ID or PIN", frappe.AuthenticationError)
worker = frappe.get_doc("Worker Profile", worker_id)
if not worker.active:
frappe.throw("This worker account is deactivated", frappe.AuthenticationError)
stored_pin = get_decrypted_password(
"Worker Profile", worker_id, fieldname="pin_hash", raise_exception=False
)
if not stored_pin or str(stored_pin) != str(pin):
frappe.throw("Invalid worker ID or PIN", frappe.AuthenticationError)
midnight = get_datetime(add_days(nowdate(), 1))
payload = {
"worker_id": worker.name,
"full_name": worker.full_name,
"role": "Field Worker",
"exp": midnight,
"iat": datetime.datetime.utcnow(),
}
token = jwt.encode(payload, get_jwt_secret(), algorithm="HS256")
return {
"token": token,
"worker_id": worker.name,
"full_name": worker.full_name,
"expires_at": str(midnight),
"assigned_activity_types": [row.activity_type for row in worker.assigned_activity_types],
"assigned_zones": [row.zone for row in worker.assigned_zones] if worker.assigned_zones else [],
}
def get_current_field_worker():
token = frappe.get_request_header("X-Worker-Token")
if not token:
frappe.throw("Missing X-Worker-Token header", frappe.AuthenticationError)
try:
payload = jwt.decode(token, get_jwt_secret(), algorithms=["HS256"])
except jwt.ExpiredSignatureError:
frappe.throw("Session expired, please log in again", frappe.AuthenticationError)
except jwt.InvalidTokenError:
frappe.throw("Invalid session token", frappe.AuthenticationError)
return payload
@frappe.whitelist(allow_guest=True)
def whoami():
worker = get_current_field_worker()
return {"authenticated_as": worker}
@@ -0,0 +1,50 @@
import base64
import io
import json
import frappe
import qrcode
from frappe.utils.file_manager import save_file
@frappe.whitelist()
def generate_worker_qr(worker_id):
"""Admin-only. Generates (or regenerates) the QR badge for a worker,
attaches it to their Worker Profile record, and returns the image.
The QR intentionally does NOT contain the PIN — only the API endpoint
and worker ID, so a lost/photographed badge alone cannot be used to
log in."""
if "System Manager" not in frappe.get_roles(frappe.session.user):
frappe.throw("Not permitted", frappe.PermissionError)
if not frappe.db.exists("Worker Profile", worker_id):
frappe.throw("Worker not found")
payload = {
"api_url": frappe.utils.get_url(),
"worker_id": worker_id,
}
token = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
qr_img = qrcode.make(token)
buf = io.BytesIO()
qr_img.save(buf, format="PNG")
png_bytes = buf.getvalue()
file_doc = save_file(
fname=f"{worker_id}_badge.png",
content=png_bytes,
dt="Worker Profile",
dn=worker_id,
is_private=0,
)
frappe.db.set_value("Worker Profile", worker_id, "qr_config_token", token)
frappe.db.set_value("Worker Profile", worker_id, "qr_image", file_doc.file_url)
frappe.db.commit()
return {
"qr_config_token": token,
"qr_image_base64": base64.b64encode(png_bytes).decode(),
"file_url": file_doc.file_url,
}
@@ -0,0 +1,8 @@
// Copyright (c) 2026, Agri Seedling Operations Team and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Cover Log", {
// refresh(frm) {
// },
// });
@@ -0,0 +1,102 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "hash",
"creation": "2026-07-31 01:52:42.333856",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"date",
"tray_ids",
"cover_type",
"greenhouse_zone",
"worker",
"planned_duration_days",
"scheduled_uncovering_date",
"cover_condition",
"notes"
],
"fields": [
{
"fieldname": "date",
"fieldtype": "Datetime",
"in_list_view": 1,
"label": "Date",
"reqd": 1
},
{
"fieldname": "tray_ids",
"fieldtype": "Small Text",
"label": "Tray IDs"
},
{
"fieldname": "cover_type",
"fieldtype": "Select",
"label": "Cover Type",
"options": "Humidity Dome\nShade Cloth\nPlastic Film"
},
{
"fieldname": "greenhouse_zone",
"fieldtype": "Link",
"label": "Greenhouse Zone",
"options": "Greenhouse Zone"
},
{
"fieldname": "worker",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Worker",
"options": "Worker Profile",
"reqd": 1
},
{
"fieldname": "planned_duration_days",
"fieldtype": "Int",
"label": "Planned Duration (days)"
},
{
"fieldname": "scheduled_uncovering_date",
"fieldtype": "Date",
"label": "Scheduled Uncovering Date"
},
{
"fieldname": "cover_condition",
"fieldtype": "Select",
"label": "Cover Condition",
"options": "New\nReused\nDamaged"
},
{
"fieldname": "notes",
"fieldtype": "Small Text",
"label": "Notes"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-07-31 01:52:42.333856",
"modified_by": "Administrator",
"module": "Agri Seedling",
"name": "Cover Log",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class CoverLog(Document):
pass
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestCoverLog(FrappeTestCase):
pass
@@ -0,0 +1,30 @@
{
"doctype": "DocType",
"name": "Customer Interaction",
"module": "Agri Seedling",
"istable": 0,
"issingle": 0,
"is_submittable": 0,
"autoname": "naming_series:",
"naming_series": "CI-.YYYY.-.#####",
"track_changes": 1,
"fields": [
{"fieldname": "interaction_datetime", "label": "Date & Time", "fieldtype": "Datetime", "reqd": 1, "default": "Now"},
{"fieldname": "customer", "label": "Customer", "fieldtype": "Link", "options": "Customer Profile", "reqd": 1, "in_list_view": 1},
{"fieldname": "interaction_type", "label": "Interaction Type", "fieldtype": "Select", "reqd": 1, "in_list_view": 1,
"options": "Phone Call\nFarm Visit\nEmail Inquiry\nComplaint\nOrder Inquiry"},
{"fieldname": "seedling_variety", "label": "Seedling Variety / Order of Interest", "fieldtype": "Link", "options": "Seed Variety"},
{"fieldname": "column_break_1", "fieldtype": "Column Break"},
{"fieldname": "summary", "label": "Interaction Summary", "fieldtype": "Small Text", "reqd": 1},
{"fieldname": "feedback", "label": "Customer Feedback", "fieldtype": "Select", "options": "Positive\nNeutral\nNegative"},
{"fieldname": "follow_up_required", "label": "Follow-up Required", "fieldtype": "Check"},
{"fieldname": "follow_up_task", "label": "Follow-up Task Description", "fieldtype": "Small Text", "depends_on": "follow_up_required"},
{"fieldname": "follow_up_assignee", "label": "Assignee", "fieldtype": "Link", "options": "User", "depends_on": "follow_up_required"},
{"fieldname": "next_contact_date", "label": "Next Contact Date", "fieldtype": "Date", "depends_on": "follow_up_required"},
{"fieldname": "staff_member", "label": "Staff Member", "fieldtype": "Link", "options": "User", "default": "user"}
],
"permissions": [
{"role": "Admin", "read": 1, "write": 1, "create": 1, "delete": 1, "report": 1},
{"role": "Stock Manager", "read": 1, "write": 1, "create": 1}
]
}
@@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class CustomerInteraction(Document):
pass
@@ -0,0 +1,8 @@
// Copyright (c) 2026, Agri Seedling Operations Team and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Customer Profile", {
// refresh(frm) {
// },
// });
@@ -0,0 +1,80 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:customer_name",
"creation": "2026-07-31 01:42:50.210093",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"customer_name",
"phone",
"email",
"address",
"customer_type",
"notes"
],
"fields": [
{
"fieldname": "customer_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Customer Name",
"reqd": 1,
"unique": 1
},
{
"fieldname": "phone",
"fieldtype": "Data",
"label": "Phone"
},
{
"fieldname": "email",
"fieldtype": "Data",
"label": "Email"
},
{
"fieldname": "address",
"fieldtype": "Small Text",
"label": "Address"
},
{
"fieldname": "customer_type",
"fieldtype": "Select",
"label": "Customer Type",
"options": "Retail\nWholesale\nInstitutional"
},
{
"fieldname": "notes",
"fieldtype": "Text",
"label": "Notes"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-07-31 01:42:50.210093",
"modified_by": "Administrator",
"module": "Agri Seedling",
"name": "Customer Profile",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class CustomerProfile(Document):
pass
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestCustomerProfile(FrappeTestCase):
pass
@@ -0,0 +1,37 @@
{
"doctype": "DocType",
"name": "Dispatch Record",
"module": "Agri Seedling",
"istable": 0,
"issingle": 0,
"is_submittable": 1,
"autoname": "naming_series:",
"naming_series": "DSP-.YYYY.-.#####",
"track_changes": 1,
"fields": [
{"fieldname": "order", "label": "Order", "fieldtype": "Link", "options": "Order Patch", "reqd": 1, "in_list_view": 1},
{"fieldname": "customer", "label": "Customer", "fieldtype": "Link", "options": "Customer Profile", "reqd": 1, "fetch_from": "order.customer", "in_list_view": 1},
{"fieldname": "delivery_address", "label": "Delivery Address", "fieldtype": "Small Text"},
{"fieldname": "column_break_1", "fieldtype": "Column Break"},
{"fieldname": "quantity_dispatched", "label": "Quantity Dispatched (trays)", "fieldtype": "Int", "reqd": 1},
{"fieldname": "boxes_used", "label": "Boxes Used", "fieldtype": "Int"},
{"fieldname": "box_batch_number", "label": "Box Batch Number", "fieldtype": "Data"},
{"fieldname": "tape_rolls_used", "label": "Tape Rolls Used", "fieldtype": "Float"},
{"fieldname": "rope_meters_used", "label": "Rope Meters Used", "fieldtype": "Float"},
{"fieldname": "section_break_1", "label": "Transport", "fieldtype": "Section Break"},
{"fieldname": "transport_mode", "label": "Transport Mode", "fieldtype": "Select", "options": "Company Vehicle\nThird-Party Courier"},
{"fieldname": "driver_name", "label": "Driver Name", "fieldtype": "Data"},
{"fieldname": "vehicle_transport_id", "label": "Vehicle / Transport ID", "fieldtype": "Data"},
{"fieldname": "column_break_2", "fieldtype": "Column Break"},
{"fieldname": "dispatch_datetime", "label": "Dispatch Date & Time", "fieldtype": "Datetime", "reqd": 1, "default": "Now"},
{"fieldname": "fuel_liters", "label": "Fuel Consumed (L)", "fieldtype": "Float"},
{"fieldname": "quality_check_pass", "label": "Final QC Result", "fieldtype": "Select", "options": "Pass\nFail", "reqd": 1},
{"fieldname": "quality_check_notes", "label": "QC Notes", "fieldtype": "Small Text"},
{"fieldname": "invoice_reference", "label": "Invoice Reference", "fieldtype": "Data", "read_only": 1}
],
"permissions": [
{"role": "Admin", "read": 1, "write": 1, "create": 1, "delete": 1, "submit": 1, "cancel": 1, "amend": 1, "report": 1},
{"role": "Stock Manager", "read": 1, "write": 1, "create": 1, "submit": 1},
{"role": "Accountant", "read": 1, "report": 1}
]
}
@@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class DispatchRecord(Document):
pass
@@ -0,0 +1,8 @@
// Copyright (c) 2026, Agri Seedling Operations Team and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Greenhouse Watering Log", {
// refresh(frm) {
// },
// });
@@ -0,0 +1,109 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "hash",
"creation": "2026-07-31 01:52:50.842597",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"date",
"greenhouse_zone",
"water_volume_liters",
"duration_minutes",
"watering_method",
"worker",
"temp_before",
"temp_after",
"humidity_reading",
"notes"
],
"fields": [
{
"fieldname": "date",
"fieldtype": "Datetime",
"in_list_view": 1,
"label": "Date",
"reqd": 1
},
{
"fieldname": "greenhouse_zone",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Greenhouse Zone",
"options": "Greenhouse Zone",
"reqd": 1
},
{
"fieldname": "water_volume_liters",
"fieldtype": "Float",
"label": "Water Volume (L)"
},
{
"fieldname": "duration_minutes",
"fieldtype": "Int",
"label": "Duration (min)"
},
{
"fieldname": "watering_method",
"fieldtype": "Select",
"label": "Watering Method",
"options": "Manual Hosing\nDrip Line\nOverhead Misting"
},
{
"fieldname": "worker",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Worker",
"options": "Worker Profile",
"reqd": 1
},
{
"fieldname": "temp_before",
"fieldtype": "Float",
"label": "Temp Before (\u00b0C)"
},
{
"fieldname": "temp_after",
"fieldtype": "Float",
"label": "Temp After (\u00b0C)"
},
{
"fieldname": "humidity_reading",
"fieldtype": "Float",
"label": "Humidity Reading (%)"
},
{
"fieldname": "notes",
"fieldtype": "Small Text",
"label": "Notes"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-07-31 01:52:50.842597",
"modified_by": "Administrator",
"module": "Agri Seedling",
"name": "Greenhouse Watering Log",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class GreenhouseWateringLog(Document):
pass
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestGreenhouseWateringLog(FrappeTestCase):
pass
@@ -0,0 +1,33 @@
{
"doctype": "DocType",
"name": "Greenhouse Zone",
"module": "Agri Seedling",
"custom": 0,
"istable": 0,
"issingle": 0,
"is_submittable": 0,
"autoname": "field:zone_id",
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1,
"fields": [
{"fieldname": "zone_id", "label": "Zone ID", "fieldtype": "Data", "reqd": 1, "unique": 1, "in_list_view": 1},
{"fieldname": "zone_name", "label": "Zone Name", "fieldtype": "Data", "in_list_view": 1},
{"fieldname": "column_break_1", "fieldtype": "Column Break"},
{"fieldname": "capacity_trays", "label": "Tray Capacity", "fieldtype": "Int"},
{"fieldname": "restricted_until", "label": "Restricted Until (REI lock)", "fieldtype": "Datetime", "read_only": 1,
"description": "Set automatically when a spray with an active Re-Entry Interval is submitted for this zone."},
{"fieldname": "section_break_1", "fieldtype": "Section Break"},
{"fieldname": "owner_stock_manager", "label": "Owning Stock Manager", "fieldtype": "Link", "options": "User"},
{"fieldname": "active", "label": "Active", "fieldtype": "Check", "default": "1"},
{"fieldname": "notes", "label": "Notes", "fieldtype": "Small Text"}
],
"permissions": [
{"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1},
{"role": "Admin", "read": 1, "write": 1, "create": 1, "delete": 1},
{"role": "Stock Manager", "read": 1, "write": 1},
{"role": "Inventory Manager", "read": 1},
{"role": "Accountant", "read": 1},
{"role": "Field Worker", "read": 1}
]
}
@@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class GreenhouseZone(Document):
pass
@@ -0,0 +1,8 @@
// Copyright (c) 2026, Agri Seedling Operations Team and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Media Mix Batch", {
// refresh(frm) {
// },
// });
@@ -0,0 +1,124 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "hash",
"creation": "2026-07-31 01:52:32.575812",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"date",
"shift",
"coco_peat_liters",
"vermiculite_liters",
"perlite_liters",
"compost_kg",
"total_volume_liters",
"jigs_used",
"mixer_equipment_id",
"run_duration_minutes",
"worker",
"moisture_content",
"texture_observation"
],
"fields": [
{
"fieldname": "date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Date",
"reqd": 1
},
{
"fieldname": "shift",
"fieldtype": "Select",
"label": "Shift",
"options": "Morning\nAfternoon"
},
{
"fieldname": "coco_peat_liters",
"fieldtype": "Float",
"label": "Coco Peat (L)"
},
{
"fieldname": "vermiculite_liters",
"fieldtype": "Float",
"label": "Vermiculite (L)"
},
{
"fieldname": "perlite_liters",
"fieldtype": "Float",
"label": "Perlite (L)"
},
{
"fieldname": "compost_kg",
"fieldtype": "Float",
"label": "Compost (kg)"
},
{
"fieldname": "total_volume_liters",
"fieldtype": "Float",
"label": "Total Volume (L)"
},
{
"fieldname": "jigs_used",
"fieldtype": "Data",
"label": "Jigs Used"
},
{
"fieldname": "mixer_equipment_id",
"fieldtype": "Data",
"label": "Mixer Equipment ID"
},
{
"fieldname": "run_duration_minutes",
"fieldtype": "Int",
"label": "Run Duration (min)"
},
{
"fieldname": "worker",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Worker",
"options": "Worker Profile",
"reqd": 1
},
{
"fieldname": "moisture_content",
"fieldtype": "Data",
"label": "Moisture Content"
},
{
"fieldname": "texture_observation",
"fieldtype": "Small Text",
"label": "Texture Observation"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-07-31 01:52:32.575812",
"modified_by": "Administrator",
"module": "Agri Seedling",
"name": "Media Mix Batch",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class MediaMixBatch(Document):
pass
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestMediaMixBatch(FrappeTestCase):
pass
@@ -0,0 +1,8 @@
// Copyright (c) 2026, Agri Seedling Operations Team and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Order Patch", {
// refresh(frm) {
// },
// });
@@ -0,0 +1,113 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "hash",
"creation": "2026-07-31 01:49:58.431409",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"customer",
"seedling_variety",
"quantity",
"requested_delivery_date",
"greenhouse_zone",
"worker_team",
"estimated_readiness_date",
"priority_level",
"special_handling_notes",
"status"
],
"fields": [
{
"fieldname": "customer",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Customer",
"options": "Customer Profile",
"reqd": 1
},
{
"fieldname": "seedling_variety",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Seedling Variety",
"options": "Seed Variety",
"reqd": 1
},
{
"fieldname": "quantity",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Quantity",
"reqd": 1
},
{
"fieldname": "requested_delivery_date",
"fieldtype": "Date",
"label": "Requested Delivery Date"
},
{
"fieldname": "greenhouse_zone",
"fieldtype": "Link",
"label": "Greenhouse Zone",
"options": "Greenhouse Zone"
},
{
"fieldname": "worker_team",
"fieldtype": "Link",
"label": "Worker Team",
"options": "Worker Profile"
},
{
"fieldname": "estimated_readiness_date",
"fieldtype": "Date",
"label": "Estimated Readiness Date"
},
{
"fieldname": "priority_level",
"fieldtype": "Select",
"label": "Priority Level",
"options": "Standard\nExpress\nBulk"
},
{
"fieldname": "special_handling_notes",
"fieldtype": "Small Text",
"label": "Special Handling Notes"
},
{
"default": "Pending",
"fieldname": "status",
"fieldtype": "Select",
"label": "Status",
"options": "Pending\nIn Production\nReady for Dispatch\nDispatched"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-07-31 01:49:58.431409",
"modified_by": "Administrator",
"module": "Agri Seedling",
"name": "Order Patch",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class OrderPatch(Document):
pass
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestOrderPatch(FrappeTestCase):
pass
@@ -0,0 +1,33 @@
{
"doctype": "DocType",
"name": "Planting Record",
"module": "Agri Seedling",
"istable": 0,
"issingle": 0,
"is_submittable": 1,
"autoname": "naming_series:",
"naming_series": "PLT-.YYYY.-.#####",
"track_changes": 1,
"fields": [
{"fieldname": "planting_datetime", "label": "Date & Time", "fieldtype": "Datetime", "reqd": 1, "default": "Now"},
{"fieldname": "tray_batch_id", "label": "Tray Batch ID", "fieldtype": "Data", "reqd": 1, "in_list_view": 1},
{"fieldname": "seedling_variety", "label": "Seedling Variety", "fieldtype": "Link", "options": "Seed Variety", "reqd": 1},
{"fieldname": "seed_quantity", "label": "Seed Quantity Used", "fieldtype": "Float", "reqd": 1},
{"fieldname": "seed_lot_number", "label": "Seed Lot / Batch Number", "fieldtype": "Data", "reqd": 1},
{"fieldname": "column_break_1", "fieldtype": "Column Break"},
{"fieldname": "media_batch", "label": "Media Batch", "fieldtype": "Link", "options": "Media Mix Batch"},
{"fieldname": "zone", "label": "Greenhouse Zone", "fieldtype": "Link", "options": "Greenhouse Zone", "reqd": 1},
{"fieldname": "worker", "label": "Worker", "fieldtype": "Link", "options": "Worker Profile", "reqd": 1},
{"fieldname": "team_size", "label": "Team Size", "fieldtype": "Int"},
{"fieldname": "section_break_1", "label": "Tray Configuration", "fieldtype": "Section Break"},
{"fieldname": "tray_count", "label": "Tray Count", "fieldtype": "Int", "reqd": 1},
{"fieldname": "cell_rows", "label": "Cell Rows", "fieldtype": "Int"},
{"fieldname": "cell_columns", "label": "Cell Columns", "fieldtype": "Int"},
{"fieldname": "seeds_per_cell", "label": "Seeds per Cell", "fieldtype": "Int"},
{"fieldname": "expected_germination_date", "label": "Expected Germination Date", "fieldtype": "Date"}
],
"permissions": [
{"role": "Admin", "read": 1, "write": 1, "create": 1, "delete": 1, "submit": 1, "cancel": 1, "amend": 1, "report": 1},
{"role": "Field Worker", "read": 1, "create": 1, "submit": 1, "if_owner": 1}
]
}
@@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class PlantingRecord(Document):
pass
@@ -0,0 +1,39 @@
{
"doctype": "DocType",
"name": "Resource Inventory",
"module": "Agri Seedling",
"custom": 0,
"istable": 0,
"issingle": 0,
"is_submittable": 0,
"autoname": "field:resource_name",
"track_changes": 1,
"fields": [
{"fieldname": "resource_name", "label": "Resource Name", "fieldtype": "Data", "reqd": 1, "unique": 1, "in_list_view": 1},
{"fieldname": "category", "label": "Category", "fieldtype": "Select", "in_list_view": 1, "reqd": 1,
"options": "Labor\nWater\nFungicides\nPesticides\nSeeds\nFoliar Feeds\nElectricity\nBoxes\nTapes\nRope\nTransport\nJigs\nFuel"},
{"fieldname": "unit_of_measure", "label": "Unit of Measure", "fieldtype": "Select",
"options": "Person-hours\nLiters\nGrams\nCount\nkWh\nRolls\nMeters\nTrips\nKilometers"},
{"fieldname": "column_break_1", "fieldtype": "Column Break"},
{"fieldname": "current_stock", "label": "Current Stock", "fieldtype": "Float", "in_list_view": 1},
{"fieldname": "reorder_threshold", "label": "Reorder Threshold", "fieldtype": "Float",
"description": "e.g. 7-day supply equivalent for fungicides, 50 units for boxes — see Appendix C."},
{"fieldname": "reorder_quantity", "label": "Standard Reorder Quantity", "fieldtype": "Float"},
{"fieldname": "alert_priority", "label": "Alert Priority", "fieldtype": "Select", "options": "LOW\nNORMAL\nHIGH\nCRITICAL", "default": "NORMAL"},
{"fieldname": "section_break_1", "label": "Batch / Traceability", "fieldtype": "Section Break"},
{"fieldname": "batch_number", "label": "Batch / Lot Number", "fieldtype": "Data"},
{"fieldname": "supplier", "label": "Supplier", "fieldtype": "Data"},
{"fieldname": "expiry_date", "label": "Expiry Date", "fieldtype": "Date"},
{"fieldname": "registration_number", "label": "Chemical Registration Number", "fieldtype": "Data",
"description": "Required for fungicides/pesticides per Sec 9.3 compliance."},
{"fieldname": "cost_rate", "label": "Cost Rate (per unit)", "fieldtype": "Currency"},
{"fieldname": "approval_required_role", "label": "Approval Required From", "fieldtype": "Select",
"options": "\nStock Manager\nAdmin\nAdmin + Stock Manager"}
],
"permissions": [
{"role": "Admin", "read": 1, "write": 1, "create": 1, "delete": 1},
{"role": "Stock Manager", "read": 1, "write": 1, "create": 1},
{"role": "Inventory Manager", "read": 1, "write": 1, "create": 1},
{"role": "Accountant", "read": 1}
]
}
@@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class ResourceInventory(Document):
pass
@@ -0,0 +1,38 @@
{
"doctype": "DocType",
"name": "Scouting Report",
"module": "Agri Seedling",
"custom": 0,
"istable": 0,
"issingle": 0,
"is_submittable": 1,
"autoname": "naming_series:",
"naming_series": "SCT-.YYYY.-.#####",
"track_changes": 1,
"fields": [
{"fieldname": "inspection_datetime", "label": "Date & Time", "fieldtype": "Datetime", "reqd": 1, "default": "Now", "in_list_view": 1},
{"fieldname": "zone", "label": "Greenhouse Zone", "fieldtype": "Link", "options": "Greenhouse Zone", "reqd": 1, "in_list_view": 1},
{"fieldname": "worker", "label": "Worker", "fieldtype": "Link", "options": "Worker Profile", "reqd": 1, "in_list_view": 1},
{"fieldname": "column_break_1", "fieldtype": "Column Break"},
{"fieldname": "pest_disease_type", "label": "Pest / Disease Type", "fieldtype": "Select", "in_list_view": 1,
"options": "\nAphids\nWhitefly\nSpider Mites\nDamping Off\nPowdery Mildew\nBacterial Blight\nNutrient Deficiency\nOther"},
{"fieldname": "severity", "label": "Severity (1-5)", "fieldtype": "Int", "reqd": 1, "in_list_view": 1,
"description": "1 = trace, 5 = severe outbreak. Severity >= 3 triggers a Critical push alert to Admin and Stock Manager."},
{"fieldname": "seedling_variety", "label": "Seedling Variety", "fieldtype": "Link", "options": "Seed Variety"},
{"fieldname": "percent_trays_affected", "label": "Est. % Trays Affected", "fieldtype": "Percent"},
{"fieldname": "section_break_1", "label": "Evidence", "fieldtype": "Section Break"},
{"fieldname": "photo", "label": "Photo", "fieldtype": "Attach Image",
"description": "Required (min 1) when severity >= 3 — enforced in validate()."},
{"fieldname": "gps_latitude", "label": "GPS Latitude", "fieldtype": "Float", "read_only": 1},
{"fieldname": "gps_longitude", "label": "GPS Longitude", "fieldtype": "Float", "read_only": 1},
{"fieldname": "notes", "label": "Notes / Observations", "fieldtype": "Small Text"},
{"fieldname": "section_break_2", "label": "Follow-up", "fieldtype": "Section Break"},
{"fieldname": "recommended_action", "label": "Recommended Action Flag", "fieldtype": "Small Text", "read_only": 1},
{"fieldname": "escalated", "label": "Escalated to Admin", "fieldtype": "Check", "read_only": 1, "default": "0"}
],
"permissions": [
{"role": "Admin", "read": 1, "write": 1, "create": 1, "delete": 1, "submit": 1, "cancel": 1, "amend": 1, "report": 1},
{"role": "Stock Manager", "read": 1, "create": 1, "write": 1, "submit": 1, "report": 1},
{"role": "Field Worker", "read": 1, "create": 1, "submit": 1, "if_owner": 1}
]
}
@@ -0,0 +1,20 @@
import frappe
from frappe import _
from frappe.model.document import Document
class ScoutingReport(Document):
def validate(self):
if self.severity and self.severity >= 3 and not self.photo:
frappe.throw(_("At least one photo is required when severity is 3 or higher."))
if self.severity and self.severity >= 3:
self.recommended_action = _(
"Critical severity — isolate affected trays in {0} and notify Admin."
).format(self.zone)
def on_submit(self):
# Actual notification dispatch lives in utils/notifications.py (see hooks.py doc_events)
if self.severity and self.severity >= 3:
self.escalated = 1
self.db_set("escalated", 1)
@@ -0,0 +1,8 @@
// Copyright (c) 2026, Agri Seedling Operations Team and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Seed Variety", {
// refresh(frm) {
// },
// });
@@ -0,0 +1,67 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:variety_name",
"creation": "2026-07-31 01:41:09.884281",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"variety_name",
"germination_rate_benchmark",
"days_to_germination",
"supplier"
],
"fields": [
{
"fieldname": "variety_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Variety Name",
"reqd": 1,
"unique": 1
},
{
"fieldname": "germination_rate_benchmark",
"fieldtype": "Percent",
"label": "Germination Rate Benchmark"
},
{
"fieldname": "days_to_germination",
"fieldtype": "Int",
"label": "Days To Germination"
},
{
"fieldname": "supplier",
"fieldtype": "Data",
"label": "Supplier"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-07-31 01:41:09.884281",
"modified_by": "Administrator",
"module": "Agri Seedling",
"name": "Seed Variety",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SeedVariety(Document):
pass
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestSeedVariety(FrappeTestCase):
pass
@@ -0,0 +1,8 @@
// Copyright (c) 2026, Agri Seedling Operations Team and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Seedling Watering Log", {
// refresh(frm) {
// },
// });
@@ -0,0 +1,95 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "hash",
"creation": "2026-07-31 01:53:00.645959",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"date",
"tray_ids_or_zone",
"water_volume_liters",
"growth_stage",
"watering_method",
"worker",
"abnormalities",
"notes"
],
"fields": [
{
"fieldname": "date",
"fieldtype": "Datetime",
"in_list_view": 1,
"label": "Date",
"reqd": 1
},
{
"fieldname": "tray_ids_or_zone",
"fieldtype": "Small Text",
"label": "Tray IDs / Zone"
},
{
"fieldname": "water_volume_liters",
"fieldtype": "Float",
"label": "Water Volume (L)"
},
{
"fieldname": "growth_stage",
"fieldtype": "Select",
"label": "Growth Stage",
"options": "Germination\nSeedling\nTransplant-Ready"
},
{
"fieldname": "watering_method",
"fieldtype": "Select",
"label": "Watering Method",
"options": "Hand Watering\nBoom Irrigator\nDrip"
},
{
"fieldname": "worker",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Worker",
"options": "Worker Profile",
"reqd": 1
},
{
"fieldname": "abnormalities",
"fieldtype": "Small Text",
"label": "Abnormalities Observed"
},
{
"fieldname": "notes",
"fieldtype": "Small Text",
"label": "Notes"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-07-31 01:53:00.645959",
"modified_by": "Administrator",
"module": "Agri Seedling",
"name": "Seedling Watering Log",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SeedlingWateringLog(Document):
pass
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestSeedlingWateringLog(FrappeTestCase):
pass
@@ -0,0 +1,8 @@
// Copyright (c) 2026, Agri Seedling Operations Team and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Soaking Record", {
// refresh(frm) {
// },
// });
@@ -0,0 +1,121 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "hash",
"creation": "2026-07-31 01:53:16.144036",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"date_start_time",
"seed_tray_batch_id",
"seedling_variety",
"seed_quantity",
"solution_type",
"solution_concentration",
"total_volume_liters",
"soak_duration_hours",
"water_temp_c",
"worker",
"expected_germination_rate",
"observations"
],
"fields": [
{
"fieldname": "date_start_time",
"fieldtype": "Datetime",
"in_list_view": 1,
"label": "Date & Start Time",
"reqd": 1
},
{
"fieldname": "seed_tray_batch_id",
"fieldtype": "Data",
"label": "Seed/Tray Batch ID"
},
{
"fieldname": "seedling_variety",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Seedling Variety",
"options": "Seed Variety",
"reqd": 1
},
{
"fieldname": "seed_quantity",
"fieldtype": "Data",
"label": "Seed Quantity"
},
{
"fieldname": "solution_type",
"fieldtype": "Select",
"label": "Soaking Solution Type",
"options": "Plain Water\nGibberellic Acid\nHydrogen Peroxide\nOther"
},
{
"fieldname": "solution_concentration",
"fieldtype": "Data",
"label": "Solution Concentration"
},
{
"fieldname": "total_volume_liters",
"fieldtype": "Float",
"label": "Total Volume (L)"
},
{
"fieldname": "soak_duration_hours",
"fieldtype": "Float",
"label": "Soak Duration (hrs)"
},
{
"fieldname": "water_temp_c",
"fieldtype": "Float",
"label": "Water Temperature (\u00b0C)"
},
{
"fieldname": "worker",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Worker",
"options": "Worker Profile",
"reqd": 1
},
{
"fieldname": "expected_germination_rate",
"fieldtype": "Percent",
"label": "Expected Germination Rate Benchmark (%)"
},
{
"fieldname": "observations",
"fieldtype": "Small Text",
"label": "Observations at End of Soak"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-07-31 01:53:16.144036",
"modified_by": "Administrator",
"module": "Agri Seedling",
"name": "Soaking Record",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SoakingRecord(Document):
pass
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestSoakingRecord(FrappeTestCase):
pass
@@ -0,0 +1,8 @@
// Copyright (c) 2026, Agri Seedling Operations Team and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Spray Log Evening", {
// refresh(frm) {
// },
// });
@@ -0,0 +1,161 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "hash",
"creation": "2026-07-31 01:53:08.496728",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"date_time",
"greenhouse_zone",
"chemical_category",
"product_name",
"registration_number",
"concentration",
"volume_prepared_liters",
"volume_sprayed_liters",
"water_volume_liters",
"worker",
"ppe_gloves",
"ppe_mask",
"ppe_goggles",
"ppe_coveralls",
"morning_spray_reference",
"pest_status_change",
"weather_update",
"decontamination_confirmed"
],
"fields": [
{
"fieldname": "date_time",
"fieldtype": "Datetime",
"in_list_view": 1,
"label": "Date & Time",
"reqd": 1
},
{
"fieldname": "greenhouse_zone",
"fieldtype": "Link",
"label": "Greenhouse Zone / Tray Range",
"options": "Greenhouse Zone"
},
{
"fieldname": "chemical_category",
"fieldtype": "Select",
"label": "Chemical Category",
"options": "Fungicide\nPesticide\nFoliar"
},
{
"fieldname": "product_name",
"fieldtype": "Data",
"label": "Product Name"
},
{
"fieldname": "registration_number",
"fieldtype": "Data",
"label": "Registration Number"
},
{
"fieldname": "concentration",
"fieldtype": "Data",
"label": "Concentration"
},
{
"fieldname": "volume_prepared_liters",
"fieldtype": "Float",
"label": "Volume Prepared (L)"
},
{
"fieldname": "volume_sprayed_liters",
"fieldtype": "Float",
"label": "Volume Sprayed (L)"
},
{
"fieldname": "water_volume_liters",
"fieldtype": "Float",
"label": "Water Volume Used (L)"
},
{
"fieldname": "worker",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Worker",
"options": "Worker Profile",
"reqd": 1
},
{
"default": "0",
"fieldname": "ppe_gloves",
"fieldtype": "Check",
"label": "PPE: Gloves"
},
{
"default": "0",
"fieldname": "ppe_mask",
"fieldtype": "Check",
"label": "PPE: Mask/Respirator"
},
{
"default": "0",
"fieldname": "ppe_goggles",
"fieldtype": "Check",
"label": "PPE: Goggles"
},
{
"default": "0",
"fieldname": "ppe_coveralls",
"fieldtype": "Check",
"label": "PPE: Coveralls"
},
{
"fieldname": "morning_spray_reference",
"fieldtype": "Link",
"label": "Morning Spray Reference",
"options": "Spray Log Morning"
},
{
"fieldname": "pest_status_change",
"fieldtype": "Small Text",
"label": "Pest/Disease Status Change"
},
{
"fieldname": "weather_update",
"fieldtype": "Data",
"label": "Weather Condition Update"
},
{
"default": "0",
"fieldname": "decontamination_confirmed",
"fieldtype": "Check",
"label": "Decontamination Confirmed"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-07-31 01:53:08.496728",
"modified_by": "Administrator",
"module": "Agri Seedling",
"name": "Spray Log Evening",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SprayLogEvening(Document):
pass
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestSprayLogEvening(FrappeTestCase):
pass
@@ -0,0 +1,47 @@
{
"doctype": "DocType",
"name": "Spray Log Morning",
"module": "Agri Seedling",
"custom": 0,
"istable": 0,
"issingle": 0,
"is_submittable": 1,
"autoname": "naming_series:",
"naming_series": "SPRM-.YYYY.-.#####",
"track_changes": 1,
"allow_on_submit": [],
"fields": [
{"fieldname": "spray_datetime", "label": "Date & Time", "fieldtype": "Datetime", "reqd": 1, "default": "Now"},
{"fieldname": "zone", "label": "Greenhouse Zone / Tray Range", "fieldtype": "Link", "options": "Greenhouse Zone", "reqd": 1, "in_list_view": 1},
{"fieldname": "worker", "label": "Worker", "fieldtype": "Link", "options": "Worker Profile", "reqd": 1, "in_list_view": 1},
{"fieldname": "column_break_1", "fieldtype": "Column Break"},
{"fieldname": "chemical_category", "label": "Chemical Category", "fieldtype": "Select", "reqd": 1, "in_list_view": 1,
"options": "Fungicide\nPesticide\nFoliar"},
{"fieldname": "product", "label": "Product", "fieldtype": "Link", "options": "Resource Inventory", "reqd": 1},
{"fieldname": "registration_number", "label": "Registration Number", "fieldtype": "Data", "fetch_from": "product.registration_number", "read_only": 1},
{"fieldname": "concentration", "label": "Concentration (mL/L or g/L)", "fieldtype": "Float"},
{"fieldname": "section_break_1", "label": "Volumes", "fieldtype": "Section Break"},
{"fieldname": "solution_volume_prepared", "label": "Solution Prepared (L)", "fieldtype": "Float", "reqd": 1},
{"fieldname": "solution_volume_sprayed", "label": "Solution Sprayed (L)", "fieldtype": "Float", "reqd": 1},
{"fieldname": "water_volume_used", "label": "Water Used for Dilution (L)", "fieldtype": "Float"},
{"fieldname": "target", "label": "Target (pest / deficiency)", "fieldtype": "Data"},
{"fieldname": "section_break_2", "label": "PPE Checklist", "fieldtype": "Section Break",
"description": "All four must be checked before this record can be submitted (Sec 9.3)."},
{"fieldname": "ppe_gloves", "label": "Gloves", "fieldtype": "Check"},
{"fieldname": "ppe_mask", "label": "Mask / Respirator", "fieldtype": "Check"},
{"fieldname": "column_break_2", "fieldtype": "Column Break"},
{"fieldname": "ppe_goggles", "label": "Goggles", "fieldtype": "Check"},
{"fieldname": "ppe_coveralls", "label": "Coveralls", "fieldtype": "Check"},
{"fieldname": "section_break_3", "label": "Conditions", "fieldtype": "Section Break"},
{"fieldname": "ambient_temp_c", "label": "Ambient Temp (°C)", "fieldtype": "Float"},
{"fieldname": "wind_speed", "label": "Wind Speed", "fieldtype": "Select", "options": "Calm\nLow\nModerate"},
{"fieldname": "column_break_3", "fieldtype": "Column Break"},
{"fieldname": "reentry_interval_hours", "label": "Re-Entry Interval (hrs)", "fieldtype": "Float"},
{"fieldname": "reentry_lock_expires", "label": "Zone Restricted Until", "fieldtype": "Datetime", "read_only": 1},
{"fieldname": "ppe_non_compliant", "label": "PPE Non-Compliance Flagged", "fieldtype": "Check", "read_only": 1, "default": "0"}
],
"permissions": [
{"role": "Admin", "read": 1, "write": 1, "create": 1, "delete": 1, "submit": 1, "cancel": 1, "amend": 1, "report": 1},
{"role": "Field Worker", "read": 1, "create": 1, "submit": 1, "if_owner": 1}
]
}
@@ -0,0 +1,31 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import add_to_date, now_datetime
class SprayLogMorning(Document):
def validate(self):
# PPE checklist is mandatory (Sec 9.3). Flag rather than silently block,
# so Admin gets the Critical non-compliance alert instead of the record
# just vanishing — matches the "PPE non-compliance" notification trigger.
ppe_complete = all([self.ppe_gloves, self.ppe_mask, self.ppe_goggles, self.ppe_coveralls])
self.ppe_non_compliant = 0 if ppe_complete else 1
def before_submit(self):
if self.docstatus == 1:
return
if not (self.ppe_gloves and self.ppe_mask and self.ppe_goggles and self.ppe_coveralls):
frappe.msgprint(
_("PPE checklist is incomplete. This will be flagged to Admin for review."),
indicator="orange",
)
def on_submit(self):
if self.reentry_interval_hours:
self.reentry_lock_expires = add_to_date(now_datetime(), hours=self.reentry_interval_hours)
self.db_set("reentry_lock_expires", self.reentry_lock_expires)
frappe.db.set_value("Greenhouse Zone", self.zone, "restricted_until", self.reentry_lock_expires)
def on_cancel(self):
frappe.throw(_("Spray records are immutable once submitted. Cancellation requires Admin authorization outside the normal workflow."))
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestTrayRecyclingRecord(FrappeTestCase):
pass
@@ -0,0 +1,8 @@
// Copyright (c) 2026, Agri Seedling Operations Team and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Tray Recycling Record", {
// refresh(frm) {
// },
// });
@@ -0,0 +1,125 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "hash",
"creation": "2026-07-31 01:53:25.349778",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"date",
"tray_batch_source",
"total_tray_count",
"good_count",
"damaged_count",
"discard_count",
"cleaning_agent",
"sterilization_method",
"cleaning_solution_concentration",
"soak_duration_minutes",
"worker",
"trays_returned_to_stock",
"damaged_tray_disposal_method"
],
"fields": [
{
"fieldname": "date",
"fieldtype": "Datetime",
"in_list_view": 1,
"label": "Date",
"reqd": 1
},
{
"fieldname": "tray_batch_source",
"fieldtype": "Link",
"label": "Tray Batch Source",
"options": "Dispatch Record"
},
{
"fieldname": "total_tray_count",
"fieldtype": "Int",
"label": "Total Tray Count Collected"
},
{
"fieldname": "good_count",
"fieldtype": "Int",
"label": "Good (Reusable) Count"
},
{
"fieldname": "damaged_count",
"fieldtype": "Int",
"label": "Damaged Count"
},
{
"fieldname": "discard_count",
"fieldtype": "Int",
"label": "Discard Count"
},
{
"fieldname": "cleaning_agent",
"fieldtype": "Data",
"label": "Cleaning Agent Used"
},
{
"fieldname": "sterilization_method",
"fieldtype": "Select",
"label": "Sterilization Method",
"options": "Chemical Soak\nSteam\nUV\nBleach Wash"
},
{
"fieldname": "cleaning_solution_concentration",
"fieldtype": "Data",
"label": "Cleaning Solution Concentration"
},
{
"fieldname": "soak_duration_minutes",
"fieldtype": "Int",
"label": "Soak/Sterilization Duration (min)"
},
{
"fieldname": "worker",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Worker",
"options": "Worker Profile",
"reqd": 1
},
{
"fieldname": "trays_returned_to_stock",
"fieldtype": "Int",
"label": "Trays Returned to Stock"
},
{
"fieldname": "damaged_tray_disposal_method",
"fieldtype": "Data",
"label": "Damaged Tray Disposal Method"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-07-31 01:53:25.349778",
"modified_by": "Administrator",
"module": "Agri Seedling",
"name": "Tray Recycling Record",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class TrayRecyclingRecord(Document):
pass
@@ -0,0 +1,27 @@
{
"doctype": "DocType",
"name": "Tray Registry",
"module": "Agri Seedling",
"istable": 0,
"issingle": 0,
"is_submittable": 0,
"autoname": "field:tray_id",
"track_changes": 1,
"fields": [
{"fieldname": "tray_id", "label": "Tray ID", "fieldtype": "Data", "reqd": 1, "unique": 1, "in_list_view": 1},
{"fieldname": "status", "label": "Status", "fieldtype": "Select", "in_list_view": 1,
"options": "Available\nPlanted\nCovered / Germinating\nGrowing\nTransplant-Ready\nDispatched\nAwaiting Recycling\nDamaged / Discarded"},
{"fieldname": "planting_record", "label": "Planting Record", "fieldtype": "Link", "options": "Planting Record"},
{"fieldname": "column_break_1", "fieldtype": "Column Break"},
{"fieldname": "zone", "label": "Current Zone", "fieldtype": "Link", "options": "Greenhouse Zone"},
{"fieldname": "growth_stage", "label": "Growth Stage", "fieldtype": "Select",
"options": "\nGermination\nSeedling\nTransplant-Ready"},
{"fieldname": "last_updated", "label": "Last Status Update", "fieldtype": "Datetime", "read_only": 1}
],
"permissions": [
{"role": "Admin", "read": 1, "write": 1, "create": 1, "delete": 1, "report": 1},
{"role": "Stock Manager", "read": 1, "write": 1, "create": 1},
{"role": "Inventory Manager", "read": 1, "write": 1},
{"role": "Field Worker", "read": 1, "if_owner": 1}
]
}
@@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class TrayRegistry(Document):
pass
@@ -0,0 +1,10 @@
{
"doctype": "DocType",
"name": "Worker Activity Assignment",
"module": "Agri Seedling",
"istable": 1,
"fields": [
{"fieldname": "activity_type", "label": "Activity Type", "fieldtype": "Select", "in_list_view": 1, "reqd": 1,
"options": "Scouting\nOrder Patching\nOrder Dispatching\nMixing Media\nPlanting\nCovering Seedlings\nWatering Greenhouse\nWatering Seedlings\nSpraying Morning\nSpraying Evening\nCustomer Engagement\nSoaking\nRecycling Trays"}
]
}
@@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class WorkerActivityAssignment(Document):
pass
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Agri Seedling Operations Team and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestWorkerProfile(FrappeTestCase):
pass
@@ -0,0 +1,8 @@
// Copyright (c) 2026, Agri Seedling Operations Team and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Worker Profile", {
// refresh(frm) {
// },
// });
@@ -0,0 +1,151 @@
{
"actions": [],
"autoname": "naming_series:",
"creation": "2026-07-30 17:37:40.526889",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"full_name",
"phone_number",
"linked_user",
"column_break_1",
"pin_hash",
"qr_config_token",
"active",
"section_break_1",
"assigned_zones",
"assigned_activity_types",
"section_break_2",
"gps_consent",
"onboarding_tutorial_complete",
"first_submission_reviewed",
"naming_series",
"qr_image"
],
"fields": [
{
"fieldname": "full_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Full Name",
"reqd": 1
},
{
"fieldname": "phone_number",
"fieldtype": "Data",
"label": "Phone Number",
"reqd": 1
},
{
"fieldname": "linked_user",
"fieldtype": "Link",
"label": "Linked User (Guest)",
"options": "User"
},
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
},
{
"description": "Stored as bcrypt hash. Never displayed or exported in plain text.",
"fieldname": "pin_hash",
"fieldtype": "Password",
"label": "PIN (hashed)"
},
{
"description": "Encrypted payload embedded in the printed QR card: API endpoint + worker ID + initial PIN.",
"fieldname": "qr_config_token",
"fieldtype": "Data",
"label": "QR Config Token",
"read_only": 1
},
{
"default": "1",
"fieldname": "active",
"fieldtype": "Check",
"label": "Active"
},
{
"fieldname": "section_break_1",
"fieldtype": "Section Break",
"label": "Assignment"
},
{
"fieldname": "assigned_zones",
"fieldtype": "Table MultiSelect",
"label": "Assigned Greenhouse Zones",
"options": "Worker Zone Assignment"
},
{
"fieldname": "assigned_activity_types",
"fieldtype": "Table",
"label": "Assigned Activity Types",
"options": "Worker Activity Assignment"
},
{
"fieldname": "section_break_2",
"fieldtype": "Section Break",
"label": "Consent & Compliance"
},
{
"default": "0",
"fieldname": "gps_consent",
"fieldtype": "Check",
"label": "GPS Location Consent Given"
},
{
"default": "0",
"fieldname": "onboarding_tutorial_complete",
"fieldtype": "Check",
"label": "5-Screen Tutorial Complete"
},
{
"default": "0",
"fieldname": "first_submission_reviewed",
"fieldtype": "Check",
"label": "First Submission Reviewed by Admin"
},
{
"fieldname": "naming_series",
"fieldtype": "Select",
"hidden": 1,
"label": "Naming Series",
"options": "WRK-.#####"
},
{
"fieldname": "qr_image",
"fieldtype": "Attach Image",
"label": "QR Badge",
"read_only": 1
}
],
"links": [],
"modified": "2026-07-31 05:58:56.432962",
"modified_by": "Administrator",
"module": "Agri Seedling",
"name": "Worker Profile",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"read": 1,
"role": "Admin",
"write": 1
},
{
"read": 1,
"role": "Stock Manager"
},
{
"if_owner": 1,
"read": 1,
"role": "Field Worker"
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
@@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class WorkerProfile(Document):
pass
@@ -0,0 +1,9 @@
{
"doctype": "DocType",
"name": "Worker Zone Assignment",
"module": "Agri Seedling",
"istable": 1,
"fields": [
{"fieldname": "zone", "label": "Greenhouse Zone", "fieldtype": "Link", "options": "Greenhouse Zone", "reqd": 1, "in_list_view": 1}
]
}
@@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class WorkerZoneAssignment(Document):
pass
@@ -0,0 +1,37 @@
import frappe
def send_daily_report():
"""Scheduled 18:00 daily — emails resource consumption summary to
Stock Manager and Admin (Sec 8.3)."""
from agri_seedling.agri_seedling.utils.notifications import _users_with_role
recipients = _users_with_role(["Stock Manager", "Admin"])
if not recipients:
return
resources = frappe.get_all(
"Resource Inventory", fields=["resource_name", "category", "current_stock", "unit_of_measure"]
)
rows = "\n".join(f"{r.resource_name} ({r.category}): {r.current_stock} {r.unit_of_measure or ''}" for r in resources)
frappe.sendmail(
recipients=recipients,
subject="Daily Resource Consumption Report",
message=f"<pre>{rows}</pre>",
)
def execute(filters=None):
"""Query Report entry point for on-demand viewing in the web dashboard."""
columns = [
{"label": "Resource", "fieldname": "resource_name", "fieldtype": "Data", "width": 200},
{"label": "Category", "fieldname": "category", "fieldtype": "Data", "width": 150},
{"label": "Current Stock", "fieldname": "current_stock", "fieldtype": "Float", "width": 120},
{"label": "Unit", "fieldname": "unit_of_measure", "fieldtype": "Data", "width": 100},
]
data = frappe.get_all(
"Resource Inventory",
fields=["resource_name", "category", "current_stock", "unit_of_measure"],
order_by="category",
)
return columns, data
+20
View File
@@ -0,0 +1,20 @@
import frappe
from frappe.utils import get_datetime, nowdate, add_days
def set_field_worker_session_expiry(login_manager=None):
"""Field Worker sessions expire at midnight (Sec 9.1). Other roles use
the standard 8-hour token expiry configured in site_config."""
user = frappe.session.user
if user == "Administrator":
return
if "Field Worker" not in frappe.get_roles(user):
return
midnight = get_datetime(add_days(nowdate(), 1))
frappe.db.set_value(
"Sessions",
{"user": user, "sid": frappe.session.sid},
"lastupdate",
midnight,
update_modified=False,
)
@@ -0,0 +1,38 @@
import frappe
from frappe.utils import now_datetime
def enforce_ppe_checklist(doc, method=None):
ppe_complete = all([doc.ppe_gloves, doc.ppe_mask, doc.ppe_goggles, doc.ppe_coveralls])
doc.ppe_non_compliant = 0 if ppe_complete else 1
def start_reentry_interval_lock(doc, method=None):
from frappe.utils import add_to_date
if doc.reentry_interval_hours and doc.zone:
expiry = add_to_date(now_datetime(), hours=doc.reentry_interval_hours)
frappe.db.set_value("Greenhouse Zone", doc.zone, "restricted_until", expiry)
if doc.ppe_non_compliant:
from agri_seedling.agri_seedling.utils.notifications import _send_push, _users_with_role
recipients = _users_with_role(["Admin"])
_send_push(
recipients,
f"PPE COMPLIANCE BREACH: Worker {doc.worker} submitted a spray log for Zone {doc.zone} "
f"without full PPE confirmation. Immediate review required.",
priority="CRITICAL",
)
def release_expired_reentry_locks():
"""Scheduled every 15 minutes — clear restricted_until once REI has expired."""
frappe.db.sql(
"""
UPDATE `tabGreenhouse Zone`
SET restricted_until = NULL
WHERE restricted_until IS NOT NULL AND restricted_until <= %s
""",
(now_datetime(),),
)
+11
View File
@@ -0,0 +1,11 @@
import frappe
def check_jig_maintenance_due():
"""Daily scheduled job — jigs at/above 200 usage-hours trigger a LOW
priority reminder to Stock Manager (Sec 5.12)."""
if not frappe.db.table_exists("Jig"):
return
due = frappe.get_all("Jig", filters={"usage_hours": [">=", 200], "maintenance_flagged": 0}, pluck="name")
for jig in due:
frappe.db.set_value("Jig", jig, "maintenance_flagged", 1)
@@ -0,0 +1,52 @@
import frappe
def notify_scouting_alert(doc, method=None):
"""Severity >= 3 -> Critical push to Admin + Stock Manager (Sec 6.2)."""
if not doc.severity or doc.severity < 3:
return
recipients = _users_with_role(["Admin", "Stock Manager"])
message = (
f"PEST ALERT: {doc.pest_disease_type or 'Issue'} detected in Zone {doc.zone} "
f"— Severity {doc.severity}. Worker: {doc.worker}. Immediate action required."
)
_send_push(recipients, message, priority="CRITICAL")
def notify_dispatch_complete(doc, method=None):
recipients = _users_with_role(["Admin", "Accountant"])
message = f"Order {doc.order} dispatched to {doc.customer} on {doc.dispatch_datetime}."
_send_email(recipients, subject=f"Order {doc.order} Dispatched", message=message)
def notify_order_patched(doc, method=None):
recipients = _users_with_role(["Stock Manager"])
message = f"Order {doc.name} has been patched and scheduled for production."
_send_push(recipients, message, priority="HIGH")
def _users_with_role(roles):
users = set()
for role in roles:
users.update(frappe.get_all("Has Role", filters={"role": role, "parenttype": "User"}, pluck="parent"))
return list(users)
def _send_push(recipients, message, priority="NORMAL"):
# TODO: wire to Firebase Cloud Messaging. Placeholder writes to the
# Notification Log so behavior is visible during development.
for user in recipients:
frappe.get_doc(
{
"doctype": "Notification Log",
"for_user": user,
"type": "Alert",
"subject": message,
}
).insert(ignore_permissions=True)
def _send_email(recipients, subject, message):
if not recipients:
return
frappe.sendmail(recipients=recipients, subject=subject, message=message)
@@ -0,0 +1,17 @@
import frappe
def field_worker_match_condition(user):
"""Returns a SQL condition string. Field Workers only see their own
submissions; all other roles are unrestricted (Sec 9.2)."""
user = user or frappe.session.user
if "Field Worker" not in frappe.get_roles(user) or user in ("Administrator",):
return ""
return f"`owner` = {frappe.db.escape(user)}"
def field_worker_has_permission(doc, user=None, permission_type=None):
user = user or frappe.session.user
if "Field Worker" not in frappe.get_roles(user):
return True
return doc.owner == user

Some files were not shown because too many files have changed in this diff Show More