Skip to content

Case Study: ScoopJoy ERP

This chapter brings together every concept from the guide into one complete, real-world implementation plan. ScoopJoy is our fictional franchise ice cream chain with 10 outlets, a central kitchen, and ambitions to scale to 50 outlets within two years. It is the running example you have seen throughout the course — here we wire all the pieces together end to end.

ScoopJoy is a premium ice cream brand operating in India:

  • 10 retail outlets across Mumbai, Bangalore, and Delhi
  • 1 Central Kitchen producing ice cream, sorbets, and waffle cones
  • 50+ employees across all locations
  • Revenue streams: retail POS sales (70%), B2B bulk orders to restaurants/hotels (20%), franchise royalties (10%)
  • Pain points with the current system: Excel-based inventory tracking, manual royalty calculations, no real-time visibility across outlets, delayed financial consolidation

The first job in any ERP project is translating business needs into modules and DocTypes. Most of ScoopJoy’s needs map directly onto built-in ERPNext modules; only the franchise-specific logic needs a custom app.

Business NeedERPNext ModuleKey DocTypesCustomizations
Multi-outlet financial managementAccountingCompany, Journal Entry, Payment EntryParent + child company structure
Raw material procurementBuyingPurchase Order, Purchase Receipt, SupplierCustom approval workflow for orders > 50K
Central Kitchen productionManufacturingBOM, Work Order, Stock EntryBatch tracking with expiry dates
Outlet inventory managementStockWarehouse, Stock Entry, Stock ReconciliationInter-company stock transfer automation
Retail POS at each outletPOSPOS Invoice, POS Profile, POS Opening EntryPer-outlet POS profiles, loyalty program
B2B sales to restaurantsSellingSales Order, Sales Invoice, Delivery NoteCustom pricing rules per customer tier
Employee managementHR (HRMS)Employee, Attendance, Payroll EntryMulti-company payroll, shift scheduling
Franchise partner managementCustom AppFranchise Outlet, Franchise AgreementRoyalty calculator, franchise dashboard
Payment collectionPaymentsPayment Entry, Razorpay SettingsRazorpay integration for online payments
Business intelligenceReportsCustom Script Reports, DashboardsOutlet performance, batch expiry reports

We chose Option B: Multi-Company with Parent + Child Companies (see Chapter 31). A single Frappe site hosts a parent company with five children — one per region plus the kitchen and head office:

ScoopJoy company structure
Rendering diagram…

Rationale:

  • Separate P&L per outlet region for franchise accountability
  • Consolidated group reports for management
  • Shared Item master eliminates duplication
  • Inter-company transactions for Central Kitchen distribution
  • User permissions isolate franchise managers to their own data

The rollout runs over six months, each month building on the last — infrastructure and master data first, then the core modules, then the custom app, integrations, testing, and finally go-live.

Month 1: Setup, master data, chart of accounts

Section titled “Month 1: Setup, master data, chart of accounts”
  1. Infrastructure (weeks 1–2). Deploy ERPNext v16 on the production server (Ubuntu 24.04, MariaDB 11.4, Redis 7), set up a staging mirror, configure DNS (a single site, since this is multi-company not multi-tenant), create the parent company ScoopJoy Group and five child companies, and set up a chart of accounts per company.

  2. Master data migration (weeks 3–4). Import the Item master (200+ items: ice cream flavors, cones, toppings, packaging), the Customer master (500+ retail + 50 B2B customers), and the Supplier master (30 raw-material suppliers). Set up warehouses per company (raw materials, WIP, finished goods) and configure Item Groups: Ice Cream, Sorbets, Cones, Toppings, Packaging, Raw Materials.

A one-time setup script ties the moving parts together so the environment can be rebuilt reproducibly:

franchise_management/setup.py
def setup_scoopjoy():
"""One-time setup script for ScoopJoy ERP."""
# Create companies
setup_companies()
# Import items from Excel
import_items_from_excel("~/setup-data/items.xlsx")
# Set up warehouses
for company in get_child_companies("ScoopJoy Group"):
create_standard_warehouses(company)
# Set up POS profiles
for outlet in get_outlet_companies():
create_pos_profile(outlet)
frappe.db.commit()

