Merge branch 'develop' into UAE-VAT-Format

This commit is contained in:
Mohammad Hasnain Mohsin Rajan 2020-10-28 21:06:11 +05:30 committed by GitHub
commit 6e9e6d2491
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
264 changed files with 16500 additions and 7159 deletions

View File

@ -101,7 +101,7 @@ class Account(NestedSet):
return
if not frappe.db.get_value("Account",
{'account_name': self.account_name, 'company': ancestors[0]}, 'name'):
frappe.throw(_("Please add the account to root level Company - %s" % ancestors[0]))
frappe.throw(_("Please add the account to root level Company - {}").format(ancestors[0]))
elif self.parent_account:
descendants = get_descendants_of('Company', self.company)
if not descendants: return
@ -164,9 +164,19 @@ class Account(NestedSet):
def create_account_for_child_company(self, parent_acc_name_map, descendants, parent_acc_name):
for company in descendants:
company_bold = frappe.bold(company)
parent_acc_name_bold = frappe.bold(parent_acc_name)
if not parent_acc_name_map.get(company):
frappe.throw(_("While creating account for child Company {0}, parent account {1} not found. Please create the parent account in corresponding COA")
.format(company, parent_acc_name))
frappe.throw(_("While creating account for Child Company {0}, parent account {1} not found. Please create the parent account in corresponding COA")
.format(company_bold, parent_acc_name_bold), title=_("Account Not Found"))
# validate if parent of child company account to be added is a group
if (frappe.db.get_value("Account", self.parent_account, "is_group")
and not frappe.db.get_value("Account", parent_acc_name_map[company], "is_group")):
msg = _("While creating account for Child Company {0}, parent account {1} found as a ledger account.").format(company_bold, parent_acc_name_bold)
msg += "<br><br>"
msg += _("Please convert the parent account in corresponding child company to a group account.")
frappe.throw(msg, title=_("Invalid Parent Account"))
filters = {
"account_name": self.account_name,
@ -309,8 +319,9 @@ def update_account_number(name, account_name, account_number=None, from_descenda
allow_child_account_creation = _("Allow Account Creation Against Child Company")
message = _("Account {0} exists in parent company {1}.").format(frappe.bold(old_acc_name), frappe.bold(ancestor))
message += "<br>" + _("Renaming it is only allowed via parent company {0}, \
to avoid mismatch.").format(frappe.bold(ancestor)) + "<br><br>"
message += "<br>"
message += _("Renaming it is only allowed via parent company {0}, to avoid mismatch.").format(frappe.bold(ancestor))
message += "<br><br>"
message += _("To overrule this, enable '{0}' in company {1}").format(allow_child_account_creation, frappe.bold(account.company))
frappe.throw(message, title=_("Rename Not Allowed"))

View File

@ -111,6 +111,17 @@ class TestAccount(unittest.TestCase):
self.assertEqual(acc_tc_4, "Test Sync Account - _TC4")
self.assertEqual(acc_tc_5, "Test Sync Account - _TC5")
def test_add_account_to_a_group(self):
frappe.db.set_value("Account", "Office Rent - _TC3", "is_group", 1)
acc = frappe.new_doc("Account")
acc.account_name = "Test Group Account"
acc.parent_account = "Office Rent - _TC3"
acc.company = "_Test Company 3"
self.assertRaises(frappe.ValidationError, acc.insert)
frappe.db.set_value("Account", "Office Rent - _TC3", "is_group", 0)
def test_account_rename_sync(self):
frappe.local.flags.pop("ignore_root_company_validation", None)
@ -160,6 +171,7 @@ class TestAccount(unittest.TestCase):
for doc in to_delete:
frappe.delete_doc("Account", doc)
def _make_test_records(verbose):
from frappe.test_runner import make_test_objects

View File

@ -40,7 +40,7 @@
"fields": [
{
"default": "1",
"description": "If enabled, the system will post accounting entries for inventory automatically.",
"description": "If enabled, the system will post accounting entries for inventory automatically",
"fieldname": "auto_accounting_for_stock",
"fieldtype": "Check",
"hidden": 1,
@ -48,23 +48,23 @@
"label": "Make Accounting Entry For Every Stock Movement"
},
{
"description": "Accounting entry frozen up to this date, nobody can do / modify entry except role specified below.",
"description": "Accounting entries are frozen up to this date. Nobody can create or modify entries except users with the role specified below",
"fieldname": "acc_frozen_upto",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Accounts Frozen Upto"
"label": "Accounts Frozen Till Date"
},
{
"description": "Users with this role are allowed to set frozen accounts and create / modify accounting entries against frozen accounts",
"fieldname": "frozen_accounts_modifier",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Role Allowed to Set Frozen Accounts & Edit Frozen Entries",
"label": "Role Allowed to Set Frozen Accounts and Edit Frozen Entries",
"options": "Role"
},
{
"default": "Billing Address",
"description": "Address used to determine Tax Category in transactions.",
"description": "Address used to determine Tax Category in transactions",
"fieldname": "determine_address_tax_category_from",
"fieldtype": "Select",
"label": "Determine Address Tax Category From",
@ -75,7 +75,7 @@
"fieldtype": "Column Break"
},
{
"description": "Role that is allowed to submit transactions that exceed credit limits set.",
"description": "This role is allowed to submit transactions that exceed credit limits",
"fieldname": "credit_controller",
"fieldtype": "Link",
"in_list_view": 1,
@ -127,7 +127,7 @@
"default": "0",
"fieldname": "show_inclusive_tax_in_print",
"fieldtype": "Check",
"label": "Show Inclusive Tax In Print"
"label": "Show Inclusive Tax in Print"
},
{
"fieldname": "column_break_12",
@ -165,7 +165,7 @@
},
{
"default": "0",
"description": "Only select if you have setup Cash Flow Mapper documents",
"description": "Only select this if you have set up the Cash Flow Mapper documents",
"fieldname": "use_custom_cash_flow",
"fieldtype": "Check",
"label": "Use Custom Cash Flow Format"
@ -177,7 +177,7 @@
"label": "Automatically Fetch Payment Terms"
},
{
"description": "Percentage you are allowed to bill more against the amount ordered. For example: If the order value is $100 for an item and tolerance is set as 10% then you are allowed to bill for $110.",
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
"fieldname": "over_billing_allowance",
"fieldtype": "Currency",
"label": "Over Billing Allowance (%)"
@ -199,7 +199,7 @@
},
{
"default": "0",
"description": "If this is unchecked direct GL Entries will be created to book Deferred Revenue/Expense",
"description": "If this is unchecked, direct GL entries will be created to book deferred revenue or expense",
"fieldname": "book_deferred_entries_via_journal_entry",
"fieldtype": "Check",
"label": "Book Deferred Entries Via Journal Entry"
@ -214,7 +214,7 @@
},
{
"default": "Days",
"description": "If \"Months\" is selected then fixed amount will be booked as deferred revenue or expense for each month irrespective of number of days in a month. Will be prorated if deferred revenue or expense is not booked for an entire month.",
"description": "If \"Months\" is selected, a fixed amount will be booked as deferred revenue or expense for each month irrespective of the number of days in a month. It will be prorated if deferred revenue or expense is not booked for an entire month",
"fieldname": "book_deferred_entries_based_on",
"fieldtype": "Select",
"label": "Book Deferred Entries Based On",
@ -226,7 +226,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2020-10-07 14:58:50.325577",
"modified": "2020-10-13 11:32:52.268826",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",
@ -254,4 +254,4 @@
"sort_field": "modified",
"sort_order": "ASC",
"track_changes": 1
}
}

View File

@ -23,13 +23,13 @@ class CashierClosing(Document):
where posting_date=%s and posting_time>=%s and posting_time<=%s and owner=%s
""", (self.date, self.from_time, self.time, self.user))
self.outstanding_amount = flt(values[0][0] if values else 0)
def make_calculations(self):
total = 0.00
for i in self.payments:
total += flt(i.amount)
self.net_amount = total + self.outstanding_amount + self.expense - self.custody + self.returns
self.net_amount = total + self.outstanding_amount + flt(self.expense) - flt(self.custody) + flt(self.returns)
def validate_time(self):
if self.from_time >= self.time:

View File

@ -1,4 +1,5 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "field:mode_of_payment",
@ -28,7 +29,7 @@
"fieldtype": "Select",
"in_standard_filter": 1,
"label": "Type",
"options": "Cash\nBank\nGeneral"
"options": "Cash\nBank\nGeneral\nPhone"
},
{
"fieldname": "accounts",
@ -45,7 +46,9 @@
],
"icon": "fa fa-credit-card",
"idx": 1,
"modified": "2020-09-18 17:26:09.703215",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2020-09-18 17:57:23.835236",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Mode of Payment",

View File

@ -6,7 +6,7 @@ frappe.ui.form.on('Opening Invoice Creation Tool', {
frm.set_query('party_type', 'invoices', function(doc, cdt, cdn) {
return {
filters: {
'name': ['in', 'Customer,Supplier']
'name': ['in', 'Customer, Supplier']
}
};
});
@ -14,29 +14,46 @@ frappe.ui.form.on('Opening Invoice Creation Tool', {
if (frm.doc.company) {
frm.trigger('setup_company_filters');
}
frappe.realtime.on('opening_invoice_creation_progress', data => {
if (!frm.doc.import_in_progress) {
frm.dashboard.reset();
frm.doc.import_in_progress = true;
}
if (data.user != frappe.session.user) return;
if (data.count == data.total) {
setTimeout((title) => {
frm.doc.import_in_progress = false;
frm.clear_table("invoices");
frm.refresh_fields();
frm.page.clear_indicator();
frm.dashboard.hide_progress(title);
frappe.msgprint(__("Opening {0} Invoice created", [frm.doc.invoice_type]));
}, 1500, data.title);
return;
}
frm.dashboard.show_progress(data.title, (data.count / data.total) * 100, data.message);
frm.page.set_indicator(__('In Progress'), 'orange');
});
},
refresh: function(frm) {
frm.disable_save();
frm.trigger("make_dashboard");
!frm.doc.import_in_progress && frm.trigger("make_dashboard");
frm.page.set_primary_action(__('Create Invoices'), () => {
let btn_primary = frm.page.btn_primary.get(0);
return frm.call({
doc: frm.doc,
freeze: true,
btn: $(btn_primary),
method: "make_invoices",
freeze_message: __("Creating {0} Invoice", [frm.doc.invoice_type]),
callback: (r) => {
if(!r.exc){
frappe.msgprint(__("Opening {0} Invoice created", [frm.doc.invoice_type]));
frm.clear_table("invoices");
frm.refresh_fields();
frm.reload_doc();
}
}
freeze_message: __("Creating {0} Invoice", [frm.doc.invoice_type])
});
});
if (frm.doc.create_missing_party) {
frm.set_df_property("party", "fieldtype", "Data", frm.doc.name, "invoices");
}
},
setup_company_filters: function(frm) {

View File

@ -4,9 +4,12 @@
from __future__ import unicode_literals
import frappe
import traceback
from json import dumps
from frappe import _, scrub
from frappe.utils import flt, nowdate
from frappe.model.document import Document
from frappe.utils.background_jobs import enqueue
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions
@ -61,67 +64,48 @@ class OpeningInvoiceCreationTool(Document):
prepare_invoice_summary(doctype, invoices)
return invoices_summary, max_count
def make_invoices(self):
names = []
mandatory_error_msg = _("Row {0}: {1} is required to create the Opening {2} Invoices")
def validate_company(self):
if not self.company:
frappe.throw(_("Please select the Company"))
def set_missing_values(self, row):
row.qty = row.qty or 1.0
row.temporary_opening_account = row.temporary_opening_account or get_temporary_opening_account(self.company)
row.party_type = "Customer" if self.invoice_type == "Sales" else "Supplier"
row.item_name = row.item_name or _("Opening Invoice Item")
row.posting_date = row.posting_date or nowdate()
row.due_date = row.due_date or nowdate()
company_details = frappe.get_cached_value('Company', self.company,
["default_currency", "default_letter_head"], as_dict=1) or {}
def validate_mandatory_invoice_fields(self, row):
if not frappe.db.exists(row.party_type, row.party):
if self.create_missing_party:
self.add_party(row.party_type, row.party)
else:
frappe.throw(_("Row #{}: {} {} does not exist.").format(row.idx, frappe.bold(row.party_type), frappe.bold(row.party)))
mandatory_error_msg = _("Row #{0}: {1} is required to create the Opening {2} Invoices")
for d in ("Party", "Outstanding Amount", "Temporary Opening Account"):
if not row.get(scrub(d)):
frappe.throw(mandatory_error_msg.format(row.idx, d, self.invoice_type))
def get_invoices(self):
invoices = []
for row in self.invoices:
if not row.qty:
row.qty = 1.0
# always mandatory fields for the invoices
if not row.temporary_opening_account:
row.temporary_opening_account = get_temporary_opening_account(self.company)
row.party_type = "Customer" if self.invoice_type == "Sales" else "Supplier"
# Allow to create invoice even if no party present in customer or supplier.
if not frappe.db.exists(row.party_type, row.party):
if self.create_missing_party:
self.add_party(row.party_type, row.party)
else:
frappe.throw(_("{0} {1} does not exist.").format(frappe.bold(row.party_type), frappe.bold(row.party)))
if not row.item_name:
row.item_name = _("Opening Invoice Item")
if not row.posting_date:
row.posting_date = nowdate()
if not row.due_date:
row.due_date = nowdate()
for d in ("Party", "Outstanding Amount", "Temporary Opening Account"):
if not row.get(scrub(d)):
frappe.throw(mandatory_error_msg.format(row.idx, _(d), self.invoice_type))
args = self.get_invoice_dict(row=row)
if not args:
if not row:
continue
self.set_missing_values(row)
self.validate_mandatory_invoice_fields(row)
invoice = self.get_invoice_dict(row)
company_details = frappe.get_cached_value('Company', self.company, ["default_currency", "default_letter_head"], as_dict=1) or {}
if company_details:
args.update({
invoice.update({
"currency": company_details.get("default_currency"),
"letter_head": company_details.get("default_letter_head")
})
invoices.append(invoice)
doc = frappe.get_doc(args).insert()
doc.submit()
names.append(doc.name)
if len(self.invoices) > 5:
frappe.publish_realtime(
"progress", dict(
progress=[row.idx, len(self.invoices)],
title=_('Creating {0}').format(doc.doctype)
),
user=frappe.session.user
)
return names
return invoices
def add_party(self, party_type, party):
party_doc = frappe.new_doc(party_type)
@ -140,14 +124,12 @@ class OpeningInvoiceCreationTool(Document):
def get_invoice_dict(self, row=None):
def get_item_dict():
default_uom = frappe.db.get_single_value("Stock Settings", "stock_uom") or _("Nos")
cost_center = row.get('cost_center') or frappe.get_cached_value('Company',
self.company, "cost_center")
cost_center = row.get('cost_center') or frappe.get_cached_value('Company', self.company, "cost_center")
if not cost_center:
frappe.throw(
_("Please set the Default Cost Center in {0} company.").format(frappe.bold(self.company))
)
frappe.throw(_("Please set the Default Cost Center in {0} company.").format(frappe.bold(self.company)))
income_expense_account_field = "income_account" if row.party_type == "Customer" else "expense_account"
default_uom = frappe.db.get_single_value("Stock Settings", "stock_uom") or _("Nos")
rate = flt(row.outstanding_amount) / flt(row.qty)
return frappe._dict({
@ -161,18 +143,9 @@ class OpeningInvoiceCreationTool(Document):
"cost_center": cost_center
})
if not row:
return None
party_type = "Customer"
income_expense_account_field = "income_account"
if self.invoice_type == "Purchase":
party_type = "Supplier"
income_expense_account_field = "expense_account"
item = get_item_dict()
args = frappe._dict({
invoice = frappe._dict({
"items": [item],
"is_opening": "Yes",
"set_posting_time": 1,
@ -180,21 +153,76 @@ class OpeningInvoiceCreationTool(Document):
"cost_center": self.cost_center,
"due_date": row.due_date,
"posting_date": row.posting_date,
frappe.scrub(party_type): row.party,
frappe.scrub(row.party_type): row.party,
"is_pos": 0,
"doctype": "Sales Invoice" if self.invoice_type == "Sales" else "Purchase Invoice"
})
accounting_dimension = get_accounting_dimensions()
for dimension in accounting_dimension:
args.update({
invoice.update({
dimension: item.get(dimension)
})
if self.invoice_type == "Sales":
args["is_pos"] = 0
return invoice
return args
def make_invoices(self):
self.validate_company()
invoices = self.get_invoices()
if len(invoices) < 50:
return start_import(invoices)
else:
from frappe.core.page.background_jobs.background_jobs import get_info
from frappe.utils.scheduler import is_scheduler_inactive
if is_scheduler_inactive() and not frappe.flags.in_test:
frappe.throw(_("Scheduler is inactive. Cannot import data."), title=_("Scheduler Inactive"))
enqueued_jobs = [d.get("job_name") for d in get_info()]
if self.name not in enqueued_jobs:
enqueue(
start_import,
queue="default",
timeout=6000,
event="opening_invoice_creation",
job_name=self.name,
invoices=invoices,
now=frappe.conf.developer_mode or frappe.flags.in_test
)
def start_import(invoices):
errors = 0
names = []
for idx, d in enumerate(invoices):
try:
publish(idx, len(invoices), d.doctype)
doc = frappe.get_doc(d)
doc.insert()
doc.submit()
frappe.db.commit()
names.append(doc.name)
except Exception:
errors += 1
frappe.db.rollback()
message = "\n".join(["Data:", dumps(d, default=str, indent=4), "--" * 50, "\nException:", traceback.format_exc()])
frappe.log_error(title="Error while creating Opening Invoice", message=message)
frappe.db.commit()
if errors:
frappe.msgprint(_("You had {} errors while creating opening invoices. Check {} for more details")
.format(errors, "<a href='#List/Error Log' class='variant-click'>Error Log</a>"), indicator="red", title=_("Error Occured"))
return names
def publish(index, total, doctype):
if total < 5: return
frappe.publish_realtime(
"opening_invoice_creation_progress",
dict(
title=_("Opening Invoice Creation In Progress"),
message=_('Creating {} out of {} {}').format(index + 1, total, doctype),
user=frappe.session.user,
count=index+1,
total=total
))
@frappe.whitelist()
def get_temporary_opening_account(company=None):

View File

@ -44,7 +44,7 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase):
0: ["_Test Supplier", 300, "Overdue"],
1: ["_Test Supplier 1", 250, "Overdue"],
}
self.check_expected_values(invoices, expected_value, invoice_type="Purchase", )
self.check_expected_values(invoices, expected_value, "Purchase")
def get_opening_invoice_creation_dict(**args):
party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier"

View File

@ -1,313 +1,98 @@
{
"allow_copy": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"beta": 0,
"creation": "2015-12-23 21:31:52.699821",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"editable_grid": 1,
"actions": [],
"creation": "2015-12-23 21:31:52.699821",
"doctype": "DocType",
"editable_grid": 1,
"field_order": [
"payment_gateway",
"payment_channel",
"is_default",
"column_break_4",
"payment_account",
"currency",
"payment_request_message",
"message",
"message_examples"
],
"fields": [
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "payment_gateway",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"fieldname": "payment_gateway",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Payment Gateway",
"length": 0,
"no_copy": 0,
"options": "Payment Gateway",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"label": "Payment Gateway",
"options": "Payment Gateway",
"reqd": 1
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "is_default",
"fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Is Default",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"default": "0",
"fieldname": "is_default",
"fieldtype": "Check",
"label": "Is Default"
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "column_break_4",
"fieldtype": "Column Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "payment_account",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"fieldname": "payment_account",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Payment Account",
"length": 0,
"no_copy": 0,
"options": "Account",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"label": "Payment Account",
"options": "Account",
"reqd": 1
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fetch_from": "payment_account.account_currency",
"fieldname": "currency",
"fieldtype": "Read Only",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Currency",
"length": 0,
"no_copy": 0,
"options": "",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"fieldtype": "Read Only",
"label": "Currency"
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "payment_request_message",
"fieldtype": "Section Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"depends_on": "eval: doc.payment_channel !== \"Phone\"",
"fieldname": "payment_request_message",
"fieldtype": "Section Break"
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "Please click on the link below to make your payment",
"fieldname": "message",
"fieldtype": "Small Text",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Default Payment Request Message",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"default": "Please click on the link below to make your payment",
"fieldname": "message",
"fieldtype": "Small Text",
"label": "Default Payment Request Message"
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "message_examples",
"fieldtype": "HTML",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Message Examples",
"length": 0,
"no_copy": 0,
"options": "<pre><h5>Message Example</h5>\n\n&lt;p&gt; Thank You for being a part of {{ doc.company }}! We hope you are enjoying the service.&lt;/p&gt;\n\n&lt;p&gt; Please find enclosed the E Bill statement. The outstanding amount is {{ doc.grand_total }}.&lt;/p&gt;\n\n&lt;p&gt; We don't want you to be spending time running around in order to pay for your Bill.<br>After all, life is beautiful and the time you have in hand should be spent to enjoy it!<br>So here are our little ways to help you get more time for life! &lt;/p&gt;\n\n&lt;a href=\"{{ payment_url }}\"&gt; click here to pay &lt;/a&gt;\n\n</pre>\n",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"fieldname": "message_examples",
"fieldtype": "HTML",
"label": "Message Examples",
"options": "<pre><h5>Message Example</h5>\n\n&lt;p&gt; Thank You for being a part of {{ doc.company }}! We hope you are enjoying the service.&lt;/p&gt;\n\n&lt;p&gt; Please find enclosed the E Bill statement. The outstanding amount is {{ doc.grand_total }}.&lt;/p&gt;\n\n&lt;p&gt; We don't want you to be spending time running around in order to pay for your Bill.<br>After all, life is beautiful and the time you have in hand should be spent to enjoy it!<br>So here are our little ways to help you get more time for life! &lt;/p&gt;\n\n&lt;a href=\"{{ payment_url }}\"&gt; click here to pay &lt;/a&gt;\n\n</pre>\n"
},
{
"default": "Email",
"fieldname": "payment_channel",
"fieldtype": "Select",
"label": "Payment Channel",
"options": "\nEmail\nPhone"
}
],
"has_web_view": 0,
"hide_heading": 0,
"hide_toolbar": 0,
"idx": 0,
"image_view": 0,
"in_create": 0,
"is_submittable": 0,
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2018-05-16 22:43:34.970491",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Gateway Account",
"name_case": "",
"owner": "Administrator",
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2020-09-20 13:30:27.722852",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Gateway Account",
"owner": "Administrator",
"permissions": [
{
"amend": 0,
"cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts Manager",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts Manager",
"share": 1,
"write": 1
}
],
"quick_entry": 0,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 0,
"track_seen": 0
],
"sort_field": "modified",
"sort_order": "DESC"
}

View File

@ -25,7 +25,7 @@ frappe.ui.form.on("Payment Request", "onload", function(frm, dt, dn){
})
frappe.ui.form.on("Payment Request", "refresh", function(frm) {
if(frm.doc.payment_request_type == 'Inward' &&
if(frm.doc.payment_request_type == 'Inward' && frm.doc.payment_channel !== "Phone" &&
!in_list(["Initiated", "Paid"], frm.doc.status) && !frm.doc.__islocal && frm.doc.docstatus==1){
frm.add_custom_button(__('Resend Payment Email'), function(){
frappe.call({

View File

@ -48,6 +48,7 @@
"section_break_7",
"payment_gateway",
"payment_account",
"payment_channel",
"payment_order",
"amended_from"
],
@ -230,6 +231,7 @@
"label": "Recipient Message And Payment Details"
},
{
"depends_on": "eval: doc.payment_channel != \"Phone\"",
"fieldname": "print_format",
"fieldtype": "Select",
"label": "Print Format"
@ -241,6 +243,7 @@
"label": "To"
},
{
"depends_on": "eval: doc.payment_channel != \"Phone\"",
"fieldname": "subject",
"fieldtype": "Data",
"in_global_search": 1,
@ -277,16 +280,18 @@
"read_only": 1
},
{
"depends_on": "eval: doc.payment_request_type == 'Inward'",
"depends_on": "eval: doc.payment_request_type == 'Inward' || doc.payment_channel != \"Phone\"",
"fieldname": "section_break_10",
"fieldtype": "Section Break"
},
{
"depends_on": "eval: doc.payment_channel != \"Phone\"",
"fieldname": "message",
"fieldtype": "Text",
"label": "Message"
},
{
"depends_on": "eval: doc.payment_channel != \"Phone\"",
"fieldname": "message_examples",
"fieldtype": "HTML",
"label": "Message Examples",
@ -347,12 +352,21 @@
"options": "Payment Request",
"print_hide": 1,
"read_only": 1
},
{
"fetch_from": "payment_gateway_account.payment_channel",
"fieldname": "payment_channel",
"fieldtype": "Select",
"label": "Payment Channel",
"options": "\nEmail\nPhone",
"read_only": 1
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2020-07-17 14:06:42.185763",
"modified": "2020-09-18 12:24:14.178853",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Request",

View File

@ -36,7 +36,7 @@ class PaymentRequest(Document):
ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name)
if (hasattr(ref_doc, "order_type") \
and getattr(ref_doc, "order_type") != "Shopping Cart"):
ref_amount = get_amount(ref_doc)
ref_amount = get_amount(ref_doc, self.payment_account)
if existing_payment_request_amount + flt(self.grand_total)> ref_amount:
frappe.throw(_("Total Payment Request amount cannot be greater than {0} amount")
@ -76,11 +76,25 @@ class PaymentRequest(Document):
or self.flags.mute_email:
send_mail = False
if send_mail:
if send_mail and self.payment_channel != "Phone":
self.set_payment_request_url()
self.send_email()
self.make_communication_entry()
elif self.payment_channel == "Phone":
controller = get_payment_gateway_controller(self.payment_gateway)
payment_record = dict(
reference_doctype="Payment Request",
reference_docname=self.name,
payment_reference=self.reference_name,
grand_total=self.grand_total,
sender=self.email_to,
currency=self.currency,
payment_gateway=self.payment_gateway
)
controller.validate_transaction_currency(self.currency)
controller.request_for_payment(**payment_record)
def on_cancel(self):
self.check_if_payment_entry_exists()
self.set_as_cancelled()
@ -105,13 +119,14 @@ class PaymentRequest(Document):
return False
def set_payment_request_url(self):
if self.payment_account:
if self.payment_account and self.payment_channel != "Phone":
self.payment_url = self.get_payment_url()
if self.payment_url:
self.db_set('payment_url', self.payment_url)
if self.payment_url or not self.payment_gateway_account:
if self.payment_url or not self.payment_gateway_account \
or (self.payment_gateway_account and self.payment_channel == "Phone"):
self.db_set('status', 'Initiated')
def get_payment_url(self):
@ -140,10 +155,14 @@ class PaymentRequest(Document):
})
def set_as_paid(self):
payment_entry = self.create_payment_entry()
self.make_invoice()
if self.payment_channel == "Phone":
self.db_set("status", "Paid")
return payment_entry
else:
payment_entry = self.create_payment_entry()
self.make_invoice()
return payment_entry
def create_payment_entry(self, submit=True):
"""create entry"""
@ -151,7 +170,7 @@ class PaymentRequest(Document):
ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name)
if self.reference_doctype == "Sales Invoice":
if self.reference_doctype in ["Sales Invoice", "POS Invoice"]:
party_account = ref_doc.debit_to
elif self.reference_doctype == "Purchase Invoice":
party_account = ref_doc.credit_to
@ -166,8 +185,8 @@ class PaymentRequest(Document):
else:
party_amount = self.grand_total
payment_entry = get_payment_entry(self.reference_doctype, self.reference_name,
party_amount=party_amount, bank_account=self.payment_account, bank_amount=bank_amount)
payment_entry = get_payment_entry(self.reference_doctype, self.reference_name, party_amount=party_amount,
bank_account=self.payment_account, bank_amount=bank_amount)
payment_entry.update({
"reference_no": self.name,
@ -255,7 +274,7 @@ class PaymentRequest(Document):
# if shopping cart enabled and in session
if (shopping_cart_settings.enabled and hasattr(frappe.local, "session")
and frappe.local.session.user != "Guest"):
and frappe.local.session.user != "Guest") and self.payment_channel != "Phone":
success_url = shopping_cart_settings.payment_success_url
if success_url:
@ -280,7 +299,9 @@ def make_payment_request(**args):
args = frappe._dict(args)
ref_doc = frappe.get_doc(args.dt, args.dn)
grand_total = get_amount(ref_doc)
gateway_account = get_gateway_details(args) or frappe._dict()
grand_total = get_amount(ref_doc, gateway_account.get("payment_account"))
if args.loyalty_points and args.dt == "Sales Order":
from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_loyalty_points
loyalty_amount = validate_loyalty_points(ref_doc, int(args.loyalty_points))
@ -288,8 +309,6 @@ def make_payment_request(**args):
frappe.db.set_value("Sales Order", args.dn, "loyalty_amount", loyalty_amount, update_modified=False)
grand_total = grand_total - loyalty_amount
gateway_account = get_gateway_details(args) or frappe._dict()
bank_account = (get_party_bank_account(args.get('party_type'), args.get('party'))
if args.get('party_type') else '')
@ -314,9 +333,11 @@ def make_payment_request(**args):
"payment_gateway_account": gateway_account.get("name"),
"payment_gateway": gateway_account.get("payment_gateway"),
"payment_account": gateway_account.get("payment_account"),
"payment_channel": gateway_account.get("payment_channel"),
"payment_request_type": args.get("payment_request_type"),
"currency": ref_doc.currency,
"grand_total": grand_total,
"mode_of_payment": args.mode_of_payment,
"email_to": args.recipient_id or ref_doc.owner,
"subject": _("Payment Request for {0}").format(args.dn),
"message": gateway_account.get("message") or get_dummy_message(ref_doc),
@ -344,7 +365,7 @@ def make_payment_request(**args):
return pr.as_dict()
def get_amount(ref_doc):
def get_amount(ref_doc, payment_account=None):
"""get amount based on doctype"""
dt = ref_doc.doctype
if dt in ["Sales Order", "Purchase Order"]:
@ -356,6 +377,12 @@ def get_amount(ref_doc):
else:
grand_total = flt(ref_doc.outstanding_amount) / ref_doc.conversion_rate
elif dt == "POS Invoice":
for pay in ref_doc.payments:
if pay.type == "Phone" and pay.account == payment_account:
grand_total = pay.amount
break
elif dt == "Fees":
grand_total = ref_doc.outstanding_amount
@ -366,6 +393,10 @@ def get_amount(ref_doc):
frappe.throw(_("Payment Entry is already created"))
def get_existing_payment_request_amount(ref_dt, ref_dn):
"""
Get the existing payment request which are unpaid or partially paid for payment channel other than Phone
and get the summation of existing paid payment request for Phone payment channel.
"""
existing_payment_request_amount = frappe.db.sql("""
select sum(grand_total)
from `tabPayment Request`
@ -373,7 +404,9 @@ def get_existing_payment_request_amount(ref_dt, ref_dn):
reference_doctype = %s
and reference_name = %s
and docstatus = 1
and status != 'Paid'
and (status != 'Paid'
or (payment_channel = 'Phone'
and status = 'Paid'))
""", (ref_dt, ref_dn))
return flt(existing_payment_request_amount[0][0]) if existing_payment_request_amount else 0

View File

@ -51,6 +51,7 @@ frappe.ui.form.on('POS Closing Entry', {
args: {
start: frappe.datetime.get_datetime_as_string(frm.doc.period_start_date),
end: frappe.datetime.get_datetime_as_string(frm.doc.period_end_date),
pos_profile: frm.doc.pos_profile,
user: frm.doc.user
},
callback: (r) => {

View File

@ -14,19 +14,51 @@ from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import
class POSClosingEntry(Document):
def validate(self):
user = frappe.get_all('POS Closing Entry',
filters = { 'user': self.user, 'docstatus': 1 },
if frappe.db.get_value("POS Opening Entry", self.pos_opening_entry, "status") != "Open":
frappe.throw(_("Selected POS Opening Entry should be open."), title=_("Invalid Opening Entry"))
self.validate_pos_closing()
self.validate_pos_invoices()
def validate_pos_closing(self):
user = frappe.get_all("POS Closing Entry",
filters = { "user": self.user, "docstatus": 1, "pos_profile": self.pos_profile },
or_filters = {
'period_start_date': ('between', [self.period_start_date, self.period_end_date]),
'period_end_date': ('between', [self.period_start_date, self.period_end_date])
"period_start_date": ("between", [self.period_start_date, self.period_end_date]),
"period_end_date": ("between", [self.period_start_date, self.period_end_date])
})
if user:
frappe.throw(_("POS Closing Entry {} against {} between selected period"
.format(frappe.bold("already exists"), frappe.bold(self.user))), title=_("Invalid Period"))
bold_already_exists = frappe.bold(_("already exists"))
bold_user = frappe.bold(self.user)
frappe.throw(_("POS Closing Entry {} against {} between selected period")
.format(bold_already_exists, bold_user), title=_("Invalid Period"))
def validate_pos_invoices(self):
invalid_rows = []
for d in self.pos_transactions:
invalid_row = {'idx': d.idx}
pos_invoice = frappe.db.get_values("POS Invoice", d.pos_invoice,
["consolidated_invoice", "pos_profile", "docstatus", "owner"], as_dict=1)[0]
if pos_invoice.consolidated_invoice:
invalid_row.setdefault('msg', []).append(_('POS Invoice is {}').format(frappe.bold("already consolidated")))
invalid_rows.append(invalid_row)
continue
if pos_invoice.pos_profile != self.pos_profile:
invalid_row.setdefault('msg', []).append(_("POS Profile doesn't matches {}").format(frappe.bold(self.pos_profile)))
if pos_invoice.docstatus != 1:
invalid_row.setdefault('msg', []).append(_('POS Invoice is not {}').format(frappe.bold("submitted")))
if pos_invoice.owner != self.user:
invalid_row.setdefault('msg', []).append(_("POS Invoice isn't created by user {}").format(frappe.bold(self.owner)))
if frappe.db.get_value("POS Opening Entry", self.pos_opening_entry, "status") != "Open":
frappe.throw(_("Selected POS Opening Entry should be open."), title=_("Invalid Opening Entry"))
if invalid_row.get('msg'):
invalid_rows.append(invalid_row)
if not invalid_rows:
return
error_list = [_("Row #{}: {}").format(row.get('idx'), row.get('msg')) for row in invalid_rows]
frappe.throw(error_list, title=_("Invalid POS Invoices"), as_list=True)
def on_submit(self):
merge_pos_invoices(self.pos_transactions)
@ -47,16 +79,15 @@ def get_cashiers(doctype, txt, searchfield, start, page_len, filters):
return [c['user'] for c in cashiers_list]
@frappe.whitelist()
def get_pos_invoices(start, end, user):
def get_pos_invoices(start, end, pos_profile, user):
data = frappe.db.sql("""
select
name, timestamp(posting_date, posting_time) as "timestamp"
from
`tabPOS Invoice`
where
owner = %s and docstatus = 1 and
(consolidated_invoice is NULL or consolidated_invoice = '')
""", (user), as_dict=1)
owner = %s and docstatus = 1 and pos_profile = %s and ifnull(consolidated_invoice,'') = ''
""", (user, pos_profile), as_dict=1)
data = list(filter(lambda d: get_datetime(start) <= get_datetime(d.timestamp) <= get_datetime(end), data))
# need to get taxes and payments so can't avoid get_doc
@ -76,7 +107,8 @@ def make_closing_entry_from_opening(opening_entry):
closing_entry.net_total = 0
closing_entry.total_quantity = 0
invoices = get_pos_invoices(closing_entry.period_start_date, closing_entry.period_end_date, closing_entry.user)
invoices = get_pos_invoices(closing_entry.period_start_date, closing_entry.period_end_date,
closing_entry.pos_profile, closing_entry.user)
pos_transactions = []
taxes = []

View File

@ -7,8 +7,8 @@
"field_order": [
"mode_of_payment",
"opening_amount",
"closing_amount",
"expected_amount",
"closing_amount",
"difference"
],
"fields": [
@ -26,8 +26,7 @@
"in_list_view": 1,
"label": "Expected Amount",
"options": "company:company_currency",
"read_only": 1,
"reqd": 1
"read_only": 1
},
{
"fieldname": "difference",
@ -55,9 +54,10 @@
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2020-05-29 15:03:34.533607",
"modified": "2020-10-23 16:45:43.662034",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Closing Entry Detail",

View File

@ -9,80 +9,63 @@ erpnext.selling.POSInvoiceController = erpnext.selling.SellingController.extend(
this._super(doc);
},
onload() {
onload(doc) {
this._super();
if(this.frm.doc.__islocal && this.frm.doc.is_pos) {
//Load pos profile data on the invoice if the default value of Is POS is 1
me.frm.script_manager.trigger("is_pos");
me.frm.refresh_fields();
if(doc.__islocal && doc.is_pos && frappe.get_route_str() !== 'point-of-sale') {
this.frm.script_manager.trigger("is_pos");
this.frm.refresh_fields();
}
},
refresh(doc) {
this._super();
if (doc.docstatus == 1 && !doc.is_return) {
if(doc.outstanding_amount >= 0 || Math.abs(flt(doc.outstanding_amount)) < flt(doc.grand_total)) {
cur_frm.add_custom_button(__('Return'),
this.make_sales_return, __('Create'));
cur_frm.page.set_inner_btn_group_as_primary(__('Create'));
}
this.frm.add_custom_button(__('Return'), this.make_sales_return, __('Create'));
this.frm.page.set_inner_btn_group_as_primary(__('Create'));
}
if (this.frm.doc.is_return) {
if (doc.is_return && doc.__islocal) {
this.frm.return_print_format = "Sales Invoice Return";
cur_frm.set_value('consolidated_invoice', '');
this.frm.set_value('consolidated_invoice', '');
}
},
is_pos: function(frm){
is_pos: function() {
this.set_pos_data();
},
set_pos_data: function() {
set_pos_data: async function() {
if(this.frm.doc.is_pos) {
this.frm.set_value("allocate_advances_automatically", 0);
if(!this.frm.doc.company) {
this.frm.set_value("is_pos", 0);
frappe.msgprint(__("Please specify Company to proceed"));
} else {
var me = this;
return this.frm.call({
doc: me.frm.doc,
const r = await this.frm.call({
doc: this.frm.doc,
method: "set_missing_values",
callback: function(r) {
if(!r.exc) {
if(r.message) {
me.frm.pos_print_format = r.message.print_format || "";
me.frm.meta.default_print_format = r.message.print_format || "";
me.frm.allow_edit_rate = r.message.allow_edit_rate;
me.frm.allow_edit_discount = r.message.allow_edit_discount;
me.frm.doc.campaign = r.message.campaign;
me.frm.allow_print_before_pay = r.message.allow_print_before_pay;
}
me.frm.script_manager.trigger("update_stock");
me.calculate_taxes_and_totals();
if(me.frm.doc.taxes_and_charges) {
me.frm.script_manager.trigger("taxes_and_charges");
}
frappe.model.set_default_values(me.frm.doc);
me.set_dynamic_labels();
}
}
freeze: true
});
if(!r.exc) {
if(r.message) {
this.frm.pos_print_format = r.message.print_format || "";
this.frm.meta.default_print_format = r.message.print_format || "";
this.frm.doc.campaign = r.message.campaign;
this.frm.allow_print_before_pay = r.message.allow_print_before_pay;
}
this.frm.script_manager.trigger("update_stock");
this.calculate_taxes_and_totals();
this.frm.doc.taxes_and_charges && this.frm.script_manager.trigger("taxes_and_charges");
frappe.model.set_default_values(this.frm.doc);
this.set_dynamic_labels();
}
}
}
else this.frm.trigger("refresh");
},
customer() {
if (!this.frm.doc.customer) return
if (this.frm.doc.is_pos){
var pos_profile = this.frm.doc.pos_profile;
}
var me = this;
const pos_profile = this.frm.doc.pos_profile;
if(this.frm.updating_party_details) return;
erpnext.utils.get_party_details(this.frm,
"erpnext.accounts.party.get_party_details", {
@ -92,8 +75,8 @@ erpnext.selling.POSInvoiceController = erpnext.selling.SellingController.extend(
account: this.frm.doc.debit_to,
price_list: this.frm.doc.selling_price_list,
pos_profile: pos_profile
}, function() {
me.apply_pricing_rule();
}, () => {
this.apply_pricing_rule();
});
},
@ -201,5 +184,22 @@ frappe.ui.form.on('POS Invoice', {
}
frm.set_value("loyalty_amount", loyalty_amount);
}
},
request_for_payment: function (frm) {
frm.save().then(() => {
frappe.dom.freeze();
frappe.call({
method: 'create_payment_request',
doc: frm.doc,
})
.fail(() => {
frappe.dom.unfreeze();
frappe.msgprint('Payment request failed');
})
.then(() => {
frappe.msgprint('Payment request sent successfully');
});
});
}
});

View File

@ -279,8 +279,7 @@
"fieldtype": "Check",
"label": "Is Return (Credit Note)",
"no_copy": 1,
"print_hide": 1,
"set_only_once": 1
"print_hide": 1
},
{
"fieldname": "column_break1",
@ -461,7 +460,7 @@
},
{
"fieldname": "contact_mobile",
"fieldtype": "Small Text",
"fieldtype": "Data",
"hidden": 1,
"label": "Mobile No",
"read_only": 1
@ -1579,10 +1578,9 @@
}
],
"icon": "fa fa-file-text",
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2020-09-07 12:43:09.138720",
"modified": "2020-09-28 16:51:24.641755",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",

View File

@ -10,11 +10,10 @@ from erpnext.controllers.selling_controller import SellingController
from frappe.utils import cint, flt, add_months, today, date_diff, getdate, add_days, cstr, nowdate
from erpnext.accounts.utils import get_account_currency
from erpnext.accounts.party import get_party_account, get_due_date
from erpnext.accounts.doctype.loyalty_program.loyalty_program import \
get_loyalty_program_details_with_points, validate_loyalty_points
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice, get_bank_cash_account, update_multi_mode_option
from erpnext.stock.doctype.serial_no.serial_no import get_pos_reserved_serial_nos
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_loyalty_points
from erpnext.stock.doctype.serial_no.serial_no import get_pos_reserved_serial_nos, get_serial_nos
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice, get_bank_cash_account, update_multi_mode_option, get_mode_of_payment_info
from six import iteritems
@ -29,8 +28,7 @@ class POSInvoice(SalesInvoice):
# run on validate method of selling controller
super(SalesInvoice, self).validate()
self.validate_auto_set_posting_time()
self.validate_pos_paid_amount()
self.validate_pos_return()
self.validate_mode_of_payment()
self.validate_uom_is_integer("stock_uom", "stock_qty")
self.validate_uom_is_integer("uom", "qty")
self.validate_debit_to_acc()
@ -40,11 +38,11 @@ class POSInvoice(SalesInvoice):
self.validate_item_cost_centers()
self.validate_serialised_or_batched_item()
self.validate_stock_availablility()
self.validate_return_items()
self.validate_return_items_qty()
self.set_status()
self.set_account_for_mode_of_payment()
self.validate_pos()
self.verify_payment_amount()
self.validate_payment_amount()
self.validate_loyalty_transaction()
def on_submit(self):
@ -57,6 +55,7 @@ class POSInvoice(SalesInvoice):
against_psi_doc.make_loyalty_point_entry()
if self.redeem_loyalty_points and self.loyalty_points:
self.apply_loyalty_points()
self.check_phone_payments()
self.set_status(update=True)
def on_cancel(self):
@ -69,71 +68,111 @@ class POSInvoice(SalesInvoice):
against_psi_doc.delete_loyalty_point_entry()
against_psi_doc.make_loyalty_point_entry()
def validate_stock_availablility(self):
allow_negative_stock = frappe.db.get_value('Stock Settings', None, 'allow_negative_stock')
def check_phone_payments(self):
for pay in self.payments:
if pay.type == "Phone" and pay.amount >= 0:
paid_amt = frappe.db.get_value("Payment Request",
filters=dict(
reference_doctype="POS Invoice", reference_name=self.name,
mode_of_payment=pay.mode_of_payment, status="Paid"),
fieldname="grand_total")
if pay.amount != paid_amt:
return frappe.throw(_("Payment related to {0} is not completed").format(pay.mode_of_payment))
def validate_stock_availablility(self):
if self.is_return:
return
allow_negative_stock = frappe.db.get_value('Stock Settings', None, 'allow_negative_stock')
error_msg = []
for d in self.get('items'):
msg = ""
if d.serial_no:
filters = {
"item_code": d.item_code,
"warehouse": d.warehouse,
"delivery_document_no": "",
"sales_invoice": ""
}
filters = { "item_code": d.item_code, "warehouse": d.warehouse }
if d.batch_no:
filters["batch_no"] = d.batch_no
reserved_serial_nos, unreserved_serial_nos = get_pos_reserved_serial_nos(filters)
serial_nos = d.serial_no.split("\n")
serial_nos = ' '.join(serial_nos).split() # remove whitespaces
invalid_serial_nos = []
for s in serial_nos:
if s in reserved_serial_nos:
invalid_serial_nos.append(s)
if len(invalid_serial_nos):
multiple_nos = 's' if len(invalid_serial_nos) > 1 else ''
frappe.throw(_("Row #{}: Serial No{}. {} has already been transacted into another POS Invoice. Please select valid serial no.").format(
d.idx, multiple_nos, frappe.bold(', '.join(invalid_serial_nos))), title=_("Not Available"))
reserved_serial_nos = get_pos_reserved_serial_nos(filters)
serial_nos = get_serial_nos(d.serial_no)
invalid_serial_nos = [s for s in serial_nos if s in reserved_serial_nos]
bold_invalid_serial_nos = frappe.bold(', '.join(invalid_serial_nos))
if len(invalid_serial_nos) == 1:
msg = (_("Row #{}: Serial No. {} has already been transacted into another POS Invoice. Please select valid serial no.")
.format(d.idx, bold_invalid_serial_nos))
elif invalid_serial_nos:
msg = (_("Row #{}: Serial Nos. {} has already been transacted into another POS Invoice. Please select valid serial no.")
.format(d.idx, bold_invalid_serial_nos))
else:
if allow_negative_stock:
return
available_stock = get_stock_availability(d.item_code, d.warehouse)
if not (flt(available_stock) > 0):
frappe.throw(_('Row #{}: Item Code: {} is not available under warehouse {}.').format(
d.idx, frappe.bold(d.item_code), frappe.bold(d.warehouse)), title=_("Not Available"))
item_code, warehouse, qty = frappe.bold(d.item_code), frappe.bold(d.warehouse), frappe.bold(d.qty)
if flt(available_stock) <= 0:
msg = (_('Row #{}: Item Code: {} is not available under warehouse {}.').format(d.idx, item_code, warehouse))
elif flt(available_stock) < flt(d.qty):
frappe.msgprint(_('Row #{}: Stock quantity not enough for Item Code: {} under warehouse {}. Available quantity {}.').format(
d.idx, frappe.bold(d.item_code), frappe.bold(d.warehouse), frappe.bold(d.qty)), title=_("Not Available"))
msg = (_('Row #{}: Stock quantity not enough for Item Code: {} under warehouse {}. Available quantity {}.')
.format(d.idx, item_code, warehouse, qty))
if msg:
error_msg.append(msg)
if error_msg:
frappe.throw(error_msg, title=_("Item Unavailable"), as_list=True)
def validate_serialised_or_batched_item(self):
error_msg = []
for d in self.get("items"):
serialized = d.get("has_serial_no")
batched = d.get("has_batch_no")
no_serial_selected = not d.get("serial_no")
no_batch_selected = not d.get("batch_no")
msg = ""
item_code = frappe.bold(d.item_code)
if serialized and batched and (no_batch_selected or no_serial_selected):
frappe.throw(_('Row #{}: Please select a serial no and batch against item: {} or remove it to complete transaction.').format(
d.idx, frappe.bold(d.item_code)), title=_("Invalid Item"))
msg = (_('Row #{}: Please select a serial no and batch against item: {} or remove it to complete transaction.')
.format(d.idx, item_code))
if serialized and no_serial_selected:
frappe.throw(_('Row #{}: No serial number selected against item: {}. Please select one or remove it to complete transaction.').format(
d.idx, frappe.bold(d.item_code)), title=_("Invalid Item"))
msg = (_('Row #{}: No serial number selected against item: {}. Please select one or remove it to complete transaction.')
.format(d.idx, item_code))
if batched and no_batch_selected:
frappe.throw(_('Row #{}: No batch selected against item: {}. Please select a batch or remove it to complete transaction.').format(
d.idx, frappe.bold(d.item_code)), title=_("Invalid Item"))
msg = (_('Row #{}: No batch selected against item: {}. Please select a batch or remove it to complete transaction.')
.format(d.idx, item_code))
if msg:
error_msg.append(msg)
def validate_return_items(self):
if error_msg:
frappe.throw(error_msg, title=_("Invalid Item"), as_list=True)
def validate_return_items_qty(self):
if not self.get("is_return"): return
for d in self.get("items"):
if d.get("qty") > 0:
frappe.throw(_("Row #{}: You cannot add postive quantities in a return invoice. Please remove item {} to complete the return.")
.format(d.idx, frappe.bold(d.item_code)), title=_("Invalid Item"))
frappe.throw(
_("Row #{}: You cannot add postive quantities in a return invoice. Please remove item {} to complete the return.")
.format(d.idx, frappe.bold(d.item_code)), title=_("Invalid Item")
)
if d.get("serial_no"):
serial_nos = get_serial_nos(d.serial_no)
for sr in serial_nos:
serial_no_exists = frappe.db.exists("POS Invoice Item", {
"parent": self.return_against,
"serial_no": ["like", d.get("serial_no")]
})
if not serial_no_exists:
bold_return_against = frappe.bold(self.return_against)
bold_serial_no = frappe.bold(sr)
frappe.throw(
_("Row #{}: Serial No {} cannot be returned since it was not transacted in original invoice {}")
.format(d.idx, bold_serial_no, bold_return_against)
)
def validate_pos_paid_amount(self):
if len(self.payments) == 0 and self.is_pos:
def validate_mode_of_payment(self):
if len(self.payments) == 0:
frappe.throw(_("At least one mode of payment is required for POS invoice."))
def validate_change_account(self):
@ -151,20 +190,18 @@ class POSInvoice(SalesInvoice):
if flt(self.change_amount) and not self.account_for_change_amount:
frappe.msgprint(_("Please enter Account for Change Amount"), raise_exception=1)
def verify_payment_amount(self):
def validate_payment_amount(self):
total_amount_in_payments = 0
for entry in self.payments:
total_amount_in_payments += entry.amount
if not self.is_return and entry.amount < 0:
frappe.throw(_("Row #{0} (Payment Table): Amount must be positive").format(entry.idx))
if self.is_return and entry.amount > 0:
frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx))
def validate_pos_return(self):
if self.is_pos and self.is_return:
total_amount_in_payments = 0
for payment in self.payments:
total_amount_in_payments += payment.amount
if self.is_return:
invoice_total = self.rounded_total or self.grand_total
if total_amount_in_payments < invoice_total:
if total_amount_in_payments and total_amount_in_payments < invoice_total:
frappe.throw(_("Total payments amount can't be greater than {}").format(-invoice_total))
def validate_loyalty_transaction(self):
@ -219,55 +256,45 @@ class POSInvoice(SalesInvoice):
pos_profile = get_pos_profile(self.company) or {}
self.pos_profile = pos_profile.get('name')
pos = {}
profile = {}
if self.pos_profile:
pos = frappe.get_doc('POS Profile', self.pos_profile)
profile = frappe.get_doc('POS Profile', self.pos_profile)
if not self.get('payments') and not for_validate:
update_multi_mode_option(self, pos)
if not self.account_for_change_amount:
self.account_for_change_amount = frappe.get_cached_value('Company', self.company, 'default_cash_account')
if pos:
if not for_validate:
self.tax_category = pos.get("tax_category")
update_multi_mode_option(self, profile)
if self.is_return and not for_validate:
add_return_modes(self, profile)
if profile:
if not for_validate and not self.customer:
self.customer = pos.customer
self.customer = profile.customer
self.ignore_pricing_rule = pos.ignore_pricing_rule
if pos.get('account_for_change_amount'):
self.account_for_change_amount = pos.get('account_for_change_amount')
if pos.get('warehouse'):
self.set_warehouse = pos.get('warehouse')
self.ignore_pricing_rule = profile.ignore_pricing_rule
self.account_for_change_amount = profile.get('account_for_change_amount') or self.account_for_change_amount
self.set_warehouse = profile.get('warehouse') or self.set_warehouse
for fieldname in ('naming_series', 'currency', 'letter_head', 'tc_name',
for fieldname in ('currency', 'letter_head', 'tc_name',
'company', 'select_print_heading', 'write_off_account', 'taxes_and_charges',
'write_off_cost_center', 'apply_discount_on', 'cost_center'):
if (not for_validate) or (for_validate and not self.get(fieldname)):
self.set(fieldname, pos.get(fieldname))
if pos.get("company_address"):
self.company_address = pos.get("company_address")
'write_off_cost_center', 'apply_discount_on', 'cost_center', 'tax_category',
'ignore_pricing_rule', 'company_address', 'update_stock'):
if not for_validate:
self.set(fieldname, profile.get(fieldname))
if self.customer:
customer_price_list, customer_group = frappe.db.get_value("Customer", self.customer, ['default_price_list', 'customer_group'])
customer_group_price_list = frappe.db.get_value("Customer Group", customer_group, 'default_price_list')
selling_price_list = customer_price_list or customer_group_price_list or pos.get('selling_price_list')
selling_price_list = customer_price_list or customer_group_price_list or profile.get('selling_price_list')
else:
selling_price_list = pos.get('selling_price_list')
selling_price_list = profile.get('selling_price_list')
if selling_price_list:
self.set('selling_price_list', selling_price_list)
if not for_validate:
self.update_stock = cint(pos.get("update_stock"))
# set pos values in items
for item in self.get("items"):
if item.get('item_code'):
profile_details = get_pos_profile_item_details(pos, frappe._dict(item.as_dict()), pos)
profile_details = get_pos_profile_item_details(profile.get("company"), frappe._dict(item.as_dict()), profile)
for fname, val in iteritems(profile_details):
if (not for_validate) or (for_validate and not item.get(fname)):
item.set(fname, val)
@ -280,10 +307,13 @@ class POSInvoice(SalesInvoice):
if self.taxes_and_charges and not len(self.get("taxes")):
self.set_taxes()
return pos
if not self.account_for_change_amount:
self.account_for_change_amount = frappe.get_cached_value('Company', self.company, 'default_cash_account')
return profile
def set_missing_values(self, for_validate=False):
pos = self.set_pos_fields(for_validate)
profile = self.set_pos_fields(for_validate)
if not self.debit_to:
self.debit_to = get_party_account("Customer", self.customer, self.company)
@ -293,17 +323,15 @@ class POSInvoice(SalesInvoice):
super(SalesInvoice, self).set_missing_values(for_validate)
print_format = pos.get("print_format") if pos else None
print_format = profile.get("print_format") if profile else None
if not print_format and not cint(frappe.db.get_value('Print Format', 'POS Invoice', 'disabled')):
print_format = 'POS Invoice'
if pos:
if profile:
return {
"print_format": print_format,
"allow_edit_rate": pos.get("allow_user_to_edit_rate"),
"allow_edit_discount": pos.get("allow_user_to_edit_discount"),
"campaign": pos.get("campaign"),
"allow_print_before_pay": pos.get("allow_print_before_pay")
"campaign": profile.get("campaign"),
"allow_print_before_pay": profile.get("allow_print_before_pay")
}
def set_account_for_mode_of_payment(self):
@ -312,6 +340,32 @@ class POSInvoice(SalesInvoice):
if not pay.account:
pay.account = get_bank_cash_account(pay.mode_of_payment, self.company).get("account")
def create_payment_request(self):
for pay in self.payments:
if pay.type == "Phone":
if pay.amount <= 0:
frappe.throw(_("Payment amount cannot be less than or equal to 0"))
if not self.contact_mobile:
frappe.throw(_("Please enter the phone number first"))
payment_gateway = frappe.db.get_value("Payment Gateway Account", {
"payment_account": pay.account,
})
record = {
"payment_gateway": payment_gateway,
"dt": "POS Invoice",
"dn": self.name,
"payment_request_type": "Inward",
"party_type": "Customer",
"party": self.customer,
"mode_of_payment": pay.mode_of_payment,
"recipient_id": self.contact_mobile,
"submit_doc": True
}
return make_payment_request(**record)
@frappe.whitelist()
def get_stock_availability(item_code, warehouse):
latest_sle = frappe.db.sql("""select qty_after_transaction
@ -333,11 +387,9 @@ def get_stock_availability(item_code, warehouse):
sle_qty = latest_sle[0].qty_after_transaction or 0 if latest_sle else 0
pos_sales_qty = pos_sales_qty[0].qty or 0 if pos_sales_qty else 0
if sle_qty and pos_sales_qty and sle_qty > pos_sales_qty:
if sle_qty and pos_sales_qty:
return sle_qty - pos_sales_qty
else:
# when sle_qty is 0
# when sle_qty > 0 and pos_sales_qty is 0
return sle_qty
@frappe.whitelist()
@ -370,4 +422,19 @@ def make_merge_log(invoices):
})
if merge_log.get('pos_invoices'):
return merge_log.as_dict()
return merge_log.as_dict()
def add_return_modes(doc, pos_profile):
def append_payment(payment_mode):
payment = doc.append('payments', {})
payment.default = payment_mode.default
payment.mode_of_payment = payment_mode.parent
payment.account = payment_mode.default_account
payment.type = payment_mode.type
for pos_payment_method in pos_profile.get('payments'):
pos_payment_method = pos_payment_method.as_dict()
mode_of_payment = pos_payment_method.mode_of_payment
if pos_payment_method.allow_in_returns and not [d for d in doc.get('payments') if d.mode_of_payment == mode_of_payment]:
payment_mode = get_mode_of_payment_info(mode_of_payment, doc.company)
append_payment(payment_mode[0])

View File

@ -26,18 +26,25 @@ class POSInvoiceMergeLog(Document):
for d in self.pos_invoices:
status, docstatus, is_return, return_against = frappe.db.get_value(
'POS Invoice', d.pos_invoice, ['status', 'docstatus', 'is_return', 'return_against'])
bold_pos_invoice = frappe.bold(d.pos_invoice)
bold_status = frappe.bold(status)
if docstatus != 1:
frappe.throw(_("Row #{}: POS Invoice {} is not submitted yet").format(d.idx, d.pos_invoice))
frappe.throw(_("Row #{}: POS Invoice {} is not submitted yet").format(d.idx, bold_pos_invoice))
if status == "Consolidated":
frappe.throw(_("Row #{}: POS Invoice {} has been {}").format(d.idx, d.pos_invoice, status))
if is_return and return_against not in [d.pos_invoice for d in self.pos_invoices] and status != "Consolidated":
# if return entry is not getting merged in the current pos closing and if it is not consolidated
frappe.throw(
_("Row #{}: Return Invoice {} cannot be made against unconsolidated invoice. \
You can add original invoice {} manually to proceed.")
.format(d.idx, frappe.bold(d.pos_invoice), frappe.bold(return_against))
)
frappe.throw(_("Row #{}: POS Invoice {} has been {}").format(d.idx, bold_pos_invoice, bold_status))
if is_return and return_against and return_against not in [d.pos_invoice for d in self.pos_invoices]:
bold_return_against = frappe.bold(return_against)
return_against_status = frappe.db.get_value('POS Invoice', return_against, "status")
if return_against_status != "Consolidated":
# if return entry is not getting merged in the current pos closing and if it is not consolidated
bold_unconsolidated = frappe.bold("not Consolidated")
msg = (_("Row #{}: Original Invoice {} of return invoice {} is {}. ")
.format(d.idx, bold_return_against, bold_pos_invoice, bold_unconsolidated))
msg += _("Original invoice should be consolidated before or along with the return invoice.")
msg += "<br><br>"
msg += _("You can add original invoice {} manually to proceed.").format(bold_return_against)
frappe.throw(msg)
def on_submit(self):
pos_invoice_docs = [frappe.get_doc("POS Invoice", d.pos_invoice) for d in self.pos_invoices]

View File

@ -17,18 +17,25 @@ class POSOpeningEntry(StatusUpdater):
def validate_pos_profile_and_cashier(self):
if self.company != frappe.db.get_value("POS Profile", self.pos_profile, "company"):
frappe.throw(_("POS Profile {} does not belongs to company {}".format(self.pos_profile, self.company)))
frappe.throw(_("POS Profile {} does not belongs to company {}").format(self.pos_profile, self.company))
if not cint(frappe.db.get_value("User", self.user, "enabled")):
frappe.throw(_("User {} has been disabled. Please select valid user/cashier".format(self.user)))
frappe.throw(_("User {} is disabled. Please select valid user/cashier").format(self.user))
def validate_payment_method_account(self):
invalid_modes = []
for d in self.balance_details:
account = frappe.db.get_value("Mode of Payment Account",
{"parent": d.mode_of_payment, "company": self.company}, "default_account")
if not account:
frappe.throw(_("Please set default Cash or Bank account in Mode of Payment {0}")
.format(get_link_to_form("Mode of Payment", mode_of_payment)), title=_("Missing Account"))
invalid_modes.append(get_link_to_form("Mode of Payment", d.mode_of_payment))
if invalid_modes:
if invalid_modes == 1:
msg = _("Please set default Cash or Bank account in Mode of Payment {}")
else:
msg = _("Please set default Cash or Bank account in Mode of Payments {}")
frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account"))
def on_submit(self):
self.set_status(update=True)

View File

@ -6,6 +6,7 @@
"engine": "InnoDB",
"field_order": [
"default",
"allow_in_returns",
"mode_of_payment"
],
"fields": [
@ -24,11 +25,19 @@
"label": "Mode of Payment",
"options": "Mode of Payment",
"reqd": 1
},
{
"default": "0",
"fieldname": "allow_in_returns",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Allow In Returns"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2020-05-29 15:08:41.704844",
"modified": "2020-10-20 12:58:46.114456",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Payment Method",

View File

@ -290,28 +290,30 @@
"fieldname": "warehouse",
"fieldtype": "Link",
"label": "Warehouse",
"mandatory_depends_on": "update_stock",
"oldfieldname": "warehouse",
"oldfieldtype": "Link",
"options": "Warehouse"
},
{
"default": "0",
"fieldname": "update_stock",
"fieldtype": "Check",
"label": "Update Stock"
"options": "Warehouse",
"reqd": 1
},
{
"default": "0",
"fieldname": "ignore_pricing_rule",
"fieldtype": "Check",
"label": "Ignore Pricing Rule"
},
{
"default": "1",
"fieldname": "update_stock",
"fieldtype": "Check",
"label": "Update Stock",
"read_only": 1
}
],
"icon": "icon-cog",
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2020-10-01 17:29:27.759088",
"modified": "2020-10-20 13:16:50.665081",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Profile",

View File

@ -56,19 +56,29 @@ class POSProfile(Document):
if not self.payments:
frappe.throw(_("Payment methods are mandatory. Please add at least one payment method."))
default_mode_of_payment = [d.default for d in self.payments if d.default]
if not default_mode_of_payment:
default_mode = [d.default for d in self.payments if d.default]
if not default_mode:
frappe.throw(_("Please select a default mode of payment"))
if len(default_mode_of_payment) > 1:
if len(default_mode) > 1:
frappe.throw(_("You can only select one mode of payment as default"))
invalid_modes = []
for d in self.payments:
account = frappe.db.get_value("Mode of Payment Account",
{"parent": d.mode_of_payment, "company": self.company}, "default_account")
account = frappe.db.get_value(
"Mode of Payment Account",
{"parent": d.mode_of_payment, "company": self.company},
"default_account"
)
if not account:
frappe.throw(_("Please set default Cash or Bank account in Mode of Payment {0}")
.format(get_link_to_form("Mode of Payment", mode_of_payment)), title=_("Missing Account"))
invalid_modes.append(get_link_to_form("Mode of Payment", d.mode_of_payment))
if invalid_modes:
if invalid_modes == 1:
msg = _("Please set default Cash or Bank account in Mode of Payment {}")
else:
msg = _("Please set default Cash or Bank account in Mode of Payments {}")
frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account"))
def on_update(self):
self.set_defaults()

View File

@ -9,8 +9,7 @@ frappe.ui.form.on('POS Settings', {
get_invoice_fields: function(frm) {
frappe.model.with_doctype("POS Invoice", () => {
var fields = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function(d) {
if (frappe.model.no_value_type.indexOf(d.fieldtype) === -1 ||
['Table', 'Button'].includes(d.fieldtype)) {
if (frappe.model.no_value_type.indexOf(d.fieldtype) === -1 || ['Button'].includes(d.fieldtype)) {
return { label: d.label + ' (' + d.fieldtype + ')', value: d.fieldname };
} else {
return null;

View File

@ -151,14 +151,16 @@ class PurchaseInvoice(BuyingController):
["account_type", "report_type", "account_currency"], as_dict=True)
if account.report_type != "Balance Sheet":
frappe.throw(_("Please ensure {} account is a Balance Sheet account. \
You can change the parent account to a Balance Sheet account or select a different account.")
.format(frappe.bold("Credit To")), title=_("Invalid Account"))
frappe.throw(
_("Please ensure {} account is a Balance Sheet account. You can change the parent account to a Balance Sheet account or select a different account.")
.format(frappe.bold("Credit To")), title=_("Invalid Account")
)
if self.supplier and account.account_type != "Payable":
frappe.throw(_("Please ensure {} account is a Payable account. \
Change the account type to Payable or select a different account.")
.format(frappe.bold("Credit To")), title=_("Invalid Account"))
frappe.throw(
_("Please ensure {} account is a Payable account. Change the account type to Payable or select a different account.")
.format(frappe.bold("Credit To")), title=_("Invalid Account")
)
self.party_account_currency = account.account_currency
@ -244,10 +246,10 @@ class PurchaseInvoice(BuyingController):
if self.update_stock and (not item.from_warehouse):
if for_validate and item.expense_account and item.expense_account != warehouse_account[item.warehouse]["account"]:
frappe.msgprint(_('''Row {0}: Expense Head changed to {1} because account {2}
is not linked to warehouse {3} or it is not the default inventory account'''.format(
item.idx, frappe.bold(warehouse_account[item.warehouse]["account"]),
frappe.bold(item.expense_account), frappe.bold(item.warehouse))))
msg = _("Row {}: Expense Head changed to {} ").format(item.idx, frappe.bold(warehouse_account[item.warehouse]["account"]))
msg += _("because account {} is not linked to warehouse {} ").format(frappe.bold(item.expense_account), frappe.bold(item.warehouse))
msg += _("or it is not the default inventory account")
frappe.msgprint(msg, title=_("Expense Head Changed"))
item.expense_account = warehouse_account[item.warehouse]["account"]
else:
@ -259,19 +261,19 @@ class PurchaseInvoice(BuyingController):
if negative_expense_booked_in_pr:
if for_validate and item.expense_account and item.expense_account != stock_not_billed_account:
frappe.msgprint(_('''Row {0}: Expense Head changed to {1} because
expense is booked against this account in Purchase Receipt {2}'''.format(
item.idx, frappe.bold(stock_not_billed_account), frappe.bold(item.purchase_receipt))))
msg = _("Row {}: Expense Head changed to {} ").format(item.idx, frappe.bold(stock_not_billed_account))
msg += _("because expense is booked against this account in Purchase Receipt {}").format(frappe.bold(item.purchase_receipt))
frappe.msgprint(msg, title=_("Expense Head Changed"))
item.expense_account = stock_not_billed_account
else:
# If no purchase receipt present then book expense in 'Stock Received But Not Billed'
# This is done in cases when Purchase Invoice is created before Purchase Receipt
if for_validate and item.expense_account and item.expense_account != stock_not_billed_account:
frappe.msgprint(_('''Row {0}: Expense Head changed to {1} as no Purchase
Receipt is created against Item {2}. This is done to handle accounting for cases
when Purchase Receipt is created after Purchase Invoice'''.format(
item.idx, frappe.bold(stock_not_billed_account), frappe.bold(item.item_code))))
msg = _("Row {}: Expense Head changed to {} ").format(item.idx, frappe.bold(stock_not_billed_account))
msg += _("as no Purchase Receipt is created against Item {}. ").format(frappe.bold(item.item_code))
msg += _("This is done to handle accounting for cases when Purchase Receipt is created after Purchase Invoice")
frappe.msgprint(msg, title=_("Expense Head Changed"))
item.expense_account = stock_not_billed_account
@ -299,10 +301,11 @@ class PurchaseInvoice(BuyingController):
for d in self.get('items'):
if not d.purchase_order:
throw(_("""Purchase Order Required for item {0}
To submit the invoice without purchase order please set
{1} as {2} in {3}""").format(frappe.bold(d.item_code), frappe.bold(_('Purchase Order Required')),
frappe.bold('No'), get_link_to_form('Buying Settings', 'Buying Settings', 'Buying Settings')))
msg = _("Purchase Order Required for item {}").format(frappe.bold(d.item_code))
msg += "<br><br>"
msg += _("To submit the invoice without purchase order please set {} ").format(frappe.bold(_('Purchase Order Required')))
msg += _("as {} in {}").format(frappe.bold('No'), get_link_to_form('Buying Settings', 'Buying Settings', 'Buying Settings'))
throw(msg, title=_("Mandatory Purchase Order"))
def pr_required(self):
stock_items = self.get_stock_items()
@ -313,10 +316,11 @@ class PurchaseInvoice(BuyingController):
for d in self.get('items'):
if not d.purchase_receipt and d.item_code in stock_items:
throw(_("""Purchase Receipt Required for item {0}
To submit the invoice without purchase receipt please set
{1} as {2} in {3}""").format(frappe.bold(d.item_code), frappe.bold(_('Purchase Receipt Required')),
frappe.bold('No'), get_link_to_form('Buying Settings', 'Buying Settings', 'Buying Settings')))
msg = _("Purchase Receipt Required for item {}").format(frappe.bold(d.item_code))
msg += "<br><br>"
msg += _("To submit the invoice without purchase receipt please set {} ").format(frappe.bold(_('Purchase Receipt Required')))
msg += _("as {} in {}").format(frappe.bold('No'), get_link_to_form('Buying Settings', 'Buying Settings', 'Buying Settings'))
throw(msg, title=_("Mandatory Purchase Receipt"))
def validate_write_off_account(self):
if self.write_off_amount and not self.write_off_account:

View File

@ -479,14 +479,14 @@ class SalesInvoice(SellingController):
frappe.throw(_("Debit To is required"), title=_("Account Missing"))
if account.report_type != "Balance Sheet":
frappe.throw(_("Please ensure {} account is a Balance Sheet account. \
You can change the parent account to a Balance Sheet account or select a different account.")
.format(frappe.bold("Debit To")), title=_("Invalid Account"))
msg = _("Please ensure {} account is a Balance Sheet account. ").format(frappe.bold("Debit To"))
msg += _("You can change the parent account to a Balance Sheet account or select a different account.")
frappe.throw(msg, title=_("Invalid Account"))
if self.customer and account.account_type != "Receivable":
frappe.throw(_("Please ensure {} account is a Receivable account. \
Change the account type to Receivable or select a different account.")
.format(frappe.bold("Debit To")), title=_("Invalid Account"))
msg = _("Please ensure {} account is a Receivable account. ").format(frappe.bold("Debit To"))
msg += _("Change the account type to Receivable or select a different account.")
frappe.throw(msg, title=_("Invalid Account"))
self.party_account_currency = account.account_currency
@ -1141,8 +1141,10 @@ class SalesInvoice(SellingController):
where redeem_against=%s''', (lp_entry[0].name), as_dict=1)
if against_lp_entry:
invoice_list = ", ".join([d.invoice for d in against_lp_entry])
frappe.throw(_('''{} can't be cancelled since the Loyalty Points earned has been redeemed.
First cancel the {} No {}''').format(self.doctype, self.doctype, invoice_list))
frappe.throw(
_('''{} can't be cancelled since the Loyalty Points earned has been redeemed. First cancel the {} No {}''')
.format(self.doctype, self.doctype, invoice_list)
)
else:
frappe.db.sql('''delete from `tabLoyalty Point Entry` where invoice=%s''', (self.name))
# Set loyalty program
@ -1613,17 +1615,25 @@ def update_multi_mode_option(doc, pos_profile):
payment.type = payment_mode.type
doc.set('payments', [])
invalid_modes = []
for pos_payment_method in pos_profile.get('payments'):
pos_payment_method = pos_payment_method.as_dict()
payment_mode = get_mode_of_payment_info(pos_payment_method.mode_of_payment, doc.company)
if not payment_mode:
frappe.throw(_("Please set default Cash or Bank account in Mode of Payment {0}")
.format(get_link_to_form("Mode of Payment", pos_payment_method.mode_of_payment)), title=_("Missing Account"))
invalid_modes.append(get_link_to_form("Mode of Payment", pos_payment_method.mode_of_payment))
continue
payment_mode[0].default = pos_payment_method.default
append_payment(payment_mode[0])
if invalid_modes:
if invalid_modes == 1:
msg = _("Please set default Cash or Bank account in Mode of Payment {}")
else:
msg = _("Please set default Cash or Bank account in Mode of Payments {}")
frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account"))
def get_all_mode_of_payments(doc):
return frappe.db.sql("""
select mpa.default_account, mpa.parent, mp.type as type

View File

@ -243,7 +243,11 @@ def check_amount_vs_description(amount_matching, description_matching):
continue
if "reference_no" in am_match and "reference_no" in des_match:
if difflib.SequenceMatcher(lambda x: x == " ", am_match["reference_no"], des_match["reference_no"]).ratio() > 70:
# Sequence Matcher does not handle None as input
am_reference = am_match["reference_no"] or ""
des_reference = des_match["reference_no"] or ""
if difflib.SequenceMatcher(lambda x: x == " ", am_reference, des_reference).ratio() > 70:
if am_match not in result:
result.append(am_match)
if result:

View File

@ -307,7 +307,7 @@ def get_accounts(company, root_type):
where company=%s and root_type=%s order by lft""", (company, root_type), as_dict=True)
def filter_accounts(accounts, depth=10):
def filter_accounts(accounts, depth=20):
parent_children_map = {}
accounts_by_name = {}
for d in accounts:

View File

@ -63,6 +63,7 @@ def get_pos_entries(filters, group_by_field):
FROM
`tabPOS Invoice` p {from_sales_invoice_payment}
WHERE
p.docstatus = 1 and
{group_by_mop_condition}
{conditions}
ORDER BY

View File

@ -796,7 +796,7 @@ def get_children(doctype, parent, company, is_root=False):
return acc
def create_payment_gateway_account(gateway):
def create_payment_gateway_account(gateway, payment_channel="Email"):
from erpnext.setup.setup_wizard.operations.company_setup import create_bank_account
company = frappe.db.get_value("Global Defaults", None, "default_company")
@ -831,7 +831,8 @@ def create_payment_gateway_account(gateway):
"is_default": 1,
"payment_gateway": gateway,
"payment_account": bank_account.name,
"currency": bank_account.account_currency
"currency": bank_account.account_currency,
"payment_channel": payment_channel
}).insert(ignore_permissions=True)
except frappe.DuplicateEntryError:

View File

@ -46,26 +46,26 @@
{
"fieldname": "po_required",
"fieldtype": "Select",
"label": "Purchase Order Required for Purchase Invoice & Receipt Creation",
"label": "Is Purchase Order Required for Purchase Invoice & Receipt Creation?",
"options": "No\nYes"
},
{
"fieldname": "pr_required",
"fieldtype": "Select",
"label": "Purchase Receipt Required for Purchase Invoice Creation",
"label": "Is Purchase Receipt Required for Purchase Invoice Creation?",
"options": "No\nYes"
},
{
"default": "0",
"fieldname": "maintain_same_rate",
"fieldtype": "Check",
"label": "Maintain same rate throughout purchase cycle"
"label": "Maintain Same Rate Throughout the Purchase Cycle"
},
{
"default": "0",
"fieldname": "allow_multiple_items",
"fieldtype": "Check",
"label": "Allow Item to be added multiple times in a transaction"
"label": "Allow Item To Be Added Multiple Times in a Transaction"
},
{
"fieldname": "subcontract",
@ -93,9 +93,10 @@
],
"icon": "fa fa-cog",
"idx": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2020-05-15 14:49:32.513611",
"modified": "2020-10-13 12:00:23.276329",
"modified_by": "Administrator",
"module": "Buying",
"name": "Buying Settings",
@ -113,4 +114,4 @@
],
"sort_field": "modified",
"sort_order": "DESC"
}
}

View File

@ -19,6 +19,8 @@ from erpnext.controllers.accounts_controller import update_child_qty_rate
from erpnext.controllers.status_updater import OverAllowanceError
from erpnext.manufacturing.doctype.blanket_order.test_blanket_order import make_blanket_order
from erpnext.stock.doctype.batch.test_batch import make_new_batch
from erpnext.controllers.buying_controller import get_backflushed_subcontracted_raw_materials
class TestPurchaseOrder(unittest.TestCase):
def test_make_purchase_receipt(self):
@ -686,7 +688,7 @@ class TestPurchaseOrder(unittest.TestCase):
def test_exploded_items_in_subcontracted(self):
item_code = "_Test Subcontracted FG Item 1"
make_subcontracted_item(item_code)
make_subcontracted_item(item_code=item_code)
po = create_purchase_order(item_code=item_code, qty=1,
is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC", include_exploded_items=1)
@ -708,7 +710,7 @@ class TestPurchaseOrder(unittest.TestCase):
def test_backflush_based_on_stock_entry(self):
item_code = "_Test Subcontracted FG Item 1"
make_subcontracted_item(item_code)
make_subcontracted_item(item_code=item_code)
make_item('Sub Contracted Raw Material 1', {
'is_stock_item': 1,
'is_sub_contracted_item': 1
@ -767,6 +769,129 @@ class TestPurchaseOrder(unittest.TestCase):
update_backflush_based_on("BOM")
def test_backflushed_based_on_for_multiple_batches(self):
item_code = "_Test Subcontracted FG Item 2"
make_item('Sub Contracted Raw Material 2', {
'is_stock_item': 1,
'is_sub_contracted_item': 1
})
make_subcontracted_item(item_code=item_code, has_batch_no=1, create_new_batch=1,
raw_materials=["Sub Contracted Raw Material 2"])
update_backflush_based_on("Material Transferred for Subcontract")
order_qty = 500
po = create_purchase_order(item_code=item_code, qty=order_qty,
is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC")
make_stock_entry(target="_Test Warehouse - _TC",
item_code = "Sub Contracted Raw Material 2", qty=552, basic_rate=100)
rm_items = [
{"item_code":item_code,"rm_item_code":"Sub Contracted Raw Material 2","item_name":"_Test Item",
"qty":552,"warehouse":"_Test Warehouse - _TC", "stock_uom":"Nos"}]
rm_item_string = json.dumps(rm_items)
se = frappe.get_doc(make_subcontract_transfer_entry(po.name, rm_item_string))
se.submit()
for batch in ["ABCD1", "ABCD2", "ABCD3", "ABCD4"]:
make_new_batch(batch_id=batch, item_code=item_code)
pr = make_purchase_receipt(po.name)
# partial receipt
pr.get('items')[0].qty = 30
pr.get('items')[0].batch_no = "ABCD1"
purchase_order = po.name
purchase_order_item = po.items[0].name
for batch_no, qty in {"ABCD2": 60, "ABCD3": 70, "ABCD4":40}.items():
pr.append("items", {
"item_code": pr.get('items')[0].item_code,
"item_name": pr.get('items')[0].item_name,
"uom": pr.get('items')[0].uom,
"stock_uom": pr.get('items')[0].stock_uom,
"warehouse": pr.get('items')[0].warehouse,
"conversion_factor": pr.get('items')[0].conversion_factor,
"cost_center": pr.get('items')[0].cost_center,
"rate": pr.get('items')[0].rate,
"qty": qty,
"batch_no": batch_no,
"purchase_order": purchase_order,
"purchase_order_item": purchase_order_item
})
pr.submit()
pr1 = make_purchase_receipt(po.name)
pr1.get('items')[0].qty = 300
pr1.get('items')[0].batch_no = "ABCD1"
pr1.save()
pr_key = ("Sub Contracted Raw Material 2", po.name)
consumed_qty = get_backflushed_subcontracted_raw_materials([po.name]).get(pr_key)
self.assertTrue(pr1.supplied_items[0].consumed_qty > 0)
self.assertTrue(pr1.supplied_items[0].consumed_qty, flt(552.0) - flt(consumed_qty))
update_backflush_based_on("BOM")
def test_supplied_qty_against_subcontracted_po(self):
item_code = "_Test Subcontracted FG Item 5"
make_item('Sub Contracted Raw Material 4', {
'is_stock_item': 1,
'is_sub_contracted_item': 1
})
make_subcontracted_item(item_code=item_code, raw_materials=["Sub Contracted Raw Material 4"])
update_backflush_based_on("Material Transferred for Subcontract")
order_qty = 250
po = create_purchase_order(item_code=item_code, qty=order_qty,
is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC", do_not_save=True)
# Add same subcontracted items multiple times
po.append("items", {
"item_code": item_code,
"qty": order_qty,
"schedule_date": add_days(nowdate(), 1),
"warehouse": "_Test Warehouse - _TC"
})
po.set_missing_values()
po.submit()
# Material receipt entry for the raw materials which will be send to supplier
make_stock_entry(target="_Test Warehouse - _TC",
item_code = "Sub Contracted Raw Material 4", qty=500, basic_rate=100)
rm_items = [
{
"item_code":item_code,"rm_item_code":"Sub Contracted Raw Material 4","item_name":"_Test Item",
"qty":250,"warehouse":"_Test Warehouse - _TC", "stock_uom":"Nos", "name": po.supplied_items[0].name
},
{
"item_code":item_code,"rm_item_code":"Sub Contracted Raw Material 4","item_name":"_Test Item",
"qty":250,"warehouse":"_Test Warehouse - _TC", "stock_uom":"Nos", "name": po.supplied_items[1].name
},
]
# Raw Materials transfer entry from stores to supplier's warehouse
rm_item_string = json.dumps(rm_items)
se = frappe.get_doc(make_subcontract_transfer_entry(po.name, rm_item_string))
se.submit()
po_doc = frappe.get_doc("Purchase Order", po.name)
for row in po_doc.supplied_items:
# Valid that whether transferred quantity is matching with supplied qty or not in the purchase order
self.assertEqual(row.supplied_qty, 250.0)
update_backflush_based_on("BOM")
def test_advance_payment_entry_unlink_against_purchase_order(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
frappe.db.set_value("Accounts Settings", "Accounts Settings",
@ -839,27 +964,33 @@ def make_pr_against_po(po, received_qty=0):
pr.submit()
return pr
def make_subcontracted_item(item_code):
def make_subcontracted_item(**args):
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
if not frappe.db.exists('Item', item_code):
make_item(item_code, {
args = frappe._dict(args)
if not frappe.db.exists('Item', args.item_code):
make_item(args.item_code, {
'is_stock_item': 1,
'is_sub_contracted_item': 1
'is_sub_contracted_item': 1,
'has_batch_no': args.get("has_batch_no") or 0
})
if not frappe.db.exists('Item', "Test Extra Item 1"):
make_item("Test Extra Item 1", {
'is_stock_item': 1,
})
if not args.raw_materials:
if not frappe.db.exists('Item', "Test Extra Item 1"):
make_item("Test Extra Item 1", {
'is_stock_item': 1,
})
if not frappe.db.exists('Item', "Test Extra Item 2"):
make_item("Test Extra Item 2", {
'is_stock_item': 1,
})
if not frappe.db.exists('Item', "Test Extra Item 2"):
make_item("Test Extra Item 2", {
'is_stock_item': 1,
})
if not frappe.db.get_value('BOM', {'item': item_code}, 'name'):
make_bom(item = item_code, raw_materials = ['_Test FG Item', 'Test Extra Item 1'])
args.raw_materials = ['_Test FG Item', 'Test Extra Item 1']
if not frappe.db.get_value('BOM', {'item': args.item_code}, 'name'):
make_bom(item = args.item_code, raw_materials = args.get("raw_materials"))
def update_backflush_based_on(based_on):
doc = frappe.get_doc('Buying Settings')

View File

@ -29,14 +29,12 @@ frappe.ui.form.on("Request for Quotation",{
refresh: function(frm, cdt, cdn) {
if (frm.doc.docstatus === 1) {
frm.add_custom_button(__('Create'),
function(){ frm.trigger("make_suppplier_quotation") }, __("Supplier Quotation"));
frm.add_custom_button(__("View"),
function(){ frappe.set_route('List', 'Supplier Quotation',
{'request_for_quotation': frm.doc.name}) }, __("Supplier Quotation"));
frm.add_custom_button(__('Supplier Quotation'),
function(){ frm.trigger("make_suppplier_quotation") }, __("Create"));
frm.add_custom_button(__("Send Supplier Emails"), function() {
frm.add_custom_button(__("Send Emails to Suppliers"), function() {
frappe.call({
method: 'erpnext.buying.doctype.request_for_quotation.request_for_quotation.send_supplier_emails',
freeze: true,
@ -47,150 +45,82 @@ frappe.ui.form.on("Request for Quotation",{
frm.reload_doc();
}
});
});
}, __("Tools"));
frm.add_custom_button(__('Download PDF'), () => {
var suppliers = [];
const fields = [{
fieldtype: 'Link',
label: __('Select a Supplier'),
fieldname: 'supplier',
options: 'Supplier',
reqd: 1,
get_query: () => {
return {
filters: [
["Supplier", "name", "in", frm.doc.suppliers.map((row) => {return row.supplier;})]
]
}
}
}];
frappe.prompt(fields, data => {
var child = locals[cdt][cdn]
var w = window.open(
frappe.urllib.get_full_url("/api/method/erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_pdf?"
+"doctype="+encodeURIComponent(frm.doc.doctype)
+"&name="+encodeURIComponent(frm.doc.name)
+"&supplier="+encodeURIComponent(data.supplier)
+"&no_letterhead=0"));
if(!w) {
frappe.msgprint(__("Please enable pop-ups")); return;
}
},
'Download PDF for Supplier',
'Download');
},
__("Tools"));
frm.page.set_inner_btn_group_as_primary(__('Create'));
}
},
get_suppliers_button: function (frm) {
var doc = frm.doc;
var dialog = new frappe.ui.Dialog({
title: __("Get Suppliers"),
fields: [
{
"fieldtype": "Select", "label": __("Get Suppliers By"),
"fieldname": "search_type",
"options": ["Tag","Supplier Group"],
"reqd": 1,
onchange() {
if(dialog.get_value('search_type') == 'Tag'){
frappe.call({
method: 'erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_supplier_tag',
}).then(r => {
dialog.set_df_property("tag", "options", r.message)
});
}
}
},
{
"fieldtype": "Link", "label": __("Supplier Group"),
"fieldname": "supplier_group",
"options": "Supplier Group",
"reqd": 0,
"depends_on": "eval:doc.search_type == 'Supplier Group'"
},
{
"fieldtype": "Select", "label": __("Tag"),
"fieldname": "tag",
"reqd": 0,
"depends_on": "eval:doc.search_type == 'Tag'",
},
{
"fieldtype": "Button", "label": __("Add All Suppliers"),
"fieldname": "add_suppliers"
},
]
});
dialog.fields_dict.add_suppliers.$input.click(function() {
var args = dialog.get_values();
if(!args) return;
dialog.hide();
//Remove blanks
for (var j = 0; j < frm.doc.suppliers.length; j++) {
if(!frm.doc.suppliers[j].hasOwnProperty("supplier")) {
frm.get_field("suppliers").grid.grid_rows[j].remove();
}
}
function load_suppliers(r) {
if(r.message) {
for (var i = 0; i < r.message.length; i++) {
var exists = false;
if (r.message[i].constructor === Array){
var supplier = r.message[i][0];
} else {
var supplier = r.message[i].name;
}
for (var j = 0; j < doc.suppliers.length;j++) {
if (supplier === doc.suppliers[j].supplier) {
exists = true;
}
}
if(!exists) {
var d = frm.add_child('suppliers');
d.supplier = supplier;
frm.script_manager.trigger("supplier", d.doctype, d.name);
}
}
}
frm.refresh_field("suppliers");
}
if (args.search_type === "Tag" && args.tag) {
return frappe.call({
type: "GET",
method: "frappe.desk.doctype.tag.tag.get_tagged_docs",
args: {
"doctype": "Supplier",
"tag": args.tag
},
callback: load_suppliers
});
} else if (args.supplier_group) {
return frappe.call({
method: "frappe.client.get_list",
args: {
doctype: "Supplier",
order_by: "name",
fields: ["name"],
filters: [["Supplier", "supplier_group", "=", args.supplier_group]]
},
callback: load_suppliers
});
}
});
dialog.show();
},
make_suppplier_quotation: function(frm) {
var doc = frm.doc;
var dialog = new frappe.ui.Dialog({
title: __("For Supplier"),
title: __("Create Supplier Quotation"),
fields: [
{ "fieldtype": "Select", "label": __("Supplier"),
"fieldname": "supplier",
"options": doc.suppliers.map(d => d.supplier),
"reqd": 1,
"default": doc.suppliers.length === 1 ? doc.suppliers[0].supplier_name : "" },
{ "fieldtype": "Button", "label": __('Create Supplier Quotation'),
"fieldname": "make_supplier_quotation", "cssClass": "btn-primary" },
]
],
primary_action_label: __("Create"),
primary_action: (args) => {
if(!args) return;
dialog.hide();
return frappe.call({
type: "GET",
method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.make_supplier_quotation_from_rfq",
args: {
"source_name": doc.name,
"for_supplier": args.supplier
},
freeze: true,
callback: function(r) {
if(!r.exc) {
var doc = frappe.model.sync(r.message);
frappe.set_route("Form", r.message.doctype, r.message.name);
}
}
});
}
});
dialog.fields_dict.make_supplier_quotation.$input.click(function() {
var args = dialog.get_values();
if(!args) return;
dialog.hide();
return frappe.call({
type: "GET",
method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.make_supplier_quotation_from_rfq",
args: {
"source_name": doc.name,
"for_supplier": args.supplier
},
freeze: true,
callback: function(r) {
if(!r.exc) {
var doc = frappe.model.sync(r.message);
frappe.set_route("Form", r.message.doctype, r.message.name);
}
}
});
});
dialog.show()
},
@ -273,42 +203,6 @@ frappe.ui.form.on("Request for Quotation Supplier",{
})
},
download_pdf: function(frm, cdt, cdn) {
var child = locals[cdt][cdn]
var w = window.open(
frappe.urllib.get_full_url("/api/method/erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_pdf?"
+"doctype="+encodeURIComponent(frm.doc.doctype)
+"&name="+encodeURIComponent(frm.doc.name)
+"&supplier_idx="+encodeURIComponent(child.idx)
+"&no_letterhead=0"));
if(!w) {
frappe.msgprint(__("Please enable pop-ups")); return;
}
},
no_quote: function(frm, cdt, cdn) {
var d = locals[cdt][cdn];
if (d.no_quote) {
if (d.quote_status != __('Received')) {
frappe.model.set_value(cdt, cdn, 'quote_status', 'No Quote');
} else {
frappe.msgprint(__("Cannot set a received RFQ to No Quote"));
frappe.model.set_value(cdt, cdn, 'no_quote', 0);
}
} else {
d.quote_status = __('Pending');
frm.call({
method:"update_rfq_supplier_status",
doc: frm.doc,
args: {
sup_name: d.supplier
},
callback: function(r) {
frm.refresh_field("suppliers");
}
});
}
}
})
erpnext.buying.RequestforQuotationController = erpnext.buying.BuyingController.extend({
@ -332,7 +226,8 @@ erpnext.buying.RequestforQuotationController = erpnext.buying.BuyingController.e
per_ordered: ["<", 99.99]
}
})
}, __("Get items from"));
}, __("Get Items From"));
// Get items from Opportunity
this.frm.add_custom_button(__('Opportunity'),
function() {
@ -344,7 +239,8 @@ erpnext.buying.RequestforQuotationController = erpnext.buying.BuyingController.e
company: me.frm.doc.company
},
})
}, __("Get items from"));
}, __("Get Items From"));
// Get items from open Material Requests based on supplier
this.frm.add_custom_button(__('Possible Supplier'), function() {
// Create a dialog window for the user to pick their supplier
@ -382,8 +278,13 @@ erpnext.buying.RequestforQuotationController = erpnext.buying.BuyingController.e
}
}
d.show();
}, __("Get items from"));
}, __("Get Items From"));
// Get Suppliers
this.frm.add_custom_button(__('Get Suppliers'),
function() {
me.get_suppliers_button(me.frm);
});
}
},
@ -393,9 +294,108 @@ erpnext.buying.RequestforQuotationController = erpnext.buying.BuyingController.e
tc_name: function() {
this.get_terms();
}
});
},
get_suppliers_button: function (frm) {
var doc = frm.doc;
var dialog = new frappe.ui.Dialog({
title: __("Get Suppliers"),
fields: [
{
"fieldtype": "Select", "label": __("Get Suppliers By"),
"fieldname": "search_type",
"options": ["Tag","Supplier Group"],
"reqd": 1,
onchange() {
if(dialog.get_value('search_type') == 'Tag'){
frappe.call({
method: 'erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_supplier_tag',
}).then(r => {
dialog.set_df_property("tag", "options", r.message)
});
}
}
},
{
"fieldtype": "Link", "label": __("Supplier Group"),
"fieldname": "supplier_group",
"options": "Supplier Group",
"reqd": 0,
"depends_on": "eval:doc.search_type == 'Supplier Group'"
},
{
"fieldtype": "Select", "label": __("Tag"),
"fieldname": "tag",
"reqd": 0,
"depends_on": "eval:doc.search_type == 'Tag'",
}
],
primary_action_label: __("Add Suppliers"),
primary_action : (args) => {
if(!args) return;
dialog.hide();
//Remove blanks
for (var j = 0; j < frm.doc.suppliers.length; j++) {
if(!frm.doc.suppliers[j].hasOwnProperty("supplier")) {
frm.get_field("suppliers").grid.grid_rows[j].remove();
}
}
function load_suppliers(r) {
if(r.message) {
for (var i = 0; i < r.message.length; i++) {
var exists = false;
if (r.message[i].constructor === Array){
var supplier = r.message[i][0];
} else {
var supplier = r.message[i].name;
}
for (var j = 0; j < doc.suppliers.length;j++) {
if (supplier === doc.suppliers[j].supplier) {
exists = true;
}
}
if(!exists) {
var d = frm.add_child('suppliers');
d.supplier = supplier;
frm.script_manager.trigger("supplier", d.doctype, d.name);
}
}
}
frm.refresh_field("suppliers");
}
if (args.search_type === "Tag" && args.tag) {
return frappe.call({
type: "GET",
method: "frappe.desk.doctype.tag.tag.get_tagged_docs",
args: {
"doctype": "Supplier",
"tag": args.tag
},
callback: load_suppliers
});
} else if (args.supplier_group) {
return frappe.call({
method: "frappe.client.get_list",
args: {
doctype: "Supplier",
order_by: "name",
fields: ["name"],
filters: [["Supplier", "supplier_group", "=", args.supplier_group]]
},
callback: load_suppliers
});
}
}
});
dialog.show();
},
});
// for backward compatibility: combine new and previous states
$.extend(cur_frm.cscript, new erpnext.buying.RequestforQuotationController({frm: cur_frm}));

View File

@ -12,9 +12,10 @@
"vendor",
"column_break1",
"transaction_date",
"status",
"amended_from",
"suppliers_section",
"suppliers",
"get_suppliers_button",
"items_section",
"items",
"link_to_mrs",
@ -31,11 +32,7 @@
"terms",
"printing_settings",
"select_print_heading",
"letter_head",
"more_info",
"status",
"column_break3",
"amended_from"
"letter_head"
],
"fields": [
{
@ -83,6 +80,7 @@
"width": "50%"
},
{
"default": "Today",
"fieldname": "transaction_date",
"fieldtype": "Date",
"in_list_view": 1,
@ -99,16 +97,11 @@
{
"fieldname": "suppliers",
"fieldtype": "Table",
"label": "Supplier Detail",
"label": "Suppliers",
"options": "Request for Quotation Supplier",
"print_hide": 1,
"reqd": 1
},
{
"fieldname": "get_suppliers_button",
"fieldtype": "Button",
"label": "Get Suppliers"
},
{
"fieldname": "items_section",
"fieldtype": "Section Break",
@ -144,6 +137,7 @@
"print_hide": 1
},
{
"allow_on_submit": 1,
"fetch_from": "email_template.response",
"fetch_if_empty": 1,
"fieldname": "message_for_supplier",
@ -206,14 +200,6 @@
"options": "Letter Head",
"print_hide": 1
},
{
"collapsible": 1,
"fieldname": "more_info",
"fieldtype": "Section Break",
"label": "More Information",
"oldfieldtype": "Section Break",
"options": "fa fa-file-text"
},
{
"fieldname": "status",
"fieldtype": "Select",
@ -227,10 +213,6 @@
"reqd": 1,
"search_index": 1
},
{
"fieldname": "column_break3",
"fieldtype": "Column Break"
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
@ -275,9 +257,10 @@
}
],
"icon": "fa fa-shopping-cart",
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2020-10-01 14:54:50.888729",
"modified": "2020-10-16 17:49:09.561929",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation",

View File

@ -28,6 +28,10 @@ class RequestforQuotation(BuyingController):
super(RequestforQuotation, self).set_qty_as_per_stock_uom()
self.update_email_id()
if self.docstatus < 1:
# after amend and save, status still shows as cancelled, until submit
frappe.db.set(self, 'status', 'Draft')
def validate_duplicate_supplier(self):
supplier_list = [d.supplier for d in self.suppliers]
if len(supplier_list) != len(set(supplier_list)):
@ -82,7 +86,7 @@ class RequestforQuotation(BuyingController):
# make new user if required
update_password_link, contact = self.update_supplier_contact(rfq_supplier, self.get_link())
self.update_supplier_part_no(rfq_supplier)
self.update_supplier_part_no(rfq_supplier.supplier)
self.supplier_rfq_mail(rfq_supplier, update_password_link, self.get_link())
rfq_supplier.email_sent = 1
if not rfq_supplier.contact:
@ -93,11 +97,11 @@ class RequestforQuotation(BuyingController):
# RFQ link for supplier portal
return get_url("/rfq/" + self.name)
def update_supplier_part_no(self, args):
self.vendor = args.supplier
def update_supplier_part_no(self, supplier):
self.vendor = supplier
for item in self.items:
item.supplier_part_no = frappe.db.get_value('Item Supplier',
{'parent': item.item_code, 'supplier': args.supplier}, 'supplier_part_no')
{'parent': item.item_code, 'supplier': supplier}, 'supplier_part_no')
def update_supplier_contact(self, rfq_supplier, link):
'''Create a new user for the supplier if not set in contact'''
@ -197,23 +201,22 @@ class RequestforQuotation(BuyingController):
def update_rfq_supplier_status(self, sup_name=None):
for supplier in self.suppliers:
if sup_name == None or supplier.supplier == sup_name:
if supplier.quote_status != _('No Quote'):
quote_status = _('Received')
for item in self.items:
sqi_count = frappe.db.sql("""
SELECT
COUNT(sqi.name) as count
FROM
`tabSupplier Quotation Item` as sqi,
`tabSupplier Quotation` as sq
WHERE sq.supplier = %(supplier)s
AND sqi.docstatus = 1
AND sqi.request_for_quotation_item = %(rqi)s
AND sqi.parent = sq.name""",
{"supplier": supplier.supplier, "rqi": item.name}, as_dict=1)[0]
if (sqi_count.count) == 0:
quote_status = _('Pending')
supplier.quote_status = quote_status
quote_status = _('Received')
for item in self.items:
sqi_count = frappe.db.sql("""
SELECT
COUNT(sqi.name) as count
FROM
`tabSupplier Quotation Item` as sqi,
`tabSupplier Quotation` as sq
WHERE sq.supplier = %(supplier)s
AND sqi.docstatus = 1
AND sqi.request_for_quotation_item = %(rqi)s
AND sqi.parent = sq.name""",
{"supplier": supplier.supplier, "rqi": item.name}, as_dict=1)[0]
if (sqi_count.count) == 0:
quote_status = _('Pending')
supplier.quote_status = quote_status
@frappe.whitelist()
@ -322,16 +325,15 @@ def create_rfq_items(sq_doc, supplier, data):
})
@frappe.whitelist()
def get_pdf(doctype, name, supplier_idx):
doc = get_rfq_doc(doctype, name, supplier_idx)
def get_pdf(doctype, name, supplier):
doc = get_rfq_doc(doctype, name, supplier)
if doc:
download_pdf(doctype, name, doc=doc)
def get_rfq_doc(doctype, name, supplier_idx):
if cint(supplier_idx):
def get_rfq_doc(doctype, name, supplier):
if supplier:
doc = frappe.get_doc(doctype, name)
args = doc.get('suppliers')[cint(supplier_idx) - 1]
doc.update_supplier_part_no(args)
doc.update_supplier_part_no(supplier)
return doc
@frappe.whitelist()

View File

@ -25,14 +25,10 @@ class TestRequestforQuotation(unittest.TestCase):
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.get('suppliers')[0].supplier)
sq.submit()
# No Quote first supplier quotation
rfq.get('suppliers')[1].no_quote = 1
rfq.get('suppliers')[1].quote_status = 'No Quote'
rfq.update_rfq_supplier_status() #rfq.get('suppliers')[1].supplier)
self.assertEqual(rfq.get('suppliers')[0].quote_status, 'Received')
self.assertEqual(rfq.get('suppliers')[1].quote_status, 'No Quote')
self.assertEqual(rfq.get('suppliers')[1].quote_status, 'Pending')
def test_make_supplier_quotation(self):
rfq = make_request_for_quotation()

View File

@ -84,9 +84,6 @@ QUnit.test("Test: Request for Quotation", function (assert) {
cur_frm.fields_dict.suppliers.grid.grid_rows[0].toggle_view();
},
() => frappe.timeout(1),
() => {
frappe.click_check('No Quote');
},
() => frappe.timeout(1),
() => {
cur_frm.cur_grid.toggle_view();
@ -125,7 +122,6 @@ QUnit.test("Test: Request for Quotation", function (assert) {
() => frappe.timeout(1),
() => {
assert.ok(cur_frm.fields_dict.suppliers.grid.grid_rows[1].doc.quote_status == "Received");
assert.ok(cur_frm.fields_dict.suppliers.grid.grid_rows[0].doc.no_quote == 1);
},
() => done()
]);

View File

@ -27,10 +27,11 @@
"stock_qty",
"warehouse_and_reference",
"warehouse",
"project_name",
"col_break4",
"material_request",
"material_request_item",
"section_break_24",
"project_name",
"section_break_23",
"page_break"
],
@ -161,7 +162,7 @@
{
"fieldname": "project_name",
"fieldtype": "Link",
"label": "Project Name",
"label": "Project",
"options": "Project",
"print_hide": 1
},
@ -249,11 +250,18 @@
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "section_break_24",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2020-06-12 19:10:36.333441",
"modified": "2020-09-24 17:26:46.276934",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation Item",

View File

@ -9,19 +9,19 @@
"email_sent",
"supplier",
"contact",
"no_quote",
"quote_status",
"column_break_3",
"supplier_name",
"email_id",
"download_pdf"
"email_id"
],
"fields": [
{
"allow_on_submit": 1,
"columns": 2,
"default": "1",
"fieldname": "send_email",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Send Email"
},
{
@ -35,7 +35,7 @@
"read_only": 1
},
{
"columns": 4,
"columns": 2,
"fieldname": "supplier",
"fieldtype": "Link",
"in_list_view": 1,
@ -45,7 +45,7 @@
},
{
"allow_on_submit": 1,
"columns": 3,
"columns": 2,
"fieldname": "contact",
"fieldtype": "Link",
"in_list_view": 1,
@ -55,19 +55,11 @@
},
{
"allow_on_submit": 1,
"default": "0",
"depends_on": "eval:doc.docstatus >= 1 && doc.quote_status != 'Received'",
"fieldname": "no_quote",
"fieldtype": "Check",
"label": "No Quote"
},
{
"allow_on_submit": 1,
"depends_on": "eval:doc.docstatus >= 1 && !doc.no_quote",
"depends_on": "eval:doc.docstatus >= 1",
"fieldname": "quote_status",
"fieldtype": "Select",
"label": "Quote Status",
"options": "Pending\nReceived\nNo Quote",
"options": "Pending\nReceived",
"read_only": 1
},
{
@ -90,17 +82,12 @@
"in_list_view": 1,
"label": "Email Id",
"no_copy": 1
},
{
"allow_on_submit": 1,
"fieldname": "download_pdf",
"fieldtype": "Button",
"label": "Download PDF"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2020-09-28 19:31:11.855588",
"modified": "2020-10-16 12:23:41.769820",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation Supplier",

View File

@ -91,12 +91,7 @@ class SupplierQuotation(BuyingController):
for my_item in self.items) if include_me else 0
if (sqi_count.count + self_count) == 0:
quote_status = _('Pending')
if quote_status == _('Received') and doc_sup.quote_status == _('No Quote'):
frappe.msgprint(_("{0} indicates that {1} will not provide a quotation, but all items \
have been quoted. Updating the RFQ quote status.").format(doc.name, self.supplier))
frappe.db.set_value('Request for Quotation Supplier', doc_sup.name, 'quote_status', quote_status)
frappe.db.set_value('Request for Quotation Supplier', doc_sup.name, 'no_quote', 0)
elif doc_sup.quote_status != _('No Quote'):
frappe.db.set_value('Request for Quotation Supplier', doc_sup.name, 'quote_status', quote_status)
def get_list_context(context=None):

View File

@ -946,8 +946,10 @@ def validate_conversion_rate(currency, conversion_rate, conversion_rate_label, c
company_currency = frappe.get_cached_value('Company', company, "default_currency")
if not conversion_rate:
throw(_("{0} is mandatory. Maybe Currency Exchange record is not created for {1} to {2}.").format(
conversion_rate_label, currency, company_currency))
throw(
_("{0} is mandatory. Maybe Currency Exchange record is not created for {1} to {2}.")
.format(conversion_rate_label, currency, company_currency)
)
def validate_taxes_and_charges(tax):

View File

@ -5,7 +5,7 @@ from __future__ import unicode_literals
import frappe
from frappe import _, msgprint
from frappe.utils import flt,cint, cstr, getdate
from six import iteritems
from erpnext.accounts.party import get_party_details
from erpnext.stock.get_item_details import get_conversion_factor
from erpnext.buying.utils import validate_for_items, update_last_purchase_rate
@ -112,8 +112,8 @@ class BuyingController(StockController):
"docstatus": 1
})]
if self.is_return and len(not_cancelled_asset):
frappe.throw(_("{} has submitted assets linked to it. You need to cancel the assets to create purchase return.".format(self.return_against)),
title=_("Not Allowed"))
frappe.throw(_("{} has submitted assets linked to it. You need to cancel the assets to create purchase return.")
.format(self.return_against), title=_("Not Allowed"))
def get_asset_items(self):
if self.doctype not in ['Purchase Order', 'Purchase Invoice', 'Purchase Receipt']:
@ -298,10 +298,10 @@ class BuyingController(StockController):
title=_("Limit Crossed"))
transferred_batch_qty_map = get_transferred_batch_qty_map(item.purchase_order, item.item_code)
backflushed_batch_qty_map = get_backflushed_batch_qty_map(item.purchase_order, item.item_code)
# backflushed_batch_qty_map = get_backflushed_batch_qty_map(item.purchase_order, item.item_code)
for raw_material in transferred_raw_materials + non_stock_items:
rm_item_key = '{}{}'.format(raw_material.rm_item_code, item.purchase_order)
rm_item_key = (raw_material.rm_item_code, item.purchase_order)
raw_material_data = backflushed_raw_materials_map.get(rm_item_key, {})
consumed_qty = raw_material_data.get('qty', 0)
@ -330,8 +330,10 @@ class BuyingController(StockController):
set_serial_nos(raw_material, consumed_serial_nos, qty)
if raw_material.batch_nos:
backflushed_batch_qty_map = raw_material_data.get('consumed_batch', {})
batches_qty = get_batches_with_qty(raw_material.rm_item_code, raw_material.main_item_code,
qty, transferred_batch_qty_map, backflushed_batch_qty_map)
qty, transferred_batch_qty_map, backflushed_batch_qty_map, item.purchase_order)
for batch_data in batches_qty:
qty = batch_data['qty']
raw_material.batch_no = batch_data['batch']
@ -343,6 +345,10 @@ class BuyingController(StockController):
rm = self.append('supplied_items', {})
rm.update(raw_material_data)
if not rm.main_item_code:
rm.main_item_code = fg_item_doc.item_code
rm.reference_name = fg_item_doc.name
rm.required_qty = qty
rm.consumed_qty = qty
@ -792,8 +798,8 @@ class BuyingController(StockController):
asset.set(field, None)
asset.supplier = None
if asset.docstatus == 1 and delete_asset:
frappe.throw(_('Cannot cancel this document as it is linked with submitted asset {0}.\
Please cancel the it to continue.').format(frappe.utils.get_link_to_form('Asset', asset.name)))
frappe.throw(_('Cannot cancel this document as it is linked with submitted asset {0}. Please cancel it to continue.')
.format(frappe.utils.get_link_to_form('Asset', asset.name)))
asset.flags.ignore_validate_update_after_submit = True
asset.flags.ignore_mandatory = True
@ -873,7 +879,7 @@ def get_subcontracted_raw_materials_from_se(purchase_order, fg_item):
AND se.purpose='Send to Subcontractor'
AND se.purchase_order = %s
AND IFNULL(sed.t_warehouse, '') != ''
AND sed.subcontracted_item = %s
AND IFNULL(sed.subcontracted_item, '') in ('', %s)
GROUP BY sed.item_code, sed.subcontracted_item
"""
raw_materials = frappe.db.multisql({
@ -890,39 +896,49 @@ def get_subcontracted_raw_materials_from_se(purchase_order, fg_item):
return raw_materials
def get_backflushed_subcontracted_raw_materials(purchase_orders):
common_query = """
SELECT
CONCAT(prsi.rm_item_code, pri.purchase_order) AS item_key,
SUM(prsi.consumed_qty) AS qty,
{serial_no_concat_syntax} AS serial_nos,
{batch_no_concat_syntax} AS batch_nos
FROM `tabPurchase Receipt` pr, `tabPurchase Receipt Item` pri, `tabPurchase Receipt Item Supplied` prsi
WHERE
pr.name = pri.parent
AND pr.name = prsi.parent
AND pri.purchase_order IN %s
AND pri.item_code = prsi.main_item_code
AND pr.docstatus = 1
GROUP BY prsi.rm_item_code, pri.purchase_order
"""
purchase_receipts = frappe.get_all("Purchase Receipt Item",
fields = ["purchase_order", "item_code", "name", "parent"],
filters={"docstatus": 1, "purchase_order": ("in", list(purchase_orders))})
backflushed_raw_materials = frappe.db.multisql({
'mariadb': common_query.format(
serial_no_concat_syntax="GROUP_CONCAT(prsi.serial_no)",
batch_no_concat_syntax="GROUP_CONCAT(prsi.batch_no)"
),
'postgres': common_query.format(
serial_no_concat_syntax="STRING_AGG(prsi.serial_no, ',')",
batch_no_concat_syntax="STRING_AGG(prsi.batch_no, ',')"
)
}, (purchase_orders, ), as_dict=1)
distinct_purchase_receipts = {}
for pr in purchase_receipts:
key = (pr.purchase_order, pr.item_code, pr.parent)
distinct_purchase_receipts.setdefault(key, []).append(pr.name)
backflushed_raw_materials_map = frappe._dict()
for item in backflushed_raw_materials:
backflushed_raw_materials_map.setdefault(item.item_key, item)
for args, references in iteritems(distinct_purchase_receipts):
purchase_receipt_supplied_items = get_supplied_items(args[1], args[2], references)
for data in purchase_receipt_supplied_items:
pr_key = (data.rm_item_code, args[0])
if pr_key not in backflushed_raw_materials_map:
backflushed_raw_materials_map.setdefault(pr_key, frappe._dict({
"qty": 0.0,
"serial_no": [],
"batch_no": [],
"consumed_batch": {}
}))
row = backflushed_raw_materials_map.get(pr_key)
row.qty += data.consumed_qty
for field in ["serial_no", "batch_no"]:
if data.get(field):
row[field].append(data.get(field))
if data.get("batch_no"):
if data.get("batch_no") in row.consumed_batch:
row.consumed_batch[data.get("batch_no")] += data.consumed_qty
else:
row.consumed_batch[data.get("batch_no")] = data.consumed_qty
return backflushed_raw_materials_map
def get_supplied_items(item_code, purchase_receipt, references):
return frappe.get_all("Purchase Receipt Item Supplied",
fields=["rm_item_code", "consumed_qty", "serial_no", "batch_no"],
filters={"main_item_code": item_code, "parent": purchase_receipt, "reference_name": ("in", references)})
def get_asset_item_details(asset_items):
asset_items_data = {}
for d in frappe.get_all('Item', fields = ["name", "auto_create_assets", "asset_naming_series"],
@ -1004,14 +1020,15 @@ def get_transferred_batch_qty_map(purchase_order, fg_item):
SELECT
sed.batch_no,
SUM(sed.qty) AS qty,
sed.item_code
sed.item_code,
sed.subcontracted_item
FROM `tabStock Entry` se,`tabStock Entry Detail` sed
WHERE
se.name = sed.parent
AND se.docstatus=1
AND se.purpose='Send to Subcontractor'
AND se.purchase_order = %s
AND sed.subcontracted_item = %s
AND ifnull(sed.subcontracted_item, '') in ('', %s)
AND sed.batch_no IS NOT NULL
GROUP BY
sed.batch_no,
@ -1019,8 +1036,10 @@ def get_transferred_batch_qty_map(purchase_order, fg_item):
""", (purchase_order, fg_item), as_dict=1)
for batch_data in transferred_batches:
transferred_batch_qty_map.setdefault((batch_data.item_code, fg_item), {})
transferred_batch_qty_map[(batch_data.item_code, fg_item)][batch_data.batch_no] = batch_data.qty
key = ((batch_data.item_code, fg_item)
if batch_data.subcontracted_item else (batch_data.item_code, purchase_order))
transferred_batch_qty_map.setdefault(key, {})
transferred_batch_qty_map[key][batch_data.batch_no] = batch_data.qty
return transferred_batch_qty_map
@ -1057,10 +1076,11 @@ def get_backflushed_batch_qty_map(purchase_order, fg_item):
return backflushed_batch_qty_map
def get_batches_with_qty(item_code, fg_item, required_qty, transferred_batch_qty_map, backflushed_batch_qty_map):
def get_batches_with_qty(item_code, fg_item, required_qty, transferred_batch_qty_map, backflushed_batches, po):
# Returns available batches to be backflushed based on requirements
transferred_batches = transferred_batch_qty_map.get((item_code, fg_item), {})
backflushed_batches = backflushed_batch_qty_map.get((item_code, fg_item), {})
if not transferred_batches:
transferred_batches = transferred_batch_qty_map.get((item_code, po), {})
available_batches = []

View File

@ -49,6 +49,22 @@ data = {
'fieldname': 'reference_dn', 'label': 'Reference Name', 'fieldtype': 'Dynamic Link', 'options': 'reference_dt',
'insert_after': 'reference_dt'
}
],
'Stock Entry': [
{
'fieldname': 'inpatient_medication_entry', 'label': 'Inpatient Medication Entry', 'fieldtype': 'Link', 'options': 'Inpatient Medication Entry',
'insert_after': 'credit_note', 'read_only': True
}
],
'Stock Entry Detail': [
{
'fieldname': 'patient', 'label': 'Patient', 'fieldtype': 'Link', 'options': 'Patient',
'insert_after': 'po_detail', 'read_only': True
},
{
'fieldname': 'inpatient_medication_entry_child', 'label': 'Inpatient Medication Entry Child', 'fieldtype': 'Data',
'insert_after': 'patient', 'read_only': True
}
]
},
'on_setup': 'erpnext.healthcare.setup.setup_healthcare'

View File

@ -7,7 +7,7 @@ import frappe
import json
from frappe import _
from frappe.model.mapper import get_mapped_doc
from frappe.utils import flt, cstr
from frappe.utils import flt, cstr, getdate
from frappe.email.doctype.email_group.email_group import add_subscribers
def get_course(program):
@ -67,6 +67,13 @@ def mark_attendance(students_present, students_absent, course_schedule=None, stu
:param date: Date.
"""
if student_group:
academic_year = frappe.db.get_value('Student Group', student_group, 'academic_year')
if academic_year:
year_start_date, year_end_date = frappe.db.get_value('Academic Year', academic_year, ['year_start_date', 'year_end_date'])
if getdate(date) < getdate(year_start_date) or getdate(date) > getdate(year_end_date):
frappe.throw(_('Attendance cannot be marked outside of Academic Year {0}').format(academic_year))
present = json.loads(students_present)
absent = json.loads(students_absent)

View File

@ -30,6 +30,23 @@ frappe.ui.form.on('Assessment Plan', {
frappe.set_route('Form', 'Assessment Result Tool');
}, __('Tools'));
}
frm.set_query('course', function() {
return {
query: 'erpnext.education.doctype.program_enrollment.program_enrollment.get_program_courses',
filters: {
'program': frm.doc.program
}
};
});
frm.set_query('academic_term', function() {
return {
filters: {
'academic_year': frm.doc.academic_year
}
};
});
},
course: function(frm) {

View File

@ -12,8 +12,8 @@
"assessment_group",
"grading_scale",
"column_break_2",
"course",
"program",
"course",
"academic_year",
"academic_term",
"section_break_5",
@ -198,7 +198,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2020-05-09 14:56:26.746988",
"modified": "2020-10-23 15:55:35.076251",
"modified_by": "Administrator",
"module": "Education",
"name": "Assessment Plan",

View File

@ -7,6 +7,23 @@ frappe.ui.form.on('Assessment Result', {
frm.trigger('setup_chart');
}
frm.set_df_property('details', 'read_only', 1);
frm.set_query('course', function() {
return {
query: 'erpnext.education.doctype.program_enrollment.program_enrollment.get_program_courses',
filters: {
'program': frm.doc.program
}
};
});
frm.set_query('academic_term', function() {
return {
filters: {
'academic_year': frm.doc.academic_year
}
};
});
},
onload: function(frm) {

View File

@ -41,5 +41,24 @@ frappe.ui.form.on("Instructor", {
}
};
});
frm.set_query("academic_term", "instructor_log", function(_doc, cdt, cdn) {
let d = locals[cdt][cdn];
return {
filters: {
"academic_year": d.academic_year
}
};
});
frm.set_query("course", "instructor_log", function(_doc, cdt, cdn) {
let d = locals[cdt][cdn];
return {
query: "erpnext.education.doctype.program_enrollment.program_enrollment.get_program_courses",
filters: {
"program": d.program
}
};
});
}
});

View File

@ -1,336 +1,88 @@
{
"allow_copy": 0,
"allow_events_in_timeline": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"beta": 0,
"creation": "2017-12-27 08:55:52.680284",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
"actions": [],
"creation": "2017-12-27 08:55:52.680284",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"academic_year",
"academic_term",
"department",
"column_break_3",
"program",
"course",
"student_group",
"section_break_8",
"other_details"
],
"fields": [
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "academic_year",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Academic Year",
"length": 0,
"no_copy": 0,
"options": "Academic Year",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"fieldname": "academic_year",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Academic Year",
"options": "Academic Year",
"reqd": 1
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "academic_term",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Academic Term",
"length": 0,
"no_copy": 0,
"options": "Academic Term",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"fieldname": "academic_term",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Academic Term",
"options": "Academic Term"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "department",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Department",
"length": 0,
"no_copy": 0,
"options": "Department",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"fieldname": "department",
"fieldtype": "Link",
"label": "Department",
"options": "Department"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "column_break_3",
"fieldtype": "Column Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "program",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Program",
"length": 0,
"no_copy": 0,
"options": "Program",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"fieldname": "program",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Program",
"options": "Program",
"reqd": 1
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "course",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Course",
"length": 0,
"no_copy": 0,
"options": "Course",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"fieldname": "course",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Course",
"options": "Course"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "student_group",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Student Group",
"length": 0,
"no_copy": 0,
"options": "Student Group",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"fieldname": "student_group",
"fieldtype": "Link",
"label": "Student Group",
"options": "Student Group"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "section_break_8",
"fieldtype": "Section Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"fieldname": "section_break_8",
"fieldtype": "Section Break"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "other_details",
"fieldtype": "Small Text",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Other details",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"fieldname": "other_details",
"fieldtype": "Small Text",
"label": "Other details"
}
],
"has_web_view": 0,
"hide_heading": 0,
"hide_toolbar": 0,
"idx": 0,
"image_view": 0,
"in_create": 0,
"is_submittable": 0,
"issingle": 0,
"istable": 1,
"max_attachments": 0,
"modified": "2018-11-04 03:38:30.902942",
"modified_by": "Administrator",
"module": "Education",
"name": "Instructor Log",
"name_case": "",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"read_only": 0,
"read_only_onload": 0,
"restrict_to_domain": "Education",
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1,
"track_seen": 0,
"track_views": 0
],
"istable": 1,
"links": [],
"modified": "2020-10-23 15:15:50.759657",
"modified_by": "Administrator",
"module": "Education",
"name": "Instructor Log",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"restrict_to_domain": "Education",
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@ -6,13 +6,13 @@ from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from frappe import _
from frappe.utils import get_link_to_form
from frappe.utils import get_link_to_form, getdate
from erpnext.education.api import get_student_group_students
class StudentAttendance(Document):
def validate(self):
self.validate_mandatory()
self.validate_date()
self.set_date()
self.set_student_group()
self.validate_student()
@ -27,6 +27,18 @@ class StudentAttendance(Document):
frappe.throw(_('{0} or {1} is mandatory').format(frappe.bold('Student Group'),
frappe.bold('Course Schedule')), title=_('Mandatory Fields'))
def validate_date(self):
if not self.leave_application and getdate(self.date) > getdate():
frappe.throw(_('Attendance cannot be marked for future dates.'))
if self.student_group:
academic_year = frappe.db.get_value('Student Group', self.student_group, 'academic_year')
if academic_year:
year_start_date, year_end_date = frappe.db.get_value('Academic Year', academic_year, ['year_start_date', 'year_end_date'])
if year_start_date and year_end_date:
if getdate(self.date) < getdate(year_start_date) or getdate(self.date) > getdate(year_end_date):
frappe.throw(_('Attendance cannot be marked outside of Academic Year {0}').format(academic_year))
def set_student_group(self):
if self.course_schedule:
self.student_group = frappe.db.get_value('Course Schedule', self.course_schedule, 'student_group')

