Merge branch 'develop' into debit-credit-opening-invoice-tool

This commit is contained in:
Marica 2020-10-23 19:56:15 +05:30 committed by GitHub
commit 6f45a86920
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 2058 additions and 1027 deletions

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,22 +153,77 @@ 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",
"update_stock": 0
})
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

@ -201,5 +201,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

@ -15,6 +15,7 @@ from erpnext.accounts.doctype.loyalty_program.loyalty_program import \
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 six import iteritems
@ -57,6 +58,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,6 +71,18 @@ class POSInvoice(SalesInvoice):
against_psi_doc.delete_loyalty_point_entry()
against_psi_doc.make_loyalty_point_entry()
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):
allow_negative_stock = frappe.db.get_value('Stock Settings', None, 'allow_negative_stock')
@ -312,6 +326,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

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

@ -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

@ -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

@ -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

@ -56,7 +56,7 @@ class Employee(NestedSet):
if existing_user_id:
remove_user_permission(
"Employee", self.name, existing_user_id)
def after_rename(self, old, new, merge):
self.db_set("employee", new)
@ -181,8 +181,11 @@ class Employee(NestedSet):
)
if reports_to:
link_to_employees = [frappe.utils.get_link_to_form('Employee', employee.name, label=employee.employee_name) for employee in reports_to]
throw(_("Employee status cannot be set to 'Left' as following employees are currently reporting to this employee:&nbsp;")
+ ', '.join(link_to_employees), EmployeeLeftValidationError)
message = _("The following employees are currently still reporting to {0}:").format(frappe.bold(self.employee_name))
message += "<br><br><ul><li>" + "</li><li>".join(link_to_employees)
message += "</li></ul><br>"
message += _("Please make sure the employees above report to another Active employee.")
throw(message, EmployeeLeftValidationError, _("Cannot Relieve Employee"))
if not self.relieving_date:
throw(_("Please enter relieving date."))
@ -215,7 +218,7 @@ class Employee(NestedSet):
def validate_preferred_email(self):
if self.prefered_contact_email and not self.get(scrub(self.prefered_contact_email)):
frappe.msgprint(_("Please enter " + self.prefered_contact_email))
frappe.msgprint(_("Please enter {0}").format(self.prefered_contact_email))
def validate_onboarding_process(self):
employee_onboarding = frappe.get_all("Employee Onboarding",
@ -417,9 +420,9 @@ def get_employee_emails(employee_list):
@frappe.whitelist()
def get_children(doctype, parent=None, company=None, is_root=False, is_tree=False):
filters = []
filters = [['status', '!=', 'Left']]
if company and company != 'All Companies':
filters = [['company', '=', company]]
filters.append(['company', '=', company])
fields = ['name as value', 'employee_name as title']

View File

@ -28,144 +28,110 @@
{
"fieldname": "employee_settings",
"fieldtype": "Section Break",
"label": "Employee Settings",
"show_days": 1,
"show_seconds": 1
"label": "Employee Settings"
},
{
"description": "Enter retirement age in years",
"fieldname": "retirement_age",
"fieldtype": "Data",
"label": "Retirement Age",
"show_days": 1,
"show_seconds": 1
"label": "Retirement Age"
},
{
"default": "Naming Series",
"description": "Employee record is created using selected field. ",
"description": "Employee records are created using the selected field",
"fieldname": "emp_created_by",
"fieldtype": "Select",
"label": "Employee Records to be created by",
"options": "Naming Series\nEmployee Number\nFull Name",
"show_days": 1,
"show_seconds": 1
"label": "Employee Records to Be Created By",
"options": "Naming Series\nEmployee Number\nFull Name"
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break",
"show_days": 1,
"show_seconds": 1
"fieldtype": "Column Break"
},
{
"default": "0",
"description": "Don't send Employee Birthday Reminders",
"description": "Don't send employee birthday reminders",
"fieldname": "stop_birthday_reminders",
"fieldtype": "Check",
"label": "Stop Birthday Reminders",
"show_days": 1,
"show_seconds": 1
"label": "Stop Birthday Reminders"
},
{
"default": "1",
"fieldname": "expense_approver_mandatory_in_expense_claim",
"fieldtype": "Check",
"label": "Expense Approver Mandatory In Expense Claim",
"show_days": 1,
"show_seconds": 1
"label": "Expense Approver Mandatory In Expense Claim"
},
{
"collapsible": 1,
"fieldname": "leave_settings",
"fieldtype": "Section Break",
"label": "Leave Settings",
"show_days": 1,
"show_seconds": 1
"label": "Leave Settings"
},
{
"fieldname": "leave_approval_notification_template",
"fieldtype": "Link",
"label": "Leave Approval Notification Template",
"options": "Email Template",
"show_days": 1,
"show_seconds": 1
"options": "Email Template"
},
{
"fieldname": "leave_status_notification_template",
"fieldtype": "Link",
"label": "Leave Status Notification Template",
"options": "Email Template",
"show_days": 1,
"show_seconds": 1
"options": "Email Template"
},
{
"fieldname": "column_break_18",
"fieldtype": "Column Break",
"show_days": 1,
"show_seconds": 1
"fieldtype": "Column Break"
},
{
"default": "1",
"fieldname": "leave_approver_mandatory_in_leave_application",
"fieldtype": "Check",
"label": "Leave Approver Mandatory In Leave Application",
"show_days": 1,
"show_seconds": 1
"label": "Leave Approver Mandatory In Leave Application"
},
{
"default": "0",
"fieldname": "show_leaves_of_all_department_members_in_calendar",
"fieldtype": "Check",
"label": "Show Leaves Of All Department Members In Calendar",
"show_days": 1,
"show_seconds": 1
"label": "Show Leaves Of All Department Members In Calendar"
},
{
"collapsible": 1,
"fieldname": "hiring_settings",
"fieldtype": "Section Break",
"label": "Hiring Settings",
"show_days": 1,
"show_seconds": 1
"label": "Hiring Settings"
},
{
"default": "0",
"fieldname": "check_vacancies",
"fieldtype": "Check",
"label": "Check Vacancies On Job Offer Creation",
"show_days": 1,
"show_seconds": 1
"label": "Check Vacancies On Job Offer Creation"
},
{
"default": "0",
"fieldname": "auto_leave_encashment",
"fieldtype": "Check",
"label": "Auto Leave Encashment",
"show_days": 1,
"show_seconds": 1
"label": "Auto Leave Encashment"
},
{
"default": "0",
"fieldname": "restrict_backdated_leave_application",
"fieldtype": "Check",
"label": "Restrict Backdated Leave Application",
"show_days": 1,
"show_seconds": 1
"label": "Restrict Backdated Leave Applications"
},
{
"depends_on": "eval:doc.restrict_backdated_leave_application == 1",
"fieldname": "role_allowed_to_create_backdated_leave_application",
"fieldtype": "Link",
"label": "Role Allowed to Create Backdated Leave Application",
"options": "Role",
"show_days": 1,
"show_seconds": 1
"options": "Role"
}
],
"icon": "fa fa-cog",
"idx": 1,
"issingle": 1,
"links": [],
"modified": "2020-06-04 15:15:09.865476",
"modified": "2020-10-13 11:49:46.168027",
"modified_by": "Administrator",
"module": "HR",
"name": "HR Settings",
@ -183,4 +149,4 @@
],
"sort_field": "modified",
"sort_order": "ASC"
}
}