With master data in place, configure the built-in modules.

Inventory & stock:

  • Batch tracking for ice cream items (mandatory batch + expiry)
  • Stock reorder levels per warehouse
  • BOMs for each ice cream flavor (raw materials → finished product)
  • Inter-company stock transfer workflow from Central Kitchen to outlets

Sales & POS:

  • POS profiles per outlet (payment methods, warehouse, print format)
  • Pricing rules (retail vs. B2B, volume discounts)
  • A customer loyalty program
  • A POS print format with brand logo

Purchasing:

  • A supplier quotation workflow
  • Purchase Order approval: auto-approve < 50K, manager approval > 50K
  • Recurring purchase orders for staple ingredients (milk, sugar, cream)

Everything ERPNext does not handle natively lives in a focused custom app, franchise_management:

Terminal window
bench new-app franchise_management
bench --site scoopjoy.com install-app franchise_management

The app holds four key DocTypes and supporting modules.

1. Franchise Outlet — master data for each location

Section titled “1. Franchise Outlet — master data for each location”

The controller generates a franchise code, guards the status transition, and — on update — provisions a User Permission so the franchise manager only sees their own company’s data.

franchise_management/doctype/franchise_outlet/franchise_outlet.py
import frappe
from frappe.model.document import Document
from frappe.utils import today, getdate
class FranchiseOutlet(Document):
def validate(self):
self.validate_opening_date()
self.generate_franchise_code()
self.validate_status_transition()
def validate_opening_date(self):
if self.opening_date and getdate(self.opening_date) > getdate(today()):
frappe.throw("Opening date cannot be in the future.")
def generate_franchise_code(self):
if not self.franchise_code:
prefix = "".join(
word[0] for word in self.outlet_name.split()[:3]
).upper()
city_code = (self.city or "XXX")[:3].upper()
self.franchise_code = f"FR-{prefix}-{city_code}"
def validate_status_transition(self):
if self.is_new():
return
old_status = self.db_get("status")
if old_status == "Closed" and self.status == "Active":
frappe.throw("Cannot reactivate a closed outlet.")
def on_update(self):
self.update_user_permissions()
def update_user_permissions(self):
if self.franchise_manager:
if not frappe.db.exists("User Permission", {
"user": self.franchise_manager,
"allow": "Company",
"for_value": self.company
}):
frappe.get_doc({
"doctype": "User Permission",
"user": self.franchise_manager,
"allow": "Company",
"for_value": self.company,
"apply_to_all_doctypes": 1
}).insert(ignore_permissions=True)

2. Franchise Agreement — submittable contract

Section titled “2. Franchise Agreement — submittable contract”

A Submittable DocType. On on_submit it provisions the outlet’s warehouse and POS profile and emails the manager — a good example of using the document lifecycle to drive side effects.