View File

@ -52,6 +52,8 @@ frappe.ui.form.on('Student Attendance Tool', {
},
date: function(frm) {
if (frm.doc.date > frappe.datetime.get_today())
frappe.throw(__("Cannot mark attendance for future dates."));
frm.trigger("student_group");
},
@ -133,8 +135,8 @@ education.StudentsEditor = Class.extend({
return !stud.disabled && !stud.checked;
});
frappe.confirm(__("Do you want to update attendance?<br>Present: {0}\
<br>Absent: {1}", [students_present.length, students_absent.length]),
frappe.confirm(__("Do you want to update attendance? <br> Present: {0} <br> Absent: {1}",
[students_present.length, students_absent.length]),
function() { //ifyes
if(!frappe.request.ajax_count) {
frappe.call({

View File

@ -1,333 +1,118 @@
{
"allow_copy": 1,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"beta": 0,
"creation": "2016-11-16 17:12:46.437539",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
"actions": [],
"allow_copy": 1,
"creation": "2016-11-16 17:12:46.437539",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"based_on",
"group_based_on",
"column_break_2",
"student_group",
"academic_year",
"academic_term",
"course_schedule",
"date",
"attendance",
"students_html"
],
"fields": [
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "",
"fieldname": "based_on",
"fieldtype": "Select",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Based On",
"length": 0,
"no_copy": 0,
"options": "Student Group\nCourse Schedule",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
"fieldname": "based_on",
"fieldtype": "Select",
"label": "Based On",
"options": "Student Group\nCourse Schedule"
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "Batch",
"depends_on": "eval:doc.based_on == \"Student Group\"",
"fieldname": "group_based_on",
"fieldtype": "Select",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Group Based On",
"length": 0,
"no_copy": 0,
"options": "Batch\nCourse\nActivity",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
"default": "Batch",
"depends_on": "eval:doc.based_on == \"Student Group\"",
"fieldname": "group_based_on",
"fieldtype": "Select",
"label": "Group Based On",
"options": "Batch\nCourse\nActivity"
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "column_break_2",
"fieldtype": "Column Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
"fieldname": "column_break_2",
"fieldtype": "Column Break"
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"depends_on": "eval:doc.based_on ==\"Student Group\"",
"fieldname": "student_group",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Student Group",
"length": 0,
"no_copy": 0,
"options": "Student Group",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
"depends_on": "eval:doc.based_on ==\"Student Group\"",
"fieldname": "student_group",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Student Group",
"options": "Student Group",
"reqd": 1
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"depends_on": "eval:doc.based_on ==\"Course Schedule\"",
"fieldname": "course_schedule",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Course Schedule",
"length": 0,
"no_copy": 0,
"options": "Course Schedule",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
"depends_on": "eval:doc.based_on ==\"Course Schedule\"",
"fieldname": "course_schedule",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Course Schedule",
"options": "Course Schedule",
"reqd": 1
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"depends_on": "eval:doc.based_on ==\"Student Group\"",
"fieldname": "date",
"fieldtype": "Date",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Date",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
"depends_on": "eval:doc.based_on ==\"Student Group\"",
"fieldname": "date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Date",
"reqd": 1
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"depends_on": "eval: (doc.course_schedule \n|| (doc.student_group && doc.date))",
"fieldname": "attendance",
"fieldtype": "Section Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Attendance",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
"depends_on": "eval: (doc.course_schedule \n|| (doc.student_group && doc.date))",
"fieldname": "attendance",
"fieldtype": "Section Break",
"label": "Attendance"
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "students_html",
"fieldtype": "HTML",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Students HTML",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
"fieldname": "students_html",
"fieldtype": "HTML",
"label": "Students HTML"
},
{
"fetch_from": "student_group.academic_year",
"fieldname": "academic_year",
"fieldtype": "Link",
"label": "Academic Year",
"options": "Academic Year",
"read_only": 1
},
{
"fetch_from": "student_group.academic_term",
"fieldname": "academic_term",
"fieldtype": "Link",
"label": "Academic Term",
"options": "Academic Term",
"read_only": 1
}
],
"has_web_view": 0,
"hide_heading": 1,
"hide_toolbar": 1,
"idx": 0,
"image_view": 0,
"in_create": 0,
"is_submittable": 0,
"issingle": 1,
"istable": 0,
"max_attachments": 0,
"modified": "2017-11-10 18:55:36.168044",
"modified_by": "Administrator",
"module": "Education",
"name": "Student Attendance Tool",
"name_case": "",
"owner": "Administrator",
],
"hide_toolbar": 1,
"issingle": 1,
"links": [],
"modified": "2020-10-23 17:52:28.078971",
"modified_by": "Administrator",
"module": "Education",
"name": "Student Attendance Tool",
"owner": "Administrator",
"permissions": [
{
"amend": 0,
"apply_user_permissions": 0,
"cancel": 0,
"create": 1,
"delete": 0,
"email": 0,
"export": 0,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 0,
"read": 1,
"report": 0,
"role": "Instructor",
"set_user_permissions": 0,
"share": 0,
"submit": 0,
"create": 1,
"read": 1,
"role": "Instructor",
"write": 1
},
},
{
"amend": 0,
"apply_user_permissions": 0,
"cancel": 0,
"create": 1,
"delete": 0,
"email": 0,
"export": 0,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 0,
"read": 1,
"report": 0,
"role": "Academics User",
"set_user_permissions": 0,
"share": 0,
"submit": 0,
"create": 1,
"read": 1,
"role": "Academics User",
"write": 1
}
],
"quick_entry": 0,
"read_only": 0,
"read_only_onload": 0,
"restrict_to_domain": "Education",
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 0,
"track_seen": 0
],
"restrict_to_domain": "Education",
"sort_field": "modified",
"sort_order": "DESC"
}

View File

@ -0,0 +1,28 @@
{% if not jQuery.isEmptyObject(data) %}
<h5 style="margin-top: 20px;"> {{ __("Balance Details") }} </h5>
<table class="table table-bordered small">
<thead>
<tr>
<th style="width: 20%">{{ __("Account Type") }}</th>
<th style="width: 20%" class="text-right">{{ __("Current Balance") }}</th>
<th style="width: 20%" class="text-right">{{ __("Available Balance") }}</th>
<th style="width: 20%" class="text-right">{{ __("Reserved Balance") }}</th>
<th style="width: 20%" class="text-right">{{ __("Uncleared Balance") }}</th>
</tr>
</thead>
<tbody>
{% for(const [key, value] of Object.entries(data)) { %}
<tr>
<td> {%= key %} </td>
<td class="text-right"> {%= value["current_balance"] %} </td>
<td class="text-right"> {%= value["available_balance"] %} </td>
<td class="text-right"> {%= value["reserved_balance"] %} </td>
<td class="text-right"> {%= value["uncleared_balance"] %} </td>
</tr>
{% } %}
</tbody>
</table>
{% else %}
<p style="margin-top: 30px;"> Account Balance Information Not Available. </p>
{% endif %}

View File

@ -0,0 +1,118 @@
import base64
import requests
from requests.auth import HTTPBasicAuth
import datetime
class MpesaConnector():
def __init__(self, env="sandbox", app_key=None, app_secret=None, sandbox_url="https://sandbox.safaricom.co.ke",
live_url="https://safaricom.co.ke"):
"""Setup configuration for Mpesa connector and generate new access token."""
self.env = env
self.app_key = app_key
self.app_secret = app_secret
if env == "sandbox":
self.base_url = sandbox_url
else:
self.base_url = live_url
self.authenticate()
def authenticate(self):
"""
This method is used to fetch the access token required by Mpesa.
Returns:
access_token (str): This token is to be used with the Bearer header for further API calls to Mpesa.
"""
authenticate_uri = "/oauth/v1/generate?grant_type=client_credentials"
authenticate_url = "{0}{1}".format(self.base_url, authenticate_uri)
r = requests.get(
authenticate_url,
auth=HTTPBasicAuth(self.app_key, self.app_secret)
)
self.authentication_token = r.json()['access_token']
return r.json()['access_token']
def get_balance(self, initiator=None, security_credential=None, party_a=None, identifier_type=None,
remarks=None, queue_timeout_url=None,result_url=None):
"""
This method uses Mpesa's Account Balance API to to enquire the balance on a M-Pesa BuyGoods (Till Number).
Args:
initiator (str): Username used to authenticate the transaction.
security_credential (str): Generate from developer portal.
command_id (str): AccountBalance.
party_a (int): Till number being queried.
identifier_type (int): Type of organization receiving the transaction. (MSISDN/Till Number/Organization short code)
remarks (str): Comments that are sent along with the transaction(maximum 100 characters).
queue_timeout_url (str): The url that handles information of timed out transactions.
result_url (str): The url that receives results from M-Pesa api call.
Returns:
OriginatorConverstionID (str): The unique request ID for tracking a transaction.
ConversationID (str): The unique request ID returned by mpesa for each request made
ResponseDescription (str): Response Description message
"""
payload = {
"Initiator": initiator,
"SecurityCredential": security_credential,
"CommandID": "AccountBalance",
"PartyA": party_a,
"IdentifierType": identifier_type,
"Remarks": remarks,
"QueueTimeOutURL": queue_timeout_url,
"ResultURL": result_url
}
headers = {'Authorization': 'Bearer {0}'.format(self.authentication_token), 'Content-Type': "application/json"}
saf_url = "{0}{1}".format(self.base_url, "/mpesa/accountbalance/v1/query")
r = requests.post(saf_url, headers=headers, json=payload)
return r.json()
def stk_push(self, business_shortcode=None, passcode=None, amount=None, callback_url=None, reference_code=None,
phone_number=None, description=None):
"""
This method uses Mpesa's Express API to initiate online payment on behalf of a customer.
Args:
business_shortcode (int): The short code of the organization.
passcode (str): Get from developer portal
amount (int): The amount being transacted
callback_url (str): A CallBack URL is a valid secure URL that is used to receive notifications from M-Pesa API.
reference_code(str): Account Reference: This is an Alpha-Numeric parameter that is defined by your system as an Identifier of the transaction for CustomerPayBillOnline transaction type.
phone_number(int): The Mobile Number to receive the STK Pin Prompt.
description(str): This is any additional information/comment that can be sent along with the request from your system. MAX 13 characters
Success Response:
CustomerMessage(str): Messages that customers can understand.
CheckoutRequestID(str): This is a global unique identifier of the processed checkout transaction request.
ResponseDescription(str): Describes Success or failure
MerchantRequestID(str): This is a global unique Identifier for any submitted payment request.
ResponseCode(int): 0 means success all others are error codes. e.g.404.001.03
Error Reponse:
requestId(str): This is a unique requestID for the payment request
errorCode(str): This is a predefined code that indicates the reason for request failure.
errorMessage(str): This is a predefined code that indicates the reason for request failure.
"""
time = str(datetime.datetime.now()).split(".")[0].replace("-", "").replace(" ", "").replace(":", "")
password = "{0}{1}{2}".format(str(business_shortcode), str(passcode), time)
encoded = base64.b64encode(bytes(password, encoding='utf8'))
payload = {
"BusinessShortCode": business_shortcode,
"Password": encoded.decode("utf-8"),
"Timestamp": time,
"TransactionType": "CustomerPayBillOnline",
"Amount": amount,
"PartyA": int(phone_number),
"PartyB": business_shortcode,
"PhoneNumber": int(phone_number),
"CallBackURL": callback_url,
"AccountReference": reference_code,
"TransactionDesc": description
}
headers = {'Authorization': 'Bearer {0}'.format(self.authentication_token), 'Content-Type': "application/json"}
saf_url = "{0}{1}".format(self.base_url, "/mpesa/stkpush/v1/processrequest")
r = requests.post(saf_url, headers=headers, json=payload)
return r.json()

View File

@ -0,0 +1,53 @@
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
def create_custom_pos_fields():
"""Create custom fields corresponding to POS Settings and POS Invoice."""
pos_field = {
"POS Invoice": [
{
"fieldname": "request_for_payment",
"label": "Request for Payment",
"fieldtype": "Button",
"hidden": 1,
"insert_after": "contact_email"
},
{
"fieldname": "mpesa_receipt_number",
"label": "Mpesa Receipt Number",
"fieldtype": "Data",
"read_only": 1,
"insert_after": "company"
}
]
}
if not frappe.get_meta("POS Invoice").has_field("request_for_payment"):
create_custom_fields(pos_field)
record_dict = [{
"doctype": "POS Field",
"fieldname": "contact_mobile",
"label": "Mobile No",
"fieldtype": "Data",
"options": "Phone",
"parenttype": "POS Settings",
"parent": "POS Settings",
"parentfield": "invoice_fields"
},
{
"doctype": "POS Field",
"fieldname": "request_for_payment",
"label": "Request for Payment",
"fieldtype": "Button",
"parenttype": "POS Settings",
"parent": "POS Settings",
"parentfield": "invoice_fields"
}
]
create_pos_settings(record_dict)
def create_pos_settings(record_dict):
for record in record_dict:
if frappe.db.exists("POS Field", {"fieldname": record.get("fieldname")}):
continue
frappe.get_doc(record).insert()

View File

@ -0,0 +1,36 @@
// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on('Mpesa Settings', {
onload_post_render: function(frm) {
frm.events.setup_account_balance_html(frm);
},
refresh: function(frm) {
frappe.realtime.on("refresh_mpesa_dashboard", function(){
frm.reload_doc();
});
},
get_account_balance: function(frm) {
if (!frm.initiator_name && !frm.security_credentials) {
frappe.throw(__("Please set the initiator name and the security credential"));
}
frappe.call({
method: "get_account_balance_info",
doc: frm.doc
});
},
setup_account_balance_html: function(frm) {
if (!frm.doc.account_balance) return;
$("div").remove(".form-dashboard-section.custom");
frm.dashboard.add_section(
frappe.render_template('account_balance', {
data: JSON.parse(frm.doc.account_balance)
})
);
frm.dashboard.show();
}
});