View File

@ -55,10 +55,11 @@ class BOM(WebsiteGenerator):
conflicting_bom = frappe.get_doc("BOM", name)
if conflicting_bom.item != self.item:
msg = (_("A BOM with name {0} already exists for item {1}.")
.format(frappe.bold(name), frappe.bold(conflicting_bom.item)))
frappe.throw(_("""A BOM with name {0} already exists for item {1}.
<br> Did you rename the item? Please contact Administrator / Tech support
""").format(frappe.bold(name), frappe.bold(conflicting_bom.item)))
frappe.throw(_("{0}{1} Did you rename the item? Please contact Administrator / Tech support")
.format(msg, "<br>"))
self.name = name
@ -72,6 +73,7 @@ class BOM(WebsiteGenerator):
self.validate_uom_is_interger()
self.set_bom_material_details()
self.validate_materials()
self.set_routing_operations()
self.validate_operations()
self.calculate_cost()
self.update_cost(update_parent=False, from_child_bom=True, save=False)
@ -111,18 +113,13 @@ class BOM(WebsiteGenerator):
def get_routing(self):
if self.routing:
self.set("operations", [])
for d in frappe.get_all("BOM Operation", fields = ["*"],
filters = {'parenttype': 'Routing', 'parent': self.routing}, order_by="idx"):
child = self.append('operations', {
"operation": d.operation,
"workstation": d.workstation,
"description": d.description,
"time_in_mins": d.time_in_mins,
"batch_size": d.batch_size,
"operating_cost": d.operating_cost,
"idx": d.idx
})
child.hour_rate = flt(d.hour_rate / self.conversion_rate, 2)
fields = ["sequence_id", "operation", "workstation", "description",
"time_in_mins", "batch_size", "operating_cost", "idx", "hour_rate"]
for row in frappe.get_all("BOM Operation", fields = fields,
filters = {'parenttype': 'Routing', 'parent': self.routing}, order_by="sequence_id, idx"):
child = self.append('operations', row)
child.hour_rate = flt(row.hour_rate / self.conversion_rate, 2)
def set_bom_material_details(self):
for item in self.get("items"):
@ -571,6 +568,10 @@ class BOM(WebsiteGenerator):
if act_pbom and act_pbom[0][0]:
frappe.throw(_("Cannot deactivate or cancel BOM as it is linked with other BOMs"))
def set_routing_operations(self):
if self.routing and self.with_operations and not self.operations:
self.get_routing()
def validate_operations(self):
if self.with_operations and not self.get('operations'):
frappe.throw(_("Operations cannot be left blank"))

View File

@ -1,10 +1,12 @@
{
"actions": [],
"creation": "2013-02-22 01:27:49",
"doctype": "DocType",
"document_type": "Setup",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"sequence_id",
"operation",
"workstation",
"description",
@ -106,11 +108,19 @@
"fieldname": "batch_size",
"fieldtype": "Int",
"label": "Batch Size"
},
{
"depends_on": "eval:doc.parenttype == \"Routing\"",
"fieldname": "sequence_id",
"fieldtype": "Int",
"label": "Sequence ID"
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"modified": "2020-06-16 17:01:11.128420",
"links": [],
"modified": "2020-10-13 18:14:10.018774",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Operation",

View File

@ -36,6 +36,7 @@
"items",
"more_information",
"operation_id",
"sequence_id",
"transferred_qty",
"requested_qty",
"column_break_20",
@ -297,10 +298,18 @@
"fieldname": "operation_row_number",
"fieldtype": "Select",
"label": "Operation Row Number"
},
{
"fieldname": "sequence_id",
"fieldtype": "Int",
"label": "Sequence Id",
"print_hide": 1,
"read_only": 1
}
],
"is_submittable": 1,
"modified": "2020-08-24 15:21:21.398267",
"links": [],
"modified": "2020-10-14 12:58:25.327897",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card",

View File

@ -5,7 +5,7 @@
from __future__ import unicode_literals
import frappe
import datetime
from frappe import _
from frappe import _, bold
from frappe.model.mapper import get_mapped_doc
from frappe.model.document import Document
from frappe.utils import (flt, cint, time_diff_in_hours, get_datetime, getdate,
@ -16,12 +16,14 @@ from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings
class OverlapError(frappe.ValidationError): pass
class OperationMismatchError(frappe.ValidationError): pass
class OperationSequenceError(frappe.ValidationError): pass
class JobCard(Document):
def validate(self):
self.validate_time_logs()
self.set_status()
self.validate_operation_id()
self.validate_sequence_id()
def validate_time_logs(self):
self.total_completed_qty = 0.0
@ -196,14 +198,14 @@ class JobCard(Document):
def validate_job_card(self):
if not self.time_logs:
frappe.throw(_("Time logs are required for {0} {1}")
.format(frappe.bold("Job Card"), get_link_to_form("Job Card", self.name)))
.format(bold("Job Card"), get_link_to_form("Job Card", self.name)))
if self.for_quantity and self.total_completed_qty != self.for_quantity:
total_completed_qty = frappe.bold(_("Total Completed Qty"))
qty_to_manufacture = frappe.bold(_("Qty to Manufacture"))
total_completed_qty = bold(_("Total Completed Qty"))
qty_to_manufacture = bold(_("Qty to Manufacture"))
frappe.throw(_("The {0} ({1}) must be equal to {2} ({3})"
.format(total_completed_qty, frappe.bold(self.total_completed_qty), qty_to_manufacture,frappe.bold(self.for_quantity))))
frappe.throw(_("The {0} ({1}) must be equal to {2} ({3})")
.format(total_completed_qty, bold(self.total_completed_qty), qty_to_manufacture,bold(self.for_quantity)))
def update_work_order(self):
if not self.work_order:
@ -213,10 +215,7 @@ class JobCard(Document):
from_time_list, to_time_list = [], []
field = "operation_id"
data = frappe.get_all('Job Card',
fields = ["sum(total_time_in_mins) as time_in_mins", "sum(total_completed_qty) as completed_qty"],
filters = {"docstatus": 1, "work_order": self.work_order, field: self.get(field)})
data = self.get_current_operation_data()
if data and len(data) > 0:
for_quantity = data[0].completed_qty
time_in_mins = data[0].time_in_mins
@ -246,6 +245,11 @@ class JobCard(Document):
wo.set_actual_dates()
wo.save()
def get_current_operation_data(self):
return frappe.get_all('Job Card',
fields = ["sum(total_time_in_mins) as time_in_mins", "sum(total_completed_qty) as completed_qty"],
filters = {"docstatus": 1, "work_order": self.work_order, "operation_id": self.operation_id})
def set_transferred_qty(self, update_status=False):
if not self.items:
self.transferred_qty = self.for_quantity if self.docstatus == 1 else 0
@ -310,9 +314,32 @@ class JobCard(Document):
def validate_operation_id(self):
if (self.get("operation_id") and self.get("operation_row_number") and self.operation and self.work_order and
frappe.get_cached_value("Work Order Operation", self.operation_row_number, "name") != self.operation_id):
work_order = frappe.bold(get_link_to_form("Work Order", self.work_order))
work_order = bold(get_link_to_form("Work Order", self.work_order))
frappe.throw(_("Operation {0} does not belong to the work order {1}")
.format(frappe.bold(self.operation), work_order), OperationMismatchError)
.format(bold(self.operation), work_order), OperationMismatchError)
def validate_sequence_id(self):
if not (self.work_order and self.sequence_id): return
current_operation_qty = 0.0
data = self.get_current_operation_data()
if data and len(data) > 0:
current_operation_qty = flt(data[0].completed_qty)
current_operation_qty += flt(self.total_completed_qty)
data = frappe.get_all("Work Order Operation",
fields = ["operation", "status", "completed_qty"],
filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ('<', self.sequence_id)},
order_by = "sequence_id, idx")
message = "Job Card {0}: As per the sequence of the operations in the work order {1}".format(bold(self.name),
bold(get_link_to_form("Work Order", self.work_order)))
for row in data:
if row.status != "Completed" and row.completed_qty < current_operation_qty:
frappe.throw(_("{0}, complete the operation {1} before the operation {2}.")
.format(message, bold(row.operation), bold(self.operation)), OperationSequenceError)
@frappe.whitelist()
def get_operation_details(work_order, operation):