franchise_management/doctype/franchise_agreement/franchise_agreement.py
import frappe
from frappe.model.document import Document
from frappe.utils import getdate, add_days
class FranchiseAgreement(Document):
def validate(self):
self.validate_dates()
self.calculate_agreement_summary()
def validate_dates(self):
if getdate(self.end_date) <= getdate(self.start_date):
frappe.throw("End date must be after start date.")
def calculate_agreement_summary(self):
self.duration_months = frappe.utils.month_diff(
self.end_date, self.start_date
)
def on_submit(self):
self.status = "Active"
self.create_outlet_warehouse()
self.create_pos_profile()
self.send_approval_notification()
def on_cancel(self):
self.status = "Cancelled"
def create_outlet_warehouse(self):
outlet = frappe.get_doc("Franchise Outlet", self.franchise_outlet)
warehouse_name = f"{outlet.outlet_name} Store"
if not frappe.db.exists("Warehouse",
{"warehouse_name": warehouse_name, "company": outlet.company}):
wh = frappe.get_doc({
"doctype": "Warehouse",
"warehouse_name": warehouse_name,
"company": outlet.company,
"parent_warehouse": f"All Warehouses - {outlet.company_abbr}"
})
wh.insert(ignore_permissions=True)
def create_pos_profile(self):
outlet = frappe.get_doc("Franchise Outlet", self.franchise_outlet)
if not frappe.db.exists("POS Profile",
{"franchise_outlet": self.franchise_outlet}):
pos = frappe.get_doc({
"doctype": "POS Profile",
"name": f"POS - {outlet.outlet_name}",
"company": outlet.company,
"warehouse": f"{outlet.outlet_name} Store - {outlet.company_abbr}",
"write_off_account": f"Write Off - {outlet.company_abbr}",
"write_off_cost_center": f"Main - {outlet.company_abbr}",
"payments": [
{"mode_of_payment": "Cash", "default": 1},
{"mode_of_payment": "UPI"},
]
})
pos.flags.franchise_outlet = self.franchise_outlet
pos.insert(ignore_permissions=True)
def send_approval_notification(self):
outlet = frappe.get_doc("Franchise Outlet", self.franchise_outlet)
if outlet.franchise_manager:
frappe.sendmail(
recipients=[outlet.franchise_manager],
subject=f"Franchise Agreement Approved - {outlet.outlet_name}",
message=f"""
Your franchise agreement has been approved.
<br><br>
<b>Outlet:</b> {outlet.outlet_name}<br>
<b>Period:</b> {self.start_date} to {self.end_date}<br>
<b>Royalty Rate:</b> {self.royalty_percentage}%
"""
)

3. Franchise royalty calculator — monthly computation

Section titled “3. Franchise royalty calculator — monthly computation”

A whitelisted method that sums each outlet’s submitted Sales Invoices for the month, applies the agreement’s percentage (with a monthly minimum floor), creates a Franchise Royalty record, and posts the matching Journal Entry.

franchise_management/royalty.py
import frappe
from frappe.utils import getdate, get_first_day, get_last_day, flt
@frappe.whitelist()
def calculate_monthly_royalty(outlet, month):
"""Calculate and post royalty for a franchise outlet.
Args:
outlet: Franchise Outlet name
month: Month string like "2025-06"
"""
outlet_doc = frappe.get_doc("Franchise Outlet", outlet)
agreement = get_active_agreement(outlet)
if not agreement:
frappe.throw(f"No active franchise agreement for {outlet}")
# Calculate date range
year, mon = month.split("-")
from_date = get_first_day(f"{year}-{mon}-01")
to_date = get_last_day(f"{year}-{mon}-01")
# Get total sales for the month
total_sales = frappe.db.sql("""
SELECT COALESCE(SUM(grand_total), 0) as total
FROM `tabSales Invoice`
WHERE company = %s
AND posting_date BETWEEN %s AND %s
AND docstatus = 1
""", (outlet_doc.company, from_date, to_date), as_dict=True)[0].total
# Calculate royalty
royalty_amount = flt(total_sales) * flt(agreement.royalty_percentage) / 100
minimum = flt(agreement.monthly_minimum_royalty)
final_royalty = max(royalty_amount, minimum)
# Create royalty record
royalty = frappe.get_doc({
"doctype": "Franchise Royalty",
"franchise_outlet": outlet,
"franchise_agreement": agreement.name,
"month": month,
"total_sales": total_sales,
"royalty_percentage": agreement.royalty_percentage,
"calculated_royalty": royalty_amount,
"minimum_royalty": minimum,
"final_royalty": final_royalty
})
royalty.insert(ignore_permissions=True)
royalty.submit()
# Create journal entry for royalty
je = create_royalty_journal_entry(outlet_doc, final_royalty, month)
return {
"royalty": royalty.name,
"journal_entry": je,
"amount": final_royalty
}
def get_active_agreement(outlet):
agreements = frappe.get_all("Franchise Agreement",
filters={
"franchise_outlet": outlet,
"status": "Active",
"docstatus": 1
},
fields=["name", "royalty_percentage", "monthly_minimum_royalty"],
order_by="start_date desc",
limit=1
)
return agreements[0] if agreements else None
def create_royalty_journal_entry(outlet_doc, amount, month):
company = outlet_doc.company
je = frappe.get_doc({
"doctype": "Journal Entry",
"voucher_type": "Journal Entry",
"company": company,
"posting_date": get_last_day(f"{month}-01"),
"user_remark": f"Franchise royalty for {outlet_doc.outlet_name} - {month}",
"accounts": [
{
"account": f"Franchise Royalty Expense - {outlet_doc.company_abbr}",
"debit_in_account_currency": amount,
"cost_center": f"Main - {outlet_doc.company_abbr}"
},
{
"account": f"Franchise Royalty Payable - {outlet_doc.company_abbr}",
"credit_in_account_currency": amount,
"cost_center": f"Main - {outlet_doc.company_abbr}"
}
]
})
je.insert(ignore_permissions=True)
je.submit()
return je.name