View File

@ -0,0 +1,135 @@
{
"actions": [],
"autoname": "field:payment_gateway_name",
"creation": "2020-09-10 13:21:27.398088",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"payment_gateway_name",
"consumer_key",
"consumer_secret",
"initiator_name",
"till_number",
"sandbox",
"column_break_4",
"online_passkey",
"security_credential",
"get_account_balance",
"account_balance"
],
"fields": [
{
"fieldname": "payment_gateway_name",
"fieldtype": "Data",
"label": "Payment Gateway Name",
"reqd": 1,
"unique": 1
},
{
"fieldname": "consumer_key",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Consumer Key",
"reqd": 1
},
{
"fieldname": "consumer_secret",
"fieldtype": "Password",
"in_list_view": 1,
"label": "Consumer Secret",
"reqd": 1
},
{
"fieldname": "till_number",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Till Number",
"reqd": 1
},
{
"default": "0",
"fieldname": "sandbox",
"fieldtype": "Check",
"label": "Sandbox"
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fieldname": "online_passkey",
"fieldtype": "Password",
"label": " Online PassKey",
"reqd": 1
},
{
"fieldname": "initiator_name",
"fieldtype": "Data",
"label": "Initiator Name"
},
{
"fieldname": "security_credential",
"fieldtype": "Small Text",
"label": "Security Credential"
},
{
"fieldname": "account_balance",
"fieldtype": "Long Text",
"hidden": 1,
"label": "Account Balance",
"read_only": 1
},
{
"fieldname": "get_account_balance",
"fieldtype": "Button",
"label": "Get Account Balance"
}
],
"links": [],
"modified": "2020-09-25 20:21:38.215494",
"modified_by": "Administrator",
"module": "ERPNext Integrations",
"name": "Mpesa Settings",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts User",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC"
}