View File

@ -1,4 +1,5 @@
{
"actions": [],
"creation": "2014-11-27 14:12:07.542534",
"doctype": "DocType",
"document_type": "Document",
@ -36,7 +37,7 @@
{
"default": "0",
"depends_on": "eval:!doc.disable_capacity_planning",
"description": "Plan time logs outside Workstation Working Hours.",
"description": "Plan time logs outside Workstation working hours",
"fieldname": "allow_overtime",
"fieldtype": "Check",
"label": "Allow Overtime"
@ -56,17 +57,17 @@
{
"default": "30",
"depends_on": "eval:!doc.disable_capacity_planning",
"description": "Try planning operations for X days in advance.",
"description": "Plan operations X days in advance",
"fieldname": "capacity_planning_for_days",
"fieldtype": "Int",
"label": "Capacity Planning For (Days)"
},
{
"depends_on": "eval:!doc.disable_capacity_planning",
"description": "Default 10 mins",
"description": "Default: 10 mins",
"fieldname": "mins_between_operations",
"fieldtype": "Int",
"label": "Time Between Operations (in mins)"
"label": "Time Between Operations (Mins)"
},
{
"fieldname": "section_break_6",
@ -92,14 +93,14 @@
},
{
"default": "0",
"description": "Allow multiple Material Consumption against a Work Order",
"description": "Allow multiple material consumptions against a Work Order",
"fieldname": "material_consumption",
"fieldtype": "Check",
"label": "Allow Multiple Material Consumption"
},
{
"default": "0",
"description": "Update BOM cost automatically via Scheduler, based on latest valuation rate / price list rate / last purchase rate of raw materials.",
"description": "Update BOM cost automatically via scheduler, based on the latest Valuation Rate/Price List Rate/Last Purchase Rate of raw materials",
"fieldname": "update_bom_costs_automatically",
"fieldtype": "Check",
"label": "Update BOM Cost Automatically"
@ -135,7 +136,7 @@
{
"fieldname": "over_production_for_sales_and_work_order_section",
"fieldtype": "Section Break",
"label": "Over Production for Sales and Work Order"
"label": "Overproduction for Sales and Work Order"
},
{
"fieldname": "raw_materials_consumption_section",
@ -157,8 +158,10 @@
}
],
"icon": "icon-wrench",
"index_web_pages_for_search": 1,
"issingle": 1,
"modified": "2019-11-26 13:10:45.569341",
"links": [],
"modified": "2020-10-13 10:55:43.996581",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Manufacturing Settings",
@ -175,4 +178,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}
}

View File

@ -9,3 +9,23 @@ test_records = frappe.get_test_records('Operation')
class TestOperation(unittest.TestCase):
pass
def make_operation(*args, **kwargs):
args = args if args else kwargs
if isinstance(args, tuple):
args = args[0]
args = frappe._dict(args)
try:
doc = frappe.get_doc({
"doctype": "Operation",
"name": args.operation,
"workstation": args.workstation
})
doc.insert()
return doc
except frappe.DuplicateEntryError:
return frappe.get_doc("Operation", args.operation)

View File

@ -237,7 +237,9 @@ def make_bom(**args):
'item': args.item,
'currency': args.currency or 'USD',
'quantity': args.quantity or 1,
'company': args.company or '_Test Company'
'company': args.company or '_Test Company',
'routing': args.routing,
'with_operations': args.with_operations or 0
})
for item in args.raw_materials:

View File