4. Franchise dashboard — a custom Desk page

Section titled “4. Franchise dashboard — a custom Desk page”

The server side aggregates outlet, sales, top-item, and royalty summaries in one whitelisted call; the client side renders the stat cards and charts.

franchise_management/page/franchise_dashboard/franchise_dashboard.py
import frappe
from frappe.utils import today, add_months, get_first_day
@frappe.whitelist()
def get_dashboard_data():
"""Return aggregated dashboard data for the franchise overview."""
return {
"outlets": get_outlet_summary(),
"sales": get_sales_summary(),
"top_items": get_top_selling_items(),
"royalty_summary": get_royalty_summary()
}
def get_sales_summary():
first_day = get_first_day(today())
return frappe.db.sql("""
SELECT
si.company,
COUNT(si.name) as invoice_count,
SUM(si.grand_total) as total_revenue,
AVG(si.grand_total) as avg_order_value
FROM `tabSales Invoice` si
WHERE si.posting_date >= %s
AND si.docstatus = 1
GROUP BY si.company
ORDER BY total_revenue DESC
""", first_day, as_dict=True)
def get_top_selling_items():
first_day = get_first_day(today())
return frappe.db.sql("""
SELECT
sii.item_code,
sii.item_name,
SUM(sii.qty) as total_qty,
SUM(sii.amount) as total_amount
FROM `tabSales Invoice Item` sii
JOIN `tabSales Invoice` si ON si.name = sii.parent
WHERE si.posting_date >= %s
AND si.docstatus = 1
GROUP BY sii.item_code, sii.item_name
ORDER BY total_qty DESC
LIMIT 10
""", first_day, as_dict=True)
# get_outlet_summary() and get_royalty_summary() follow the same pattern.
franchise_management/page/franchise_dashboard/franchise_dashboard.js
frappe.pages["franchise-dashboard"].on_page_load = function (wrapper) {
const page = frappe.ui.make_app_page({
parent: wrapper,
title: "Franchise Dashboard",
single_column: true,
});
page.main.html(`
<div class="franchise-dashboard">
<div class="row mb-4">
<div class="col-md-3"><div id="active-outlets" class="stat-card"></div></div>
<div class="col-md-3"><div id="monthly-revenue" class="stat-card"></div></div>
<div class="col-md-3"><div id="avg-order" class="stat-card"></div></div>
<div class="col-md-3"><div id="total-royalty" class="stat-card"></div></div>
</div>
<div class="row">
<div class="col-md-8"><div id="sales-chart"></div></div>
<div class="col-md-4"><div id="top-items"></div></div>
</div>
</div>
`);
load_dashboard_data(page);
};
function load_dashboard_data(page) {
frappe.call({
method: "franchise_management.page.franchise_dashboard.franchise_dashboard.get_dashboard_data",
callback: function (r) {
if (r.message) {
render_stats(r.message);
render_sales_chart(r.message.sales);
render_top_items(r.message.top_items);
}
},
});
}
function render_stats(data) {
const active = data.outlets.find((o) => o.status === "Active");
document.getElementById("active-outlets").innerHTML = `
<h3>${active ? active.count : 0}</h3>
<p>Active Outlets</p>
`;
const total_revenue = data.sales.reduce((sum, s) => sum + s.total_revenue, 0);
document.getElementById("monthly-revenue").innerHTML = `
<h3>${format_currency(total_revenue)}</h3>
<p>This Month's Revenue</p>
`;
}