View File

@ -0,0 +1,208 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
from json import loads, dumps
import frappe
from frappe.model.document import Document
from frappe import _
from frappe.utils import call_hook_method, fmt_money
from frappe.integrations.utils import create_request_log, create_payment_gateway
from frappe.utils import get_request_site_address
from erpnext.erpnext_integrations.utils import create_mode_of_payment
from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_connector import MpesaConnector
from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_custom_fields import create_custom_pos_fields
class MpesaSettings(Document):
supported_currencies = ["KES"]
def validate_transaction_currency(self, currency):
if currency not in self.supported_currencies:
frappe.throw(_("Please select another payment method. Mpesa does not support transactions in currency '{0}'").format(currency))
def on_update(self):
create_custom_pos_fields()
create_payment_gateway('Mpesa-' + self.payment_gateway_name, settings='Mpesa Settings', controller=self.payment_gateway_name)
call_hook_method('payment_gateway_enabled', gateway='Mpesa-' + self.payment_gateway_name, payment_channel="Phone")
# required to fetch the bank account details from the payment gateway account
frappe.db.commit()
create_mode_of_payment('Mpesa-' + self.payment_gateway_name, payment_type="Phone")
def request_for_payment(self, **kwargs):
if frappe.flags.in_test:
from erpnext.erpnext_integrations.doctype.mpesa_settings.test_mpesa_settings import get_payment_request_response_payload
response = frappe._dict(get_payment_request_response_payload())
else:
response = frappe._dict(generate_stk_push(**kwargs))
self.handle_api_response("CheckoutRequestID", kwargs, response)
def get_account_balance_info(self):
payload = dict(
reference_doctype="Mpesa Settings",
reference_docname=self.name,
doc_details=vars(self)
)
if frappe.flags.in_test:
from erpnext.erpnext_integrations.doctype.mpesa_settings.test_mpesa_settings import get_test_account_balance_response
response = frappe._dict(get_test_account_balance_response())
else:
response = frappe._dict(get_account_balance(payload))
self.handle_api_response("ConversationID", payload, response)
def handle_api_response(self, global_id, request_dict, response):
"""Response received from API calls returns a global identifier for each transaction, this code is returned during the callback."""
# check error response
if getattr(response, "requestId"):
req_name = getattr(response, "requestId")
error = response
else:
# global checkout id used as request name
req_name = getattr(response, global_id)
error = None
create_request_log(request_dict, "Host", "Mpesa", req_name, error)
if error:
frappe.throw(_(getattr(response, "errorMessage")), title=_("Transaction Error"))
def generate_stk_push(**kwargs):
"""Generate stk push by making a API call to the stk push API."""
args = frappe._dict(kwargs)
try:
callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.verify_transaction"
mpesa_settings = frappe.get_doc("Mpesa Settings", args.payment_gateway[6:])
env = "production" if not mpesa_settings.sandbox else "sandbox"
connector = MpesaConnector(env=env,
app_key=mpesa_settings.consumer_key,
app_secret=mpesa_settings.get_password("consumer_secret"))
mobile_number = sanitize_mobile_number(args.sender)
response = connector.stk_push(business_shortcode=mpesa_settings.till_number,
passcode=mpesa_settings.get_password("online_passkey"), amount=args.grand_total,
callback_url=callback_url, reference_code=mpesa_settings.till_number,
phone_number=mobile_number, description="POS Payment")
return response
except Exception:
frappe.log_error(title=_("Mpesa Express Transaction Error"))
frappe.throw(_("Issue detected with Mpesa configuration, check the error logs for more details"), title=_("Mpesa Express Error"))
def sanitize_mobile_number(number):
"""Add country code and strip leading zeroes from the phone number."""
return "254" + str(number).lstrip("0")
@frappe.whitelist(allow_guest=True)
def verify_transaction(**kwargs):
"""Verify the transaction result received via callback from stk."""
transaction_response = frappe._dict(kwargs["Body"]["stkCallback"])
checkout_id = getattr(transaction_response, "CheckoutRequestID", "")
request = frappe.get_doc("Integration Request", checkout_id)
transaction_data = frappe._dict(loads(request.data))
if transaction_response['ResultCode'] == 0:
if request.reference_doctype and request.reference_docname:
try:
doc = frappe.get_doc(request.reference_doctype,
request.reference_docname)
doc.run_method("on_payment_authorized", 'Completed')
item_response = transaction_response["CallbackMetadata"]["Item"]
mpesa_receipt = fetch_param_value(item_response, "MpesaReceiptNumber", "Name")
frappe.db.set_value("POS Invoice", doc.reference_name, "mpesa_receipt_number", mpesa_receipt)
request.handle_success(transaction_response)
except Exception:
request.handle_failure(transaction_response)
frappe.log_error(frappe.get_traceback())
else:
request.handle_failure(transaction_response)
frappe.publish_realtime('process_phone_payment', doctype="POS Invoice",
docname=transaction_data.payment_reference, user=request.owner, message=transaction_response)
def get_account_balance(request_payload):
"""Call account balance API to send the request to the Mpesa Servers."""
try:
mpesa_settings = frappe.get_doc("Mpesa Settings", request_payload.get("reference_docname"))
env = "production" if not mpesa_settings.sandbox else "sandbox"
connector = MpesaConnector(env=env,
app_key=mpesa_settings.consumer_key,
app_secret=mpesa_settings.get_password("consumer_secret"))
callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.process_balance_info"
response = connector.get_balance(mpesa_settings.initiator_name, mpesa_settings.security_credential, mpesa_settings.till_number, 4, mpesa_settings.name, callback_url, callback_url)
return response
except Exception:
frappe.log_error(title=_("Account Balance Processing Error"))
frappe.throw(title=_("Error"), message=_("Please check your configuration and try again"))
@frappe.whitelist(allow_guest=True)
def process_balance_info(**kwargs):
"""Process and store account balance information received via callback from the account balance API call."""
account_balance_response = frappe._dict(kwargs["Result"])
conversation_id = getattr(account_balance_response, "ConversationID", "")
request = frappe.get_doc("Integration Request", conversation_id)
if request.status == "Completed":
return
transaction_data = frappe._dict(loads(request.data))
if account_balance_response["ResultCode"] == 0:
try:
result_params = account_balance_response["ResultParameters"]["ResultParameter"]
balance_info = fetch_param_value(result_params, "AccountBalance", "Key")
balance_info = format_string_to_json(balance_info)
ref_doc = frappe.get_doc(transaction_data.reference_doctype, transaction_data.reference_docname)
ref_doc.db_set("account_balance", balance_info)
request.handle_success(account_balance_response)
frappe.publish_realtime("refresh_mpesa_dashboard")
except Exception:
request.handle_failure(account_balance_response)
frappe.log_error(title=_("Mpesa Account Balance Processing Error"), message=account_balance_response)
else:
request.handle_failure(account_balance_response)
def format_string_to_json(balance_info):
"""
Format string to json.
e.g: '''Working Account|KES|481000.00|481000.00|0.00|0.00'''
=> {'Working Account': {'current_balance': '481000.00',
'available_balance': '481000.00',
'reserved_balance': '0.00',
'uncleared_balance': '0.00'}}
"""
balance_dict = frappe._dict()
for account_info in balance_info.split("&"):
account_info = account_info.split('|')
balance_dict[account_info[0]] = dict(
current_balance=fmt_money(account_info[2], currency="KES"),
available_balance=fmt_money(account_info[3], currency="KES"),
reserved_balance=fmt_money(account_info[4], currency="KES"),
uncleared_balance=fmt_money(account_info[5], currency="KES")
)
return dumps(balance_dict)
def fetch_param_value(response, key, key_field):
"""Fetch the specified key from list of dictionary. Key is identified via the key field."""
for param in response:
if param[key_field] == key:
return param["Value"]

