Merge branch 'version-16-hotfix' into mergify/bp/version-16-hotfix/pr-57245

This commit is contained in:
kaulith
2026-07-23 11:59:44 +05:30
committed by GitHub
42 changed files with 2412 additions and 799 deletions

View File

@@ -120,6 +120,7 @@ class Account(NestedSet):
self.validate_account_currency()
self.validate_root_company_and_sync_account_to_children()
self.validate_receivable_payable_account_type()
self.validate_stock_account_type_change()
def validate_parent_child_account_type(self):
if self.parent_account:
@@ -208,6 +209,36 @@ class Account(NestedSet):
frappe.msgprint(msg)
self.add_comment("Comment", msg)
def validate_stock_account_type_change(self):
doc_before_save = self.get_doc_before_save()
if not (doc_before_save and doc_before_save.account_type == "Stock"):
return
if self.account_type == "Stock":
return
if self.stock_ledger_entry_exists():
frappe.throw(
_(
"The account type of {0} cannot be changed from {1} because stock ledger entries exist against it."
).format(frappe.bold(self.name), frappe.bold(_("Stock")))
)
def stock_ledger_entry_exists(self):
from erpnext.stock import get_warehouse_account_map
warehouse_account = get_warehouse_account_map(self.company)
warehouses = [wh for wh, details in warehouse_account.items() if details.account == self.name]
if not warehouses:
return False
return bool(
frappe.db.count(
"Stock Ledger Entry",
filters={"warehouse": ("in", warehouses), "is_cancelled": 0},
)
)
def validate_root_details(self):
doc_before_save = self.get_doc_before_save()

View File

@@ -307,6 +307,31 @@ class TestAccount(ERPNextTestSuite):
acc.account_currency = "USD"
self.assertRaises(frappe.ValidationError, acc.save)
def test_stock_account_type_change_with_ledger_entries(self):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
company = "_Test Company with perpetual inventory"
warehouse = "Stores - TCP1"
stock_account = get_warehouse_account(frappe.get_doc("Warehouse", warehouse))
make_stock_entry(
item_code="_Test Item",
target=warehouse,
company=company,
qty=5,
basic_rate=100,
)
account = frappe.get_doc("Account", stock_account)
self.assertEqual(account.account_type, "Stock")
account.account_type = ""
self.assertRaises(frappe.ValidationError, account.save)
account.reload()
account.account_name = f"{account.account_name} Updated"
account.save() # non-type change stays allowed
def test_account_balance(self):
from erpnext.accounts.utils import get_balance_on

View File

@@ -14,7 +14,8 @@
"section_break_mjlv",
"due_date",
"column_break_qghl",
"amount"
"amount",
"currency"
],
"fields": [
{
@@ -55,8 +56,18 @@
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Amount",
"options": "currency",
"precision": "2"
},
{
"fieldname": "currency",
"fieldtype": "Link",
"hidden": 1,
"label": "Currency",
"options": "Currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "column_break_lnjp",
"fieldtype": "Column Break"
@@ -74,7 +85,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-01-19 02:21:36.455830",
"modified": "2026-07-11 00:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Reference",

View File

@@ -784,6 +784,7 @@ def set_payment_references(payment_schedules):
"description": row.get("description"),
"due_date": row.get("due_date"),
"amount": row.get("payment_amount"),
"currency": row.get("currency"),
}
)

View File