Razorpay payment gateway (Chapter 22): install the payments app (bench get-app payments && bench --site scoopjoy.com install-app payments), configure Razorpay Settings with API keys, set up a Payment Gateway Account per company, and create Payment Request automation for B2B invoices.

WhatsApp notifications via the WhatsApp Business API:

franchise_management/integrations/whatsapp.py
import frappe
import requests
def send_whatsapp_message(phone, message, template_name=None):
"""Send a WhatsApp message via the WhatsApp Business API."""
settings = frappe.get_single("Franchise Settings")
if not settings.whatsapp_api_key:
frappe.log_error("WhatsApp API key not configured")
return
payload = {
"messaging_product": "whatsapp",
"to": phone,
"type": "text",
"text": {"body": message}
}
response = requests.post(
f"https://graph.facebook.com/v18.0/{settings.whatsapp_phone_id}/messages",
headers={
"Authorization": f"Bearer {settings.whatsapp_api_key}",
"Content-Type": "application/json"
},
json=payload,
timeout=30
)
if response.status_code != 200:
frappe.log_error(message=response.text, title="WhatsApp API Error")
return response.json()

Mobile app REST API (Chapter 08): whitelisted endpoints the franchise mobile app calls, each starting with a permission check.

franchise_management/api.py
import frappe
from frappe.utils import today, get_first_day
@frappe.whitelist()
def get_outlet_performance(outlet, from_date=None, to_date=None):
"""API endpoint for mobile app: get outlet performance metrics."""
outlet_doc = frappe.get_doc("Franchise Outlet", outlet)
if not frappe.has_permission("Franchise Outlet", doc=outlet_doc):
frappe.throw("Insufficient permissions", frappe.PermissionError)
from_date = from_date or get_first_day(today())
to_date = to_date or today()
sales_data = frappe.db.sql("""
SELECT
COUNT(*) as total_invoices,
COALESCE(SUM(grand_total), 0) as total_revenue,
COALESCE(AVG(grand_total), 0) as avg_order_value,
COALESCE(MAX(grand_total), 0) as highest_order
FROM `tabSales Invoice`
WHERE company = %s
AND posting_date BETWEEN %s AND %s
AND docstatus = 1
""", (outlet_doc.company, from_date, to_date), as_dict=True)[0]
return {
"outlet": outlet,
"outlet_name": outlet_doc.outlet_name,
"from_date": str(from_date),
"to_date": str(to_date),
"total_invoices": sales_data.total_invoices,
"total_revenue": float(sales_data.total_revenue),
"avg_order_value": float(sales_data.avg_order_value),
"highest_order": float(sales_data.highest_order)
}
@frappe.whitelist()
def get_stock_status(outlet):
"""API endpoint for mobile app: get current stock levels."""
outlet_doc = frappe.get_doc("Franchise Outlet", outlet)
if not frappe.has_permission("Franchise Outlet", doc=outlet_doc):
frappe.throw("Insufficient permissions", frappe.PermissionError)
warehouse = frappe.db.get_value("Warehouse",
{"company": outlet_doc.company, "is_default": 1}, "name")
stock = frappe.db.sql("""
SELECT
b.item_code,
i.item_name,
b.actual_qty,
b.reserved_qty,
b.actual_qty - b.reserved_qty as available_qty,
ir.warehouse_reorder_level as reorder_level
FROM `tabBin` b
JOIN `tabItem` i ON i.name = b.item_code
LEFT JOIN `tabItem Reorder` ir ON ir.parent = i.name
AND ir.warehouse = b.warehouse
WHERE b.warehouse = %s
AND b.actual_qty > 0
ORDER BY b.actual_qty ASC
""", warehouse, as_dict=True)
return {
"outlet": outlet,
"warehouse": warehouse,
"items": stock,
"low_stock_count": len([s for s in stock
if s.reorder_level and s.available_qty < s.reorder_level])
}