View File

@ -0,0 +1,240 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from __future__ import unicode_literals
from json import dumps
import frappe
import unittest
from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings import process_balance_info, verify_transaction
from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice
class TestMpesaSettings(unittest.TestCase):
def test_creation_of_payment_gateway(self):
create_mpesa_settings(payment_gateway_name="_Test")
mode_of_payment = frappe.get_doc("Mode of Payment", "Mpesa-_Test")
self.assertTrue(frappe.db.exists("Payment Gateway Account", {'payment_gateway': "Mpesa-_Test"}))
self.assertTrue(mode_of_payment.name)
self.assertEquals(mode_of_payment.type, "Phone")
def test_processing_of_account_balance(self):
mpesa_doc = create_mpesa_settings(payment_gateway_name="_Account Balance")
mpesa_doc.get_account_balance_info()
callback_response = get_account_balance_callback_payload()
process_balance_info(**callback_response)
integration_request = frappe.get_doc("Integration Request", "AG_20200927_00007cdb1f9fb6494315")
# test integration request creation and successful update of the status on receiving callback response
self.assertTrue(integration_request)
self.assertEquals(integration_request.status, "Completed")
# test formatting of account balance received as string to json with appropriate currency symbol
mpesa_doc.reload()
self.assertEquals(mpesa_doc.account_balance, dumps({
"Working Account": {
"current_balance": "Sh 481,000.00",
"available_balance": "Sh 481,000.00",
"reserved_balance": "Sh 0.00",
"uncleared_balance": "Sh 0.00"
}
}))
def test_processing_of_callback_payload(self):
create_mpesa_settings(payment_gateway_name="Payment")
mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account")
frappe.db.set_value("Account", mpesa_account, "account_currency", "KES")
pos_invoice = create_pos_invoice(do_not_submit=1)
pos_invoice.append("payments", {'mode_of_payment': 'Mpesa-Payment', 'account': mpesa_account, 'amount': 500})
pos_invoice.contact_mobile = "093456543894"
pos_invoice.currency = "KES"
pos_invoice.save()
pr = pos_invoice.create_payment_request()
# test payment request creation
self.assertEquals(pr.payment_gateway, "Mpesa-Payment")
callback_response = get_payment_callback_payload()
verify_transaction(**callback_response)
# test creation of integration request
integration_request = frappe.get_doc("Integration Request", "ws_CO_061020201133231972")
# test integration request creation and successful update of the status on receiving callback response
self.assertTrue(integration_request)
self.assertEquals(integration_request.status, "Completed")
pos_invoice.reload()
integration_request.reload()
self.assertEquals(pos_invoice.mpesa_receipt_number, "LGR7OWQX0R")
self.assertEquals(integration_request.status, "Completed")
def create_mpesa_settings(payment_gateway_name="Express"):
if frappe.db.exists("Mpesa Settings", payment_gateway_name):
return frappe.get_doc("Mpesa Settings", payment_gateway_name)
doc = frappe.get_doc(dict( #nosec
doctype="Mpesa Settings",
payment_gateway_name=payment_gateway_name,
consumer_key="5sMu9LVI1oS3oBGPJfh3JyvLHwZOdTKn",
consumer_secret="VI1oS3oBGPJfh3JyvLHw",
online_passkey="LVI1oS3oBGPJfh3JyvLHwZOd",
till_number="174379"
))
doc.insert(ignore_permissions=True)
return doc
def get_test_account_balance_response():
"""Response received after calling the account balance API."""
return {
"ResultType":0,
"ResultCode":0,
"ResultDesc":"The service request has been accepted successfully.",
"OriginatorConversationID":"10816-694520-2",
"ConversationID":"AG_20200927_00007cdb1f9fb6494315",
"TransactionID":"LGR0000000",
"ResultParameters":{
"ResultParameter":[
{
"Key":"ReceiptNo",
"Value":"LGR919G2AV"
},
{
"Key":"Conversation ID",
"Value":"AG_20170727_00004492b1b6d0078fbe"
},
{
"Key":"FinalisedTime",
"Value":20170727101415
},
{
"Key":"Amount",
"Value":10
},
{
"Key":"TransactionStatus",
"Value":"Completed"
},
{
"Key":"ReasonType",
"Value":"Salary Payment via API"
},
{
"Key":"TransactionReason"
},
{
"Key":"DebitPartyCharges",
"Value":"Fee For B2C Payment|KES|33.00"
},
{
"Key":"DebitAccountType",
"Value":"Utility Account"
},
{
"Key":"InitiatedTime",
"Value":20170727101415
},
{
"Key":"Originator Conversation ID",
"Value":"19455-773836-1"
},
{
"Key":"CreditPartyName",
"Value":"254708374149 - John Doe"
},
{
"Key":"DebitPartyName",
"Value":"600134 - Safaricom157"
}
]
},
"ReferenceData":{
"ReferenceItem":{
"Key":"Occasion",
"Value":"aaaa"
}
}
}
def get_payment_request_response_payload():
"""Response received after successfully calling the stk push process request API."""
return {
"MerchantRequestID": "8071-27184008-1",
"CheckoutRequestID": "ws_CO_061020201133231972",
"ResultCode": 0,
"ResultDesc": "The service request is processed successfully.",
"CallbackMetadata": {
"Item": [
{ "Name": "Amount", "Value": 500.0 },
{ "Name": "MpesaReceiptNumber", "Value": "LGR7OWQX0R" },
{ "Name": "TransactionDate", "Value": 20201006113336 },
{ "Name": "PhoneNumber", "Value": 254723575670 }
]
}
}
def get_payment_callback_payload():
"""Response received from the server as callback after calling the stkpush process request API."""
return {
"Body":{
"stkCallback":{
"MerchantRequestID":"19465-780693-1",
"CheckoutRequestID":"ws_CO_061020201133231972",
"ResultCode":0,
"ResultDesc":"The service request is processed successfully.",
"CallbackMetadata":{
"Item":[
{
"Name":"Amount",
"Value":500
},
{
"Name":"MpesaReceiptNumber",
"Value":"LGR7OWQX0R"
},
{
"Name":"Balance"
},
{
"Name":"TransactionDate",
"Value":20170727154800
},
{
"Name":"PhoneNumber",
"Value":254721566839
}
]
}
}
}
}
def get_account_balance_callback_payload():
"""Response received from the server as callback after calling the account balance API."""
return {
"Result":{
"ResultType": 0,
"ResultCode": 0,
"ResultDesc": "The service request is processed successfully.",
"OriginatorConversationID": "16470-170099139-1",
"ConversationID": "AG_20200927_00007cdb1f9fb6494315",
"TransactionID": "OIR0000000",
"ResultParameters": {
"ResultParameter": [
{
"Key": "AccountBalance",
"Value": "Working Account|KES|481000.00|481000.00|0.00|0.00"
},
{ "Key": "BOCompletedTime", "Value": 20200927234123 }
]
},
"ReferenceData": {
"ReferenceItem": {
"Key": "QueueTimeoutURL",
"Value": "https://internalsandbox.safaricom.co.ke/mpesa/abresults/v1/submit"
}
}
}
}