@@ -1371,8 +1371,9 @@ class StockController(AccountsController):
if outstanding > 0:
reservations[key].append(row)
precision = frappe.get_precision("Serial and Batch Entry", "qty")
for (batch_no, warehouse), reserved_qty in outstanding_qty.items():
if flt(reserved_qty, 6) <= 0:
if flt(reserved_qty, precision) <= 0:
continue
batch_qty = get_batch_qty(
@@ -1383,7 +1384,7 @@ class StockController(AccountsController):
consider_negative_batches=True,
)
if flt(batch_qty, 6) >= flt(reserved_qty, 6):
if flt(batch_qty, precision) >= flt(reserved_qty, precision):
continue
vouchers = ", ".join(

View File

@@ -7,7 +7,11 @@
"engine": "InnoDB",
"field_order": [
"scheduled_time",
"column_break_xaox",
"status",
"created_through_portal",
"email_verified",
"verification_token",
"customer_details_section",
"customer_name",
"customer_phone_number",
@@ -54,7 +58,8 @@
"fieldtype": "Datetime",
"in_list_view": 1,
"label": "Scheduled Time",
"reqd": 1
"reqd": 1,
"search_index": 1
},
{
"fieldname": "status",
@@ -77,8 +82,8 @@
"fieldname": "customer_email",
"fieldtype": "Data",
"label": "Email",
"reqd": 1,
"options": "Email"
"options": "Email",
"reqd": 1
},
{
"fieldname": "linked_docs_section",
@@ -100,13 +105,43 @@
"fieldtype": "Dynamic Link",
"label": "Party",
"options": "appointment_with"
},
{
"default": "0",
"fieldname": "created_through_portal",
"fieldtype": "Check",
"label": "Created through Portal",
"read_only": 1,
"set_only_once": 1
},
{
"fieldname": "column_break_xaox",
"fieldtype": "Column Break"
},
{
"default": "0",
"depends_on": "eval:doc.created_through_portal === 1;",
"fieldname": "email_verified",
"fieldtype": "Check",
"label": "Email Verified",
"read_only": 1
},
{
"fieldname": "verification_token",
"fieldtype": "Data",
"label": "Verification Token",
"hidden": 1,
"read_only": 1,
"no_copy": 1,
"search_index": 1
}
],
"links": [],
"modified": "2026-06-06 13:05:59.300573",
"modified": "2026-07-20 02:00:00.000000",
"modified_by": "Administrator",
"module": "CRM",
"name": "Appointment",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -158,8 +193,9 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -3,14 +3,20 @@
from collections import Counter
from datetime import timedelta
from urllib.parse import urlencode
import frappe
from frappe import _
from frappe.desk.form.assign_to import add as add_assignment
from frappe.model.document import Document
from frappe.share import add_docshare
from frappe.utils import get_url, getdate, now
from frappe.utils.verified_command import get_signed_params
from frappe.utils import add_to_date, cint, date_diff, get_datetime, get_url, getdate, now, now_datetime
from frappe.utils.data import sha256_hash
from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
class Appointment(Document):
@@ -24,104 +30,227 @@ class Appointment(Document):
appointment_with: DF.Link | None
calendar_event: DF.Link | None
created_through_portal: DF.Check
customer_details: DF.LongText | None
customer_email: DF.Data
customer_name: DF.Data
customer_phone_number: DF.Data | None
customer_skype: DF.Data | None
email_verified: DF.Check
party: DF.DynamicLink | None
scheduled_time: DF.Datetime
status: DF.Literal["Open", "Unverified", "Closed"]
verification_token: DF.Data | None
# end: auto-generated types
def find_lead_by_email(self):
lead_list = frappe.get_list(
"Lead", filters={"email_id": self.customer_email}, ignore_permissions=True
)
if lead_list:
return lead_list[0].name
return None
def validate(self):
self.validate_status_update()
if not self.has_value_changed("scheduled_time"):
return
def find_customer_by_email(self):
customer_list = frappe.get_list(
"Customer", filters={"email_id": self.customer_email}, ignore_permissions=True
self.validate_backdated_booking()
if is_appointment_scheduling_enabled():
self.validate_advanced_booking()
self.validate_holiday()
self.validate_slot_timing()
self.validate_available_time_slot()
def validate_status_update(self):
if not self.has_value_changed("status"):
return
if not self.created_through_portal:
if self.status == "Unverified":
frappe.throw(_("Appointments created manually cannot have 'Unverified' status."))
return
if self.status == "Unverified" and self.email_verified:
frappe.throw(_("A verified appointment cannot be moved back to 'Unverified' status."))
if self.status == "Open" and not self.email_verified:
frappe.throw(
_("An appointment booked through the portal can only be opened via email verification.")
)
def validate_backdated_booking(self):
if get_datetime(self.scheduled_time) < now_datetime():
frappe.throw(_("Appointment cannot be scheduled for a past time."))
def validate_advanced_booking(self):
advance_booking_days = cint(get_booking_settings().advance_booking_days)
if advance_booking_days and date_diff(self.scheduled_time, now_datetime()) > advance_booking_days:
frappe.throw(
_("Appointment can only be scheduled up to {0} day(s) in advance.").format(
advance_booking_days
)
)
def validate_holiday(self):
holiday_list = get_booking_settings().holiday_list
if not holiday_list:
frappe.throw(_("Please add a valid Holiday List on Appointment Booking Settings."))
if is_holiday(holiday_list, getdate(self.scheduled_time)):
frappe.throw(_("Appointment cannot be scheduled on a holiday."))
def validate_slot_timing(self):
settings = get_booking_settings()
if not settings.availability_of_slots:
frappe.throw(_("No availability of slots are found. Please add on Appointment Booking Settings."))
scheduled_time = get_datetime(self.scheduled_time)
day_of_week = WEEKDAYS[scheduled_time.weekday()]
slot_start = timedelta(
hours=scheduled_time.hour, minutes=scheduled_time.minute, seconds=scheduled_time.second
)
if customer_list:
return customer_list[0].name
return None
slot_end = slot_start + timedelta(minutes=cint(settings.appointment_duration))
for slot in settings.availability_of_slots:
if slot.day_of_week == day_of_week and slot.from_time <= slot_start and slot_end <= slot.to_time:
return
frappe.throw(_("Appointment must be scheduled within the available slot timings."))
def validate_available_time_slot(self):
settings = get_booking_settings()
if not cint(settings.number_of_agents):
return
# the locking read serializes concurrent bookings for the same window,
# so two simultaneous requests cannot both pass the capacity check
booked = count_overlapping_appointments(
self.scheduled_time,
cint(settings.appointment_duration),
exclude_appointment=self.name,
for_update=True,
)
if booked >= cint(settings.number_of_agents):
frappe.throw(_("Time slot is not available"))
def before_insert(self):
number_of_appointments_in_same_slot = frappe.db.count(
"Appointment", filters={"scheduled_time": self.scheduled_time}
)
number_of_agents = frappe.db.get_single_value("Appointment Booking Settings", "number_of_agents")
if number_of_agents != 0:
if number_of_appointments_in_same_slot >= number_of_agents:
frappe.throw(_("Time slot is not available"))
# Link lead
if not self.party:
lead = self.find_lead_by_email()
customer = self.find_customer_by_email()
if customer:
self.appointment_with = "Customer"
self.party = customer
else:
self.appointment_with = "Lead"
self.party = lead
# Set status to "Unverified" for new Appointments.
if self.created_through_portal:
self.status = "Unverified"
return
self.link_customer_lead()
def after_insert(self):
if self.party:
# Create Calendar event
if not self.created_through_portal and self.party:
self.auto_assign()
self.create_calendar_event()
else:
# Set status to unverified
self.db_set("status", "Unverified")
# Send email to confirm
self.send_confirmation_email()
return
# Send email to confirm
self.send_confirmation_email()
def on_update(self):
# capture transitions before nested saves during materialization
# refresh the before-save snapshot
status_changed = self.has_value_changed("status")
email_just_verified = bool(
self.created_through_portal and self.email_verified
) and self.has_value_changed("email_verified")
self.link_auto_assign_and_create_calendar_event()
if email_just_verified:
self.send_appointment_confirmed_email()
if status_changed:
self.update_event_and_assignments_status()
def on_trash(self):
# the Event only references the party, not the appointment,
# so it must be cleaned up explicitly
if not self.calendar_event:
return
event = self.calendar_event
self.db_set("calendar_event", None, update_modified=False)
frappe.delete_doc("Event", event, ignore_permissions=True)
def send_confirmation_email(self):
verify_url = self._get_verify_url()
template = "confirm_appointment"
args = {
"link": verify_url,
"site_url": frappe.utils.get_url(),
"full_name": self.customer_name,
}
self.send_email_to_customer(
template="confirm_appointment",
subject=_("Appointment Confirmation"),
args={"link": self._get_verify_url(), "expiry_minutes": get_verification_link_expiry()},
)
frappe.msgprint(_("Please check your email to confirm the appointment."))
def send_appointment_confirmed_email(self):
self.send_email_to_customer(
template="appointment_confirmed",
subject=_("Appointment Confirmed"),
args={"scheduled_time": frappe.utils.format_datetime(self.scheduled_time)},
reference_doctype="Appointment",
reference_name=self.name,
)
def send_email_to_customer(self, template, subject, args, **kwargs):
frappe.sendmail(
recipients=[self.customer_email],
template=template,
args=args,
subject=_("Appointment Confirmation"),
args={"full_name": self.customer_name, "site_url": frappe.utils.get_url(), **args},
subject=subject,
**kwargs,
)
if frappe.session.user == "Guest":
frappe.msgprint(_("Please check your email to confirm the appointment"))
else:
frappe.msgprint(
_("Appointment was created. But no lead was found. Please check the email to confirm")
)
def on_change(self):
# Sync Calendar
if not self.calendar_event:
def link_auto_assign_and_create_calendar_event(self):
if self.is_new() or (self.created_through_portal and not self.email_verified):
return
if not self.calendar_event:
# first materialization: link the party, assign an agent, create the event
self.link_customer_lead()
self.auto_assign()
self.create_calendar_event()
self.sync_calendar_event()
def sync_calendar_event(self):
if not self.calendar_event or not self.has_value_changed("scheduled_time"):
return
cal_event = frappe.get_doc("Event", self.calendar_event)
cal_event.starts_on = self.scheduled_time
cal_event.save(ignore_permissions=True)
def set_verified(self, email):
if email != self.customer_email:
frappe.throw(_("Email verification failed."))
# Create new lead
def update_event_and_assignments_status(self):
"""Close or reopen the calendar event and assignments along with the appointment."""
if self.status == "Unverified":
return
is_closed = self.status == "Closed"
new_status = "Closed" if is_closed else "Open"
if self.calendar_event:
frappe.db.set_value("Event", self.calendar_event, "status", new_status)
# only move ToDos between Open and Closed - never touch Cancelled ones
todo_filters = {
"reference_type": "Appointment",
"reference_name": self.name,
"status": "Open" if is_closed else "Closed",
}
frappe.db.set_value("ToDo", todo_filters, "status", new_status)
def link_customer_lead(self):
if not self.party:
customer = self.find_party_by_email("Customer")
self.appointment_with = "Customer" if customer else "Lead"
self.party = customer or self.find_party_by_email("Lead")
self.create_lead_and_link()
# Remove unverified status
self.status = "Open"
# Create calender event
self.auto_assign()
self.create_calendar_event()
self.save(ignore_permissions=True)
if not frappe.in_test:
frappe.db.commit()
def find_party_by_email(self, doctype):
party = frappe.get_all(doctype, filters={"email_id": self.customer_email}, limit=1, pluck="name")
return party[0] if party else None
def create_lead_and_link(self):
# Return if already linked
@@ -140,86 +269,39 @@ class Appointment(Document):
if self.customer_details:
lead.append(
"notes",
{
"note": self.customer_details,
"added_by": frappe.session.user,
"added_on": now(),
},
{"note": self.customer_details, "added_by": frappe.session.user, "added_on": now()},
)
lead.insert(ignore_permissions=True)
# Link lead
self.party = lead.name
self.party = lead.insert(ignore_permissions=True).name
def auto_assign(self):
existing_assignee = self.get_assignee_from_latest_opportunity()
if existing_assignee:
# If the latest opportunity is assigned to someone
# Assign the appointment to the same
self.assign_agent(existing_assignee)
return
if self._assign:
return
available_agents = _get_agents_sorted_by_asc_workload(getdate(self.scheduled_time))
for agent in available_agents:
if _check_agent_availability(agent, self.scheduled_time):
self.assign_agent(agent[0])
break
if existing_assignee := self.get_assignee_from_latest_opportunity():
# assign to whoever handles the party's latest opportunity
self.assign_agent(existing_assignee)
return
busy_agents = get_busy_agents(self.scheduled_time)
for agent in _get_agents_sorted_by_asc_workload(getdate(self.scheduled_time)):
if agent not in busy_agents:
self.assign_agent(agent)
break
def get_assignee_from_latest_opportunity(self):
if not self.party:
if not self.party or not frappe.db.exists("Lead", self.party):
return None
if not frappe.db.exists("Lead", self.party):
return None
opporutnities = frappe.get_list(
opportunities = frappe.get_all(
"Opportunity",
filters={
"party_name": self.party,
},
ignore_permissions=True,
filters={"party_name": self.party},
fields=["_assign"],
order_by="creation desc",
limit=1,
)
if not opporutnities:
return None
latest_opportunity = frappe.get_doc("Opportunity", opporutnities[0].name)
assignee = latest_opportunity._assign
if not assignee:
return None
assignee = frappe.parse_json(assignee)[0]
return assignee
def create_calendar_event(self):
if self.calendar_event:
return
appointment_event = frappe.get_doc(
{
"doctype": "Event",
"subject": " ".join(["Appointment with", self.customer_name]),
"starts_on": self.scheduled_time,
"status": "Open",
"type": "Public",
"send_reminder": frappe.db.get_single_value(
"Appointment Booking Settings", "email_reminders"
),
"event_participants": [
dict(reference_doctype=self.appointment_with, reference_docname=self.party)
],
}
)
employee = _get_employee_from_user(self._assign)
if employee:
appointment_event.append(
"event_participants", dict(reference_doctype="Employee", reference_docname=employee.name)
)
appointment_event.insert(ignore_permissions=True)
self.calendar_event = appointment_event.name
self.save(ignore_permissions=True)
def _get_verify_url(self):
verify_route = "/book_appointment/verify"
params = {"email": self.customer_email, "appointment": self.name}
return get_url(verify_route + "?" + get_signed_params(params))
assignees = opportunities and frappe.parse_json(opportunities[0]._assign or "[]")
return assignees[0] if assignees else None
def assign_agent(self, agent):
if not frappe.has_permission(doc=self, user=agent):
@@ -227,45 +309,157 @@ class Appointment(Document):
add_assignment({"doctype": self.doctype, "name": self.name, "assign_to": [agent]})
def create_calendar_event(self):
if self.calendar_event:
return
event = frappe.get_doc(
{
"doctype": "Event",
"subject": f"Appointment with {self.customer_name}",
"starts_on": self.scheduled_time,
"status": "Open",
"type": "Public",
"send_reminder": cint(get_booking_settings().email_reminders),
"event_participants": self.get_event_participants(),
}
).insert(ignore_permissions=True)
self.calendar_event = event.name
self.save(ignore_permissions=True)
def get_event_participants(self):
participants = [dict(reference_doctype=self.appointment_with, reference_docname=self.party)]
if employee := _get_employee_from_user(self._assign):
participants.append(dict(reference_doctype="Employee", reference_docname=employee.name))
return participants
def _get_verify_url(self):
key = self.generate_verification_key()
return get_url("/book_appointment/verify?" + urlencode({"key": key}))
def generate_verification_key(self):
# store only the hash; the raw key lives solely in the emailed link
key = frappe.generate_hash()
self.db_set("verification_token", sha256_hash(key), update_modified=False)
return key
def get_booking_settings():
return frappe.get_cached_doc("Appointment Booking Settings")
def is_appointment_scheduling_enabled():
return bool(cint(get_booking_settings().enable_scheduling))
def get_verification_link_expiry():
"""Verification link expiry window in minutes."""
return cint(get_booking_settings().verification_link_expiry_duration)
def count_overlapping_appointments(
scheduled_time, appointment_duration, exclude_appointment=None, for_update=False
):
"""Count non-Closed appointments whose duration window overlaps `scheduled_time`.
With `for_update`, the range stays locked until commit, serializing concurrent bookings."""
# select the rows (not COUNT) so `for_update` stays valid: PostgreSQL
# rejects `FOR UPDATE` combined with an aggregate function
appointment = frappe.qb.DocType("Appointment")
query = (
frappe.qb.from_(appointment)
.select(appointment.name)
.where(appointment.scheduled_time > add_to_date(scheduled_time, minutes=-appointment_duration))
.where(appointment.scheduled_time < add_to_date(scheduled_time, minutes=appointment_duration))
.where(appointment.status != "Closed")
)
if exclude_appointment:
query = query.where(appointment.name != exclude_appointment)
if for_update:
query = query.for_update()
return len(query.run())
def handle_expired_unverified_appointments():
"""Close or delete Unverified appointments whose verification link has expired."""
expiry = get_verification_link_expiry()
if not expiry:
return
cutoff = add_to_date(now_datetime(), minutes=-expiry)
filters = {"status": "Unverified", "creation": ("<", cutoff)}
action = get_booking_settings().action_for_expired_unverified_appointments or "Mark as Closed"
if action == "Mark as Closed":
frappe.db.set_value("Appointment", filters, "status", "Closed")
elif action == "Delete Permanently":
for name in frappe.get_all("Appointment", filters=filters, pluck="name"):
frappe.delete_doc("Appointment", name, ignore_permissions=True)
def _get_agents_sorted_by_asc_workload(date):
appointments = frappe.get_all("Appointment", fields="*")
agent_list = _get_agent_list_as_strings()
if not appointments:
return agent_list
appointment_counter = Counter(agent_list)
for appointment in appointments:
assign_data = appointment._assign
if isinstance(assign_data, str):
assign_data = assign_data.strip()
if not assign_data:
continue
assigned_to = frappe.parse_json(assign_data)
if assigned_to and (assigned_to[0] in agent_list) and getdate(appointment.scheduled_time) == date:
appointment_counter[assigned_to[0]] += 1
sorted_agent_list = appointment_counter.most_common()
sorted_agent_list.reverse()
return sorted_agent_list
# count only the given day's assignments; scheduled_time is indexed so the
# date range is resolved in SQL instead of scanning every appointment ever
workload = Counter(agent.user for agent in get_booking_settings().agent_list)
assigns = frappe.get_all(
"Appointment",
filters=[
["_assign", "is", "set"],
["scheduled_time", ">=", getdate(date)],
["scheduled_time", "<", add_to_date(getdate(date), days=1)],
],
pluck="_assign",
)
for assign in assigns:
assignees = frappe.parse_json((assign or "").strip() or "[]")
if assignees and assignees[0] in workload:
workload[assignees[0]] += 1
return [agent for agent, _workload in reversed(workload.most_common())]
def _get_agent_list_as_strings():
agent_list_as_strings = []
agent_list = frappe.get_doc("Appointment Booking Settings").agent_list
for agent in agent_list:
agent_list_as_strings.append(agent.user)
return agent_list_as_strings
def get_busy_agents(scheduled_time):
"""Agents already assigned to a non-Closed appointment overlapping `scheduled_time`."""
duration = _get_appointment_duration()
assigns = frappe.get_all(
"Appointment",
filters=[
["scheduled_time", ">", add_to_date(scheduled_time, minutes=-duration)],
["scheduled_time", "<", add_to_date(scheduled_time, minutes=duration)],
["status", "!=", "Closed"],
],
pluck="_assign",
)
return {assignee for assign in assigns for assignee in frappe.parse_json(assign or "[]")}
def _check_agent_availability(agent_email, scheduled_time):
appointemnts_at_scheduled_time = frappe.get_all("Appointment", filters={"scheduled_time": scheduled_time})
for appointment in appointemnts_at_scheduled_time:
if appointment._assign == agent_email:
return False
return True
return agent_email not in get_busy_agents(scheduled_time)
def get_booked_slot_times(from_time, to_time):
"""scheduled_times of non-Closed appointments within (from_time, to_time), for slot availability."""
return frappe.get_all(
"Appointment",
filters=[
["scheduled_time", ">", from_time],
["scheduled_time", "<", to_time],
["status", "!=", "Closed"],
],
pluck="scheduled_time",
)
def _get_appointment_duration():
return cint(get_booking_settings().appointment_duration)
def _get_employee_from_user(user):
employee_docname = frappe.db.get_value("Employee", {"user_id": user})
if employee_docname:
return frappe.get_doc("Employee", employee_docname)
return None
return frappe.get_doc("Employee", employee_docname) if employee_docname else None

View File

@@ -1,37 +1,167 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import datetime
import unittest
from unittest.mock import patch
from urllib.parse import parse_qs, urlparse
import frappe
from frappe.utils import add_to_date, getdate, now_datetime, set_request
from frappe.utils.data import sha256_hash
from erpnext.crm.doctype.appointment.appointment import (
Appointment,
_check_agent_availability,
handle_expired_unverified_appointments,
)
from erpnext.setup.doctype.holiday_list.test_holiday_list import make_holiday_list
from erpnext.tests.utils import ERPNextTestSuite
from erpnext.www.book_appointment.index import create_appointment, get_appointment_slots
from erpnext.www.book_appointment.verify import index as verify_index
LEAD_EMAIL = "test_appointment_lead@example.com"
VERIFICATION_EXPIRY_MINUTES = 30
ALL_WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
def create_test_appointment():
test_appointment = frappe.get_doc(
{
"doctype": "Appointment",
"status": "Open",
"customer_name": "Test Lead",
"customer_phone_number": "666",
"customer_skype": "test",
"customer_email": LEAD_EMAIL,
"scheduled_time": datetime.datetime.now(),
"customer_details": "Hello, Friend!",
}
)
def create_test_appointment(**kwargs):
args = {
"doctype": "Appointment",
"status": "Open",
"customer_name": "Test Lead",
"customer_phone_number": "666",
"customer_skype": "test",
"customer_email": LEAD_EMAIL,
"scheduled_time": add_to_date(now_datetime(), hours=2),
"customer_details": "Hello, Friend!",
}
args.update(kwargs)
test_appointment = frappe.get_doc(args)
test_appointment.insert()
return test_appointment
def create_lead(email, name="Existing Lead"):
frappe.db.delete("Lead", {"email_id": email})
return frappe.get_doc({"doctype": "Lead", "lead_name": name, "email_id": email}).insert(
ignore_permissions=True
)
def set_booking_setting(field, value):
frappe.db.set_single_value("Appointment Booking Settings", field, value)
def slot_on(days_from_now, hour, minute=0):
day = datetime.date.today() + datetime.timedelta(days=days_from_now)
return datetime.datetime.combine(day, datetime.time(hour, minute))
def backdate_creation(appointment_name, minutes):
frappe.db.set_value(
"Appointment",
appointment_name,
"creation",
add_to_date(now_datetime(), minutes=-minutes),
update_modified=False,
)
def get_status(appointment_name):
return frappe.db.get_value("Appointment", appointment_name, "status")
def get_assignees(appointment_name):
return frappe.parse_json(frappe.db.get_value("Appointment", appointment_name, "_assign") or "[]")
def get_todo_statuses(appointment_name):
return frappe.get_all(
"ToDo",
filters={"reference_type": "Appointment", "reference_name": appointment_name},
pluck="status",
)
def parse_verify_url(verify_url):
parsed = urlparse(verify_url)
return parsed, {key: value[0] for key, value in parse_qs(parsed.query).items()}
class TestAppointment(ERPNextTestSuite):
def setUp(self):
set_booking_setting("verification_link_expiry_duration", VERIFICATION_EXPIRY_MINUTES)
frappe.db.delete("Lead", {"email_id": LEAD_EMAIL})
self.test_appointment = create_test_appointment()
self.test_appointment.set_verified(self.test_appointment.customer_email)
def _configure_booking_settings(self, holiday_dates=None, agents=None):
holiday_list = make_holiday_list(
"_Test Appointment Holiday List",
from_date=getdate(),
to_date=add_to_date(getdate(), days=60),
holiday_dates=holiday_dates or [],
)
settings = frappe.get_doc("Appointment Booking Settings")
settings.enable_scheduling = 1
settings.enable_appointment_portal = 1
settings.appointment_duration = 30
settings.advance_booking_days = 30
settings.verification_link_expiry_duration = VERIFICATION_EXPIRY_MINUTES
settings.holiday_list = holiday_list.name
settings.set("agent_list", [])
for agent in agents or ["Administrator"]:
settings.append("agent_list", {"user": agent})
settings.set("availability_of_slots", [])
for day in ALL_WEEKDAYS:
settings.append(
"availability_of_slots", {"day_of_week": day, "from_time": "09:00:00", "to_time": "17:00:00"}
)
settings.save()
def _create_portal_appointment(self, email, days_from_now=7, time="10:00:00"):
"""Book as Guest. The verification email is mocked and kept on
``self._verification_email_mock`` for assertions."""
if not getattr(self, "_booking_settings_configured", False):
self._configure_booking_settings()
self._booking_settings_configured = True
with self.set_user("Guest"), patch.object(Appointment, "send_confirmation_email") as mock_send:
appointment = create_appointment(
date=str(datetime.date.today() + datetime.timedelta(days=days_from_now)),
time=time,
tz="UTC",
contact={"name": "Portal Visitor", "email": email, "number": "123", "skype": "", "notes": ""},
)
self._verification_email_mock = mock_send
return appointment
def _request_verification(self, appointment, verify_url=None):
"""Simulate the GET request made by clicking the emailed verification link.
The confirmation email sent on successful verification is mocked and kept
on ``self._confirmed_email_mock`` for assertions.
"""
parsed, params = parse_verify_url(verify_url or appointment._get_verify_url())
old_request = getattr(frappe.local, "request", None)
old_form_dict = frappe.local.form_dict
old_user = frappe.session.user
try:
# the real link is clicked by an anonymous visitor; set_user resets
# form_dict, so switch the user before populating the request
frappe.set_user("Guest")
set_request(method="GET", path=f"{parsed.path}?{parsed.query}")
frappe.local.form_dict = frappe._dict(params)
context = frappe._dict()
with patch.object(Appointment, "send_appointment_confirmed_email") as mock_confirmed:
verify_index.get_context(context)
self._confirmed_email_mock = mock_confirmed
return context
finally:
frappe.set_user(old_user)
frappe.local.request = old_request
frappe.local.form_dict = old_form_dict
frappe.local.flags.commit = False
def test_calendar_event_created(self):
cal_event = frappe.get_doc("Event", self.test_appointment.calendar_event)
@@ -39,3 +169,371 @@ class TestAppointment(ERPNextTestSuite):
def test_lead_linked(self):
self.assertTrue(self.test_appointment.party)
def test_desk_created_appointment_skips_email_verification(self):
"""Appointments created from the desk (created_through_portal unset) must be
linked and confirmed immediately - no verification email should be sent."""
with patch.object(Appointment, "send_confirmation_email") as mock_send:
appointment = create_test_appointment(customer_email="another_desk_lead@example.com")
mock_send.assert_not_called()
self.assertEqual(appointment.status, "Open")
self.assertTrue(appointment.party)
frappe.db.delete("Lead", {"email_id": "another_desk_lead@example.com"})
def test_portal_booking_stays_unverified_for_existing_lead(self):
"""A portal booking whose email matches an existing Lead/Customer must NOT
be auto-linked - it must stay Unverified until the email is confirmed."""
create_lead("existing_lead@example.com")
appointment = self._create_portal_appointment("existing_lead@example.com", days_from_now=5)
self._verification_email_mock.assert_called_once()
self.assertTrue(appointment.created_through_portal)
self.assertEqual(appointment.status, "Unverified")
self.assertFalse(appointment.email_verified)
self.assertFalse(appointment.party)
def test_verify_url_uses_opaque_token(self):
appointment = self._create_portal_appointment("portal_visitor@example.com")
parsed, params = parse_verify_url(appointment._get_verify_url())
# the link carries only an opaque key - no email, name or signed params
self.assertEqual(set(params), {"key"})
self.assertNotIn("email", parsed.query)
# only the hash of that key is stored on the appointment
stored = frappe.db.get_value("Appointment", appointment.name, "verification_token")
self.assertEqual(stored, sha256_hash(params["key"]))
def test_email_verification_within_expiry_window(self):
# Link used within the validity window - verification succeeds and the
# appointment gets linked, assigned and added to the calendar
on_time = self._create_portal_appointment("portal_visitor_on_time@example.com")
context = self._request_verification(on_time)
self.assertTrue(context.success)
self._confirmed_email_mock.assert_called_once()
on_time.reload()
self.assertEqual(on_time.status, "Open")
self.assertTrue(on_time.email_verified)
self.assertTrue(on_time.party)
self.assertTrue(on_time.calendar_event)
# Link used after the validity window - verification fails
late = self._create_portal_appointment("portal_visitor_late@example.com", days_from_now=10)
after_expiry = add_to_date(now_datetime(), minutes=VERIFICATION_EXPIRY_MINUTES + 1)
with patch.object(verify_index, "now_datetime", return_value=after_expiry):
context = self._request_verification(late)
self.assertFalse(context.success)
self._confirmed_email_mock.assert_not_called()
late.reload()
self.assertEqual(late.status, "Unverified")
self.assertFalse(late.email_verified)
self.assertFalse(late.party)
def test_verification_link_reused_after_success(self):
appointment = self._create_portal_appointment("portal_visitor_twice@example.com")
verify_url = appointment._get_verify_url()
context = self._request_verification(appointment, verify_url=verify_url)
self.assertTrue(context.success)
self._confirmed_email_mock.assert_called_once()
# re-clicking the link is idempotent and does not send another email
context = self._request_verification(appointment, verify_url=verify_url)
self.assertTrue(context.success)
self.assertIn("already verified", context.message)
self._confirmed_email_mock.assert_not_called()
def test_verification_link_for_deleted_appointment(self):
"""A verification link can outlive its appointment - clicking it must
render a friendly message, not crash."""
appointment = self._create_portal_appointment("portal_visitor_gone@example.com")
verify_url = appointment._get_verify_url()
frappe.delete_doc("Appointment", appointment.name, ignore_permissions=True)
context = self._request_verification(appointment, verify_url=verify_url)
self.assertFalse(context.success)
self.assertIn("book the appointment again", context.message)
def test_reschedule_syncs_calendar_event(self):
new_time = add_to_date(self.test_appointment.scheduled_time, hours=1)
self.test_appointment.scheduled_time = new_time
self.test_appointment.save()
starts_on = frappe.db.get_value("Event", self.test_appointment.calendar_event, "starts_on")
self.assertEqual(starts_on, new_time)
def test_portal_endpoint_disabled(self):
self._configure_booking_settings()
set_booking_setting("enable_appointment_portal", 0)
with self.set_user("Guest"), self.assertRaises(frappe.Redirect):
create_appointment(
date=str(datetime.date.today() + datetime.timedelta(days=3)),
time="10:00:00",
tz="UTC",
contact={
"name": "Blocked",
"email": "blocked@example.com",
"number": "1",
"skype": "",
"notes": "",
},
)
def test_booked_slot_unavailable_on_portal(self):
from frappe.utils.data import get_system_timezone
self._configure_booking_settings()
tz = get_system_timezone()
day = datetime.date.today() + datetime.timedelta(days=2)
def get_availability():
with self.set_user("Guest"):
slots = get_appointment_slots(str(day), tz)
return {slot["time"].strftime("%H:%M"): slot["availability"] for slot in slots}
booked = create_test_appointment(
customer_email="slot_taken@example.com", scheduled_time=slot_on(2, 10)
)
availability = get_availability()
self.assertFalse(availability["10:00"])
self.assertTrue(availability["13:00"])
# closing the appointment frees its slot on the portal
booked.status = "Closed"
booked.save()
self.assertTrue(get_availability()["10:00"])
# an off-grid desk appointment blocks every portal slot it overlaps
create_test_appointment(customer_email="off_grid@example.com", scheduled_time=slot_on(2, 13, 15))
availability = get_availability()
self.assertFalse(availability["13:00"])
self.assertFalse(availability["13:30"])
self.assertTrue(availability["14:00"])
def test_expired_unverified_appointments_are_closed(self):
stale = self._create_portal_appointment("portal_visitor_stale@example.com", days_from_now=8)
fresh = self._create_portal_appointment("portal_visitor_fresh@example.com", days_from_now=9)
verify_url = stale._get_verify_url()
backdate_creation(stale.name, VERIFICATION_EXPIRY_MINUTES + 15)
set_booking_setting("action_for_expired_unverified_appointments", "Mark as Closed")
handle_expired_unverified_appointments()
self.assertEqual(get_status(stale.name), "Closed")
self.assertEqual(get_status(fresh.name), "Unverified")
# Open appointments are never touched, regardless of age
self.assertEqual(get_status(self.test_appointment.name), "Open")
# clicking the link of a closed appointment renders a friendly message
context = self._request_verification(stale, verify_url=verify_url)
self.assertFalse(context.success)
self.assertIn("closed", context.message)
def test_expired_unverified_appointments_are_deleted(self):
stale = self._create_portal_appointment("portal_visitor_purged@example.com", days_from_now=8)
fresh = self._create_portal_appointment("portal_visitor_kept@example.com", days_from_now=9)
backdate_creation(stale.name, VERIFICATION_EXPIRY_MINUTES + 15)
set_booking_setting("action_for_expired_unverified_appointments", "Delete Permanently")
handle_expired_unverified_appointments()
self.assertFalse(frappe.db.exists("Appointment", stale.name))
self.assertTrue(frappe.db.exists("Appointment", fresh.name))
self.assertTrue(frappe.db.exists("Appointment", self.test_appointment.name))
def test_cleanup_skipped_when_expiry_not_configured(self):
appointment = self._create_portal_appointment("portal_visitor_no_expiry@example.com")
backdate_creation(appointment.name, 5)
set_booking_setting("verification_link_expiry_duration", 0)
handle_expired_unverified_appointments()
self.assertEqual(get_status(appointment.name), "Unverified")
def test_status_transition_rules(self):
# desk appointments can never be Unverified
with self.assertRaises(frappe.ValidationError):
create_test_appointment(customer_email="desk_unverified@example.com", status="Unverified")
# portal appointments cannot be opened manually before verification
unverified = self._create_portal_appointment("manual_open@example.com")
unverified.status = "Open"
with self.assertRaises(frappe.ValidationError):
unverified.save(ignore_permissions=True)
# verified appointments cannot be reverted to Unverified
verified = self._create_portal_appointment("revert_unverified@example.com", days_from_now=8)
self._request_verification(verified)
verified.reload()
verified.status = "Unverified"
with self.assertRaises(frappe.ValidationError):
verified.save(ignore_permissions=True)
# both desk and verified portal appointments can be closed and reopened
for appointment in (self.test_appointment, verified):
appointment.reload()
appointment.status = "Closed"
appointment.save(ignore_permissions=True)
appointment.status = "Open"
appointment.save(ignore_permissions=True)
self.assertEqual(appointment.status, "Open")
def test_agent_auto_assignment(self):
agent_email = "appointment_agent@example.com"
if not frappe.db.exists("User", agent_email):
frappe.get_doc(
{"doctype": "User", "email": agent_email, "first_name": "Appointment Agent"}
).insert(ignore_permissions=True)
self._configure_booking_settings(agents=["Administrator", agent_email])
first = create_test_appointment(
customer_email="assigned_one@example.com", scheduled_time=slot_on(2, 11)
)
second = create_test_appointment(
customer_email="assigned_two@example.com", scheduled_time=slot_on(2, 11)
)
# both appointments in the same slot get an agent, and never the same one
self.assertTrue(get_assignees(first.name))
self.assertTrue(get_assignees(second.name))
self.assertNotEqual(get_assignees(first.name), get_assignees(second.name))
# closing an assigned appointment closes its ToDo without re-assigning
first.reload()
first.status = "Closed"
first.save()
self.assertTrue(get_todo_statuses(first.name))
self.assertTrue(all(status == "Closed" for status in get_todo_statuses(first.name)))
# reopening brings the ToDos back
first.status = "Open"
first.save()
self.assertTrue(all(status == "Open" for status in get_todo_statuses(first.name)))
def test_agent_busy_for_the_whole_appointment_duration(self):
self._configure_booking_settings()
slot = slot_on(3, 11)
appointment = create_test_appointment(customer_email="busy_agent@example.com", scheduled_time=slot)
assignee = get_assignees(appointment.name)[0]
# busy anywhere inside the 30-minute appointment window, free right after it
self.assertFalse(_check_agent_availability(assignee, slot))
self.assertFalse(_check_agent_availability(assignee, slot + datetime.timedelta(minutes=15)))
self.assertTrue(_check_agent_availability(assignee, slot + datetime.timedelta(minutes=30)))
def test_closed_appointment_closes_calendar_event(self):
self.test_appointment.status = "Closed"
self.test_appointment.save()
event_status = frappe.db.get_value("Event", self.test_appointment.calendar_event, "status")
self.assertEqual(event_status, "Closed")
# reopening the appointment reopens the calendar event
self.test_appointment.status = "Open"
self.test_appointment.save()
event_status = frappe.db.get_value("Event", self.test_appointment.calendar_event, "status")
self.assertEqual(event_status, "Open")
def test_deleting_appointment_deletes_calendar_event(self):
event = self.test_appointment.calendar_event
self.assertTrue(frappe.db.exists("Event", event))
frappe.delete_doc("Appointment", self.test_appointment.name)
self.assertFalse(frappe.db.exists("Event", event))
def test_backdated_appointment_is_rejected(self):
with self.assertRaises(frappe.ValidationError):
create_test_appointment(
customer_email="backdated@example.com",
scheduled_time=add_to_date(now_datetime(), hours=-1),
)
def test_booking_beyond_advance_window_is_rejected(self):
self._configure_booking_settings()
set_booking_setting("advance_booking_days", 7)
# within the advance booking window - allowed
within = create_test_appointment(
customer_email="advance_within@example.com", scheduled_time=slot_on(5, 10)
)
self.assertTrue(frappe.db.exists("Appointment", within.name))
# beyond the advance booking window - rejected
with self.assertRaises(frappe.ValidationError):
create_test_appointment(
customer_email="advance_beyond@example.com", scheduled_time=slot_on(8, 10)
)
def test_appointment_on_holiday_is_rejected(self):
holiday = add_to_date(getdate(), days=3)
self._configure_booking_settings(
holiday_dates=[{"holiday_date": holiday, "description": "Test Holiday"}]
)
with self.assertRaises(frappe.ValidationError):
create_test_appointment(customer_email="on_holiday@example.com", scheduled_time=slot_on(3, 10))
# the day after the holiday is bookable
after_holiday = create_test_appointment(
customer_email="after_holiday@example.com", scheduled_time=slot_on(4, 10)
)
self.assertTrue(frappe.db.exists("Appointment", after_holiday.name))
def test_appointment_outside_slot_timing_is_rejected(self):
self._configure_booking_settings()
# before the slot opens
with self.assertRaises(frappe.ValidationError):
create_test_appointment(customer_email="before_opening@example.com", scheduled_time=slot_on(2, 8))
# starts within the slot but would end after it closes
with self.assertRaises(frappe.ValidationError):
create_test_appointment(
customer_email="past_closing@example.com", scheduled_time=slot_on(2, 16, 45)
)
# within the slot timings
within = create_test_appointment(
customer_email="within_slot@example.com", scheduled_time=slot_on(2, 10)
)
self.assertTrue(frappe.db.exists("Appointment", within.name))
def test_overlapping_time_slot_capacity(self):
set_booking_setting("number_of_agents", 1)
set_booking_setting("appointment_duration", 30)
slot = slot_on(1, 10)
first = create_test_appointment(customer_email="slot_first@example.com", scheduled_time=slot)
# a booking starting inside the first appointment's duration is rejected
with self.assertRaises(frappe.ValidationError):
create_test_appointment(
customer_email="slot_overlap@example.com",
scheduled_time=slot + datetime.timedelta(minutes=15),
)
# rescheduling must not count the appointment's own booked slot
first.scheduled_time = slot + datetime.timedelta(minutes=10)
first.save()
# a booking starting exactly when the rescheduled one ends is allowed
adjacent = create_test_appointment(
customer_email="slot_adjacent@example.com",
scheduled_time=slot + datetime.timedelta(minutes=40),
)
self.assertTrue(frappe.db.exists("Appointment", adjacent.name))
# a closed (cancelled) appointment frees its slot
first.status = "Closed"
first.save()
after_cancellation = create_test_appointment(
customer_email="after_cancellation@example.com", scheduled_time=slot
)
self.assertTrue(frappe.db.exists("Appointment", after_cancellation.name))

View File

@@ -1,48 +1,56 @@
{
"actions": [],
"allow_bulk_edit": 1,
"creation": "2019-08-27 10:56:48.309824",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"enable_scheduling",
"agent_detail_section",
"availability_of_slots",
"number_of_agents",
"agent_list",
"holiday_list",
"appointment_details_section",
"appointment_duration",
"email_reminders",
"column_break_ehiq",
"agent_list",
"number_of_agents",
"agent_detail_section",
"enable_scheduling",
"availability_of_slots",
"section_break_bkln",
"column_break_alwa",
"advance_booking_days",
"column_break_bspp",
"holiday_list",
"success_details",
"success_redirect_url"
"enable_appointment_portal",
"verification_link_expiry_duration",
"column_break_fovk",
"success_redirect_url",
"action_for_expired_unverified_appointments"
],
"fields": [
{
"depends_on": "eval:doc.enable_scheduling === 1;",
"fieldname": "availability_of_slots",
"fieldtype": "Table",
"label": "Availability Of Slots",
"options": "Appointment Booking Slots",
"reqd": 1
"mandatory_depends_on": "eval:doc.enable_scheduling === 1;",
"options": "Appointment Booking Slots"
},
{
"default": "1",
"fieldname": "number_of_agents",
"fieldtype": "Int",
"hidden": 1,
"in_list_view": 1,
"label": "Number of Concurrent Appointments",
"read_only": 1,
"reqd": 1
"read_only": 1
},
{
"depends_on": "eval:doc.enable_scheduling === 1;",
"fieldname": "holiday_list",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Holiday List",
"options": "Holiday List",
"reqd": 1
"mandatory_depends_on": "eval:doc.enable_scheduling === 1;",
"options": "Holiday List"
},
{
"default": "60",
@@ -60,29 +68,31 @@
},
{
"default": "7",
"depends_on": "eval:doc.enable_scheduling === 1;",
"fieldname": "advance_booking_days",
"fieldtype": "Int",
"label": "Number of days appointments can be booked in advance",
"reqd": 1
"mandatory_depends_on": "eval:doc.enable_scheduling === 1;"
},
{
"fieldname": "agent_list",
"fieldtype": "Table MultiSelect",
"label": "Agents",
"options": "Assignment Rule User",
"reqd": 1
"mandatory_depends_on": "eval:doc.enable_scheduling === 1;",
"options": "Assignment Rule User"
},
{
"default": "0",
"fieldname": "enable_scheduling",
"fieldtype": "Check",
"label": "Enable Appointment Scheduling",
"reqd": 1
"mandatory_depends_on": "eval:doc.enable_appointment_portal === 1;"
},
{
"fieldname": "agent_detail_section",
"fieldtype": "Section Break",
"label": "Agent Details"
"hide_border": 1,
"label": "Appointment Scheduling"
},
{
"fieldname": "appointment_details_section",
@@ -92,20 +102,68 @@
{
"fieldname": "success_details",
"fieldtype": "Section Break",
"label": "Success Settings"
"label": "Appointment Booking Portal Settings"
},
{
"description": "Leave blank for home.\nThis is relative to site URL, for example \"about\" will redirect to \"https://yoursitename.com/about\"",
"fieldname": "success_redirect_url",
"fieldtype": "Data",
"label": "Success Redirect URL"
"label": "Success Redirect URL",
"permlevel": 1
},
{
"default": "30",
"depends_on": "eval: doc.enable_scheduling === 1;",
"description": "In Minutes (min: 15 mins, max: 60 mins)",
"fieldname": "verification_link_expiry_duration",
"fieldtype": "Int",
"label": "Verification Link Expiry Duration",
"mandatory_depends_on": "eval:doc.enable_appointment_portal === 1;",
"max_value": 60.0,
"min_value": 15.0,
"non_negative": 1,
"permlevel": 1
},
{
"fieldname": "column_break_ehiq",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "enable_appointment_portal",
"fieldtype": "Check",
"label": "Enable Appointment Booking Through Portal",
"permlevel": 1
},
{
"fieldname": "column_break_fovk",
"fieldtype": "Column Break"
},
{
"default": "Mark as Closed",
"fieldname": "action_for_expired_unverified_appointments",
"fieldtype": "Select",
"label": "Action for Expired Unverified Appointments",
"options": "Mark as Closed\nDelete Permanently",
"permlevel": 1
},
{
"fieldname": "section_break_bkln",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_alwa",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_bspp",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"hide_toolbar": 0,
"issingle": 1,
"links": [],
"modified": "2026-03-16 13:28:21.198138",
"modified": "2026-07-20 00:11:18.996384",
"modified_by": "Administrator",
"module": "CRM",
"name": "Appointment Booking Settings",
@@ -139,6 +197,15 @@
"role": "Sales Manager",
"share": 1,
"write": 1
},
{
"email": 1,
"permlevel": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"quick_entry": 1,

View File

@@ -3,11 +3,11 @@
import datetime
import typing
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import getdate
class AppointmentBookingSettings(Document):
@@ -24,33 +24,43 @@ class AppointmentBookingSettings(Document):
AppointmentBookingSlots,
)
action_for_expired_unverified_appointments: DF.Literal["Mark as Closed", "Delete Permanently"]
advance_booking_days: DF.Int
agent_list: DF.TableMultiSelect[AssignmentRuleUser]
appointment_duration: DF.Int
availability_of_slots: DF.Table[AppointmentBookingSlots]
email_reminders: DF.Check
enable_appointment_portal: DF.Check
enable_scheduling: DF.Check
holiday_list: DF.Link
holiday_list: DF.Link | None
number_of_agents: DF.Int
success_redirect_url: DF.Data | None
verification_link_expiry_duration: DF.Int
# end: auto-generated types
agent_list: typing.ClassVar[list] = [] # Hack
min_date = "01/01/1970 "
format_string = "%d/%m/%Y %H:%M:%S"
def validate(self):
self.validate_availability_of_slots()
def save(self):
self.number_of_agents = len(self.agent_list)
super().save()
self.validate_appointment_scheduling()
self.validate_portal_booking()
def validate_appointment_scheduling(self):
if not self.enable_scheduling:
return
self.validate_availability_of_slots()
self.validate_holiday_list()
self.validate_advance_booking_days()
def validate_availability_of_slots(self):
if not self.availability_of_slots:
frappe.throw(
_("Please fill up the Availability of Slots table to enable Appointment Scheduling.")
)
format_string = "%Y-%m-%d %H:%M:%S"
for record in self.availability_of_slots:
from_time = datetime.datetime.strptime(self.min_date + record.from_time, self.format_string)
to_time = datetime.datetime.strptime(self.min_date + record.to_time, self.format_string)
to_time - from_time
from_time = datetime.datetime.strptime(f"1970-01-01 {record.from_time}", format_string)
to_time = datetime.datetime.strptime(f"1970-01-01 {record.to_time}", format_string)
self.validate_from_and_to_time(from_time, to_time, record)
self.duration_is_divisible(from_time, to_time)
@@ -65,3 +75,38 @@ class AppointmentBookingSettings(Document):
timedelta = to_time - from_time
if timedelta.total_seconds() % (self.appointment_duration * 60):
frappe.throw(_("The difference between from time and To Time must be a multiple of Appointment"))
def validate_holiday_list(self):
if not self.holiday_list:
frappe.throw(_("Please select a Holiday List to enable Appointment Scheduling."))
hl_from_date, hl_to_date = frappe.get_cached_value(
"Holiday List", self.holiday_list, ["from_date", "to_date"]
)
now = getdate()
if not (now >= hl_from_date and now <= hl_to_date):
frappe.throw(_("Holiday List - {0} is not valid for current date.").format(self.holiday_list))
def validate_advance_booking_days(self):
if not self.advance_booking_days:
frappe.throw(_("Advance Booking Days is mandatory for Appointment Scheduling."))
def validate_portal_booking(self):
if not self.enable_appointment_portal:
return
if not self.enable_scheduling:
frappe.throw(
_("Appointment Scheduling needs to be enabled for Appointment Booking through portal.")
)
self.validate_link_expiry_duration()
def validate_link_expiry_duration(self):
if (
not self.verification_link_expiry_duration
or self.verification_link_expiry_duration > 60
or self.verification_link_expiry_duration < 15
):
frappe.throw(_("'Verification Link Expiry Duration' must be between 15 to 60 minutes."))

View File

@@ -1,10 +1,125 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import unittest
import datetime
import frappe
from frappe.utils import add_to_date, getdate
from erpnext.setup.doctype.holiday_list.test_holiday_list import make_holiday_list
from erpnext.tests.utils import ERPNextTestSuite
class TestAppointmentBookingSettings(ERPNextTestSuite):
pass
def assert_invalid(self, settings):
with self.assertRaises(frappe.ValidationError):
settings.save()
def make_settings(self, appointment_duration=30):
doc = frappe.new_doc("Appointment Booking Settings")
doc.appointment_duration = appointment_duration
return doc
def dt(self, hms):
# the controller parses times against a fixed epoch date
return datetime.datetime.strptime("1970-01-01 " + hms, "%Y-%m-%d %H:%M:%S")
def get_valid_scheduling_settings(self):
holiday_list = make_holiday_list(
"_Test Booking Settings Holiday List",
from_date=getdate(),
to_date=add_to_date(getdate(), days=30),
holiday_dates=[],
)
settings = frappe.get_doc("Appointment Booking Settings")
settings.enable_scheduling = 1
settings.appointment_duration = 30
settings.advance_booking_days = 7
settings.verification_link_expiry_duration = 30
settings.holiday_list = holiday_list.name
settings.set("agent_list", [])
settings.append("agent_list", {"user": "Administrator"})
settings.set("availability_of_slots", [])
settings.append(
"availability_of_slots",
{"day_of_week": "Monday", "from_time": "09:00:00", "to_time": "17:00:00"},
)
return settings
def test_from_time_must_precede_to_time(self):
doc = self.make_settings()
record = frappe._dict(day_of_week="Monday")
self.assertRaises(
frappe.ValidationError,
doc.validate_from_and_to_time,
self.dt("18:00:00"),
self.dt("09:00:00"),
record,
)
doc.validate_from_and_to_time(self.dt("09:00:00"), self.dt("18:00:00"), record) # valid order
def test_slot_length_must_be_a_multiple_of_the_duration(self):
doc = self.make_settings(appointment_duration=30)
# 60 minutes is two 30-minute appointments -> fine
doc.duration_is_divisible(self.dt("09:00:00"), self.dt("10:00:00"))
# 45 minutes leaves a partial appointment -> rejected
self.assertRaises(
frappe.ValidationError, doc.duration_is_divisible, self.dt("09:00:00"), self.dt("09:45:00")
)
def test_scheduling_requires_slots(self):
settings = self.get_valid_scheduling_settings()
settings.set("availability_of_slots", [])
self.assert_invalid(settings)
def test_validate_checks_every_slot(self):
settings = self.get_valid_scheduling_settings()
settings.append(
"availability_of_slots",
{"day_of_week": "Tuesday", "from_time": "09:00:00", "to_time": "09:45:00"},
)
self.assert_invalid(settings)
def test_scheduling_requires_holiday_list_covering_today(self):
settings = self.get_valid_scheduling_settings()
settings.holiday_list = None
self.assert_invalid(settings)
expired_list = make_holiday_list(
"_Test Booking Settings Expired Holiday List",
from_date=add_to_date(getdate(), days=-60),
to_date=add_to_date(getdate(), days=-30),
holiday_dates=[],
)
settings.holiday_list = expired_list.name
self.assert_invalid(settings)
def test_scheduling_requires_advance_booking_days(self):
settings = self.get_valid_scheduling_settings()
settings.advance_booking_days = 0
self.assert_invalid(settings)
def test_portal_requires_scheduling(self):
settings = frappe.get_doc("Appointment Booking Settings")
settings.enable_scheduling = 0
settings.enable_appointment_portal = 1
self.assert_invalid(settings)
def test_portal_expiry_duration_bounds(self):
settings = self.get_valid_scheduling_settings()
settings.enable_appointment_portal = 1
settings.verification_link_expiry_duration = 5
self.assert_invalid(settings)
def test_number_of_agents_derived_from_agent_list(self):
settings = self.get_valid_scheduling_settings()
settings.number_of_agents = 99
settings.save()
self.assertEqual(frappe.db.get_single_value("Appointment Booking Settings", "number_of_agents"), 1)

View File

@@ -447,6 +447,7 @@ scheduler_events = {
],
"hourly_long": [],
"hourly_maintenance": [
"erpnext.crm.doctype.appointment.appointment.handle_expired_unverified_appointments",
"erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries",
"erpnext.utilities.bulk_transaction.retry",
"erpnext.projects.doctype.project.project.collect_project_status",

File diff suppressed because it is too large Load Diff

View File

@@ -121,7 +121,7 @@
"in_list_view": 1,
"label": "% Completed",
"no_copy": 1,
"read_only": 1
"read_only_depends_on": "eval:doc.percent_complete_method != 'Manual'"
},
{
"fieldname": "column_break_5",
@@ -484,7 +484,7 @@
"index_web_pages_for_search": 1,
"links": [],
"max_attachments": 4,
"modified": "2026-07-14 14:32:11.328347",
"modified": "2026-07-21 11:23:22.000000",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project",

View File

@@ -222,6 +222,8 @@ class Project(Document):
if self.percent_complete_method == "Manual":
if self.status == "Completed":
self.percent_complete = 100
elif flt(self.percent_complete) < 0 or flt(self.percent_complete) > 100:
frappe.throw(_("% Complete must be between 0 and 100"))
return
total = frappe.db.count("Task", dict(project=self.name))

View File

@@ -244,6 +244,61 @@ class TestProject(ERPNextTestSuite):
project.save()
self.assertEqual(project.status, "Completed")
def _project_with_tasks(self, method, count):
name = f"_Test PercentComplete {frappe.generate_hash(length=8)}"
project = frappe.get_doc(
{
"doctype": "Project",
"project_name": name,
"status": "Open",
"percent_complete_method": method,
"company": "_Test Company",
"expected_start_date": nowdate(),
}
).insert()
task_names = []
for i in range(count):
task = frappe.get_doc(
{
"doctype": "Task",
"subject": f"{name} Task {i}",
"project": project.name,
"status": "Open",
"exp_start_date": nowdate(),
"exp_end_date": nowdate(),
}
).insert()
task_names.append(task.name)
return project, task_names
def test_percent_complete_manual(self):
project, tasks = self._project_with_tasks("Manual", 2)
# manual value is preserved on save, even with linked tasks
project.percent_complete = 42
project.save()
self.assertEqual(project.percent_complete, 42)
# task updates do not overwrite the manual value
frappe.db.set_value("Task", tasks[0], "status", "Completed")
project.update_percent_complete()
self.assertEqual(project.percent_complete, 42)
# out-of-range values are rejected
project.percent_complete = 150
self.assertRaises(frappe.ValidationError, project.save)
project.reload()
project.percent_complete = -10
self.assertRaises(frappe.ValidationError, project.save)
project.reload()
# Completed status forces 100 regardless of the manual value
project.percent_complete = 42
project.status = "Completed"
project.save()
self.assertEqual(project.percent_complete, 100)
def _create_portal_user(self, email):
"""A user with no Project-related role, so read access can only come from
control_access_for_project_users() sharing the doc with them."""

View File

@@ -116,31 +116,37 @@ def get_data(filters, group_fieldname=None):
def group_by(data, fieldname):
groups = {row.get(fieldname) for row in data}
grouped_data = []
for group in sorted(groups):
group_row = {
fieldname: group,
"hours": sum(row.get("hours") for row in data if row.get(fieldname) == group),
"billing_hours": sum(row.get("billing_hours") for row in data if row.get(fieldname) == group),
"billing_amount": sum(row.get("billing_amount") for row in data if row.get(fieldname) == group),
"indent": 0,
"is_group": 1,
}
if fieldname == "employee":
group_row["employee_name"] = next(
row.get("employee_name") for row in data if row.get(fieldname) == group
)
groups = {}
for row in data:
groups.setdefault(row.get(fieldname), []).append(row)
grouped_data.append(group_row)
for row in data:
if row.get(fieldname) != group:
continue
grouped_data = []
for group in sorted(groups, key=lambda g: (g is None, g)):
hours = billing_hours = billing_amount = 0
child_rows = []
for row in groups[group]:
hours += row.get("hours") or 0
billing_hours += row.get("billing_hours") or 0
billing_amount += row.get("billing_amount") or 0
_row = row.copy()
_row[fieldname] = None
_row["indent"] = 1
_row["is_group"] = 0
grouped_data.append(_row)
child_rows.append(_row)
group_row = {
fieldname: group,
"hours": hours,
"billing_hours": billing_hours,
"billing_amount": billing_amount,
"indent": 0,
"is_group": 1,
}
if fieldname == "employee":
group_row["employee_name"] = groups[group][0].get("employee_name")
grouped_data.append(group_row)
grouped_data.extend(child_rows)
return grouped_data

View File

@@ -11,6 +11,36 @@ erpnext.stock.StockController = class StockController extends frappe.ui.form.Con
}
}
onload_post_render() {
this.set_route_options_for_new_doc();
}
set_route_options_for_new_doc() {
// While creating a Batch or Serial and Batch Bundle from the link
// field, copy details from the line item to the new form
if (!this.frm.fields_dict.items) return;
let batch_no_field = this.frm.get_docfield("items", "batch_no");
if (batch_no_field) {
batch_no_field.get_route_options_for_new_doc = (row) => {
return {
item: row.doc.item_code,
};
};
}
let sbb_field = this.frm.get_docfield("items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
return {
item_code: row.doc.item_code,
warehouse: row.doc.warehouse || row.doc.s_warehouse || row.doc.t_warehouse,
voucher_type: this.frm.doc.doctype,
};
};
}
}
barcode(doc, cdt, cdn) {
let row = locals[cdt][cdn];
if (row.barcode) {

View File

@@ -518,7 +518,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
return;
}
schedules.forEach((schedule) => (schedule.__checked = 1));
schedules.forEach((schedule) => {
schedule.__checked = 1;
schedule.currency = frm.doc.currency;
});
const dialog = new frappe.ui.Dialog({
title: __("Select Payment Schedule"),
@@ -552,10 +555,19 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
in_list_view: 1,
read_only: 1,
},
{
fieldtype: "Link",
fieldname: "currency",
label: __("Currency"),
options: "Currency",
hidden: 1,
read_only: 1,
},
{
fieldtype: "Currency",
fieldname: "payment_amount",
label: __("Amount"),
options: "currency",
in_list_view: 1,
read_only: 1,
},
@@ -637,34 +649,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
erpnext.toggle_serial_batch_fields(this.frm);
}
set_route_options_for_new_doc() {
// While creating the batch from the link field, copy item from line item to batch form
if (this.frm.fields_dict["items"].grid.get_field("batch_no")) {
let batch_no_field = this.frm.get_docfield("items", "batch_no");
if (batch_no_field) {
batch_no_field.get_route_options_for_new_doc = function (row) {
return {
item: row.doc.item_code,
};
};
}
}
// While creating the SABB from the link field, copy item, doctype from line item to SABB form
if (this.frm.fields_dict["items"].grid.get_field("serial_and_batch_bundle")) {
let sbb_field = this.frm.get_docfield("items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
return {
item_code: row.doc.item_code,
voucher_type: this.frm.doc.doctype,
};
};
}
}
}
scan_barcode() {
frappe.flags.dialog_set = false;
this.barcode_scanner.process_scan();

View File

@@ -59,6 +59,11 @@ def get_data(
"reserved_qty": ["!=", 0],
"reserved_qty_for_production": ["!=", 0],
"reserved_qty_for_sub_contract": ["!=", 0],
"reserved_qty_for_production_plan": ["!=", 0],
"reserved_stock": ["!=", 0],
"ordered_qty": ["!=", 0],
"indented_qty": ["!=", 0],
"planned_qty": ["!=", 0],
"actual_qty": ["!=", 0],
},
filters=filters,

View File

@@ -3,17 +3,17 @@
frappe.ui.form.on("Bin", {
refresh(frm) {
frm.trigger("recalculate_bin_quantity");
frm.trigger("recalculate_values");
},
recalculate_bin_quantity(frm) {
frm.add_custom_button(__("Recalculate Bin Qty"), () => {
recalculate_values(frm) {
frm.add_custom_button(__("Recalculate Values"), () => {
frappe.call({
method: "recalculate_qty",
method: "recalculate_values",
freeze: true,
doc: frm.doc,
callback: function (r) {
frappe.show_alert(__("Bin Qty Recalculated"), 2);
frappe.show_alert(__("Bin Values Recalculated"), 2);
},
});
});

View File

@@ -37,7 +37,7 @@ class Bin(Document):
# end: auto-generated types
@frappe.whitelist()
def recalculate_qty(self):
def recalculate_values(self):
from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production
from erpnext.stock.stock_balance import (
get_indented_qty,
@@ -46,7 +46,19 @@ class Bin(Document):
get_reserved_qty,
)
self.actual_qty = get_actual_qty(self.item_code, self.warehouse)
last_sle = get_last_sle_values(self.item_code, self.warehouse)
self.actual_qty = last_sle.qty_after_transaction
self.valuation_rate = last_sle.valuation_rate
self.stock_value = last_sle.stock_value
from erpnext.stock.utils import get_valuation_method
if get_valuation_method(self.item_code) == "Standard Cost":
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import get_item_standard_rate
self.stock_value = flt(self.actual_qty) * flt(
get_item_standard_rate(self.item_code, self.company)
)
self.planned_qty = get_planned_qty(self.item_code, self.warehouse)
self.indented_qty = get_indented_qty(self.item_code, self.warehouse)
self.ordered_qty = get_ordered_qty(self.item_code, self.warehouse)
@@ -302,20 +314,23 @@ def update_qty(bin_name, args):
def get_actual_qty(item_code, warehouse):
return get_last_sle_values(item_code, warehouse).qty_after_transaction
def get_last_sle_values(item_code, warehouse):
sle = frappe.qb.DocType("Stock Ledger Entry")
last_sle_qty = (
last_sle = (
frappe.qb.from_(sle)
.select(sle.qty_after_transaction)
.select(sle.qty_after_transaction, sle.valuation_rate, sle.stock_value)
.where((sle.item_code == item_code) & (sle.warehouse == warehouse) & (sle.is_cancelled == 0))
.orderby(sle.posting_datetime, order=Order.desc)
.orderby(sle.creation, order=Order.desc)
.limit(1)
.run()
.run(as_dict=True)
)
actual_qty = 0.0
if last_sle_qty:
actual_qty = last_sle_qty[0][0]
if last_sle:
return last_sle[0]
return actual_qty
return frappe._dict(qty_after_transaction=0.0, valuation_rate=0.0, stock_value=0.0)

View File

@@ -26,6 +26,35 @@ class TestBin(ERPNextTestSuite):
bin = _create_bin(item_code, warehouse)
self.assertEqual(bin.item_code, item_code)
def test_recalculate_values(self):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
item_code = make_item().name
warehouse = "_Test Warehouse - _TC"
make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100)
bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
bin.db_set({"actual_qty": 0, "valuation_rate": 0, "stock_value": 0})
bin.reload()
bin.recalculate_values()
self.assertEqual(bin.actual_qty, 10)
self.assertEqual(bin.valuation_rate, 100)
self.assertEqual(bin.stock_value, 1000)
def test_recalculate_values_without_sle(self):
item_code = make_item().name
warehouse = "_Test Warehouse - _TC"
bin = _create_bin(item_code, warehouse)
bin.db_set({"actual_qty": 5, "valuation_rate": 50, "stock_value": 250})
bin.reload()
bin.recalculate_values()
self.assertEqual(bin.actual_qty, 0)
self.assertEqual(bin.valuation_rate, 0)
self.assertEqual(bin.stock_value, 0)
def test_index_exists(self):
indexes = frappe.db.sql("show index from tabBin where Non_unique = 0", as_dict=1)
if not any(index.get("Key_name") == "unique_item_warehouse" for index in indexes):

View File

@@ -569,8 +569,6 @@ frappe.ui.form.on("Stock Entry", {
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
}
frm.events.set_route_options_for_new_doc(frm);
frm.set_df_property(
"items",
"cannot_add_rows",
@@ -583,28 +581,6 @@ frappe.ui.form.on("Stock Entry", {
);
},
set_route_options_for_new_doc(frm) {
let batch_no_field = frm.get_docfield("items", "batch_no");
if (batch_no_field) {
batch_no_field.get_route_options_for_new_doc = function (row) {
return {
item: row.doc.item_code,
};
};
}
let sbb_field = frm.get_docfield("items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
return {
item_code: row.doc.item_code,
voucher_type: frm.doc.doctype,
warehouse: row.doc.s_warehouse || row.doc.t_warehouse,
};
};
}
},
get_items_from_transit_entry: function (frm) {
if (frm.doc.docstatus === 0 && !frm.doc.subcontracting_inward_order) {
frm.add_custom_button(
@@ -1312,6 +1288,7 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
}
onload_post_render() {
super.onload_post_render();
var me = this;
if (me.frm.doc.__islocal && me.frm.doc.company && !me.frm.doc.amended_from) {
me.company();

View File

@@ -3151,7 +3151,7 @@ class StockEntry(StockController, SubcontractingInwardController):
self.process_loss_qty = flt(
(flt(self.fg_completed_qty) * flt(self.process_loss_percentage)) / 100
)
elif self.process_loss_qty and not self.process_loss_percentage:
elif self.process_loss_qty and self.fg_completed_qty:
self.process_loss_percentage = flt(
(flt(self.process_loss_qty) / flt(self.fg_completed_qty)) * 100
)

View File

@@ -2943,6 +2943,28 @@ class TestStockEntry(ERPNextTestSuite):
self.assertEqual(se.items[2].qty, 4.5)
self.assertEqual(se.items[2].amount, 5)
def test_process_loss_percentage_resyncs_from_qty(self):
# changing fg qty recomputes process_loss_qty
se = frappe.new_doc("Stock Entry")
se.purpose = "Manufacture"
se.fg_completed_qty = 200
se.process_loss_qty = 100
se.process_loss_percentage = 80
se.set_process_loss_qty()
self.assertEqual(se.process_loss_percentage, 50)
def test_process_loss_qty_derived_from_percentage_when_qty_blank(self):
se = frappe.new_doc("Stock Entry")
se.purpose = "Manufacture"
se.fg_completed_qty = 200
se.process_loss_percentage = 25
se.set_process_loss_qty()
self.assertEqual(se.process_loss_qty, 50)
def make_serialized_item(self, **args):
args = frappe._dict(args)

View File

@@ -46,17 +46,6 @@ frappe.ui.form.on("Stock Reconciliation", {
};
});
let sbb_field = frm.get_docfield("items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
return {
item_code: row.doc.item_code,
warehouse: row.doc.warehouse,
voucher_type: frm.doc.doctype,
};
};
}
if (frm.doc.company) {
erpnext.queries.setup_queries(frm, "Warehouse", function () {
return erpnext.queries.warehouse(frm.doc);

View File

@@ -48,11 +48,19 @@ frappe.pages["stock-balance"].on_page_load = function (wrapper) {
sort_by: "projected_qty",
sort_order: "asc",
options: [
{ fieldname: "projected_qty", label: __("Projected qty") },
{ fieldname: "reserved_qty", label: __("Reserved for sale") },
{ fieldname: "reserved_qty_for_production", label: __("Reserved for manufacturing") },
{ fieldname: "reserved_qty_for_sub_contract", label: __("Reserved for sub contracting") },
{ fieldname: "actual_qty", label: __("Actual qty in stock") },
{ fieldname: "projected_qty", label: __("Projected Qty") },
{ fieldname: "reserved_qty", label: __("Reserved Qty") },
{ fieldname: "reserved_qty_for_production", label: __("Reserved Qty for Production") },
{ fieldname: "reserved_qty_for_sub_contract", label: __("Reserved Qty for Subcontract") },
{
fieldname: "reserved_qty_for_production_plan",
label: __("Reserved Qty for Production Plan"),
},
{ fieldname: "reserved_stock", label: __("Reserved Stock") },
{ fieldname: "ordered_qty", label: __("Ordered Qty") },
{ fieldname: "indented_qty", label: __("Requested Qty") },
{ fieldname: "planned_qty", label: __("Planned Qty") },
{ fieldname: "actual_qty", label: __("Actual Qty") },
],
},
change: function (sort_by, sort_order) {

View File

@@ -306,6 +306,7 @@ class FIFOSlots:
# prepare single sle voucher detail lookup
self.prepare_stock_reco_voucher_wise_count()
self.float_precision = get_float_precision()
if stock_ledger_entries is None:
# streaming path: nested queries invalidate the streaming cursor below,
@@ -370,6 +371,7 @@ class FIFOSlots:
row, fifo_queue, transferred_item_key, serial_nos, batch_nos, from_end
)
self._revalue_stock_reconciliation_slots(row, fifo_queue, batch_nos)
self._update_balances(row, key)
self._trim_serial_fifo_queue(row, key, fifo_queue)
@@ -393,6 +395,36 @@ class FIFOSlots:
# Stock reconciliation stores the final balance; FIFO needs the movement delta.
row.actual_qty = flt(row.qty_after_transaction) - flt(prev_balance_qty)
def _revalue_stock_reconciliation_slots(self, row: dict, fifo_queue: list, batch_nos: list) -> None:
if row.voucher_type != "Stock Reconciliation" or row.has_serial_no:
return
if row.has_batch_no:
if flt(row.actual_qty) > 0:
self._revalue_reconciled_batch_slots(fifo_queue, batch_nos)
return
for slot in fifo_queue:
if is_qty_slot(slot):
slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * flt(row.valuation_rate))
def _revalue_reconciled_batch_slots(self, fifo_queue: list, batch_nos: list) -> None:
for batch_no, _use_batchwise_valuation, qty, stock_value_difference in batch_nos:
if not flt(qty):
continue
slots = [
slot
for slot in fifo_queue
if is_batch_slot(slot) and slot[BATCH_SLOT_BATCH_INDEX] == batch_no
]
if flt(sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots) - flt(qty), self.float_precision):
continue
rate = flt(stock_value_difference) / flt(qty)
for slot in slots:
slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX] * rate)
def _get_serial_and_batch_nos(
self, row: dict, bundle_wise_serial_nos: dict, bundle_wise_batch_nos: dict
) -> tuple[list, list]:

View File

@@ -379,6 +379,243 @@ class TestStockAgeing(ERPNextTestSuite):
self.assertEqual(queue, [[60.0, "2025-11-30", 60.0], [30.0, "2026-01-31", 30.0]])
self.assertEqual(report_data[0][7:15], [30.0, 30.0, 0.0, 0.0, 60.0, 60.0, 0.0, 0.0])
def test_stock_reco_revaluation_rescales_queue_values(self):
"Ledger (same wh): [+15 @ 100, reco reset >> 20 @ 50]"
sle = [
frappe._dict(
name="Flask Item",
actual_qty=15,
qty_after_transaction=15,
stock_value_difference=1500,
valuation_rate=100,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=0,
qty_after_transaction=20,
stock_value_difference=(-500),
valuation_rate=50,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Reconciliation",
voucher_no="002",
has_serial_no=False,
serial_no=None,
),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots["Flask Item"]["fifo_queue"]
self.assertEqual(queue, [[15.0, "2021-12-01", 750.0], [5.0, "2021-12-02", 250.0]])
def test_stock_reco_with_split_out_and_in_sles_revalues_queue(self):
"Ledger (same wh): [+10 @ 100, reco out >> 0, reco in >> 12 @ 2]"
sle = [
frappe._dict(
name="Flask Item",
actual_qty=10,
qty_after_transaction=10,
stock_value_difference=1000,
valuation_rate=100,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=(-10),
qty_after_transaction=0,
stock_value_difference=(-1000),
valuation_rate=100,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Reconciliation",
voucher_no="002",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=12,
qty_after_transaction=12,
stock_value_difference=24,
valuation_rate=2,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Reconciliation",
voucher_no="002",
has_serial_no=False,
serial_no=None,
),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots["Flask Item"]["fifo_queue"]
self.assertEqual(queue, [[10.0, "2021-12-01", 20.0], [2.0, "2021-12-02", 4.0]])
def test_stock_reco_decrease_rescales_slots_at_reco_rate(self):
"""Ledger (same wh): [+10 @ 100, +20 @ 250, reco reset >> 25 @ 220]
The valuation engine collapses the FIFO stack to qty_after * valuation_rate
on a reco, so remaining slot values follow the reco rate, not the lot rates."""
sle = [
frappe._dict(
name="Flask Item",
actual_qty=10,
qty_after_transaction=10,
stock_value_difference=1000,
valuation_rate=100,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=20,
qty_after_transaction=30,
stock_value_difference=5000,
valuation_rate=200,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=0,
qty_after_transaction=25,
stock_value_difference=(-500),
valuation_rate=220,
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Reconciliation",
voucher_no="003",
has_serial_no=False,
serial_no=None,
),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots["Flask Item"]["fifo_queue"]
self.assertEqual(queue, [[5.0, "2021-12-01", 1100.0], [20.0, "2021-12-02", 4400.0]])
def test_batch_stock_reco_revaluation_rescales_slot_values(self):
"Ledger (same wh, batch B): [+10 @ 100, reco out >> 0, reco in >> 12 @ 2]"
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Batch Reco Revaluation",
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
).name
batch_no = "SA-RECO-REVALUE-BATCH"
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
ignore_permissions=True
)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
def make_sle(posting_date, voucher_type, voucher_no, actual_qty, qty_after, stock_value_difference):
return frappe._dict(
name=item_code,
actual_qty=actual_qty,
qty_after_transaction=qty_after,
stock_value_difference=stock_value_difference,
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
warehouse="WH 1",
posting_date=posting_date,
voucher_type=voucher_type,
voucher_no=voucher_no,
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
)
sle = [
make_sle("2021-12-01", "Stock Entry", "001", 10, 10, 1000),
make_sle("2021-12-02", "Stock Reconciliation", "002", -10, 0, -1000),
make_sle("2021-12-02", "Stock Reconciliation", "002", 12, 12, 24),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots[item_code]["fifo_queue"]
self.assertEqual(
queue,
[
[batch_no, 1, 10.0, "2021-12-01", 20.0],
[batch_no, 1, 2.0, "2021-12-02", 4.0],
],
)
def test_partial_batch_reco_keeps_existing_slot_values(self):
"""Ledger (same wh, batch B): [+10 @ 100, single-SLE reco >> 12]
The reco entry qty (delta 2) does not cover the whole batch, so
stock_value_difference / qty is not the batch rate: skip the rescale."""
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Partial Batch Reco",
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
).name
batch_no = "SA-PARTIAL-RECO-BATCH"
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
ignore_permissions=True
)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
def make_sle(posting_date, voucher_type, voucher_no, actual_qty, qty_after, stock_value_difference):
return frappe._dict(
name=item_code,
actual_qty=actual_qty,
qty_after_transaction=qty_after,
stock_value_difference=stock_value_difference,
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
warehouse="WH 1",
posting_date=posting_date,
voucher_type=voucher_type,
voucher_no=voucher_no,
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
)
sle = [
make_sle("2021-12-01", "Stock Entry", "001", 10, 10, 1000),
make_sle("2021-12-02", "Stock Reconciliation", "002", 0, 12, -400),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots[item_code]["fifo_queue"]
self.assertEqual(
queue,
[
[batch_no, 1, 10.0, "2021-12-01", 1000.0],
[batch_no, 1, 2.0, "2021-12-01", 400.0],
],
)
def test_sequential_stock_reco_same_warehouse(self):
"""
Test back to back stock recos (same warehouse).

View File

@@ -214,7 +214,7 @@ def create_reposting_entries(rows: str | list, company: str):
"posting_date": sle.posting_date,
"posting_time": sle.posting_time,
"company": company,
"allow_nagative_stock": 1,
"allow_negative_stock": 1,
}
).submit()
@@ -260,7 +260,7 @@ def repost_based_on_transaction(rows, company=None, entries=None):
"posting_date": row.get("posting_date"),
"posting_time": row.get("posting_time"),
"company": company,
"allow_nagative_stock": 1,
"allow_negative_stock": 1,
"recalculate_valuation_rate": 1,
}
).submit()

View File

@@ -325,7 +325,7 @@ def create_reposting_entries(rows, item_code=None, warehouse=None):
"warehouse": warehouse or row.warehouse,
"posting_date": row.posting_date,
"posting_time": row.posting_time,
"allow_nagative_stock": 1,
"allow_negative_stock": 1,
}
).submit()

View File

@@ -84,6 +84,7 @@ def execute(filters=None):
bin.reserved_qty_for_production_plan,
bin.reserved_qty_for_sub_contract,
reserved_qty_for_pos,
bin.reserved_stock,
bin.projected_qty,
re_order_level,
re_order_qty,
@@ -200,6 +201,13 @@ def get_columns():
"width": 100,
"convertible": "qty",
},
{
"label": _("Reserved Stock"),
"fieldname": "reserved_stock",
"fieldtype": "Float",
"width": 100,
"convertible": "qty",
},
{
"label": _("Projected Qty"),
"fieldname": "projected_qty",
@@ -246,6 +254,7 @@ def get_bin_list(filters):
bin.reserved_qty_for_production,
bin.reserved_qty_for_sub_contract,
bin.reserved_qty_for_production_plan,
bin.reserved_stock,
bin.projected_qty,
)
.orderby(bin.item_code, bin.warehouse)

View File

@@ -0,0 +1,6 @@
<p>{{_("Dear")}} {{ full_name }},</p>
<p>{{_("Your email has been verified and your appointment has been confirmed for {0}").format(scheduled_time)}}.</p>
<p>{{_("We look forward to meeting you")}}.</p>
<br>
<p style="font-size: 85%;">{{_("This email was sent from {0}").format(site_url)}}</p>

View File

@@ -1,6 +1,7 @@
<p>{{_("Dear")}} {{ full_name }}{% if last_name %} {{ last_name}}{% endif %},</p>
<p>{{_("A new appointment has been created for you with {0}").format(site_url)}}.</p>
<p>{{_("Click on the link below to verify your email and confirm the appointment")}}.</p>
<p>{{_("This link is valid for {0} minutes").format(expiry_minutes)}}.</p>
<p style="margin: 30px 0px;">
<a href="{{ link }}" rel="nofollow" style="padding: 8px 20px; background-color: #7575ff; color: #fff; border-radius: 4px; text-decoration: none; line-height: 1; border-bottom: 3px solid rgba(0, 0, 0, 0.2); font-size: 14px; font-weight: 200;">{{ _("Verify Email") }}</a>

View File

@@ -47,7 +47,11 @@ def payment_app_import_guard():
msg = _("payments app is not installed. Please install it from {} or {}").format(
marketplace_link, github_link
)
if "payments" not in frappe.get_installed_apps():
frappe.throw(msg, title=_("Missing Payments App"), exc=frappe.AppNotInstalledError)
try:
yield
except ImportError:
frappe.throw(msg, title=_("Missing Payments App"))
frappe.throw(msg, title=_("Missing Payments App"), exc=frappe.AppNotInstalledError)

View File

@@ -3,9 +3,9 @@
import frappe
from apiclient.discovery import build
from frappe import _
from frappe.model.document import Document
from pyyoutube import Api, PyYouTubeException
class VideoSettings(Document):
@@ -28,7 +28,7 @@ class VideoSettings(Document):
def validate_youtube_api_key(self):
if self.enable_youtube_tracking and self.api_key:
try:
build("youtube", "v3", developerKey=self.api_key)
Api(api_key=self.api_key).get_i18n_languages(parts="snippet")
except Exception:
title = _("Failed to Authenticate the API key.")
self.log_error("Failed to authenticate API key")

View File

@@ -237,9 +237,9 @@ async function submit() {
frappe.show_alert(__("Appointment Created Successfully"));
}
setTimeout(() => {
let redirect_url = "/";
let redirect_url = "/book_appointment";
if (window.appointment_settings.success_redirect_url) {
redirect_url += window.appointment_settings.success_redirect_url;
redirect_url = `/${window.appointment_settings.success_redirect_url}`;
}
window.location.href = redirect_url;
}, 5000);

View File

@@ -4,6 +4,7 @@ import zoneinfo
import frappe
from frappe import _
from frappe.rate_limiter import rate_limit
from frappe.utils.data import get_system_timezone
WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
@@ -18,7 +19,7 @@ def get_context(context):
def handle_appointment_booking_disabled():
if not frappe.get_single_value("Appointment Booking Settings", "enable_scheduling"):
if not frappe.get_single_value("Appointment Booking Settings", "enable_appointment_portal"):
frappe.redirect_to_message(
_("Appointment Scheduling Disabled"),
_("Appointment Scheduling has been disabled for this site"),
@@ -64,6 +65,8 @@ def get_appointment_slots(date, timezone):
)
holiday_list = frappe.get_doc("Holiday List", settings.holiday_list)
timeslots = get_available_slots_between(query_start_time, query_end_time, settings)
# fetch the day's booked slots once instead of querying per timeslot
booked_times = get_booked_slot_times_for(timeslots, settings.appointment_duration)
# Filter and convert timeslots
converted_timeslots = []
@@ -74,7 +77,7 @@ def get_appointment_slots(date, timezone):
converted_timeslots.append(dict(time=converted_timeslot, availability=False))
continue
# Check availability
if check_availabilty(timeslot, settings) and converted_timeslot >= now:
if is_slot_available(timeslot, booked_times, settings) and converted_timeslot >= now:
converted_timeslots.append(dict(time=converted_timeslot, availability=True))
else:
converted_timeslots.append(dict(time=converted_timeslot, availability=False))
@@ -100,7 +103,8 @@ def get_available_slots_between(query_start_time, query_end_time, settings):
return timeslots
@frappe.whitelist(allow_guest=True)
@frappe.whitelist(allow_guest=True, methods=["POST"])
@rate_limit(limit=5, seconds=300)
def create_appointment(date, time, tz, contact):
handle_appointment_booking_disabled()
format_string = "%Y-%m-%d %H:%M:%S"
@@ -112,13 +116,13 @@ def create_appointment(date, time, tz, contact):
# Create a appointment document from form
appointment = frappe.new_doc("Appointment")
appointment.scheduled_time = scheduled_time
contact = json.loads(contact)
contact = frappe.parse_json(contact)
appointment.customer_name = contact.get("name", None)
appointment.customer_phone_number = contact.get("number", None)
appointment.customer_skype = contact.get("skype", None)
appointment.customer_details = contact.get("notes", None)
appointment.customer_email = contact.get("email", None)
appointment.status = "Open"
appointment.created_through_portal = 1
appointment.insert(ignore_permissions=True)
return appointment
@@ -148,8 +152,23 @@ def convert_to_system_timezone(guest_tz, datetimeobject):
return datetimeobject
def check_availabilty(timeslot, settings):
return frappe.db.count("Appointment", {"scheduled_time": timeslot}) < settings.number_of_agents
def get_booked_slot_times_for(timeslots, appointment_duration):
if not timeslots:
return []
from erpnext.crm.doctype.appointment.appointment import get_booked_slot_times
duration = datetime.timedelta(minutes=appointment_duration)
return get_booked_slot_times(min(timeslots) - duration, max(timeslots) + duration)
def is_slot_available(timeslot, booked_times, settings):
# mirror the server capacity check: count non-Closed appointments whose
# duration window overlaps this slot, without a per-slot query
duration = datetime.timedelta(minutes=settings.appointment_duration)
lower, upper = timeslot - duration, timeslot + duration
overlapping = sum(1 for booked in booked_times if lower < booked < upper)
return overlapping < settings.number_of_agents
def _is_holiday(date, holiday_list):

View File

@@ -12,7 +12,7 @@
</div>
{% else %}
<div class="alert alert-danger">
{{ _("Verification failed please check the link") }}
{{ message or _("Verification failed please check the link") }}
</div>
{% endif %}
{% endblock%}

View File

@@ -1,20 +1,58 @@
import frappe
from frappe.utils.verified_command import verify_request
from frappe import _
from frappe.utils import add_to_date, now_datetime
from frappe.utils.data import sha256_hash
from erpnext.crm.doctype.appointment.appointment import get_verification_link_expiry
def get_context(context):
if not verify_request():
key = frappe.form_dict.get("key")
if not key:
context.success = False
return context
email = frappe.form_dict["email"]
appointment_name = frappe.form_dict["appointment"]
appointment_name = frappe.db.get_value("Appointment", {"verification_token": sha256_hash(key)}, "name")
if not appointment_name:
context.success = False
context.message = _("This verification link is invalid. Please book the appointment again.")
return context
if email and appointment_name:
appointment = frappe.get_doc("Appointment", appointment_name)
appointment.set_verified(email)
appointment = frappe.get_doc("Appointment", appointment_name)
# report a settled status before expiry: a closed/verified appointment is
# more informative than a generic "expired" (and creation-based expiry would
# otherwise mask a sweeper-closed appointment)
if appointment.status == "Closed":
context.success = False
context.message = _("Appointment has been closed. Please book the appointment again.")
return context
if appointment.status == "Open":
context.success = True
context.message = _("Appointment is already verified.")
return context
else:
if now_datetime() > add_to_date(appointment.creation, minutes=get_verification_link_expiry()):
context.success = False
context.message = _("Verification link has expired.")
return context
verify_appointment(appointment)
# GET requests are rolled back at the end of the request unless this flag is set
frappe.local.flags.commit = True
context.success = True
return context
def verify_appointment(appointment):
# the signed link is the authorization; materializing the appointment
# (agent assignment) needs system privileges the Guest visitor lacks
visitor = frappe.session.user
try:
frappe.set_user("Administrator")
appointment.email_verified = True
appointment.status = "Open"
appointment.save(ignore_permissions=True)
finally:
frappe.set_user(visitor)

View File

@@ -17,7 +17,7 @@ dependencies = [
# integration dependencies
"googlemaps~=4.10.0",
"plaid-python~=7.2.1",
"python-youtube~=0.9.8",
"python-youtube~=0.9.9",
# Not used directly - required by PyQRCode for PNG generation
"pypng~=0.20220715.0",