@ -2,6 +2,13 @@
// For license information, please see license.txt
frappe.ui.form.on('Routing', {
setup: function(frm) {
frappe.meta.get_docfield("BOM Operation", "sequence_id",
frm.doc.name).in_list_view = true;
frm.fields_dict.operations.grid.refresh();
},
calculate_operating_cost: function(frm, child) {
const operating_cost = flt(flt(child.hour_rate) * flt(child.time_in_mins) / 60, 2);
frappe.model.set_value(child.doctype, child.name, "operating_cost", operating_cost);

View File

@ -3,7 +3,22 @@
# For license information, please see license.txt
from __future__ import unicode_literals
import frappe
from frappe.utils import cint
from frappe import _
from frappe.model.document import Document
class Routing(Document):
pass
def validate(self):
self.set_routing_id()
def set_routing_id(self):
sequence_id = 0
for row in self.operations:
if not row.sequence_id:
row.sequence_id = sequence_id + 1
elif sequence_id and row.sequence_id and cint(sequence_id) > cint(row.sequence_id):
frappe.throw(_("At row #{0}: the sequence id {1} cannot be less than previous row sequence id {2}")
.format(row.idx, row.sequence_id, sequence_id))
sequence_id = row.sequence_id

View File

@ -4,6 +4,88 @@
from __future__ import unicode_literals
import unittest
import frappe
from frappe.test_runner import make_test_records
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.job_card.job_card import OperationSequenceError
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
class TestRouting(unittest.TestCase):
pass
def test_sequence_id(self):
item_code = "Test Routing Item - A"
operations = [{"operation": "Test Operation A", "workstation": "Test Workstation A", "time_in_mins": 30},
{"operation": "Test Operation B", "workstation": "Test Workstation A", "time_in_mins": 20}]
make_test_records("UOM")
setup_operations(operations)
routing_doc = create_routing(routing_name="Testing Route", operations=operations)
bom_doc = setup_bom(item_code=item_code, routing=routing_doc.name)
wo_doc = make_wo_order_test_record(production_item = item_code, bom_no=bom_doc.name)
for row in routing_doc.operations:
self.assertEqual(row.sequence_id, row.idx)
for data in frappe.get_all("Job Card",
filters={"work_order": wo_doc.name}, order_by="sequence_id desc"):
job_card_doc = frappe.get_doc("Job Card", data.name)
job_card_doc.time_logs[0].completed_qty = 10
if job_card_doc.sequence_id != 1:
self.assertRaises(OperationSequenceError, job_card_doc.save)
else:
job_card_doc.save()
self.assertEqual(job_card_doc.total_completed_qty, 10)
wo_doc.cancel()
wo_doc.delete()
def setup_operations(rows):
for row in rows:
make_workstation(row)
make_operation(row)
def create_routing(**args):
args = frappe._dict(args)
doc = frappe.new_doc("Routing")
doc.update(args)
if not args.do_not_save:
try:
for operation in args.operations:
doc.append("operations", operation)
doc.insert()
except frappe.DuplicateEntryError:
doc = frappe.get_doc("Routing", args.routing_name)
return doc
def setup_bom(**args):
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
args = frappe._dict(args)
if not frappe.db.exists('Item', args.item_code):
make_item(args.item_code, {
'is_stock_item': 1
})
if not args.raw_materials:
if not frappe.db.exists('Item', "Test Extra Item 1"):
make_item("Test Extra Item N-1", {
'is_stock_item': 1,
})
args.raw_materials = ['Test Extra Item N-1']
name = frappe.db.get_value('BOM', {'item': args.item_code}, 'name')
if not name:
bom_doc = make_bom(item = args.item_code, raw_materials = args.get("raw_materials"),
routing = args.routing, with_operations=1)
else:
bom_doc = frappe.get_doc("BOM", name)
return bom_doc

View File

@ -193,6 +193,42 @@ class TestWorkOrder(unittest.TestCase):
self.assertEqual(cint(bin1_on_end_production.projected_qty),
cint(bin1_on_end_production.projected_qty))
def test_backflush_qty_for_overpduction_manufacture(self):
cancel_stock_entry = []
allow_overproduction("overproduction_percentage_for_work_order", 30)
wo_order = make_wo_order_test_record(planned_start_date=now(), qty=100)
ste1 = test_stock_entry.make_stock_entry(item_code="_Test Item",
target="_Test Warehouse - _TC", qty=120, basic_rate=5000.0)
ste2 = test_stock_entry.make_stock_entry(item_code="_Test Item Home Desktop 100",
target="_Test Warehouse - _TC", qty=240, basic_rate=1000.0)
cancel_stock_entry.extend([ste1.name, ste2.name])
s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 60))
s.submit()
cancel_stock_entry.append(s.name)
s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 60))
s.submit()
cancel_stock_entry.append(s.name)
s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 60))
s.submit()
cancel_stock_entry.append(s.name)
s1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 50))
s1.submit()
cancel_stock_entry.append(s1.name)
self.assertEqual(s1.items[0].qty, 50)
self.assertEqual(s1.items[1].qty, 100)
cancel_stock_entry.reverse()
for ste in cancel_stock_entry:
doc = frappe.get_doc("Stock Entry", ste)
doc.cancel()
allow_overproduction("overproduction_percentage_for_work_order", 0)
def test_reserved_qty_for_stopped_production(self):
test_stock_entry.make_stock_entry(item_code="_Test Item",
target= self.warehouse, qty=100, basic_rate=100)

View File

@ -378,7 +378,7 @@ class WorkOrder(Document):
select
operation, description, workstation, idx,
base_hour_rate as hour_rate, time_in_mins,
"Pending" as status, parent as bom, batch_size
"Pending" as status, parent as bom, batch_size, sequence_id
from
`tabBOM Operation`
where
@ -865,6 +865,7 @@ def create_job_card(work_order, row, qty=0, enable_capacity_planning=False, auto
'bom_no': work_order.bom_no,
'project': work_order.project,
'company': work_order.company,
'sequence_id': row.get("sequence_id"),
'wip_warehouse': work_order.wip_warehouse
})

View File