View File

@ -3,6 +3,7 @@ import frappe
from frappe import _
import base64, hashlib, hmac
from six.moves.urllib.parse import urlparse
from erpnext import get_default_company
def validate_webhooks_request(doctype, hmac_key, secret_key='secret'):
def innerfn(fn):
@ -41,3 +42,22 @@ def get_webhook_address(connector_name, method, exclude_uri=False):
server_url = '{uri.scheme}://{uri.netloc}/api/method/{endpoint}'.format(uri=urlparse(url), endpoint=endpoint)
return server_url
def create_mode_of_payment(gateway, payment_type="General"):
payment_gateway_account = frappe.db.get_value("Payment Gateway Account", {
"payment_gateway": gateway
}, ['payment_account'])
if not frappe.db.exists("Mode of Payment", gateway) and payment_gateway_account:
mode_of_payment = frappe.get_doc({
"doctype": "Mode of Payment",
"mode_of_payment": gateway,
"enabled": 1,
"type": payment_type,
"accounts": [{
"doctype": "Mode of Payment Account",
"company": get_default_company(),
"default_account": payment_gateway_account
}]
})
mode_of_payment.insert(ignore_permissions=True)

View File

@ -43,7 +43,8 @@
"ignore_user_permissions": 1,
"in_list_view": 1,
"label": "Dosage",
"options": "Prescription Dosage"
"options": "Prescription Dosage",
"reqd": 1
},
{
"fieldname": "period",
@ -51,14 +52,16 @@
"ignore_user_permissions": 1,
"in_list_view": 1,
"label": "Period",
"options": "Prescription Duration"
"options": "Prescription Duration",
"reqd": 1
},
{
"fieldname": "dosage_form",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Dosage Form",
"options": "Dosage Form"
"options": "Dosage Form",
"reqd": 1
},
{
"fieldname": "column_break_7",
@ -72,7 +75,7 @@
"label": "Comment"
},
{
"depends_on": "use_interval",
"depends_on": "usage_interval",
"fieldname": "interval",
"fieldtype": "Int",
"in_list_view": 1,
@ -80,6 +83,7 @@
},
{
"default": "1",
"depends_on": "usage_interval",
"fieldname": "update_schedule",
"fieldtype": "Check",
"hidden": 1,
@ -99,12 +103,13 @@
"default": "0",
"fieldname": "usage_interval",
"fieldtype": "Check",
"hidden": 1,
"label": "Dosage by Time Interval"
}
],
"istable": 1,
"links": [],
"modified": "2020-02-26 17:02:42.741338",
"modified": "2020-09-30 23:32:09.495288",
"modified_by": "Administrator",
"module": "Healthcare",
"name": "Drug Prescription",

View File

@ -0,0 +1,37 @@
// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on('Inpatient Medication Entry', {
refresh: function(frm) {
// Ignore cancellation of doctype on cancel all
frm.ignore_doctypes_on_cancel_all = ['Stock Entry'];
frm.set_query('item_code', () => {
return {
filters: {
is_stock_item: 1
}
};
});
frm.set_query('drug_code', 'medication_orders', () => {
return {
filters: {
is_stock_item: 1
}
};
});
},
get_medication_orders: function(frm) {
frappe.call({
method: 'get_medication_orders',
doc: frm.doc,
freeze: true,
freeze_message: __('Fetching Pending Medication Orders'),
callback: function() {
refresh_field('medication_orders');
}
});
}
});

View File