End-to-end integration tests (Chapter 33) exercise the whole onboarding flow — outlet → agreement → warehouse → POS — and the monthly royalty cycle. Run them with bench --site test_site run-tests --app franchise_management --verbose.

franchise_management/tests/test_integration.py
class TestScoopJoyIntegration(FrappeTestCase):
"""End-to-end integration tests for the ScoopJoy franchise system."""
def test_full_franchise_onboarding(self):
"""Test the complete flow: outlet → agreement → warehouse → POS."""
outlet = create_test_outlet(
outlet_name="Test Outlet Andheri",
city="Mumbai",
company="_Test Company"
)
agreement = frappe.get_doc({
"doctype": "Franchise Agreement",
"franchise_outlet": outlet.name,
"start_date": "2025-01-01",
"end_date": "2027-12-31",
"royalty_percentage": 5,
"monthly_minimum_royalty": 10000,
"agreement_value": 500000
})
agreement.insert(ignore_permissions=True)
agreement.submit()
# The submit hook should have provisioned the warehouse and POS profile.
self.assertTrue(
frappe.db.exists("Warehouse", {"warehouse_name": "Test Outlet Andheri Store"})
)
self.assertTrue(
frappe.db.exists("POS Profile", {"franchise_outlet": outlet.name})
)
def test_monthly_royalty_cycle(self):
"""Test royalty calculation and journal entry creation."""
outlet = create_test_outlet()
agreement = create_and_submit_agreement(outlet.name, royalty_percentage=5)
# Simulate sales
for i in range(10):
create_test_sales_invoice(outlet.company, grand_total=10000)
result = calculate_monthly_royalty(outlet.name, "2025-06")
self.assertEqual(result["amount"], 5000) # 5% of 100,000
self.assertTrue(result["journal_entry"])

Training plan:

  • Admin training: 2 days (system configuration, user management)
  • Finance team: 3 days (accounting, inter-company transactions, reporting)
  • Outlet managers: 2 days (POS operations, stock management)
  • Franchise managers: 1 day (dashboard, reports, royalty review)

Month 6: Go-live, monitoring, optimization

Section titled “Month 6: Go-live, monitoring, optimization”

The final month is a controlled cutover with a checklist split across pre-go-live, the go-live day itself, and the first post-launch week.

Pre go-live:

  • All master data imported and verified
  • Chart of Accounts reviewed by finance team
  • Opening balances entered and verified
  • Opening stock entered via Stock Reconciliation
  • User accounts created with correct roles and permissions
  • POS profiles configured and tested at each outlet
  • Print formats customized and approved
  • Email templates configured (invoice, payment receipt, etc.)
  • Automated notifications tested
  • Backup strategy configured (daily automated backups)
  • SSL certificate installed and verified
  • Domain DNS configured correctly

Go-live day:

  • Final backup of the staging environment
  • Switch DNS to the production server
  • Verify all outlets can access POS
  • Process test transactions at each outlet
  • Verify inter-company stock transfer
  • Verify payment gateway (Razorpay) processing
  • Monitor error logs for the first 4 hours
  • Confirm the daily backup job runs successfully

Post go-live (week 1):

  • Daily review of the Error Log
  • Monitor server resources (CPU, RAM, disk)
  • Collect user feedback from each outlet
  • Fix critical bugs immediately
  • Verify the first day’s closing entries
  • Confirm POS closing reconciliation at each outlet
  • Review Background Jobs for failed tasks

Once live, scheduled jobs keep the system healthy and the royalties flowing without manual intervention. The daily health check emails admins if background jobs fail, errors spike, or disk space runs low; the monthly job calculates royalties for every active outlet.