@ -8,6 +8,7 @@
"details",
"operation",
"bom",
"sequence_id",
"description",
"col_break1",
"completed_qty",
@ -187,11 +188,19 @@
"fieldtype": "Int",
"label": "Batch Size",
"read_only": 1
},
{
"fieldname": "sequence_id",
"fieldtype": "Int",
"label": "Sequence ID",
"print_hide": 1,
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2019-12-03 19:24:29.594189",
"modified": "2020-10-14 12:58:49.241252",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Operation",

View File

@ -21,17 +21,22 @@ class TestWorkstation(unittest.TestCase):
self.assertRaises(WorkstationHolidayError, check_if_within_operating_hours,
"_Test Workstation 1", "Operation 1", "2013-02-01 10:00:00", "2013-02-02 20:00:00")
def make_workstation(**args):
def make_workstation(*args, **kwargs):
args = args if args else kwargs
if isinstance(args, tuple):
args = args[0]
args = frappe._dict(args)
workstation_name = args.workstation_name or args.workstation
try:
doc = frappe.get_doc({
"doctype": "Workstation",
"workstation_name": args.workstation_name
"workstation_name": workstation_name
})
doc.insert()
return doc
except frappe.DuplicateEntryError:
return frappe.get_doc("Workstation", args.workstation_name)
return frappe.get_doc("Workstation", workstation_name)

View File

@ -729,4 +729,5 @@ erpnext.patches.v13_0.setting_custom_roles_for_some_regional_reports
erpnext.patches.v13_0.rename_issue_doctype_fields
erpnext.patches.v13_0.change_default_pos_print_format
erpnext.patches.v13_0.set_youtube_video_id
erpnext.patches.v13_0.print_uom_after_quantity_patch
erpnext.patches.v13_0.print_uom_after_quantity_patch
erpnext.patches.v13_0.set_payment_channel_in_payment_gateway_account

View File

@ -0,0 +1,17 @@
from __future__ import unicode_literals
import frappe
def execute():
"""Set the payment gateway account as Email for all the existing payment channel."""
doc_meta = frappe.get_meta("Payment Gateway Account")
if doc_meta.get_field("payment_channel"):
return
frappe.reload_doc("Accounts", "doctype", "Payment Gateway Account")
set_payment_channel_as_email()
def set_payment_channel_as_email():
frappe.db.sql("""
UPDATE `tabPayment Gateway Account`
SET `payment_channel` = "Email"
""")

View File

@ -162,7 +162,7 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend(
// sales invoice
if(flt(doc.per_billed, 6) < 100) {
this.frm.add_custom_button(__('Invoice'), () => me.make_sales_invoice(), __('Create'));
this.frm.add_custom_button(__('Sales Invoice'), () => me.make_sales_invoice(), __('Create'));
}
// material request
@ -554,19 +554,32 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend(
},
make_purchase_order: function(){
let pending_items = this.frm.doc.items.some((item) =>{
let pending_qty = flt(item.stock_qty) - flt(item.ordered_qty);
return pending_qty > 0;
})
if(!pending_items){
frappe.throw({message: __("Purchase Order already created for all Sales Order items"), title: __("Note")});
}
var me = this;
var dialog = new frappe.ui.Dialog({
title: __("For Supplier"),
title: __("Select Items"),
fields: [
{"fieldtype": "Link", "label": __("Supplier"), "fieldname": "supplier", "options":"Supplier",
"description": __("Leave the field empty to make purchase orders for all suppliers"),
"get_query": function () {
return {
query:"erpnext.selling.doctype.sales_order.sales_order.get_supplier",
filters: {'parent': me.frm.doc.name}
}
}},
{fieldname: 'items_for_po', fieldtype: 'Table', label: 'Select Items',
{
"fieldtype": "Check",
"label": __("Against Default Supplier"),
"fieldname": "against_default_supplier",
"default": 0
},
{
"fieldtype": "Section Break",
"label": "",
"fieldname": "sec_break_dialog",
"hide_border": 1
},
{
fieldname: 'items_for_po', fieldtype: 'Table', label: 'Select Items',
fields: [
{
fieldtype:'Data',
@ -584,8 +597,8 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend(
},
{
fieldtype:'Float',
fieldname:'qty',
label: __('Quantity'),
fieldname:'pending_qty',
label: __('Pending Qty'),
read_only: 1,
in_list_view:1
},
@ -594,60 +607,86 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend(
read_only:1,
fieldname:'uom',
label: __('UOM'),
in_list_view:1,
},
{
fieldtype:'Data',
fieldname:'supplier',
label: __('Supplier'),
read_only:1,
in_list_view:1
}
},
],
data: cur_frm.doc.items,
get_data: function() {
return cur_frm.doc.items
}
},
{"fieldtype": "Button", "label": __('Create Purchase Order'), "fieldname": "make_purchase_order", "cssClass": "btn-primary"},
]
});
dialog.fields_dict.make_purchase_order.$input.click(function() {
var args = dialog.get_values();
let selected_items = dialog.fields_dict.items_for_po.grid.get_selected_children()
if(selected_items.length == 0) {
frappe.throw({message: 'Please select Item form Table', title: __('Message'), indicator:'blue'})
}
let selected_items_list = []
for(let i in selected_items){
selected_items_list.push(selected_items[i].item_code)
}
dialog.hide();
return frappe.call({
type: "GET",
method: "erpnext.selling.doctype.sales_order.sales_order.make_purchase_order",
args: {
"source_name": me.frm.doc.name,
"for_supplier": args.supplier,
"selected_items": selected_items_list
},
freeze: true,
callback: function(r) {
if(!r.exc) {
// var args = dialog.get_values();
if (args.supplier){
var doc = frappe.model.sync(r.message);
frappe.set_route("Form", r.message.doctype, r.message.name);
}
else{
frappe.route_options = {
"sales_order": me.frm.doc.name
}
frappe.set_route("List", "Purchase Order");
}
}
data: me.frm.doc.items.map((item) =>{
item.pending_qty = (flt(item.stock_qty) - flt(item.ordered_qty)) / flt(item.conversion_factor);
return item;
}).filter((item) => {return item.pending_qty > 0;})
}
})
],
primary_action_label: 'Create Purchase Order',
primary_action (args) {
if (!args) return;
let selected_items = dialog.fields_dict.items_for_po.grid.get_selected_children();
if(selected_items.length == 0) {
frappe.throw({message: 'Please select Items from the Table', title: __('Items Required'), indicator:'blue'})
}
dialog.hide();
var method = args.against_default_supplier ? "make_purchase_order_for_default_supplier" : "make_purchase_order"
return frappe.call({
type: "GET",
method: "erpnext.selling.doctype.sales_order.sales_order." + method,
args: {
"source_name": me.frm.doc.name,
"selected_items": selected_items
},
freeze: true,
callback: function(r) {
if(!r.exc) {
if (!args.against_default_supplier) {
frappe.model.sync(r.message);
frappe.set_route("Form", r.message.doctype, r.message.name);
}
else {
frappe.route_options = {
"sales_order": me.frm.doc.name
}
frappe.set_route("List", "Purchase Order");
}
}
}
})
}
});
dialog.get_field("items_for_po").grid.only_sortable()
dialog.get_field("items_for_po").refresh()
dialog.fields_dict["against_default_supplier"].df.onchange = () => {
console.log("yo");
var against_default_supplier = dialog.get_value("against_default_supplier");
var items_for_po = dialog.get_value("items_for_po");
if (against_default_supplier) {
let items_with_supplier = items_for_po.filter((item) => item.supplier)
dialog.fields_dict["items_for_po"].df.data = items_with_supplier;
dialog.get_field("items_for_po").refresh();
} else {
let pending_items = me.frm.doc.items.map((item) =>{
item.pending_qty = (flt(item.stock_qty) - flt(item.ordered_qty)) / flt(item.conversion_factor);
return item;
}).filter((item) => {return item.pending_qty > 0;});
dialog.fields_dict["items_for_po"].df.data = pending_items;
dialog.get_field("items_for_po").refresh();
}
}
dialog.get_field("items_for_po").grid.only_sortable();
dialog.get_field("items_for_po").refresh();
dialog.wrapper.find('.grid-heading-row .grid-row-check').click();
dialog.show();
},
hold_sales_order: function(){
var me = this;
var d = new frappe.ui.Dialog({

View File

@ -443,25 +443,19 @@ class SalesOrder(SellingController):
for item in self.items:
if item.ensure_delivery_based_on_produced_serial_no:
if item.item_code in normal_items:
frappe.throw(_("Cannot ensure delivery by Serial No as \
Item {0} is added with and without Ensure Delivery by \
Serial No.").format(item.item_code))
frappe.throw(_("Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No.").format(item.item_code))
if item.item_code not in reserved_items:
if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"):
frappe.throw(_("Item {0} has no Serial No. Only serilialized items \
can have delivery based on Serial No").format(item.item_code))
frappe.throw(_("Item {0} has no Serial No. Only serilialized items can have delivery based on Serial No").format(item.item_code))
if not frappe.db.exists("BOM", {"item": item.item_code, "is_active": 1}):
frappe.throw(_("No active BOM found for item {0}. Delivery by \
Serial No cannot be ensured").format(item.item_code))
frappe.throw(_("No active BOM found for item {0}. Delivery by Serial No cannot be ensured").format(item.item_code))
reserved_items.append(item.item_code)
else:
normal_items.append(item.item_code)
if not item.ensure_delivery_based_on_produced_serial_no and \
item.item_code in reserved_items:
frappe.throw(_("Cannot ensure delivery by Serial No as \
Item {0} is added with and without Ensure Delivery by \
Serial No.").format(item.item_code))
frappe.throw(_("Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No.").format(item.item_code))
def get_list_context(context=None):
from erpnext.controllers.website_list_for_contact import get_list_context
@ -785,7 +779,7 @@ def get_events(start, end, filters=None):
return data
@frappe.whitelist()
def make_purchase_order(source_name, for_supplier=None, selected_items=[], target_doc=None):
def make_purchase_order_for_default_supplier(source_name, selected_items=[], target_doc=None):
if isinstance(selected_items, string_types):
selected_items = json.loads(selected_items)
@ -822,24 +816,21 @@ def make_purchase_order(source_name, for_supplier=None, selected_items=[], targe
def update_item(source, target, source_parent):
target.schedule_date = source.delivery_date
target.qty = flt(source.qty) - flt(source.ordered_qty)
target.stock_qty = (flt(source.qty) - flt(source.ordered_qty)) * flt(source.conversion_factor)
target.qty = flt(source.qty) - (flt(source.ordered_qty) / flt(source.conversion_factor))
target.stock_qty = (flt(source.stock_qty) - flt(source.ordered_qty))
target.project = source_parent.project
suppliers =[]
if for_supplier:
suppliers.append(for_supplier)
else:
sales_order = frappe.get_doc("Sales Order", source_name)
for item in sales_order.items:
if item.supplier and item.supplier not in suppliers:
suppliers.append(item.supplier)
suppliers = [item.get('supplier') for item in selected_items if item.get('supplier') and item.get('supplier')]
suppliers = list(set(suppliers))
items_to_map = [item.get('item_code') for item in selected_items if item.get('item_code') and item.get('item_code')]
items_to_map = list(set(items_to_map))
if not suppliers:
frappe.throw(_("Please set a Supplier against the Items to be considered in the Purchase Order."))
for supplier in suppliers:
po =frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")})
po = frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")})
if len(po) == 0:
doc = get_mapped_doc("Sales Order", source_name, {
"Sales Order": {
@ -850,7 +841,8 @@ def make_purchase_order(source_name, for_supplier=None, selected_items=[], targe
"contact_mobile",
"contact_email",
"contact_person",
"taxes_and_charges"
"taxes_and_charges",
"shipping_address"
],
"validation": {
"docstatus": ["=", 1]
@ -872,52 +864,82 @@ def make_purchase_order(source_name, for_supplier=None, selected_items=[], targe
"item_tax_template"
],
"postprocess": update_item,
"condition": lambda doc: doc.ordered_qty < doc.qty and doc.supplier == supplier and doc.item_code in selected_items
"condition": lambda doc: doc.ordered_qty < doc.stock_qty and doc.supplier == supplier and doc.item_code in items_to_map
}
}, target_doc, set_missing_values)
if not for_supplier:
doc.insert()
doc.insert()
else:
suppliers =[]
if suppliers:
if not for_supplier:
frappe.db.commit()
frappe.db.commit()
return doc
else:
frappe.msgprint(_("PO already created for all sales order items"))
frappe.msgprint(_("Purchase Order already created for all Sales Order items"))
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_supplier(doctype, txt, searchfield, start, page_len, filters):
supp_master_name = frappe.defaults.get_user_default("supp_master_name")
if supp_master_name == "Supplier Name":
fields = ["name", "supplier_group"]
else:
fields = ["name", "supplier_name", "supplier_group"]
fields = ", ".join(fields)
def make_purchase_order(source_name, selected_items=[], target_doc=None):
if isinstance(selected_items, string_types):
selected_items = json.loads(selected_items)
return frappe.db.sql("""select {field} from `tabSupplier`
where docstatus < 2
and ({key} like %(txt)s
or supplier_name like %(txt)s)
and name in (select supplier from `tabSales Order Item` where parent = %(parent)s)
and name not in (select supplier from `tabPurchase Order` po inner join `tabPurchase Order Item` poi
on po.name=poi.parent where po.docstatus<2 and poi.sales_order=%(parent)s)
order by
if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999),
if(locate(%(_txt)s, supplier_name), locate(%(_txt)s, supplier_name), 99999),
name, supplier_name
limit %(start)s, %(page_len)s """.format(**{
'field': fields,
'key': frappe.db.escape(searchfield)
}), {
'txt': "%%%s%%" % txt,
'_txt': txt.replace("%", ""),
'start': start,
'page_len': page_len,
'parent': filters.get('parent')
})
items_to_map = [item.get('item_code') for item in selected_items if item.get('item_code') and item.get('item_code')]
items_to_map = list(set(items_to_map))
def set_missing_values(source, target):
target.supplier = ""
target.apply_discount_on = ""
target.additional_discount_percentage = 0.0
target.discount_amount = 0.0
target.inter_company_order_reference = ""
target.customer = ""
target.customer_name = ""
target.run_method("set_missing_values")
target.run_method("calculate_taxes_and_totals")
def update_item(source, target, source_parent):
target.schedule_date = source.delivery_date
target.qty = flt(source.qty) - (flt(source.ordered_qty) / flt(source.conversion_factor))
target.stock_qty = (flt(source.stock_qty) - flt(source.ordered_qty))
target.project = source_parent.project
# po = frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")})
doc = get_mapped_doc("Sales Order", source_name, {
"Sales Order": {
"doctype": "Purchase Order",
"field_no_map": [
"address_display",
"contact_display",
"contact_mobile",
"contact_email",
"contact_person",
"taxes_and_charges",
"shipping_address"
],
"validation": {
"docstatus": ["=", 1]
}
},
"Sales Order Item": {
"doctype": "Purchase Order Item",
"field_map": [
["name", "sales_order_item"],
["parent", "sales_order"],
["stock_uom", "stock_uom"],
["uom", "uom"],
["conversion_factor", "conversion_factor"],
["delivery_date", "schedule_date"]
],
"field_no_map": [
"rate",
"price_list_rate",
"item_tax_template",
"supplier"
],
"postprocess": update_item,
"condition": lambda doc: doc.ordered_qty < doc.stock_qty and doc.item_code in items_to_map
}
}, target_doc, set_missing_values)
return doc
@frappe.whitelist()
def make_work_orders(items, sales_order, company, project=None):

View File

@ -688,12 +688,12 @@ class TestSalesOrder(unittest.TestCase):
frappe.db.set_value("Stock Settings", None, "auto_insert_price_list_rate_if_missing", 1)
def test_drop_shipping(self):
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order_for_default_supplier, \
update_status as so_update_status
from erpnext.buying.doctype.purchase_order.purchase_order import update_status
make_stock_entry(target="_Test Warehouse - _TC", qty=10, rate=100)
# make items
po_item = make_item("_Test Item for Drop Shipping", {"is_stock_item": 1, "delivered_by_supplier": 1})
dn_item = make_item("_Test Regular Item", {"is_stock_item": 1})
so_items = [
@ -715,80 +715,61 @@ class TestSalesOrder(unittest.TestCase):
]
if frappe.db.get_value("Item", "_Test Regular Item", "is_stock_item")==1:
make_stock_entry(item="_Test Regular Item", target="_Test Warehouse - _TC", qty=10, rate=100)
make_stock_entry(item="_Test Regular Item", target="_Test Warehouse - _TC", qty=2, rate=100)
#setuo existing qty from bin
bin = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"},
fields=["ordered_qty", "reserved_qty"])
existing_ordered_qty = bin[0].ordered_qty if bin else 0.0
existing_reserved_qty = bin[0].reserved_qty if bin else 0.0
bin = frappe.get_all("Bin", filters={"item_code": dn_item.item_code,
"warehouse": "_Test Warehouse - _TC"}, fields=["reserved_qty"])
existing_reserved_qty_for_dn_item = bin[0].reserved_qty if bin else 0.0
#create so, po and partial dn
#create so, po and dn
so = make_sales_order(item_list=so_items, do_not_submit=True)
so.submit()
po = make_purchase_order(so.name, '_Test Supplier', selected_items=[so_items[0]['item_code']])
po = make_purchase_order_for_default_supplier(so.name, selected_items=[so_items[0]])
po.submit()
dn = create_dn_against_so(so.name, delivered_qty=1)
dn = create_dn_against_so(so.name, delivered_qty=2)
self.assertEqual(so.customer, po.customer)
self.assertEqual(po.items[0].sales_order, so.name)
self.assertEqual(po.items[0].item_code, po_item.item_code)
self.assertEqual(dn.items[0].item_code, dn_item.item_code)
#test ordered_qty and reserved_qty
bin = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"},
fields=["ordered_qty", "reserved_qty"])
ordered_qty = bin[0].ordered_qty if bin else 0.0
reserved_qty = bin[0].reserved_qty if bin else 0.0
self.assertEqual(abs(flt(ordered_qty)), existing_ordered_qty)
self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty)
reserved_qty = frappe.db.get_value("Bin",
{"item_code": dn_item.item_code, "warehouse": "_Test Warehouse - _TC"}, "reserved_qty")
self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty_for_dn_item + 1)
#test po_item length
self.assertEqual(len(po.items), 1)
#test per_delivered status
# test ordered_qty and reserved_qty for drop ship item
bin_po_item = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"},
fields=["ordered_qty", "reserved_qty"])
ordered_qty = bin_po_item[0].ordered_qty if bin_po_item else 0.0
reserved_qty = bin_po_item[0].reserved_qty if bin_po_item else 0.0
# drop ship PO should not impact bin, test the same
self.assertEqual(abs(flt(ordered_qty)), 0)
self.assertEqual(abs(flt(reserved_qty)), 0)
# test per_delivered status
update_status("Delivered", po.name)
self.assertEqual(flt(frappe.db.get_value("Sales Order", so.name, "per_delivered"), 2), 75.00)
self.assertEqual(flt(frappe.db.get_value("Sales Order", so.name, "per_delivered"), 2), 100.00)
po.load_from_db()
#test reserved qty after complete delivery
dn = create_dn_against_so(so.name, delivered_qty=1)
reserved_qty = frappe.db.get_value("Bin",
{"item_code": dn_item.item_code, "warehouse": "_Test Warehouse - _TC"}, "reserved_qty")
self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty_for_dn_item)
#test after closing so
# test after closing so
so.db_set('status', "Closed")
so.update_reserved_qty()
bin = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"},
# test ordered_qty and reserved_qty for drop ship item after closing so
bin_po_item = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"},
fields=["ordered_qty", "reserved_qty"])
ordered_qty = bin[0].ordered_qty if bin else 0.0
reserved_qty = bin[0].reserved_qty if bin else 0.0
ordered_qty = bin_po_item[0].ordered_qty if bin_po_item else 0.0
reserved_qty = bin_po_item[0].reserved_qty if bin_po_item else 0.0
self.assertEqual(abs(flt(ordered_qty)), existing_ordered_qty)
self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty)
self.assertEqual(abs(flt(ordered_qty)), 0)
self.assertEqual(abs(flt(reserved_qty)), 0)
reserved_qty = frappe.db.get_value("Bin",
{"item_code": dn_item.item_code, "warehouse": "_Test Warehouse - _TC"}, "reserved_qty")
self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty_for_dn_item)
# teardown
so_update_status("Draft", so.name)
dn.load_from_db()
dn.cancel()
po.cancel()
so.load_from_db()
so.cancel()
def test_reserved_qty_for_closing_so(self):
bin = frappe.get_all("Bin", filters={"item_code": "_Test Item", "warehouse": "_Test Warehouse - _TC"},

View File

@ -63,7 +63,7 @@
},
{
"default": "15",
"description": "Auto close Opportunity after 15 days",
"description": "Auto close Opportunity after the no. of days mentioned above",
"fieldname": "close_opportunity_after_days",
"fieldtype": "Int",
"label": "Close Opportunity After Days"
@ -80,18 +80,18 @@
{
"fieldname": "so_required",
"fieldtype": "Select",
"label": "Sales Order Required for Sales Invoice & Delivery Note Creation",
"label": "Is Sales Order Required for Sales Invoice & Delivery Note Creation?",
"options": "No\nYes"
},
{
"fieldname": "dn_required",
"fieldtype": "Select",
"label": "Delivery Note Required for Sales Invoice Creation",
"label": "Is Delivery Note Required for Sales Invoice Creation?",
"options": "No\nYes"
},
{
"default": "Each Transaction",
"description": "How often should project and company be updated based on Sales Transactions.",
"description": "How often should Project and Company be updated based on Sales Transactions?",
"fieldname": "sales_update_frequency",
"fieldtype": "Select",
"label": "Sales Update Frequency",
@ -108,38 +108,39 @@
"default": "0",
"fieldname": "editable_price_list_rate",
"fieldtype": "Check",
"label": "Allow user to edit Price List Rate in transactions"
"label": "Allow User to Edit Price List Rate in Transactions"
},
{
"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"
},
{
"default": "0",
"fieldname": "allow_against_multiple_purchase_orders",
"fieldtype": "Check",
"label": "Allow multiple Sales Orders against a Customer's Purchase Order"
"label": "Allow Multiple Sales Orders Against a Customer's Purchase Order"
},
{
"default": "0",
"fieldname": "validate_selling_price",
"fieldtype": "Check",
"label": "Validate Selling Price for Item against Purchase Rate or Valuation Rate"
"label": "Validate Selling Price for Item Against Purchase Rate or Valuation Rate"
},
{
"default": "0",
"fieldname": "hide_tax_id",
"fieldtype": "Check",
"label": "Hide Customer's Tax Id from Sales Transactions"
"label": "Hide Customer's Tax ID from Sales Transactions"
}
],
"icon": "fa fa-cog",
"idx": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2020-06-01 13:58:35.637858",
"modified": "2020-10-13 12:12:56.784014",
"modified_by": "Administrator",
"module": "Selling",
"name": "Selling Settings",