@ -0,0 +1,203 @@
{
"actions": [],
"autoname": "naming_series:",
"creation": "2020-09-25 14:13:20.111906",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"naming_series",
"company",
"column_break_3",
"posting_date",
"status",
"filters_section",
"item_code",
"assigned_to_practitioner",
"patient",
"practitioner",
"service_unit",
"column_break_11",
"from_date",
"to_date",
"from_time",
"to_time",
"select_medication_orders_section",
"get_medication_orders",
"medication_orders",
"section_break_18",
"update_stock",
"warehouse",
"amended_from"
],
"fields": [
{
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Naming Series",
"options": "HLC-IME-.YYYY.-"
},
{
"fieldname": "company",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Company",
"options": "Company",
"reqd": 1
},
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
{
"fieldname": "posting_date",
"fieldtype": "Date",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Posting Date",
"reqd": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"label": "Status",
"options": "\nDraft\nSubmitted\nPending\nIn Process\nCompleted\nCancelled",
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "filters_section",
"fieldtype": "Section Break",
"label": "Filters"
},
{
"fieldname": "item_code",
"fieldtype": "Link",
"label": "Item Code (Drug)",
"options": "Item"
},
{
"depends_on": "update_stock",
"description": "Warehouse from where medication stock should be consumed",
"fieldname": "warehouse",
"fieldtype": "Link",
"label": "Medication Warehouse",
"mandatory_depends_on": "update_stock",
"options": "Warehouse"
},
{
"fieldname": "patient",
"fieldtype": "Link",
"label": "Patient",
"options": "Patient"
},
{
"fieldname": "service_unit",
"fieldtype": "Link",
"label": "Healthcare Service Unit",
"options": "Healthcare Service Unit"
},
{
"fieldname": "column_break_11",
"fieldtype": "Column Break"
},
{
"fieldname": "from_date",
"fieldtype": "Date",
"label": "From Date"
},
{
"fieldname": "to_date",
"fieldtype": "Date",
"label": "To Date"
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"no_copy": 1,
"options": "Inpatient Medication Entry",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "practitioner",
"fieldtype": "Link",
"label": "Healthcare Practitioner",
"options": "Healthcare Practitioner"
},
{
"fieldname": "select_medication_orders_section",
"fieldtype": "Section Break",
"label": "Medication Orders"
},
{
"fieldname": "medication_orders",
"fieldtype": "Table",
"label": "Inpatient Medication Orders",
"options": "Inpatient Medication Entry Detail",
"read_only": 1,
"reqd": 1
},
{
"depends_on": "eval:doc.docstatus!==1",
"fieldname": "get_medication_orders",
"fieldtype": "Button",
"label": "Get Pending Medication Orders",
"print_hide": 1
},
{
"fieldname": "assigned_to_practitioner",
"fieldtype": "Link",
"label": "Assigned To",
"options": "User"
},
{
"fieldname": "section_break_18",
"fieldtype": "Section Break",
"label": "Stock Details"
},
{
"default": "1",
"fieldname": "update_stock",
"fieldtype": "Check",
"label": "Update Stock"
},
{
"fieldname": "from_time",
"fieldtype": "Time",
"label": "From Time"
},
{
"fieldname": "to_time",
"fieldtype": "Time",
"label": "To Time"
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2020-09-30 23:40:45.528715",
"modified_by": "Administrator",
"module": "Healthcare",
"name": "Inpatient Medication Entry",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@ -0,0 +1,273 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import flt, get_link_to_form, getdate, nowtime
from erpnext.stock.utils import get_latest_stock_qty
from erpnext.healthcare.doctype.healthcare_settings.healthcare_settings import get_account
class InpatientMedicationEntry(Document):
def validate(self):
self.validate_medication_orders()
def get_medication_orders(self):
self.validate_datetime_filters()
# pull inpatient medication orders based on selected filters
orders = get_pending_medication_orders(self)
if orders:
self.add_mo_to_table(orders)
return self
else:
self.set('medication_orders', [])
frappe.msgprint(_('No pending medication orders found for selected criteria'))
def validate_datetime_filters(self):
if self.from_date and self.to_date:
self.validate_from_to_dates('from_date', 'to_date')
if self.from_date and getdate(self.from_date) > getdate():
frappe.throw(_('From Date cannot be after the current date.'))
if self.to_date and getdate(self.to_date) > getdate():
frappe.throw(_('To Date cannot be after the current date.'))
if self.from_time and self.from_time > nowtime():
frappe.throw(_('From Time cannot be after the current time.'))
if self.to_time and self.to_time > nowtime():
frappe.throw(_('To Time cannot be after the current time.'))
def add_mo_to_table(self, orders):
# Add medication orders in the child table
self.set('medication_orders', [])
for data in orders:
self.append('medication_orders', {
'patient': data.patient,
'patient_name': data.patient_name,
'inpatient_record': data.inpatient_record,
'service_unit': data.service_unit,
'datetime': "%s %s" % (data.date, data.time or "00:00:00"),
'drug_code': data.drug,
'drug_name': data.drug_name,
'dosage': data.dosage,
'dosage_form': data.dosage_form,
'against_imo': data.parent,
'against_imoe': data.name
})
def on_submit(self):
self.validate_medication_orders()
success_msg = ""
if self.update_stock:
stock_entry = self.process_stock()
success_msg += _('Stock Entry {0} created and ').format(
frappe.bold(get_link_to_form('Stock Entry', stock_entry)))
self.update_medication_orders()
success_msg += _('Inpatient Medication Orders updated successfully')
frappe.msgprint(success_msg, title=_('Success'), indicator='green')
def validate_medication_orders(self):
for entry in self.medication_orders:
docstatus, is_completed = frappe.db.get_value('Inpatient Medication Order Entry', entry.against_imoe,
['docstatus', 'is_completed'])
if docstatus == 2:
frappe.throw(_('Row {0}: Cannot create Inpatient Medication Entry against cancelled Inpatient Medication Order {1}').format(
entry.idx, get_link_to_form(entry.against_imo)))
if is_completed:
frappe.throw(_('Row {0}: This Medication Order is already marked as completed').format(
entry.idx))
def on_cancel(self):
self.cancel_stock_entries()
self.update_medication_orders(on_cancel=True)
def process_stock(self):
allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock')
if not allow_negative_stock:
self.check_stock_qty()
return self.make_stock_entry()
def update_medication_orders(self, on_cancel=False):
orders, order_entry_map = self.get_order_entry_map()
# mark completion status
is_completed = 1
if on_cancel:
is_completed = 0
frappe.db.sql("""
UPDATE `tabInpatient Medication Order Entry`
SET is_completed = %(is_completed)s
WHERE name IN %(orders)s
""", {'orders': orders, 'is_completed': is_completed})
# update status and completed orders count
for order, count in order_entry_map.items():
medication_order = frappe.get_doc('Inpatient Medication Order', order)
completed_orders = flt(count)
current_value = frappe.db.get_value('Inpatient Medication Order', order, 'completed_orders')
if on_cancel:
completed_orders = flt(current_value) - flt(count)
else:
completed_orders = flt(current_value) + flt(count)
medication_order.db_set('completed_orders', completed_orders)
medication_order.set_status()
def get_order_entry_map(self):
# for marking order completion status
orders = []
# orders mapped
order_entry_map = dict()
for entry in self.medication_orders:
orders.append(entry.against_imoe)
parent = entry.against_imo
if not order_entry_map.get(parent):
order_entry_map[parent] = 0
order_entry_map[parent] += 1
return orders, order_entry_map
def check_stock_qty(self):
from erpnext.stock.stock_ledger import NegativeStockError
drug_availability = dict()
for d in self.medication_orders:
if not drug_availability.get(d.drug_code):
drug_availability[d.drug_code] = 0
drug_availability[d.drug_code] += flt(d.dosage)
for drug, dosage in drug_availability.items():
available_qty = get_latest_stock_qty(drug, self.warehouse)
# validate qty
if flt(available_qty) < flt(dosage):
frappe.throw(_('Quantity not available for {0} in warehouse {1}').format(
frappe.bold(drug), frappe.bold(self.warehouse))
+ '<br><br>' + _('Available quantity is {0}, you need {1}').format(
frappe.bold(available_qty), frappe.bold(dosage))
+ '<br><br>' + _('Please enable Allow Negative Stock in Stock Settings or create Stock Entry to proceed.'),
NegativeStockError, title=_('Insufficient Stock'))
def make_stock_entry(self):
stock_entry = frappe.new_doc('Stock Entry')
stock_entry.purpose = 'Material Issue'
stock_entry.set_stock_entry_type()
stock_entry.from_warehouse = self.warehouse
stock_entry.company = self.company
stock_entry.inpatient_medication_entry = self.name
cost_center = frappe.get_cached_value('Company', self.company, 'cost_center')
expense_account = get_account(None, 'expense_account', 'Healthcare Settings', self.company)
for entry in self.medication_orders:
se_child = stock_entry.append('items')
se_child.item_code = entry.drug_code
se_child.item_name = entry.drug_name
se_child.uom = frappe.db.get_value('Item', entry.drug_code, 'stock_uom')
se_child.stock_uom = se_child.uom
se_child.qty = flt(entry.dosage)
# in stock uom
se_child.conversion_factor = 1
se_child.cost_center = cost_center
se_child.expense_account = expense_account
# references
se_child.patient = entry.patient
se_child.inpatient_medication_entry_child = entry.name
stock_entry.submit()
return stock_entry.name
def cancel_stock_entries(self):
stock_entries = frappe.get_all('Stock Entry', {'inpatient_medication_entry': self.name})
for entry in stock_entries:
doc = frappe.get_doc('Stock Entry', entry.name)
doc.cancel()
def get_pending_medication_orders(entry):
filters, values = get_filters(entry)
data = frappe.db.sql("""
SELECT
ip.inpatient_record, ip.patient, ip.patient_name,
entry.name, entry.parent, entry.drug, entry.drug_name,
entry.dosage, entry.dosage_form, entry.date, entry.time, entry.instructions
FROM
`tabInpatient Medication Order` ip
INNER JOIN
`tabInpatient Medication Order Entry` entry
ON
ip.name = entry.parent
WHERE
ip.docstatus = 1 and
ip.company = %(company)s and
entry.is_completed = 0
{0}
ORDER BY
entry.date, entry.time
""".format(filters), values, as_dict=1)
for doc in data:
inpatient_record = doc.inpatient_record
doc['service_unit'] = get_current_healthcare_service_unit(inpatient_record)
if entry.service_unit and doc.service_unit != entry.service_unit:
data.remove(doc)
return data
def get_filters(entry):
filters = ''
values = dict(company=entry.company)
if entry.from_date:
filters += ' and entry.date >= %(from_date)s'
values['from_date'] = entry.from_date
if entry.to_date:
filters += ' and entry.date <= %(to_date)s'
values['to_date'] = entry.to_date
if entry.from_time:
filters += ' and entry.time >= %(from_time)s'
values['from_time'] = entry.from_time
if entry.to_time:
filters += ' and entry.time <= %(to_time)s'
values['to_time'] = entry.to_time
if entry.patient:
filters += ' and ip.patient = %(patient)s'
values['patient'] = entry.patient
if entry.practitioner:
filters += ' and ip.practitioner = %(practitioner)s'
values['practitioner'] = entry.practitioner
if entry.item_code:
filters += ' and entry.drug = %(item_code)s'
values['item_code'] = entry.item_code
if entry.assigned_to_practitioner:
filters += ' and ip._assign LIKE %(assigned_to)s'
values['assigned_to'] = '%' + entry.assigned_to_practitioner + '%'
return filters, values
def get_current_healthcare_service_unit(inpatient_record):
ip_record = frappe.get_doc('Inpatient Record', inpatient_record)
return ip_record.inpatient_occupancies[-1].service_unit

View File

@ -0,0 +1,16 @@
from __future__ import unicode_literals
from frappe import _
def get_data():
return {
'fieldname': 'against_imoe',
'internal_links': {
'Inpatient Medication Order': ['medication_orders', 'against_imo']
},
'transactions': [
{
'label': _('Reference'),
'items': ['Inpatient Medication Order']
}
]
}

View File

@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from __future__ import unicode_literals
import frappe
import unittest
from frappe.utils import add_days, getdate, now_datetime
from erpnext.healthcare.doctype.inpatient_record.test_inpatient_record import create_patient, create_inpatient, get_healthcare_service_unit, mark_invoiced_inpatient_occupancy
from erpnext.healthcare.doctype.inpatient_record.inpatient_record import admit_patient, discharge_patient, schedule_discharge
from erpnext.healthcare.doctype.inpatient_medication_order.test_inpatient_medication_order import create_ipmo, create_ipme
from erpnext.healthcare.doctype.healthcare_settings.healthcare_settings import get_account
class TestInpatientMedicationEntry(unittest.TestCase):
def setUp(self):
frappe.db.sql("""delete from `tabInpatient Record`""")
frappe.db.sql("""delete from `tabInpatient Medication Order`""")
frappe.db.sql("""delete from `tabInpatient Medication Entry`""")
self.patient = create_patient()
# Admit
ip_record = create_inpatient(self.patient)
ip_record.expected_length_of_stay = 0
ip_record.save()
ip_record.reload()
service_unit = get_healthcare_service_unit()
admit_patient(ip_record, service_unit, now_datetime())
self.ip_record = ip_record
def test_filters_for_fetching_pending_mo(self):
ipmo = create_ipmo(self.patient)
ipmo.submit()
ipmo.reload()
date = add_days(getdate(), -1)
filters = frappe._dict(
from_date=date,
to_date=date,
from_time='',
to_time='',
item_code='Dextromethorphan',
patient=self.patient
)
ipme = create_ipme(filters, update_stock=0)
# 3 dosages per day
self.assertEqual(len(ipme.medication_orders), 3)
self.assertEqual(getdate(ipme.medication_orders[0].datetime), date)
def test_ipme_with_stock_update(self):
ipmo = create_ipmo(self.patient)
ipmo.submit()
ipmo.reload()
date = add_days(getdate(), -1)
filters = frappe._dict(
from_date=date,
to_date=date,
from_time='',
to_time='',
item_code='Dextromethorphan',
patient=self.patient
)
make_stock_entry()
ipme = create_ipme(filters, update_stock=1)
ipme.submit()
ipme.reload()
# test order completed
is_order_completed = frappe.db.get_value('Inpatient Medication Order Entry',
ipme.medication_orders[0].against_imoe, 'is_completed')
self.assertEqual(is_order_completed, 1)
# test stock entry
stock_entry = frappe.db.exists('Stock Entry', {'inpatient_medication_entry': ipme.name})
self.assertTrue(stock_entry)
# check references
stock_entry = frappe.get_doc('Stock Entry', stock_entry)
self.assertEqual(stock_entry.items[0].patient, self.patient)
self.assertEqual(stock_entry.items[0].inpatient_medication_entry_child, ipme.medication_orders[0].name)
def tearDown(self):
# cleanup - Discharge
schedule_discharge(frappe.as_json({'patient': self.patient}))
self.ip_record.reload()
mark_invoiced_inpatient_occupancy(self.ip_record)
self.ip_record.reload()
discharge_patient(self.ip_record)
for entry in frappe.get_all('Inpatient Medication Entry'):
doc = frappe.get_doc('Inpatient Medication Entry', entry.name)
doc.cancel()
frappe.db.delete('Stock Entry', {'inpatient_medication_entry': doc.name})
doc.delete()
for entry in frappe.get_all('Inpatient Medication Order'):
doc = frappe.get_doc('Inpatient Medication Order', entry.name)
doc.cancel()
doc.delete()
def make_stock_entry():
frappe.db.set_value('Company', '_Test Company', {
'stock_adjustment_account': 'Stock Adjustment - _TC',
'default_inventory_account': 'Stock In Hand - _TC'
})
stock_entry = frappe.new_doc('Stock Entry')
stock_entry.stock_entry_type = 'Material Receipt'
stock_entry.company = '_Test Company'
stock_entry.to_warehouse = 'Stores - _TC'
expense_account = get_account(None, 'expense_account', 'Healthcare Settings', '_Test Company')
se_child = stock_entry.append('items')
se_child.item_code = 'Dextromethorphan'
se_child.item_name = 'Dextromethorphan'
se_child.uom = 'Nos'
se_child.stock_uom = 'Nos'
se_child.qty = 6
se_child.t_warehouse = 'Stores - _TC'
# in stock uom
se_child.conversion_factor = 1.0
se_child.expense_account = expense_account
stock_entry.submit()

View File

@ -0,0 +1,163 @@
{
"actions": [],
"creation": "2020-09-25 14:56:32.636569",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"patient",
"patient_name",
"inpatient_record",
"column_break_4",
"service_unit",
"datetime",
"medication_details_section",
"drug_code",
"drug_name",
"dosage",
"available_qty",
"dosage_form",
"column_break_10",
"instructions",
"references_section",
"against_imo",
"against_imoe"
],
"fields": [
{
"columns": 2,
"fieldname": "patient",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Patient",
"options": "Patient",
"reqd": 1
},
{
"fetch_from": "patient.patient_name",
"fieldname": "patient_name",
"fieldtype": "Data",
"label": "Patient Name",
"read_only": 1
},
{
"columns": 2,
"fieldname": "drug_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Drug Code",
"options": "Item",
"reqd": 1
},
{
"fetch_from": "drug_code.item_name",
"fieldname": "drug_name",
"fieldtype": "Data",
"label": "Drug Name",
"read_only": 1
},
{
"columns": 1,
"fieldname": "dosage",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Dosage",
"reqd": 1
},
{
"fieldname": "dosage_form",
"fieldtype": "Link",
"label": "Dosage Form",
"options": "Dosage Form"
},
{
"fetch_from": "patient.inpatient_record",
"fieldname": "inpatient_record",
"fieldtype": "Link",
"label": "Inpatient Record",
"options": "Inpatient Record",
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "references_section",
"fieldtype": "Section Break",
"label": "References"
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fieldname": "medication_details_section",
"fieldtype": "Section Break",
"label": "Medication Details"
},
{
"fieldname": "column_break_10",
"fieldtype": "Column Break"
},
{
"columns": 3,
"fieldname": "datetime",
"fieldtype": "Datetime",
"in_list_view": 1,
"label": "Datetime",
"reqd": 1
},
{
"fieldname": "instructions",
"fieldtype": "Small Text",
"label": "Instructions"
},
{
"columns": 2,
"fieldname": "service_unit",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Service Unit",
"options": "Healthcare Service Unit",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "against_imo",
"fieldtype": "Link",
"label": "Against Inpatient Medication Order",
"no_copy": 1,
"options": "Inpatient Medication Order",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "against_imoe",
"fieldtype": "Data",
"label": "Against Inpatient Medication Order Entry",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "available_qty",
"fieldtype": "Float",
"hidden": 1,
"label": "Available Qty",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2020-09-30 14:48:23.648223",
"modified_by": "Administrator",
"module": "Healthcare",
"name": "Inpatient Medication Entry Detail",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
class InpatientMedicationEntryDetail(Document):
pass

View File

@ -0,0 +1,106 @@
// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on('Inpatient Medication Order', {
refresh: function(frm) {
if (frm.doc.docstatus === 1) {
frm.trigger("show_progress");
}
frm.events.show_medication_order_button(frm);
frm.set_query('patient', () => {
return {
filters: {
'inpatient_record': ['!=', '']
}
};
});
},
show_medication_order_button: function(frm) {
frm.fields_dict['medication_orders'].grid.wrapper.find('.grid-add-row').hide();
frm.fields_dict['medication_orders'].grid.add_custom_button(__('Add Medication Orders'), () => {
let d = new frappe.ui.Dialog({
title: __('Add Medication Orders'),
fields: [
{
fieldname: 'drug_code',
label: __('Drug'),
fieldtype: 'Link',
options: 'Item',
reqd: 1,
"get_query": function () {
return {
filters: {'is_stock_item': 1}
};
}
},
{
fieldname: 'dosage',
label: __('Dosage'),
fieldtype: 'Link',
options: 'Prescription Dosage',
reqd: 1
},
{
fieldname: 'period',
label: __('Period'),
fieldtype: 'Link',
options: 'Prescription Duration',
reqd: 1
},
{
fieldname: 'dosage_form',
label: __('Dosage Form'),
fieldtype: 'Link',
options: 'Dosage Form',
reqd: 1
}
],
primary_action_label: __('Add'),
primary_action: () => {
let values = d.get_values();
if (values) {
frm.call({
doc: frm.doc,
method: 'add_order_entries',
args: {
order: values
},
freeze: true,
freeze_message: __('Adding Order Entries'),
callback: function() {
frm.refresh_field('medication_orders');
}
});
}
},
});
d.show();
});
},
show_progress: function(frm) {
let bars = [];
let message = '';
// completed sessions
let title = __('{0} medication orders completed', [frm.doc.completed_orders]);
if (frm.doc.completed_orders === 1) {
title = __('{0} medication order completed', [frm.doc.completed_orders]);
}
title += __(' out of {0}', [frm.doc.total_orders]);
bars.push({
'title': title,
'width': (frm.doc.completed_orders / frm.doc.total_orders * 100) + '%',
'progress_class': 'progress-bar-success'
});
if (bars[0].width == '0%') {
bars[0].width = '0.5%';
}
message = title;
frm.dashboard.add_progress(__('Status'), bars, message);
}
});

View File

@ -0,0 +1,196 @@
{
"actions": [],
"autoname": "naming_series:",
"creation": "2020-09-14 18:33:56.715736",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"patient_details_section",
"naming_series",
"patient_encounter",
"patient",
"patient_name",
"patient_age",
"inpatient_record",
"column_break_6",
"company",
"status",
"practitioner",
"start_date",
"end_date",
"medication_orders_section",
"medication_orders",
"section_break_16",
"total_orders",
"column_break_18",
"completed_orders",
"amended_from"
],
"fields": [
{
"fieldname": "patient_details_section",
"fieldtype": "Section Break",
"label": "Patient Details"
},
{
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Naming Series",
"options": "HLC-IMO-.YYYY.-"
},
{
"fieldname": "patient_encounter",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Patient Encounter",
"options": "Patient Encounter"
},
{
"fetch_from": "patient_encounter.patient",
"fieldname": "patient",
"fieldtype": "Link",
"label": "Patient",
"options": "Patient",
"read_only_depends_on": "patient_encounter",
"reqd": 1
},
{
"fetch_from": "patient.patient_name",
"fieldname": "patient_name",
"fieldtype": "Data",
"label": "Patient Name",
"read_only": 1
},
{
"fieldname": "patient_age",
"fieldtype": "Data",
"label": "Patient Age",
"read_only": 1
},
{
"fieldname": "column_break_6",
"fieldtype": "Column Break"
},
{
"fetch_from": "patient.inpatient_record",
"fieldname": "inpatient_record",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Inpatient Record",
"options": "Inpatient Record",
"read_only": 1,
"reqd": 1
},
{
"fetch_from": "patient_encounter.practitioner",
"fieldname": "practitioner",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Healthcare Practitioner",
"options": "Healthcare Practitioner",
"read_only_depends_on": "patient_encounter"
},
{
"fieldname": "start_date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Start Date",
"reqd": 1
},
{
"fieldname": "end_date",
"fieldtype": "Date",
"label": "End Date",
"read_only": 1
},
{
"depends_on": "eval: doc.patient && doc.start_date",
"fieldname": "medication_orders_section",
"fieldtype": "Section Break",
"label": "Medication Orders"
},
{
"fieldname": "medication_orders",
"fieldtype": "Table",
"label": "Medication Orders",
"options": "Inpatient Medication Order Entry"
},
{
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company",
"reqd": 1
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"no_copy": 1,
"options": "Inpatient Medication Order",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Status",
"options": "\nDraft\nSubmitted\nPending\nIn Process\nCompleted\nCancelled",
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "section_break_16",
"fieldtype": "Section Break",
"label": "Other Details"
},
{
"fieldname": "total_orders",
"fieldtype": "Float",
"label": "Total Orders",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "column_break_18",
"fieldtype": "Column Break"
},
{
"fieldname": "completed_orders",
"fieldtype": "Float",
"label": "Completed Orders",
"no_copy": 1,
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2020-09-30 21:53:27.128591",
"modified_by": "Administrator",
"module": "Healthcare",
"name": "Inpatient Medication Order",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"search_fields": "patient_encounter, patient",
"sort_field": "modified",
"sort_order": "DESC",
"title_field": "patient",
"track_changes": 1
}

View File

@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import cstr
from erpnext.healthcare.doctype.patient_encounter.patient_encounter import get_prescription_dates
class InpatientMedicationOrder(Document):
def validate(self):
self.validate_inpatient()
self.validate_duplicate()
self.set_total_orders()
self.set_status()
def on_submit(self):
self.validate_inpatient()
self.set_status()
def on_cancel(self):
self.set_status()
def validate_inpatient(self):
if not self.inpatient_record:
frappe.throw(_('No Inpatient Record found against patient {0}').format(self.patient))
def validate_duplicate(self):
existing_mo = frappe.db.exists('Inpatient Medication Order', {
'patient_encounter': self.patient_encounter,
'docstatus': ('!=', 2),
'name': ('!=', self.name)
})
if existing_mo:
frappe.throw(_('An Inpatient Medication Order {0} against Patient Encounter {1} already exists.').format(
existing_mo, self.patient_encounter), frappe.DuplicateEntryError)
def set_total_orders(self):
self.db_set('total_orders', len(self.medication_orders))
def set_status(self):
status = {
"0": "Draft",
"1": "Submitted",
"2": "Cancelled"
}[cstr(self.docstatus or 0)]
if self.docstatus == 1:
if not self.completed_orders:
status = 'Pending'
elif self.completed_orders < self.total_orders:
status = 'In Process'
else:
status = 'Completed'
self.db_set('status', status)
def add_order_entries(self, order):
if order.get('drug_code'):
dosage = frappe.get_doc('Prescription Dosage', order.get('dosage'))
dates = get_prescription_dates(order.get('period'), self.start_date)
for date in dates:
for dose in dosage.dosage_strength:
entry = self.append('medication_orders')
entry.drug = order.get('drug_code')
entry.drug_name = frappe.db.get_value('Item', order.get('drug_code'), 'item_name')
entry.dosage = dose.strength
entry.dosage_form = order.get('dosage_form')
entry.date = date
entry.time = dose.strength_time
self.end_date = dates[-1]
return

View File

@ -0,0 +1,16 @@
frappe.listview_settings['Inpatient Medication Order'] = {
add_fields: ["status"],
filters: [["status", "!=", "Cancelled"]],
get_indicator: function(doc) {
if (doc.status === "Pending") {
return [__("Pending"), "orange", "status,=,Pending"];
} else if (doc.status === "In Process") {
return [__("In Process"), "blue", "status,=,In Process"];
} else if (doc.status === "Completed") {
return [__("Completed"), "green", "status,=,Completed"];
}
}
};

View File

@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from __future__ import unicode_literals
import frappe
import unittest
from frappe.utils import add_days, getdate, now_datetime
from erpnext.healthcare.doctype.inpatient_record.test_inpatient_record import create_patient, create_inpatient, get_healthcare_service_unit, mark_invoiced_inpatient_occupancy
from erpnext.healthcare.doctype.inpatient_record.inpatient_record import admit_patient, discharge_patient, schedule_discharge
class TestInpatientMedicationOrder(unittest.TestCase):
def setUp(self):
frappe.db.sql("""delete from `tabInpatient Record`""")
self.patient = create_patient()
# Admit
ip_record = create_inpatient(self.patient)
ip_record.expected_length_of_stay = 0
ip_record.save()
ip_record.reload()
service_unit = get_healthcare_service_unit()
admit_patient(ip_record, service_unit, now_datetime())
self.ip_record = ip_record
def test_order_creation(self):
ipmo = create_ipmo(self.patient)
ipmo.submit()
ipmo.reload()
# 3 dosages per day for 2 days
self.assertEqual(len(ipmo.medication_orders), 6)
self.assertEqual(ipmo.medication_orders[0].date, add_days(getdate(), -1))
prescription_dosage = frappe.get_doc('Prescription Dosage', '1-1-1')
for i in range(len(prescription_dosage.dosage_strength)):
self.assertEqual(ipmo.medication_orders[i].time, prescription_dosage.dosage_strength[i].strength_time)
self.assertEqual(ipmo.medication_orders[3].date, getdate())
def test_inpatient_validation(self):
# Discharge
schedule_discharge(frappe.as_json({'patient': self.patient}))
self.ip_record.reload()
mark_invoiced_inpatient_occupancy(self.ip_record)
self.ip_record.reload()
discharge_patient(self.ip_record)
ipmo = create_ipmo(self.patient)
# inpatient validation
self.assertRaises(frappe.ValidationError, ipmo.insert)
def test_status(self):
ipmo = create_ipmo(self.patient)
ipmo.submit()
ipmo.reload()
self.assertEqual(ipmo.status, 'Pending')
filters = frappe._dict(from_date=add_days(getdate(), -1), to_date=add_days(getdate(), -1), from_time='', to_time='')
ipme = create_ipme(filters)
ipme.submit()
ipmo.reload()
self.assertEqual(ipmo.status, 'In Process')
filters = frappe._dict(from_date=getdate(), to_date=getdate(), from_time='', to_time='')
ipme = create_ipme(filters)
ipme.submit()
ipmo.reload()
self.assertEqual(ipmo.status, 'Completed')
def tearDown(self):
if frappe.db.get_value('Patient', self.patient, 'inpatient_record'):
# cleanup - Discharge
schedule_discharge(frappe.as_json({'patient': self.patient}))
self.ip_record.reload()
mark_invoiced_inpatient_occupancy(self.ip_record)
self.ip_record.reload()
discharge_patient(self.ip_record)
for entry in frappe.get_all('Inpatient Medication Entry'):
doc = frappe.get_doc('Inpatient Medication Entry', entry.name)
doc.cancel()
doc.delete()
for entry in frappe.get_all('Inpatient Medication Order'):
doc = frappe.get_doc('Inpatient Medication Order', entry.name)
doc.cancel()
doc.delete()
def create_dosage_form():
if not frappe.db.exists('Dosage Form', 'Tablet'):
frappe.get_doc({
'doctype': 'Dosage Form',
'dosage_form': 'Tablet'
}).insert()
def create_drug(item=None):
if not item:
item = 'Dextromethorphan'
drug = frappe.db.exists('Item', {'item_code': 'Dextromethorphan'})
if not drug:
drug = frappe.get_doc({
'doctype': 'Item',
'item_code': 'Dextromethorphan',
'item_name': 'Dextromethorphan',
'item_group': 'Products',
'stock_uom': 'Nos',
'is_stock_item': 1,
'valuation_rate': 50,
'opening_stock': 20
}).insert()
def get_orders():
create_dosage_form()
create_drug()
return {
'drug_code': 'Dextromethorphan',
'drug_name': 'Dextromethorphan',
'dosage': '1-1-1',
'dosage_form': 'Tablet',
'period': '2 Day'
}
def create_ipmo(patient):
orders = get_orders()
ipmo = frappe.new_doc('Inpatient Medication Order')
ipmo.patient = patient
ipmo.company = '_Test Company'
ipmo.start_date = add_days(getdate(), -1)
ipmo.add_order_entries(orders)
return ipmo
def create_ipme(filters, update_stock=0):
ipme = frappe.new_doc('Inpatient Medication Entry')
ipme.company = '_Test Company'
ipme.posting_date = getdate()
ipme.update_stock = update_stock
if update_stock:
ipme.warehouse = 'Stores - _TC'
for key, value in filters.items():
ipme.set(key, value)
ipme = ipme.get_medication_orders()
return ipme

View File

@ -0,0 +1,94 @@
{
"actions": [],
"creation": "2020-09-14 21:51:30.259164",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"drug",
"drug_name",
"dosage",
"dosage_form",
"instructions",
"column_break_4",
"date",
"time",
"is_completed"
],
"fields": [
{
"fieldname": "drug",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Drug",
"options": "Item",
"reqd": 1
},
{
"fetch_from": "drug.item_name",
"fieldname": "drug_name",
"fieldtype": "Data",
"label": "Drug Name",
"read_only": 1
},
{
"fieldname": "dosage",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Dosage",
"reqd": 1
},
{
"fieldname": "dosage_form",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Dosage Form",
"options": "Dosage Form",
"reqd": 1
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fieldname": "date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Date",
"reqd": 1
},
{
"fieldname": "time",
"fieldtype": "Time",
"in_list_view": 1,
"label": "Time",
"reqd": 1
},
{
"default": "0",
"fieldname": "is_completed",
"fieldtype": "Check",
"label": "Is Order Completed",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "instructions",
"fieldtype": "Small Text",
"label": "Instructions"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2020-09-30 14:03:26.755925",
"modified_by": "Administrator",
"module": "Healthcare",
"name": "Inpatient Medication Order Entry",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
class InpatientMedicationOrderEntry(Document):
pass

View File

@ -83,6 +83,7 @@ def get_healthcare_service_unit():
if not service_unit:
service_unit = frappe.new_doc("Healthcare Service Unit")
service_unit.healthcare_service_unit_name = "Test Service Unit Ip Occupancy"
service_unit.company = "_Test Company"
service_unit.service_unit_type = get_service_unit_type()
service_unit.inpatient_occupancy = 1
service_unit.occupancy_status = "Vacant"

View File

@ -58,6 +58,14 @@ frappe.ui.form.on('Patient Encounter', {
create_procedure(frm);
},'Create');
if (frm.doc.drug_prescription && frm.doc.inpatient_record && frm.doc.inpatient_status === "Admitted") {
frm.add_custom_button(__('Inpatient Medication Order'), function() {
frappe.model.open_mapped_doc({
method: 'erpnext.healthcare.doctype.patient_encounter.patient_encounter.make_ip_medication_order',
frm: frm
});
}, 'Create');
}
}
frm.set_query('patient', function() {

View File

@ -6,8 +6,9 @@ from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import cstr
from frappe.utils import cstr, getdate, add_days
from frappe import _
from frappe.model.mapper import get_mapped_doc
class PatientEncounter(Document):
def validate(self):
@ -22,20 +23,69 @@ class PatientEncounter(Document):
insert_encounter_to_medical_record(self)
def on_submit(self):
update_encounter_medical_record(self)
if self.therapies:
create_therapy_plan(self)
def on_cancel(self):
if self.appointment:
frappe.db.set_value('Patient Appointment', self.appointment, 'status', 'Open')
delete_medical_record(self)
def on_submit(self):
create_therapy_plan(self)
if self.inpatient_record and self.drug_prescription:
delete_ip_medication_order(self)
delete_medical_record(self)
def set_title(self):
self.title = _('{0} with {1}').format(self.patient_name or self.patient,
self.practitioner_name or self.practitioner)[:100]
@frappe.whitelist()
def make_ip_medication_order(source_name, target_doc=None):
def set_missing_values(source, target):
target.start_date = source.encounter_date
for entry in source.drug_prescription:
if entry.drug_code:
dosage = frappe.get_doc('Prescription Dosage', entry.dosage)
dates = get_prescription_dates(entry.period, target.start_date)
for date in dates:
for dose in dosage.dosage_strength:
order = target.append('medication_orders')
order.drug = entry.drug_code
order.drug_name = entry.drug_name
order.dosage = dose.strength
order.instructions = entry.comment
order.dosage_form = entry.dosage_form
order.date = date
order.time = dose.strength_time
target.end_date = dates[-1]
doc = get_mapped_doc('Patient Encounter', source_name, {
'Patient Encounter': {
'doctype': 'Inpatient Medication Order',
'field_map': {
'name': 'patient_encounter',
'patient': 'patient',
'patient_name': 'patient_name',
'patient_age': 'patient_age',
'inpatient_record': 'inpatient_record',
'practitioner': 'practitioner',
'start_date': 'encounter_date'
},
}
}, target_doc, set_missing_values)
return doc
def get_prescription_dates(period, start_date):
prescription_duration = frappe.get_doc('Prescription Duration', period)
days = prescription_duration.get_days()
dates = [start_date]
for i in range(1, days):
dates.append(add_days(getdate(start_date), i))
return dates
def create_therapy_plan(encounter):
if len(encounter.therapies):
doc = frappe.new_doc('Therapy Plan')
@ -51,6 +101,7 @@ def create_therapy_plan(encounter):
encounter.db_set('therapy_plan', doc.name)
frappe.msgprint(_('Therapy Plan {0} created successfully.').format(frappe.bold(doc.name)), alert=True)
def insert_encounter_to_medical_record(doc):
subject = set_subject_field(doc)
medical_record = frappe.new_doc('Patient Medical Record')
@ -63,6 +114,7 @@ def insert_encounter_to_medical_record(doc):
medical_record.reference_owner = doc.owner
medical_record.save(ignore_permissions=True)
def update_encounter_medical_record(encounter):
medical_record_id = frappe.db.exists('Patient Medical Record', {'reference_name': encounter.name})
@ -72,8 +124,17 @@ def update_encounter_medical_record(encounter):
else:
insert_encounter_to_medical_record(encounter)
def delete_medical_record(encounter):
frappe.delete_doc_if_exists('Patient Medical Record', 'reference_name', encounter.name)
record = frappe.db.exists('Patient Medical Record', {'reference_name', encounter.name})
if record:
frappe.delete_doc('Patient Medical Record', record, force=1)
def delete_ip_medication_order(encounter):
record = frappe.db.exists('Inpatient Medication Order', {'patient_encounter': encounter.name})
if record:
frappe.delete_doc('Inpatient Medication Order', record, force=1)
def set_subject_field(encounter):
subject = frappe.bold(_('Healthcare Practitioner: ')) + encounter.practitioner + '<br>'

View File

@ -5,12 +5,18 @@ def get_data():
return {
'fieldname': 'encounter',
'non_standard_fieldnames': {
'Patient Medical Record': 'reference_name'
'Patient Medical Record': 'reference_name',
'Inpatient Medication Order': 'patient_encounter'
},
'transactions': [
{
'label': _('Records'),
'items': ['Vital Signs', 'Patient Medical Record']
},
]
{
'label': _('Orders'),
'items': ['Inpatient Medication Order']
}
],
'disable_create_buttons': ['Inpatient Medication Order']
}

View File

@ -5,9 +5,9 @@ from __future__ import unicode_literals
import frappe
import unittest
from frappe.utils import getdate
from frappe.utils import getdate, flt
from erpnext.healthcare.doctype.therapy_type.test_therapy_type import create_therapy_type
from erpnext.healthcare.doctype.therapy_plan.therapy_plan import make_therapy_session
from erpnext.healthcare.doctype.therapy_plan.therapy_plan import make_therapy_session, make_sales_invoice
from erpnext.healthcare.doctype.patient_appointment.test_patient_appointment import create_healthcare_docs, create_patient
class TestTherapyPlan(unittest.TestCase):
@ -20,25 +20,45 @@ class TestTherapyPlan(unittest.TestCase):
plan = create_therapy_plan()
self.assertEquals(plan.status, 'Not Started')
session = make_therapy_session(plan.name, plan.patient, 'Basic Rehab')
session = make_therapy_session(plan.name, plan.patient, 'Basic Rehab', '_Test Company')
frappe.get_doc(session).submit()
self.assertEquals(frappe.db.get_value('Therapy Plan', plan.name, 'status'), 'In Progress')
session = make_therapy_session(plan.name, plan.patient, 'Basic Rehab')
session = make_therapy_session(plan.name, plan.patient, 'Basic Rehab', '_Test Company')
frappe.get_doc(session).submit()
self.assertEquals(frappe.db.get_value('Therapy Plan', plan.name, 'status'), 'Completed')
def test_therapy_plan_from_template(self):
patient = create_patient()
template = create_therapy_plan_template()
# check linked item
self.assertTrue(frappe.db.exists('Therapy Plan Template', {'linked_item': 'Complete Rehab'}))
def create_therapy_plan():
plan = create_therapy_plan(template)
# invoice
si = make_sales_invoice(plan.name, patient, '_Test Company', template)
si.save()
therapy_plan_template_amt = frappe.db.get_value('Therapy Plan Template', template, 'total_amount')
self.assertEquals(si.items[0].amount, therapy_plan_template_amt)
def create_therapy_plan(template=None):
patient = create_patient()
therapy_type = create_therapy_type()
plan = frappe.new_doc('Therapy Plan')
plan.patient = patient
plan.start_date = getdate()
plan.append('therapy_plan_details', {
'therapy_type': therapy_type.name,
'no_of_sessions': 2
})
if template:
plan.therapy_plan_template = template
plan = plan.set_therapy_details_from_template()
else:
plan.append('therapy_plan_details', {
'therapy_type': therapy_type.name,
'no_of_sessions': 2
})
plan.save()
return plan
@ -55,3 +75,22 @@ def create_encounter(patient, medical_department, practitioner):
encounter.save()
encounter.submit()
return encounter
def create_therapy_plan_template():
template_name = frappe.db.exists('Therapy Plan Template', 'Complete Rehab')
if not template_name:
therapy_type = create_therapy_type()
template = frappe.new_doc('Therapy Plan Template')
template.plan_name = template.item_code = template.item_name = 'Complete Rehab'
template.item_group = 'Services'
rate = frappe.db.get_value('Therapy Type', therapy_type.name, 'rate')
template.append('therapy_types', {
'therapy_type': therapy_type.name,
'no_of_sessions': 2,
'rate': rate,
'amount': 2 * flt(rate)
})
template.save()
template_name = template.name
return template_name

View File

@ -37,7 +37,8 @@ frappe.ui.form.on('Therapy Plan', {
args: {
therapy_plan: frm.doc.name,
patient: frm.doc.patient,
therapy_type: data.therapy_type
therapy_type: data.therapy_type,
company: frm.doc.company
},
freeze: true,
callback: function(r) {
@ -49,13 +50,53 @@ frappe.ui.form.on('Therapy Plan', {
});
}, __('Select Therapy Type'), __('Create'));
}, __('Create'));
if (frm.doc.therapy_plan_template && !frm.doc.invoiced) {
frm.add_custom_button(__('Sales Invoice'), function() {
frm.trigger('make_sales_invoice');
}, __('Create'));
}
}
if (frm.doc.therapy_plan_template) {
frappe.meta.get_docfield('Therapy Plan Detail', 'therapy_type', frm.doc.name).read_only = 1;
frappe.meta.get_docfield('Therapy Plan Detail', 'no_of_sessions', frm.doc.name).read_only = 1;
}
},
make_sales_invoice: function(frm) {
frappe.call({
args: {
'reference_name': frm.doc.name,
'patient': frm.doc.patient,
'company': frm.doc.company,
'therapy_plan_template': frm.doc.therapy_plan_template
},
method: 'erpnext.healthcare.doctype.therapy_plan.therapy_plan.make_sales_invoice',
callback: function(r) {
var doclist = frappe.model.sync(r.message);
frappe.set_route('Form', doclist[0].doctype, doclist[0].name);
}
});
},
therapy_plan_template: function(frm) {
if (frm.doc.therapy_plan_template) {
frappe.call({
method: 'set_therapy_details_from_template',
doc: frm.doc,
freeze: true,
freeze_message: __('Fetching Template Details'),
callback: function() {
refresh_field('therapy_plan_details');
}
});
}
},
show_progress_for_therapies: function(frm) {
let bars = [];
let message = '';
let added_min = false;
// completed sessions
let title = __('{0} sessions completed', [frm.doc.total_sessions_completed]);
@ -71,7 +112,6 @@ frappe.ui.form.on('Therapy Plan', {
});
if (bars[0].width == '0%') {
bars[0].width = '0.5%';
added_min = 0.5;
}
message = title;
frm.dashboard.add_progress(__('Status'), bars, message);

View File

@ -9,11 +9,13 @@
"naming_series",
"patient",
"patient_name",
"invoiced",
"column_break_4",
"company",
"status",
"start_date",
"section_break_3",
"therapy_plan_template",
"therapy_plan_details",
"title",
"section_break_9",
@ -46,6 +48,7 @@
"fieldtype": "Table",
"label": "Therapy Plan Details",
"options": "Therapy Plan Detail",
"read_only_depends_on": "therapy_plan_template",
"reqd": 1
},
{
@ -105,11 +108,27 @@
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Company",
"options": "Company"
"options": "Company",
"reqd": 1
},
{
"fieldname": "therapy_plan_template",
"fieldtype": "Link",
"label": "Therapy Plan Template",
"options": "Therapy Plan Template"
},
{
"default": "0",
"fieldname": "invoiced",
"fieldtype": "Check",
"label": "Invoiced",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
}
],
"links": [],
"modified": "2020-05-25 14:38:53.649315",
"modified": "2020-10-23 01:27:42.128855",
"modified_by": "Administrator",
"module": "Healthcare",
"name": "Therapy Plan",

View File

@ -5,7 +5,7 @@
from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from frappe.utils import today
from frappe.utils import flt, today
class TherapyPlan(Document):
def validate(self):
@ -33,13 +33,26 @@ class TherapyPlan(Document):
self.db_set('total_sessions', total_sessions)
self.db_set('total_sessions_completed', total_sessions_completed)
def set_therapy_details_from_template(self):
# Add therapy types in the child table
self.set('therapy_plan_details', [])
therapy_plan_template = frappe.get_doc('Therapy Plan Template', self.therapy_plan_template)
for data in therapy_plan_template.therapy_types:
self.append('therapy_plan_details', {
'therapy_type': data.therapy_type,
'no_of_sessions': data.no_of_sessions
})
return self
@frappe.whitelist()
def make_therapy_session(therapy_plan, patient, therapy_type):
def make_therapy_session(therapy_plan, patient, therapy_type, company):
therapy_type = frappe.get_doc('Therapy Type', therapy_type)
therapy_session = frappe.new_doc('Therapy Session')
therapy_session.therapy_plan = therapy_plan
therapy_session.company = company
therapy_session.patient = patient
therapy_session.therapy_type = therapy_type.name
therapy_session.duration = therapy_type.default_duration
@ -48,4 +61,39 @@ def make_therapy_session(therapy_plan, patient, therapy_type):
if frappe.flags.in_test:
therapy_session.start_date = today()
return therapy_session.as_dict()
return therapy_session.as_dict()
@frappe.whitelist()
def make_sales_invoice(reference_name, patient, company, therapy_plan_template):
from erpnext.stock.get_item_details import get_item_details
si = frappe.new_doc('Sales Invoice')
si.company = company
si.patient = patient
si.customer = frappe.db.get_value('Patient', patient, 'customer')
item = frappe.db.get_value('Therapy Plan Template', therapy_plan_template, 'linked_item')
price_list, price_list_currency = frappe.db.get_values('Price List', {'selling': 1}, ['name', 'currency'])[0]
args = {
'doctype': 'Sales Invoice',
'item_code': item,
'company': company,
'customer': si.customer,
'selling_price_list': price_list,
'price_list_currency': price_list_currency,
'plc_conversion_rate': 1.0,
'conversion_rate': 1.0
}
item_line = si.append('items', {})
item_details = get_item_details(args)
item_line.item_code = item
item_line.qty = 1
item_line.rate = item_details.price_list_rate
item_line.amount = flt(item_line.rate) * flt(item_line.qty)
item_line.reference_dt = 'Therapy Plan'
item_line.reference_dn = reference_name
item_line.description = item_details.description
si.set_missing_values(for_validate = True)
return si

View File

@ -4,10 +4,18 @@ from frappe import _
def get_data():
return {
'fieldname': 'therapy_plan',
'non_standard_fieldnames': {
'Sales Invoice': 'reference_dn'
},
'transactions': [
{
'label': _('Therapy Sessions'),
'items': ['Therapy Session']
},
{
'label': _('Billing'),
'items': ['Sales Invoice']
}
]
],
'disable_create_buttons': ['Sales Invoice']
}