franchise_management/tasks.py
def daily_health_check():
"""Run daily at 7 AM — check system health and alert admins."""
issues = []
# Check for failed background jobs
failed_jobs = frappe.db.count("RQ Job",
{"status": "failed", "modified": (">", frappe.utils.add_days(frappe.utils.today(), -1))}
)
if failed_jobs > 0:
issues.append(f"{failed_jobs} failed background jobs in last 24 hours")
# Check for error logs
error_count = frappe.db.count("Error Log",
{"creation": (">", frappe.utils.add_days(frappe.utils.now(), -1))}
)
if error_count > 50:
issues.append(f"{error_count} errors logged in last 24 hours")
# Check disk space
import shutil
total, used, free = shutil.disk_usage("/")
free_pct = free / total * 100
if free_pct < 20:
issues.append(f"Low disk space: {free_pct:.1f}% free")
if issues:
frappe.sendmail(
recipients=["admin@scoopjoy.com"],
subject="ScoopJoy ERP - Health Check Alert",
message="<br>".join(issues)
)
def calculate_all_royalties():
"""Run monthly on the 1st — calculate royalties for all active outlets."""
from frappe.utils import add_months, today
month = add_months(today(), -1)[:7] # previous month
outlets = frappe.get_all("Franchise Outlet",
filters={"status": "Active"},
pluck="name"
)
for outlet in outlets:
try:
calculate_monthly_royalty(outlet, month)
except Exception:
frappe.log_error(
message=frappe.get_traceback(),
title=f"Royalty calculation failed: {outlet}"
)

These are wired up in the app’s scheduler configuration (Chapter 18):

franchise_management/hooks.py
scheduler_events = {
"daily": [
"franchise_management.tasks.daily_health_check"
],
"monthly": [
"franchise_management.tasks.calculate_all_royalties"
],
"hourly": [
"franchise_management.tasks.sync_pos_invoices"
]
}

The full operational picture brings POS at each outlet, the central kitchen, the custom app, and the integrations together:

ScoopJoy ERP data flow
Rendering diagram…

Custom Fields:

DocTypeFieldTypePurpose
Sales Invoicefranchise_outletLinkTrack which outlet generated the sale
Itemshelf_life_daysIntIce cream shelf life for expiry tracking
Customerloyalty_tierSelectBronze/Silver/Gold loyalty classification
POS Profilefranchise_outletLinkLink POS profile to franchise outlet

Workflows:

DocTypeStatesTransitions
Purchase OrderDraft → Pending Approval → Approved → OrderedAuto-approve if < 50K
Franchise AgreementDraft → Submitted → Active → Expired/CancelledManager approval required

Reports:

ReportTypePurpose
Outlet PerformanceScript ReportRevenue, orders, avg ticket by outlet
Batch Expiry ReportScript ReportItems approaching expiry in next 7/14/30 days
Franchise Royalty SummaryScript ReportMonthly royalty by outlet with YTD totals
Consolidated P&LStandardGroup-level profit & loss statement
  1. Start with master data quality. Garbage in, garbage out. Spend extra time cleaning Item and Customer masters before import. Standardize naming conventions early.

  2. Use child companies, not just cost centers. For any chain with more than 5 outlets, the financial isolation and per-outlet reporting of child companies is worth the extra setup complexity.

  3. Automate inter-company transactions. Manual inter-company entries are error-prone. Write hooks to auto-create Purchase Invoices when Central Kitchen submits Sales Invoices to outlets.

  4. Test POS extensively before go-live. POS is the highest-volume transaction point. Test offline mode, payment methods, print formats, and closing reconciliation at every outlet.

  5. Batch tracking is non-negotiable for food businesses. Enable batch + expiry on all perishable items from day one. Retrofitting batch tracking after go-live is painful.

  6. Invest in the staging environment. Mirror production exactly. Test every migration, every patch, and every deployment on staging first. The 30 minutes spent testing saves 3 hours of production firefighting.

  7. Keep the custom app focused. The franchise_management app should only contain franchise-specific logic. Use ERPNext’s built-in modules for everything they handle well. Extend, do not replace.

  8. Plan for scale from the start. Configure background jobs for heavy computations (royalty calculation, report generation). Use frappe.enqueue() for anything that touches more than 100 records.

  9. Document your customizations. Maintain a CHANGELOG, write docstrings, and keep patches organized by version. Future developers (including yourself in 6 months) will thank you.

  10. Monitor relentlessly after go-live. Watch Error Logs, Background Jobs, and server resources daily for the first month. Set up automated alerts for anomalies. Most production issues surface within the first two weeks.