View File

@ -9,8 +9,8 @@ erpnext.PointOfSale.Payment = class {
}
init_component() {
this.prepare_dom();
this.initialize_numpad();
this.prepare_dom();
this.initialize_numpad();
this.bind_events();
this.attach_shortcuts();
@ -18,32 +18,32 @@ erpnext.PointOfSale.Payment = class {
prepare_dom() {
this.wrapper.append(
`<section class="col-span-6 flex shadow rounded payment-section bg-white mx-h-70 h-100 d-none">
`<section class="col-span-6 flex shadow rounded payment-section bg-white mx-h-70 h-100 d-none">
<div class="flex flex-col p-16 pt-8 pb-8 w-full">
<div class="text-grey mb-6 payment-section no-select pointer">
PAYMENT METHOD<span class="octicon octicon-chevron-down collapse-indicator"></span>
</div>
<div class="payment-modes flex flex-wrap"></div>
<div class="invoice-details-section"></div>
<div class="flex mt-auto justify-center w-full">
<div class="flex flex-col justify-center flex-1 ml-4">
<div class="flex w-full">
<div class="totals-remarks items-end justify-end flex flex-1">
<div class="remarks text-md-0 text-grey mr-auto"></div>
<div class="totals flex justify-end pt-4"></div>
</div>
<div class="number-pad w-40 mb-4 ml-8 d-none"></div>
</div>
<div class="flex items-center justify-center mt-4 submit-order h-16 w-full rounded bg-primary text-md text-white no-select pointer text-bold">
Complete Order
<div class="flex mt-auto justify-center w-full">
<div class="flex flex-col justify-center flex-1 ml-4">
<div class="flex w-full">
<div class="totals-remarks items-end justify-end flex flex-1">
<div class="remarks text-md-0 text-grey mr-auto"></div>
<div class="totals flex justify-end pt-4"></div>
</div>
<div class="number-pad w-40 mb-4 ml-8 d-none"></div>
</div>
<div class="flex items-center justify-center mt-4 submit-order h-16 w-full rounded bg-primary text-md text-white no-select pointer text-bold">
Complete Order
</div>
<div class="order-time flex items-center justify-end mt-2 pt-2 pb-2 w-full text-md-0 text-grey no-select pointer d-none"></div>
</div>
</div>
</div>
</section>`
)
this.$component = this.wrapper.find('.payment-section');
</div>
</div>
</div>
</section>`
)
this.$component = this.wrapper.find('.payment-section');
this.$payment_modes = this.$component.find('.payment-modes');
this.$totals_remarks = this.$component.find('.totals-remarks');
this.$totals = this.$component.find('.totals');
@ -174,6 +174,24 @@ erpnext.PointOfSale.Payment = class {
}
})
frappe.realtime.on("process_phone_payment", function(data) {
frappe.dom.unfreeze();
cur_frm.reload_doc();
let message = data["ResultDesc"];
let title = __("Payment Failed");
if (data["ResultCode"] == 0) {
title = __("Payment Received");
$('.btn.btn-xs.btn-default[data-fieldname=request_for_payment]').html(`Payment Received`)
me.events.submit_invoice();
}
frappe.msgprint({
"message": message,
"title": title
});
});
this.$payment_modes.on('click', '.shortcut', function(e) {
const value = $(this).attr('data-value');
me.selected_mode.set_value(value);
@ -509,5 +527,5 @@ erpnext.PointOfSale.Payment = class {
toggle_component(show) {
show ? this.$component.removeClass('d-none') : this.$component.addClass('d-none');
}
}
}

View File

@ -7,6 +7,10 @@ frappe.ui.form.on("Shopping Cart Settings", {
frm.fields_dict.quotation_series.df.options = frm.doc.__onload.quotation_series;
frm.refresh_field("quotation_series");
}
frm.set_query('payment_gateway_account', function() {
return { 'filters': { 'payment_channel': "Email" } };
});
},
enabled: function(frm) {
if (frm.doc.enabled === 1) {

View File

@ -57,7 +57,7 @@ class TestDeliveryNote(unittest.TestCase):
sle = frappe.get_doc("Stock Ledger Entry", {"voucher_type": "Delivery Note", "voucher_no": dn.name})
self.assertEqual(sle.stock_value_difference, -1*stock_queue[0][1])
self.assertEqual(sle.stock_value_difference, flt(-1*stock_queue[0][1], 2))
self.assertFalse(get_gl_entries("Delivery Note", dn.name))

View File

@ -1117,7 +1117,10 @@ class StockEntry(StockController):
for d in backflushed_materials.get(item.item_code):
if d.get(item.warehouse):
if (qty > req_qty):
qty-= d.get(item.warehouse)
qty = (qty/trans_qty) * flt(self.fg_completed_qty)
if cint(frappe.get_cached_value('UOM', item.stock_uom, 'must_be_whole_number')):
qty = frappe.utils.ceil(qty)
if qty > 0:
self.add_to_stock_entry_detail({
@ -1320,8 +1323,10 @@ class StockEntry(StockController):
for sr in get_serial_nos(item.serial_no):
sales_order = frappe.db.get_value("Serial No", sr, "sales_order")
if sales_order:
frappe.throw(_("Item {0} (Serial No: {1}) cannot be consumed as is reserverd\
to fullfill Sales Order {2}.").format(item.item_code, sr, sales_order))
msg = (_("(Serial No: {0}) cannot be consumed as it's reserverd to fullfill Sales Order {1}.")
.format(sr, sales_order))
frappe.throw(_("Item {0} {1}").format(item.item_code, msg))
def update_transferred_qty(self):
if self.purpose == 'Material Transfer' and self.outgoing_stock_entry:

View File

@ -82,7 +82,7 @@
"options": "FIFO\nMoving Average"
},
{
"description": "Percentage you are allowed to receive or deliver more against the quantity ordered. For example: If you have ordered 100 units. and your Allowance is 10% then you are allowed to receive 110 units.",
"description": "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.",
"fieldname": "over_delivery_receipt_allowance",
"fieldtype": "Float",
"label": "Over Delivery/Receipt Allowance (%)"
@ -91,7 +91,7 @@
"default": "Stop",
"fieldname": "action_if_quality_inspection_is_not_submitted",
"fieldtype": "Select",
"label": "Action if Quality inspection is not submitted",
"label": "Action If Quality Inspection Is Not Submitted",
"options": "Stop\nWarn"
},
{
@ -114,7 +114,7 @@
"default": "0",
"fieldname": "auto_insert_price_list_rate_if_missing",
"fieldtype": "Check",
"label": "Auto insert Price List rate if missing"
"label": "Auto Insert Price List Rate If Missing"
},
{
"default": "0",
@ -130,13 +130,13 @@
"default": "1",
"fieldname": "automatically_set_serial_nos_based_on_fifo",
"fieldtype": "Check",
"label": "Automatically Set Serial Nos based on FIFO"
"label": "Automatically Set Serial Nos Based on FIFO"
},
{
"default": "1",
"fieldname": "set_qty_in_transactions_based_on_serial_no_input",
"fieldtype": "Check",
"label": "Set Qty in Transactions based on Serial No Input"
"label": "Set Qty in Transactions Based on Serial No Input"
},
{
"fieldname": "auto_material_request",
@ -147,13 +147,13 @@
"default": "0",
"fieldname": "auto_indent",
"fieldtype": "Check",
"label": "Raise Material Request when stock reaches re-order level"
"label": "Raise Material Request When Stock Reaches Re-order Level"
},
{
"default": "0",
"fieldname": "reorder_email_notify",
"fieldtype": "Check",
"label": "Notify by Email on creation of automatic Material Request"
"label": "Notify by Email on Creation of Automatic Material Request"
},
{
"fieldname": "freeze_stock_entries",
@ -168,12 +168,12 @@
{
"fieldname": "stock_frozen_upto_days",
"fieldtype": "Int",
"label": "Freeze Stocks Older Than [Days]"
"label": "Freeze Stocks Older Than (Days)"
},
{
"fieldname": "stock_auth_role",
"fieldtype": "Link",
"label": "Role Allowed to edit frozen stock",
"label": "Role Allowed to Edit Frozen Stock",
"options": "Role"
},
{
@ -203,20 +203,21 @@
"default": "0",
"fieldname": "allow_from_dn",
"fieldtype": "Check",
"label": "Allow Material Transfer From Delivery Note and Sales Invoice"
"label": "Allow Material Transfer from Delivery Note to Sales Invoice"
},
{
"default": "0",
"fieldname": "allow_from_pr",
"fieldtype": "Check",
"label": "Allow Material Transfer From Purchase Receipt and Purchase Invoice"
"label": "Allow Material Transfer from Purchase Receipt to Purchase Invoice"
}
],
"icon": "icon-cog",
"idx": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2020-06-20 11:39:15.344112",
"modified": "2020-10-13 10:33:29.147682",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",