View File

@ -35,7 +35,7 @@
],
"istable": 1,
"links": [],
"modified": "2020-03-30 22:02:01.740109",
"modified": "2020-10-08 01:17:34.778028",
"modified_by": "Administrator",
"module": "Healthcare",
"name": "Therapy Plan Detail",

View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from __future__ import unicode_literals
# import frappe
import unittest
class TestTherapyPlanTemplate(unittest.TestCase):
pass

View File

@ -0,0 +1,57 @@
// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on('Therapy Plan Template', {
refresh: function(frm) {
frm.set_query('therapy_type', 'therapy_types', () => {
return {
filters: {
'is_billable': 1
}
};
});
},
set_totals: function(frm) {
let total_sessions = 0;
let total_amount = 0.0;
frm.doc.therapy_types.forEach((d) => {
if (d.no_of_sessions) total_sessions += cint(d.no_of_sessions);
if (d.amount) total_amount += flt(d.amount);
});
frm.set_value('total_sessions', total_sessions);
frm.set_value('total_amount', total_amount);
frm.refresh_fields();
}
});
frappe.ui.form.on('Therapy Plan Template Detail', {
therapy_type: function(frm, cdt, cdn) {
let row = locals[cdt][cdn];
frappe.call('frappe.client.get', {
doctype: 'Therapy Type',
name: row.therapy_type
}).then((res) => {
row.rate = res.message.rate;
if (!row.no_of_sessions)
row.no_of_sessions = 1;
row.amount = flt(row.rate) * cint(row.no_of_sessions);
frm.refresh_field('therapy_types');
frm.trigger('set_totals');
});
},
no_of_sessions: function(frm, cdt, cdn) {
let row = locals[cdt][cdn];
row.amount = flt(row.rate) * cint(row.no_of_sessions);
frm.refresh_field('therapy_types');
frm.trigger('set_totals');
},
rate: function(frm, cdt, cdn) {
let row = locals[cdt][cdn];
row.amount = flt(row.rate) * cint(row.no_of_sessions);
frm.refresh_field('therapy_types');
frm.trigger('set_totals');
}
});

View File

@ -0,0 +1,132 @@
{
"actions": [],
"autoname": "field:plan_name",
"creation": "2020-09-22 17:51:38.861055",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"plan_name",
"linked_item_details_section",
"item_code",
"item_name",
"item_group",
"column_break_6",
"description",
"linked_item",
"therapy_types_section",
"therapy_types",
"section_break_11",
"total_sessions",
"column_break_13",
"total_amount"
],
"fields": [
{
"fieldname": "plan_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Plan Name",
"reqd": 1,
"unique": 1
},
{
"fieldname": "therapy_types_section",
"fieldtype": "Section Break",
"label": "Therapy Types"
},
{
"fieldname": "therapy_types",
"fieldtype": "Table",
"label": "Therapy Types",
"options": "Therapy Plan Template Detail",
"reqd": 1
},
{
"fieldname": "linked_item",
"fieldtype": "Link",
"label": "Linked Item",
"options": "Item",
"read_only": 1
},
{
"fieldname": "linked_item_details_section",
"fieldtype": "Section Break",
"label": "Linked Item Details"
},
{
"fieldname": "item_code",
"fieldtype": "Data",
"label": "Item Code",
"reqd": 1,
"set_only_once": 1
},
{
"fieldname": "item_name",
"fieldtype": "Data",
"label": "Item Name",
"reqd": 1
},
{
"fieldname": "item_group",
"fieldtype": "Link",
"label": "Item Group",
"options": "Item Group",
"reqd": 1
},
{
"fieldname": "column_break_6",
"fieldtype": "Column Break"
},
{
"fieldname": "description",
"fieldtype": "Small Text",
"label": "Item Description"
},
{
"fieldname": "total_amount",
"fieldtype": "Currency",
"label": "Total Amount",
"read_only": 1
},
{
"fieldname": "section_break_11",
"fieldtype": "Section Break"
},
{
"fieldname": "total_sessions",
"fieldtype": "Int",
"label": "Total Sessions",
"read_only": 1
},
{
"fieldname": "column_break_13",
"fieldtype": "Column Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2020-10-08 00:56:58.062105",
"modified_by": "Administrator",
"module": "Healthcare",
"name": "Therapy Plan Template",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from frappe.utils import cint, flt
from erpnext.healthcare.doctype.therapy_type.therapy_type import make_item_price
class TherapyPlanTemplate(Document):
def after_insert(self):
self.create_item_from_template()
def validate(self):
self.set_totals()
def on_update(self):
doc_before_save = self.get_doc_before_save()
if not doc_before_save: return
if doc_before_save.item_name != self.item_name or doc_before_save.item_group != self.item_group \
or doc_before_save.description != self.description:
self.update_item()
if doc_before_save.therapy_types != self.therapy_types:
self.update_item_price()
def set_totals(self):
total_sessions = 0
total_amount = 0
for entry in self.therapy_types:
total_sessions += cint(entry.no_of_sessions)
total_amount += flt(entry.amount)
self.total_sessions = total_sessions
self.total_amount = total_amount
def create_item_from_template(self):
uom = frappe.db.exists('UOM', 'Nos') or frappe.db.get_single_value('Stock Settings', 'stock_uom')
item = frappe.get_doc({
'doctype': 'Item',
'item_code': self.item_code,
'item_name': self.item_name,
'item_group': self.item_group,
'description': self.description,
'is_sales_item': 1,
'is_service_item': 1,
'is_purchase_item': 0,
'is_stock_item': 0,
'show_in_website': 0,
'is_pro_applicable': 0,
'stock_uom': uom
}).insert(ignore_permissions=True, ignore_mandatory=True)
make_item_price(item.name, self.total_amount)
self.db_set('linked_item', item.name)
def update_item(self):
item_doc = frappe.get_doc('Item', {'item_code': self.linked_item})
item_doc.item_name = self.item_name
item_doc.item_group = self.item_group
item_doc.description = self.description
item_doc.ignore_mandatory = True
item_doc.save(ignore_permissions=True)
def update_item_price(self):
item_price = frappe.get_doc('Item Price', {'item_code': self.linked_item})
item_price.item_name = self.item_name
item_price.price_list_rate = self.total_amount
item_price.ignore_mandatory = True
item_price.save(ignore_permissions=True)

View File

@ -0,0 +1,13 @@
from __future__ import unicode_literals
from frappe import _
def get_data():
return {
'fieldname': 'therapy_plan_template',
'transactions': [
{
'label': _('Therapy Plans'),
'items': ['Therapy Plan']
}
]
}

View File

@ -0,0 +1,54 @@
{
"actions": [],
"creation": "2020-10-07 23:04:44.373381",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"therapy_type",
"no_of_sessions",
"rate",
"amount"
],
"fields": [
{
"fieldname": "therapy_type",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Therapy Type",
"options": "Therapy Type",
"reqd": 1
},
{
"fieldname": "no_of_sessions",
"fieldtype": "Int",
"in_list_view": 1,
"label": "No of Sessions"
},
{
"fieldname": "rate",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Rate"
},
{
"fieldname": "amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Amount",
"read_only": 1
}
],
"istable": 1,
"links": [],
"modified": "2020-10-07 23:46:54.296322",
"modified_by": "Administrator",
"module": "Healthcare",
"name": "Therapy Plan Template Detail",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
class TherapyPlanTemplateDetail(Document):
pass

View File

@ -92,7 +92,8 @@ frappe.ui.form.on('Therapy Session', {
'start_date': data.message.appointment_date,
'start_time': data.message.appointment_time,
'service_unit': data.message.service_unit,
'company': data.message.company
'company': data.message.company,
'duration': data.message.duration
};
frm.set_value(values);
}
@ -107,6 +108,7 @@ frappe.ui.form.on('Therapy Session', {
'start_date': '',
'start_time': '',
'service_unit': '',
'duration': ''
};
frm.set_value(values);
}

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