Merge branch 'develop' into hr-separation
diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml
index afabe43..2cf4444 100644
--- a/.github/workflows/patch.yml
+++ b/.github/workflows/patch.yml
@@ -115,4 +115,5 @@
echo "Updating to latest version"
git -C "apps/frappe" checkout -q -f "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"
git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA"
+ bench setup requirements --python
bench --site test_site migrate
diff --git a/.mergify.yml b/.mergify.yml
index cc8c080..d7f82e6 100644
--- a/.mergify.yml
+++ b/.mergify.yml
@@ -9,6 +9,8 @@
- author!=nabinhait
- author!=ankush
- author!=deepeshgarg007
+ - author!=mergify[bot]
+
- or:
- base=version-13
- base=version-12
@@ -19,6 +21,16 @@
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch
+ - name: Auto-close PRs on pre-release branch
+ conditions:
+ - base=version-13-pre-release
+ actions:
+ close:
+ comment:
+ message: |
+ @{{author}}, pre-release branch is not maintained anymore. Releases are directly done by merging hotfix branch to stable branches.
+
+
- name: backport to develop
conditions:
- label="backport develop"
diff --git a/erpnext/accounts/README.md b/erpnext/accounts/README.md
index da1f201..15f7039 100644
--- a/erpnext/accounts/README.md
+++ b/erpnext/accounts/README.md
@@ -10,4 +10,42 @@
- Sales Invoice (Itemised)
- Purchase Invoice (Itemised)
-All accounting entries are stored in the `General Ledger`
\ No newline at end of file
+All accounting entries are stored in the `General Ledger`
+
+## Payment Ledger
+Transactions on Receivable and Payable Account types will also be stored in `Payment Ledger`. This is so that payment reconciliation process only requires update on this ledger.
+
+### Key Fields
+| Field | Description |
+|----------------------|----------------------------------|
+| `account_type` | Receivable/Payable |
+| `account` | Accounting head |
+| `party` | Party Name |
+| `voucher_no` | Voucher No |
+| `against_voucher_no` | Linked voucher(secondary effect) |
+| `amount` | can be +ve/-ve |
+
+### Design
+`debit` and `credit` have been replaced with `account_type` and `amount`. `against_voucher_no` is populated for all entries. So, outstanding amount can be calculated by summing up amount only using `against_voucher_no`.
+
+Ex:
+1. Consider an invoice for ₹100 and a partial payment of ₹80 against that invoice. Payment Ledger will have following entries.
+
+| voucher_no | against_voucher_no | amount |
+|------------|--------------------|--------|
+| SINV-01 | SINV-01 | 100 |
+| PAY-01 | SINV-01 | -80 |
+
+
+2. Reconcile a Credit Note against an invoice using a Journal Entry
+
+An invoice for ₹100 partially reconciled against a credit of ₹70 using a Journal Entry. Payment Ledger will have the following entries.
+
+| voucher_no | against_voucher_no | amount |
+|------------|--------------------|--------|
+| SINV-01 | SINV-01 | 100 |
+| | | |
+| CR-NOTE-01 | CR-NOTE-01 | -70 |
+| | | |
+| JE-01 | CR-NOTE-01 | +70 |
+| JE-01 | SINV-01 | -70 |
diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py
index c71ea36..2610c86 100644
--- a/erpnext/accounts/doctype/account/account.py
+++ b/erpnext/accounts/doctype/account/account.py
@@ -322,9 +322,9 @@
return frappe.db.sql(
"""select name from tabAccount
where is_group = 1 and docstatus != 2 and company = %s
- and %s like %s order by name limit %s, %s"""
+ and %s like %s order by name limit %s offset %s"""
% ("%s", searchfield, "%s", "%s", "%s"),
- (filters["company"], "%%%s%%" % txt, start, page_len),
+ (filters["company"], "%%%s%%" % txt, page_len, start),
as_list=1,
)
diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py
index e5fa57d..9f71656 100644
--- a/erpnext/accounts/doctype/gl_entry/gl_entry.py
+++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py
@@ -58,16 +58,20 @@
validate_balance_type(self.account, adv_adj)
validate_frozen_account(self.account, adv_adj)
- # Update outstanding amt on against voucher
- if (
- self.against_voucher_type in ["Journal Entry", "Sales Invoice", "Purchase Invoice", "Fees"]
- and self.against_voucher
- and self.flags.update_outstanding == "Yes"
- and not frappe.flags.is_reverse_depr_entry
- ):
- update_outstanding_amt(
- self.account, self.party_type, self.party, self.against_voucher_type, self.against_voucher
- )
+ if frappe.db.get_value("Account", self.account, "account_type") not in [
+ "Receivable",
+ "Payable",
+ ]:
+ # Update outstanding amt on against voucher
+ if (
+ self.against_voucher_type in ["Journal Entry", "Sales Invoice", "Purchase Invoice", "Fees"]
+ and self.against_voucher
+ and self.flags.update_outstanding == "Yes"
+ and not frappe.flags.is_reverse_depr_entry
+ ):
+ update_outstanding_amt(
+ self.account, self.party_type, self.party, self.against_voucher_type, self.against_voucher
+ )
def check_mandatory(self):
mandatory = ["account", "voucher_type", "voucher_no", "company"]
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js
index b539bfe..7af41f3 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.js
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js
@@ -149,22 +149,6 @@
}
});
}
- else if(frm.doc.voucher_type=="Opening Entry") {
- return frappe.call({
- type:"GET",
- method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_opening_accounts",
- args: {
- "company": frm.doc.company
- },
- callback: function(r) {
- frappe.model.clear_table(frm.doc, "accounts");
- if(r.message) {
- update_jv_details(frm.doc, r.message);
- }
- cur_frm.set_value("is_opening", "Yes");
- }
- });
- }
}
},
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.json b/erpnext/accounts/doctype/journal_entry/journal_entry.json
index 4493c72..8e5ba37 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.json
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.json
@@ -137,7 +137,8 @@
"fieldname": "finance_book",
"fieldtype": "Link",
"label": "Finance Book",
- "options": "Finance Book"
+ "options": "Finance Book",
+ "read_only": 1
},
{
"fieldname": "2_add_edit_gl_entries",
@@ -538,7 +539,7 @@
"idx": 176,
"is_submittable": 1,
"links": [],
- "modified": "2022-04-06 17:18:46.865259",
+ "modified": "2022-06-23 22:01:32.348337",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry",
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py
index 5690911..63c6547 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.py
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py
@@ -400,7 +400,7 @@
against_entries = frappe.db.sql(
"""select * from `tabJournal Entry Account`
where account = %s and docstatus = 1 and parent = %s
- and (reference_type is null or reference_type in ("", "Sales Order", "Purchase Order"))
+ and (reference_type is null or reference_type in ('', 'Sales Order', 'Purchase Order'))
""",
(d.account, d.reference_name),
as_dict=True,
@@ -784,9 +784,7 @@
self.total_amount_in_words = money_in_words(amt, currency)
- def make_gl_entries(self, cancel=0, adv_adj=0):
- from erpnext.accounts.general_ledger import make_gl_entries
-
+ def build_gl_map(self):
gl_map = []
for d in self.get("accounts"):
if d.debit or d.credit:
@@ -822,7 +820,12 @@
item=d,
)
)
+ return gl_map
+ def make_gl_entries(self, cancel=0, adv_adj=0):
+ from erpnext.accounts.general_ledger import make_gl_entries
+
+ gl_map = self.build_gl_map()
if self.voucher_type in ("Deferred Revenue", "Deferred Expense"):
update_outstanding = "No"
else:
@@ -1163,24 +1166,6 @@
@frappe.whitelist()
-def get_opening_accounts(company):
- """get all balance sheet accounts for opening entry"""
- accounts = frappe.db.sql_list(
- """select
- name from tabAccount
- where
- is_group=0 and report_type='Balance Sheet' and company={0} and
- name not in (select distinct account from tabWarehouse where
- account is not null and account != '')
- order by name asc""".format(
- frappe.db.escape(company)
- )
- )
-
- return [{"account": a, "balance": get_balance_on(a)} for a in accounts]
-
-
-@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_against_jv(doctype, txt, searchfield, start, page_len, filters):
if not frappe.db.has_column("Journal Entry", searchfield):
@@ -1200,7 +1185,7 @@
AND jv.docstatus = 1
AND jv.`{0}` LIKE %(txt)s
ORDER BY jv.name DESC
- LIMIT %(offset)s, %(limit)s
+ LIMIT %(limit)s offset %(offset)s
""".format(
searchfield
),
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index c8a5746..42f48c0 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -6,7 +6,7 @@
from functools import reduce
import frappe
-from frappe import ValidationError, _, scrub, throw
+from frappe import ValidationError, _, qb, scrub, throw
from frappe.utils import cint, comma_or, flt, getdate, nowdate
import erpnext
@@ -785,7 +785,7 @@
self.set("remarks", "\n".join(remarks))
- def make_gl_entries(self, cancel=0, adv_adj=0):
+ def build_gl_map(self):
if self.payment_type in ("Receive", "Pay") and not self.get("party_account_field"):
self.setup_party_account_field()
@@ -794,7 +794,10 @@
self.add_bank_gl_entries(gl_entries)
self.add_deductions_gl_entries(gl_entries)
self.add_tax_gl_entries(gl_entries)
+ return gl_entries
+ def make_gl_entries(self, cancel=0, adv_adj=0):
+ gl_entries = self.build_gl_map()
gl_entries = process_gl_map(gl_entries)
make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj)
@@ -1180,6 +1183,9 @@
if args.get("party_type") == "Member":
return
+ ple = qb.DocType("Payment Ledger Entry")
+ common_filter = []
+
# confirm that Supplier is not blocked
if args.get("party_type") == "Supplier":
supplier_status = get_supplier_block_status(args["party"])
@@ -1201,10 +1207,13 @@
condition = " and voucher_type={0} and voucher_no={1}".format(
frappe.db.escape(args["voucher_type"]), frappe.db.escape(args["voucher_no"])
)
+ common_filter.append(ple.voucher_type == args["voucher_type"])
+ common_filter.append(ple.voucher_no == args["voucher_no"])
# Add cost center condition
if args.get("cost_center"):
condition += " and cost_center='%s'" % args.get("cost_center")
+ common_filter.append(ple.cost_center == args.get("cost_center"))
date_fields_dict = {
"posting_date": ["from_posting_date", "to_posting_date"],
@@ -1216,16 +1225,19 @@
condition += " and {0} between '{1}' and '{2}'".format(
fieldname, args.get(date_fields[0]), args.get(date_fields[1])
)
+ common_filter.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])])
if args.get("company"):
condition += " and company = {0}".format(frappe.db.escape(args.get("company")))
+ common_filter.append(ple.company == args.get("company"))
outstanding_invoices = get_outstanding_invoices(
args.get("party_type"),
args.get("party"),
args.get("party_account"),
- filters=args,
- condition=condition,
+ common_filter=common_filter,
+ min_outstanding=args.get("outstanding_amt_greater_than"),
+ max_outstanding=args.get("outstanding_amt_less_than"),
)
outstanding_invoices = split_invoices_based_on_payment_terms(outstanding_invoices)
@@ -1429,7 +1441,7 @@
voucher_type = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
supplier_condition = ""
if voucher_type == "Purchase Invoice":
- supplier_condition = "and (release_date is null or release_date <= CURDATE())"
+ supplier_condition = "and (release_date is null or release_date <= CURRENT_DATE)"
if party_account_currency == company_currency:
grand_total_field = "base_grand_total"
rounded_total_field = "base_rounded_total"
diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
index 6fabd68..4609f45 100644
--- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
@@ -4,6 +4,7 @@
import unittest
import frappe
+from frappe.tests.utils import FrappeTestCase
from frappe.utils import flt, nowdate
from erpnext.accounts.doctype.payment_entry.payment_entry import (
@@ -23,7 +24,10 @@
test_dependencies = ["Item"]
-class TestPaymentEntry(unittest.TestCase):
+class TestPaymentEntry(FrappeTestCase):
+ def tearDown(self):
+ frappe.db.rollback()
+
def test_payment_entry_against_order(self):
so = make_sales_order()
pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC")
diff --git a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json
index d961076..39e9042 100644
--- a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json
+++ b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json
@@ -1,7 +1,6 @@
{
"actions": [],
"allow_rename": 1,
- "autoname": "format:PLE-{YY}-{MM}-{######}",
"creation": "2022-05-09 19:35:03.334361",
"doctype": "DocType",
"editable_grid": 1,
@@ -138,11 +137,10 @@
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2022-05-19 18:04:44.609115",
+ "modified": "2022-05-30 19:04:55.532171",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Ledger Entry",
- "naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
diff --git a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py
index 43e19f4..52df923 100644
--- a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py
+++ b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py
@@ -6,6 +6,19 @@
from frappe import _
from frappe.model.document import Document
+from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
+ get_checks_for_pl_and_bs_accounts,
+)
+from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_filter import (
+ get_dimension_filter_map,
+)
+from erpnext.accounts.doctype.gl_entry.gl_entry import (
+ validate_balance_type,
+ validate_frozen_account,
+)
+from erpnext.accounts.utils import update_voucher_outstanding
+from erpnext.exceptions import InvalidAccountDimensionError, MandatoryAccountDimensionError
+
class PaymentLedgerEntry(Document):
def validate_account(self):
@@ -18,5 +31,119 @@
if not valid_account:
frappe.throw(_("{0} account is not of type {1}").format(self.account, self.account_type))
+ def validate_account_details(self):
+ """Account must be ledger, active and not freezed"""
+
+ ret = frappe.db.sql(
+ """select is_group, docstatus, company
+ from tabAccount where name=%s""",
+ self.account,
+ as_dict=1,
+ )[0]
+
+ if ret.is_group == 1:
+ frappe.throw(
+ _(
+ """{0} {1}: Account {2} is a Group Account and group accounts cannot be used in transactions"""
+ ).format(self.voucher_type, self.voucher_no, self.account)
+ )
+
+ if ret.docstatus == 2:
+ frappe.throw(
+ _("{0} {1}: Account {2} is inactive").format(self.voucher_type, self.voucher_no, self.account)
+ )
+
+ if ret.company != self.company:
+ frappe.throw(
+ _("{0} {1}: Account {2} does not belong to Company {3}").format(
+ self.voucher_type, self.voucher_no, self.account, self.company
+ )
+ )
+
+ def validate_allowed_dimensions(self):
+ dimension_filter_map = get_dimension_filter_map()
+ for key, value in dimension_filter_map.items():
+ dimension = key[0]
+ account = key[1]
+
+ if self.account == account:
+ if value["is_mandatory"] and not self.get(dimension):
+ frappe.throw(
+ _("{0} is mandatory for account {1}").format(
+ frappe.bold(frappe.unscrub(dimension)), frappe.bold(self.account)
+ ),
+ MandatoryAccountDimensionError,
+ )
+
+ if value["allow_or_restrict"] == "Allow":
+ if self.get(dimension) and self.get(dimension) not in value["allowed_dimensions"]:
+ frappe.throw(
+ _("Invalid value {0} for {1} against account {2}").format(
+ frappe.bold(self.get(dimension)),
+ frappe.bold(frappe.unscrub(dimension)),
+ frappe.bold(self.account),
+ ),
+ InvalidAccountDimensionError,
+ )
+ else:
+ if self.get(dimension) and self.get(dimension) in value["allowed_dimensions"]:
+ frappe.throw(
+ _("Invalid value {0} for {1} against account {2}").format(
+ frappe.bold(self.get(dimension)),
+ frappe.bold(frappe.unscrub(dimension)),
+ frappe.bold(self.account),
+ ),
+ InvalidAccountDimensionError,
+ )
+
+ def validate_dimensions_for_pl_and_bs(self):
+ account_type = frappe.db.get_value("Account", self.account, "report_type")
+
+ for dimension in get_checks_for_pl_and_bs_accounts():
+ if (
+ account_type == "Profit and Loss"
+ and self.company == dimension.company
+ and dimension.mandatory_for_pl
+ and not dimension.disabled
+ ):
+ if not self.get(dimension.fieldname):
+ frappe.throw(
+ _("Accounting Dimension <b>{0}</b> is required for 'Profit and Loss' account {1}.").format(
+ dimension.label, self.account
+ )
+ )
+
+ if (
+ account_type == "Balance Sheet"
+ and self.company == dimension.company
+ and dimension.mandatory_for_bs
+ and not dimension.disabled
+ ):
+ if not self.get(dimension.fieldname):
+ frappe.throw(
+ _("Accounting Dimension <b>{0}</b> is required for 'Balance Sheet' account {1}.").format(
+ dimension.label, self.account
+ )
+ )
+
def validate(self):
self.validate_account()
+
+ def on_update(self):
+ adv_adj = self.flags.adv_adj
+ if not self.flags.from_repost:
+ self.validate_account_details()
+ self.validate_dimensions_for_pl_and_bs()
+ self.validate_allowed_dimensions()
+ validate_balance_type(self.account, adv_adj)
+ validate_frozen_account(self.account, adv_adj)
+
+ # update outstanding amount
+ if (
+ self.against_voucher_type in ["Journal Entry", "Sales Invoice", "Purchase Invoice", "Fees"]
+ and self.flags.update_outstanding == "Yes"
+ and not frappe.flags.is_reverse_depr_entry
+ ):
+ update_voucher_outstanding(
+ self.against_voucher_type, self.against_voucher_no, self.account, self.party_type, self.party
+ )
diff --git a/erpnext/accounts/doctype/payment_order/payment_order.py b/erpnext/accounts/doctype/payment_order/payment_order.py
index 3c45d20..ff9615d 100644
--- a/erpnext/accounts/doctype/payment_order/payment_order.py
+++ b/erpnext/accounts/doctype/payment_order/payment_order.py
@@ -39,7 +39,7 @@
return frappe.db.sql(
""" select mode_of_payment from `tabPayment Order Reference`
where parent = %(parent)s and mode_of_payment like %(txt)s
- limit %(start)s, %(page_len)s""",
+ limit %(page_len)s offset %(start)s""",
{"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt},
)
@@ -51,7 +51,7 @@
""" select supplier from `tabPayment Order Reference`
where parent = %(parent)s and supplier like %(txt)s and
(payment_reference is null or payment_reference='')
- limit %(start)s, %(page_len)s""",
+ limit %(page_len)s offset %(start)s""",
{"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt},
)
diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
index e5b942f..5b2b526 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
@@ -3,16 +3,26 @@
import frappe
-from frappe import _, msgprint
+from frappe import _, msgprint, qb
from frappe.model.document import Document
+from frappe.query_builder.custom import ConstantColumn
+from frappe.query_builder.functions import IfNull
from frappe.utils import flt, getdate, nowdate, today
import erpnext
-from erpnext.accounts.utils import get_outstanding_invoices, reconcile_against_document
+from erpnext.accounts.utils import (
+ QueryPaymentLedger,
+ get_outstanding_invoices,
+ reconcile_against_document,
+)
from erpnext.controllers.accounts_controller import get_advance_payment_entries
class PaymentReconciliation(Document):
+ def __init__(self, *args, **kwargs):
+ super(PaymentReconciliation, self).__init__(*args, **kwargs)
+ self.common_filter_conditions = []
+
@frappe.whitelist()
def get_unreconciled_entries(self):
self.get_nonreconciled_payment_entries()
@@ -108,54 +118,58 @@
return list(journal_entries)
def get_dr_or_cr_notes(self):
- condition = self.get_conditions(get_return_invoices=True)
- dr_or_cr = (
- "credit_in_account_currency"
- if erpnext.get_party_account_type(self.party_type) == "Receivable"
- else "debit_in_account_currency"
- )
- reconciled_dr_or_cr = (
- "debit_in_account_currency"
- if dr_or_cr == "credit_in_account_currency"
- else "credit_in_account_currency"
- )
+ self.build_qb_filter_conditions(get_return_invoices=True)
+ ple = qb.DocType("Payment Ledger Entry")
voucher_type = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
- return frappe.db.sql(
- """ SELECT doc.name as reference_name, %(voucher_type)s as reference_type,
- (sum(gl.{dr_or_cr}) - sum(gl.{reconciled_dr_or_cr})) as amount, doc.posting_date,
- account_currency as currency
- FROM `tab{doc}` doc, `tabGL Entry` gl
- WHERE
- (doc.name = gl.against_voucher or doc.name = gl.voucher_no)
- and doc.{party_type_field} = %(party)s
- and doc.is_return = 1 and ifnull(doc.return_against, "") = ""
- and gl.against_voucher_type = %(voucher_type)s
- and doc.docstatus = 1 and gl.party = %(party)s
- and gl.party_type = %(party_type)s and gl.account = %(account)s
- and gl.is_cancelled = 0 {condition}
- GROUP BY doc.name
- Having
- amount > 0
- ORDER BY doc.posting_date
- """.format(
- doc=voucher_type,
- dr_or_cr=dr_or_cr,
- reconciled_dr_or_cr=reconciled_dr_or_cr,
- party_type_field=frappe.scrub(self.party_type),
- condition=condition or "",
- ),
- {
- "party": self.party,
- "party_type": self.party_type,
- "voucher_type": voucher_type,
- "account": self.receivable_payable_account,
- },
- as_dict=1,
+ if erpnext.get_party_account_type(self.party_type) == "Receivable":
+ self.common_filter_conditions.append(ple.account_type == "Receivable")
+ else:
+ self.common_filter_conditions.append(ple.account_type == "Payable")
+ self.common_filter_conditions.append(ple.account == self.receivable_payable_account)
+
+ # get return invoices
+ doc = qb.DocType(voucher_type)
+ return_invoices = (
+ qb.from_(doc)
+ .select(ConstantColumn(voucher_type).as_("voucher_type"), doc.name.as_("voucher_no"))
+ .where(
+ (doc.docstatus == 1)
+ & (doc[frappe.scrub(self.party_type)] == self.party)
+ & (doc.is_return == 1)
+ & (IfNull(doc.return_against, "") == "")
+ )
+ .run(as_dict=True)
)
+ outstanding_dr_or_cr = []
+ if return_invoices:
+ ple_query = QueryPaymentLedger()
+ return_outstanding = ple_query.get_voucher_outstandings(
+ vouchers=return_invoices,
+ common_filter=self.common_filter_conditions,
+ min_outstanding=-(self.minimum_payment_amount) if self.minimum_payment_amount else None,
+ max_outstanding=-(self.maximum_payment_amount) if self.maximum_payment_amount else None,
+ get_payments=True,
+ )
+
+ for inv in return_outstanding:
+ if inv.outstanding != 0:
+ outstanding_dr_or_cr.append(
+ frappe._dict(
+ {
+ "reference_type": inv.voucher_type,
+ "reference_name": inv.voucher_no,
+ "amount": -(inv.outstanding),
+ "posting_date": inv.posting_date,
+ "currency": inv.currency,
+ }
+ )
+ )
+ return outstanding_dr_or_cr
+
def add_payment_entries(self, non_reconciled_payments):
self.set("payments", [])
@@ -166,10 +180,15 @@
def get_invoice_entries(self):
# Fetch JVs, Sales and Purchase Invoices for 'invoices' to reconcile against
- condition = self.get_conditions(get_invoices=True)
+ self.build_qb_filter_conditions(get_invoices=True)
non_reconciled_invoices = get_outstanding_invoices(
- self.party_type, self.party, self.receivable_payable_account, condition=condition
+ self.party_type,
+ self.party,
+ self.receivable_payable_account,
+ common_filter=self.common_filter_conditions,
+ min_outstanding=self.minimum_invoice_amount if self.minimum_invoice_amount else None,
+ max_outstanding=self.maximum_invoice_amount if self.maximum_invoice_amount else None,
)
if self.invoice_limit:
@@ -329,89 +348,56 @@
if not invoices_to_reconcile:
frappe.throw(_("No records found in Allocation table"))
- def get_conditions(self, get_invoices=False, get_payments=False, get_return_invoices=False):
- condition = " and company = '{0}' ".format(self.company)
+ def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False):
+ self.common_filter_conditions.clear()
+ ple = qb.DocType("Payment Ledger Entry")
- if self.get("cost_center") and (get_invoices or get_payments or get_return_invoices):
- condition = " and cost_center = '{0}' ".format(self.cost_center)
+ self.common_filter_conditions.append(ple.company == self.company)
+
+ if self.get("cost_center") and (get_invoices or get_return_invoices):
+ self.common_filter_conditions.append(ple.cost_center == self.cost_center)
if get_invoices:
- condition += (
- " and posting_date >= {0}".format(frappe.db.escape(self.from_invoice_date))
- if self.from_invoice_date
- else ""
- )
- condition += (
- " and posting_date <= {0}".format(frappe.db.escape(self.to_invoice_date))
- if self.to_invoice_date
- else ""
- )
- dr_or_cr = (
- "debit_in_account_currency"
- if erpnext.get_party_account_type(self.party_type) == "Receivable"
- else "credit_in_account_currency"
- )
-
- if self.minimum_invoice_amount:
- condition += " and {dr_or_cr} >= {amount}".format(
- dr_or_cr=dr_or_cr, amount=flt(self.minimum_invoice_amount)
- )
- if self.maximum_invoice_amount:
- condition += " and {dr_or_cr} <= {amount}".format(
- dr_or_cr=dr_or_cr, amount=flt(self.maximum_invoice_amount)
- )
+ if self.from_invoice_date:
+ self.common_filter_conditions.append(ple.posting_date.gte(self.from_invoice_date))
+ if self.to_invoice_date:
+ self.common_filter_conditions.append(ple.posting_date.lte(self.to_invoice_date))
elif get_return_invoices:
- condition = " and doc.company = '{0}' ".format(self.company)
- condition += (
- " and doc.posting_date >= {0}".format(frappe.db.escape(self.from_payment_date))
- if self.from_payment_date
- else ""
- )
- condition += (
- " and doc.posting_date <= {0}".format(frappe.db.escape(self.to_payment_date))
- if self.to_payment_date
- else ""
- )
- dr_or_cr = (
- "debit_in_account_currency"
- if erpnext.get_party_account_type(self.party_type) == "Receivable"
- else "credit_in_account_currency"
- )
+ if self.from_payment_date:
+ self.common_filter_conditions.append(ple.posting_date.gte(self.from_payment_date))
+ if self.to_payment_date:
+ self.common_filter_conditions.append(ple.posting_date.lte(self.to_payment_date))
- if self.minimum_invoice_amount:
- condition += " and gl.{dr_or_cr} >= {amount}".format(
- dr_or_cr=dr_or_cr, amount=flt(self.minimum_payment_amount)
- )
- if self.maximum_invoice_amount:
- condition += " and gl.{dr_or_cr} <= {amount}".format(
- dr_or_cr=dr_or_cr, amount=flt(self.maximum_payment_amount)
- )
+ def get_conditions(self, get_payments=False):
+ condition = " and company = '{0}' ".format(self.company)
- else:
- condition += (
- " and posting_date >= {0}".format(frappe.db.escape(self.from_payment_date))
- if self.from_payment_date
- else ""
- )
- condition += (
- " and posting_date <= {0}".format(frappe.db.escape(self.to_payment_date))
- if self.to_payment_date
- else ""
- )
+ if self.get("cost_center") and get_payments:
+ condition = " and cost_center = '{0}' ".format(self.cost_center)
- if self.minimum_payment_amount:
- condition += (
- " and unallocated_amount >= {0}".format(flt(self.minimum_payment_amount))
- if get_payments
- else " and total_debit >= {0}".format(flt(self.minimum_payment_amount))
- )
- if self.maximum_payment_amount:
- condition += (
- " and unallocated_amount <= {0}".format(flt(self.maximum_payment_amount))
- if get_payments
- else " and total_debit <= {0}".format(flt(self.maximum_payment_amount))
- )
+ condition += (
+ " and posting_date >= {0}".format(frappe.db.escape(self.from_payment_date))
+ if self.from_payment_date
+ else ""
+ )
+ condition += (
+ " and posting_date <= {0}".format(frappe.db.escape(self.to_payment_date))
+ if self.to_payment_date
+ else ""
+ )
+
+ if self.minimum_payment_amount:
+ condition += (
+ " and unallocated_amount >= {0}".format(flt(self.minimum_payment_amount))
+ if get_payments
+ else " and total_debit >= {0}".format(flt(self.minimum_payment_amount))
+ )
+ if self.maximum_payment_amount:
+ condition += (
+ " and unallocated_amount <= {0}".format(flt(self.maximum_payment_amount))
+ if get_payments
+ else " and total_debit <= {0}".format(flt(self.maximum_payment_amount))
+ )
return condition
diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
index d2374b7..575ac74 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
@@ -4,93 +4,453 @@
import unittest
import frappe
-from frappe.utils import add_days, getdate
+from frappe import qb
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import add_days, nowdate
+from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
+from erpnext.accounts.party import get_party_account
+from erpnext.stock.doctype.item.test_item import create_item
-class TestPaymentReconciliation(unittest.TestCase):
- @classmethod
- def setUpClass(cls):
- make_customer()
- make_invoice_and_payment()
+class TestPaymentReconciliation(FrappeTestCase):
+ def setUp(self):
+ self.create_company()
+ self.create_item()
+ self.create_customer()
+ self.clear_old_entries()
- def test_payment_reconciliation(self):
- payment_reco = frappe.get_doc("Payment Reconciliation")
- payment_reco.company = "_Test Company"
- payment_reco.party_type = "Customer"
- payment_reco.party = "_Test Payment Reco Customer"
- payment_reco.receivable_payable_account = "Debtors - _TC"
- payment_reco.from_invoice_date = add_days(getdate(), -1)
- payment_reco.to_invoice_date = getdate()
- payment_reco.from_payment_date = add_days(getdate(), -1)
- payment_reco.to_payment_date = getdate()
- payment_reco.maximum_invoice_amount = 1000
- payment_reco.maximum_payment_amount = 1000
- payment_reco.invoice_limit = 10
- payment_reco.payment_limit = 10
- payment_reco.bank_cash_account = "_Test Bank - _TC"
- payment_reco.cost_center = "_Test Cost Center - _TC"
- payment_reco.get_unreconciled_entries()
+ def tearDown(self):
+ frappe.db.rollback()
- self.assertEqual(len(payment_reco.get("invoices")), 1)
- self.assertEqual(len(payment_reco.get("payments")), 1)
+ def create_company(self):
+ company = None
+ if frappe.db.exists("Company", "_Test Payment Reconciliation"):
+ company = frappe.get_doc("Company", "_Test Payment Reconciliation")
+ else:
+ company = frappe.get_doc(
+ {
+ "doctype": "Company",
+ "company_name": "_Test Payment Reconciliation",
+ "country": "India",
+ "default_currency": "INR",
+ "create_chart_of_accounts_based_on": "Standard Template",
+ "chart_of_accounts": "Standard",
+ }
+ )
+ company = company.save()
- payment_entry = payment_reco.get("payments")[0].reference_name
- invoice = payment_reco.get("invoices")[0].invoice_number
+ self.company = company.name
+ self.cost_center = company.cost_center
+ self.warehouse = "All Warehouses - _PR"
+ self.income_account = "Sales - _PR"
+ self.expense_account = "Cost of Goods Sold - _PR"
+ self.debit_to = "Debtors - _PR"
+ self.creditors = "Creditors - _PR"
- payment_reco.allocate_entries(
- {
- "payments": [payment_reco.get("payments")[0].as_dict()],
- "invoices": [payment_reco.get("invoices")[0].as_dict()],
- }
+ # create bank account
+ if frappe.db.exists("Account", "HDFC - _PR"):
+ self.bank = "HDFC - _PR"
+ else:
+ bank_acc = frappe.get_doc(
+ {
+ "doctype": "Account",
+ "account_name": "HDFC",
+ "parent_account": "Bank Accounts - _PR",
+ "company": self.company,
+ }
+ )
+ bank_acc.save()
+ self.bank = bank_acc.name
+
+ def create_item(self):
+ item = create_item(
+ item_code="_Test PR Item", is_stock_item=0, company=self.company, warehouse=self.warehouse
)
- payment_reco.reconcile()
+ self.item = item if isinstance(item, str) else item.item_code
- payment_entry_doc = frappe.get_doc("Payment Entry", payment_entry)
- self.assertEqual(payment_entry_doc.get("references")[0].reference_name, invoice)
+ def create_customer(self):
+ if frappe.db.exists("Customer", "_Test PR Customer"):
+ self.customer = "_Test PR Customer"
+ else:
+ customer = frappe.new_doc("Customer")
+ customer.customer_name = "_Test PR Customer"
+ customer.type = "Individual"
+ customer.save()
+ self.customer = customer.name
+ if frappe.db.exists("Customer", "_Test PR Customer 2"):
+ self.customer2 = "_Test PR Customer 2"
+ else:
+ customer = frappe.new_doc("Customer")
+ customer.customer_name = "_Test PR Customer 2"
+ customer.type = "Individual"
+ customer.save()
+ self.customer2 = customer.name
-def make_customer():
- if not frappe.db.get_value("Customer", "_Test Payment Reco Customer"):
- frappe.get_doc(
- {
- "doctype": "Customer",
- "customer_name": "_Test Payment Reco Customer",
- "customer_type": "Individual",
- "customer_group": "_Test Customer Group",
- "territory": "_Test Territory",
- }
- ).insert()
+ def create_sales_invoice(
+ self, qty=1, rate=100, posting_date=nowdate(), do_not_save=False, do_not_submit=False
+ ):
+ """
+ Helper function to populate default values in sales invoice
+ """
+ sinv = create_sales_invoice(
+ qty=qty,
+ rate=rate,
+ company=self.company,
+ customer=self.customer,
+ item_code=self.item,
+ item_name=self.item,
+ cost_center=self.cost_center,
+ warehouse=self.warehouse,
+ debit_to=self.debit_to,
+ parent_cost_center=self.cost_center,
+ update_stock=0,
+ currency="INR",
+ is_pos=0,
+ is_return=0,
+ return_against=None,
+ income_account=self.income_account,
+ expense_account=self.expense_account,
+ do_not_save=do_not_save,
+ do_not_submit=do_not_submit,
+ )
+ return sinv
+ def create_payment_entry(self, amount=100, posting_date=nowdate()):
+ """
+ Helper function to populate default values in payment entry
+ """
+ payment = create_payment_entry(
+ company=self.company,
+ payment_type="Receive",
+ party_type="Customer",
+ party=self.customer,
+ paid_from=self.debit_to,
+ paid_to=self.bank,
+ paid_amount=amount,
+ )
+ payment.posting_date = posting_date
+ return payment
-def make_invoice_and_payment():
- si = create_sales_invoice(
- customer="_Test Payment Reco Customer", qty=1, rate=690, do_not_save=True
- )
- si.cost_center = "_Test Cost Center - _TC"
- si.save()
- si.submit()
+ def clear_old_entries(self):
+ doctype_list = [
+ "GL Entry",
+ "Payment Ledger Entry",
+ "Sales Invoice",
+ "Purchase Invoice",
+ "Payment Entry",
+ "Journal Entry",
+ ]
+ for doctype in doctype_list:
+ qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
- pe = frappe.get_doc(
- {
- "doctype": "Payment Entry",
- "payment_type": "Receive",
- "party_type": "Customer",
- "party": "_Test Payment Reco Customer",
- "company": "_Test Company",
- "paid_from_account_currency": "INR",
- "paid_to_account_currency": "INR",
- "source_exchange_rate": 1,
- "target_exchange_rate": 1,
- "reference_no": "1",
- "reference_date": getdate(),
- "received_amount": 690,
- "paid_amount": 690,
- "paid_from": "Debtors - _TC",
- "paid_to": "_Test Bank - _TC",
- "cost_center": "_Test Cost Center - _TC",
- }
- )
- pe.insert()
- pe.submit()
+ def create_payment_reconciliation(self):
+ pr = frappe.new_doc("Payment Reconciliation")
+ pr.company = self.company
+ pr.party_type = "Customer"
+ pr.party = self.customer
+ pr.receivable_payable_account = get_party_account(pr.party_type, pr.party, pr.company)
+ pr.from_invoice_date = pr.to_invoice_date = pr.from_payment_date = pr.to_payment_date = nowdate()
+ return pr
+
+ def create_journal_entry(
+ self, acc1=None, acc2=None, amount=0, posting_date=None, cost_center=None
+ ):
+ je = frappe.new_doc("Journal Entry")
+ je.posting_date = posting_date or nowdate()
+ je.company = self.company
+ je.user_remark = "test"
+ if not cost_center:
+ cost_center = self.cost_center
+ je.set(
+ "accounts",
+ [
+ {
+ "account": acc1,
+ "cost_center": cost_center,
+ "debit_in_account_currency": amount if amount > 0 else 0,
+ "credit_in_account_currency": abs(amount) if amount < 0 else 0,
+ },
+ {
+ "account": acc2,
+ "cost_center": cost_center,
+ "credit_in_account_currency": amount if amount > 0 else 0,
+ "debit_in_account_currency": abs(amount) if amount < 0 else 0,
+ },
+ ],
+ )
+ return je
+
+ def test_filter_min_max(self):
+ # check filter condition minimum and maximum amount
+ self.create_sales_invoice(qty=1, rate=300)
+ self.create_sales_invoice(qty=1, rate=400)
+ self.create_sales_invoice(qty=1, rate=500)
+ self.create_payment_entry(amount=300).save().submit()
+ self.create_payment_entry(amount=400).save().submit()
+ self.create_payment_entry(amount=500).save().submit()
+
+ pr = self.create_payment_reconciliation()
+ pr.minimum_invoice_amount = 400
+ pr.maximum_invoice_amount = 500
+ pr.minimum_payment_amount = 300
+ pr.maximum_payment_amount = 600
+ pr.get_unreconciled_entries()
+ self.assertEqual(len(pr.get("invoices")), 2)
+ self.assertEqual(len(pr.get("payments")), 3)
+
+ pr.minimum_invoice_amount = 300
+ pr.maximum_invoice_amount = 600
+ pr.minimum_payment_amount = 400
+ pr.maximum_payment_amount = 500
+ pr.get_unreconciled_entries()
+ self.assertEqual(len(pr.get("invoices")), 3)
+ self.assertEqual(len(pr.get("payments")), 2)
+
+ pr.minimum_invoice_amount = (
+ pr.maximum_invoice_amount
+ ) = pr.minimum_payment_amount = pr.maximum_payment_amount = 0
+ pr.get_unreconciled_entries()
+ self.assertEqual(len(pr.get("invoices")), 3)
+ self.assertEqual(len(pr.get("payments")), 3)
+
+ def test_filter_posting_date(self):
+ # check filter condition using transaction date
+ date1 = nowdate()
+ date2 = add_days(nowdate(), -1)
+ amount = 100
+ self.create_sales_invoice(qty=1, rate=amount, posting_date=date1)
+ si2 = self.create_sales_invoice(
+ qty=1, rate=amount, posting_date=date2, do_not_save=True, do_not_submit=True
+ )
+ si2.set_posting_time = 1
+ si2.posting_date = date2
+ si2.save().submit()
+ self.create_payment_entry(amount=amount, posting_date=date1).save().submit()
+ self.create_payment_entry(amount=amount, posting_date=date2).save().submit()
+
+ pr = self.create_payment_reconciliation()
+ pr.from_invoice_date = pr.to_invoice_date = date1
+ pr.from_payment_date = pr.to_payment_date = date1
+
+ pr.get_unreconciled_entries()
+ # assert only si and pe are fetched
+ self.assertEqual(len(pr.get("invoices")), 1)
+ self.assertEqual(len(pr.get("payments")), 1)
+
+ pr.from_invoice_date = date2
+ pr.to_invoice_date = date1
+ pr.from_payment_date = date2
+ pr.to_payment_date = date1
+
+ pr.get_unreconciled_entries()
+ # assert only si and pe are fetched
+ self.assertEqual(len(pr.get("invoices")), 2)
+ self.assertEqual(len(pr.get("payments")), 2)
+
+ def test_filter_invoice_limit(self):
+ # check filter condition - invoice limit
+ transaction_date = nowdate()
+ rate = 100
+ invoices = []
+ payments = []
+ for i in range(5):
+ invoices.append(self.create_sales_invoice(qty=1, rate=rate, posting_date=transaction_date))
+ pe = self.create_payment_entry(amount=rate, posting_date=transaction_date).save().submit()
+ payments.append(pe)
+
+ pr = self.create_payment_reconciliation()
+ pr.from_invoice_date = pr.to_invoice_date = transaction_date
+ pr.from_payment_date = pr.to_payment_date = transaction_date
+ pr.invoice_limit = 2
+ pr.payment_limit = 3
+ pr.get_unreconciled_entries()
+
+ self.assertEqual(len(pr.get("invoices")), 2)
+ self.assertEqual(len(pr.get("payments")), 3)
+
+ def test_payment_against_invoice(self):
+ si = self.create_sales_invoice(qty=1, rate=200)
+ pe = self.create_payment_entry(amount=55).save().submit()
+ # second payment entry
+ self.create_payment_entry(amount=35).save().submit()
+
+ pr = self.create_payment_reconciliation()
+
+ # reconcile multiple payments against invoice
+ pr.get_unreconciled_entries()
+ invoices = [x.as_dict() for x in pr.get("invoices")]
+ payments = [x.as_dict() for x in pr.get("payments")]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ pr.reconcile()
+
+ si.reload()
+ self.assertEqual(si.status, "Partly Paid")
+ # check PR tool output post reconciliation
+ self.assertEqual(len(pr.get("invoices")), 1)
+ self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 110)
+ self.assertEqual(pr.get("payments"), [])
+
+ # cancel one PE
+ pe.reload()
+ pe.cancel()
+ pr.get_unreconciled_entries()
+ # check PR tool output
+ self.assertEqual(len(pr.get("invoices")), 1)
+ self.assertEqual(len(pr.get("payments")), 0)
+ self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 165)
+
+ def test_payment_against_journal(self):
+ transaction_date = nowdate()
+
+ sales = "Sales - _PR"
+ amount = 921
+ # debit debtors account to record an invoice
+ je = self.create_journal_entry(self.debit_to, sales, amount, transaction_date)
+ je.accounts[0].party_type = "Customer"
+ je.accounts[0].party = self.customer
+ je.save()
+ je.submit()
+
+ self.create_payment_entry(amount=amount, posting_date=transaction_date).save().submit()
+
+ pr = self.create_payment_reconciliation()
+ pr.minimum_invoice_amount = pr.maximum_invoice_amount = amount
+ pr.from_invoice_date = pr.to_invoice_date = transaction_date
+ pr.from_payment_date = pr.to_payment_date = transaction_date
+
+ pr.get_unreconciled_entries()
+ invoices = [x.as_dict() for x in pr.get("invoices")]
+ payments = [x.as_dict() for x in pr.get("payments")]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ pr.reconcile()
+
+ # check PR tool output
+ self.assertEqual(len(pr.get("invoices")), 0)
+ self.assertEqual(len(pr.get("payments")), 0)
+
+ def test_journal_against_invoice(self):
+ transaction_date = nowdate()
+ amount = 100
+ si = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date)
+
+ # credit debtors account to record a payment
+ je = self.create_journal_entry(self.bank, self.debit_to, amount, transaction_date)
+ je.accounts[1].party_type = "Customer"
+ je.accounts[1].party = self.customer
+ je.save()
+ je.submit()
+
+ pr = self.create_payment_reconciliation()
+
+ pr.get_unreconciled_entries()
+ invoices = [x.as_dict() for x in pr.get("invoices")]
+ payments = [x.as_dict() for x in pr.get("payments")]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ pr.reconcile()
+
+ # assert outstanding
+ si.reload()
+ self.assertEqual(si.status, "Paid")
+ self.assertEqual(si.outstanding_amount, 0)
+
+ # check PR tool output
+ self.assertEqual(len(pr.get("invoices")), 0)
+ self.assertEqual(len(pr.get("payments")), 0)
+
+ def test_journal_against_journal(self):
+ transaction_date = nowdate()
+ sales = "Sales - _PR"
+ amount = 100
+
+ # debit debtors account to simulate a invoice
+ je1 = self.create_journal_entry(self.debit_to, sales, amount, transaction_date)
+ je1.accounts[0].party_type = "Customer"
+ je1.accounts[0].party = self.customer
+ je1.save()
+ je1.submit()
+
+ # credit debtors account to simulate a payment
+ je2 = self.create_journal_entry(self.bank, self.debit_to, amount, transaction_date)
+ je2.accounts[1].party_type = "Customer"
+ je2.accounts[1].party = self.customer
+ je2.save()
+ je2.submit()
+
+ pr = self.create_payment_reconciliation()
+
+ pr.get_unreconciled_entries()
+ invoices = [x.as_dict() for x in pr.get("invoices")]
+ payments = [x.as_dict() for x in pr.get("payments")]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ pr.reconcile()
+
+ self.assertEqual(pr.get("invoices"), [])
+ self.assertEqual(pr.get("payments"), [])
+
+ def test_cr_note_against_invoice(self):
+ transaction_date = nowdate()
+ amount = 100
+
+ si = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date)
+
+ cr_note = self.create_sales_invoice(
+ qty=-1, rate=amount, posting_date=transaction_date, do_not_save=True, do_not_submit=True
+ )
+ cr_note.is_return = 1
+ cr_note = cr_note.save().submit()
+
+ pr = self.create_payment_reconciliation()
+
+ pr.get_unreconciled_entries()
+ invoices = [x.as_dict() for x in pr.get("invoices")]
+ payments = [x.as_dict() for x in pr.get("payments")]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ pr.reconcile()
+
+ pr.get_unreconciled_entries()
+ # check reconciliation tool output
+ # reconciled invoice and credit note shouldn't show up in selection
+ self.assertEqual(pr.get("invoices"), [])
+ self.assertEqual(pr.get("payments"), [])
+
+ # assert outstanding
+ si.reload()
+ self.assertEqual(si.status, "Paid")
+ self.assertEqual(si.outstanding_amount, 0)
+
+ def test_cr_note_partial_against_invoice(self):
+ transaction_date = nowdate()
+ amount = 100
+ allocated_amount = 80
+
+ si = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date)
+
+ cr_note = self.create_sales_invoice(
+ qty=-1, rate=amount, posting_date=transaction_date, do_not_save=True, do_not_submit=True
+ )
+ cr_note.is_return = 1
+ cr_note = cr_note.save().submit()
+
+ pr = self.create_payment_reconciliation()
+
+ pr.get_unreconciled_entries()
+ invoices = [x.as_dict() for x in pr.get("invoices")]
+ payments = [x.as_dict() for x in pr.get("payments")]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ pr.allocation[0].allocated_amount = allocated_amount
+ pr.reconcile()
+
+ # assert outstanding
+ si.reload()
+ self.assertEqual(si.status, "Partly Paid")
+ self.assertEqual(si.outstanding_amount, 20)
+
+ pr.get_unreconciled_entries()
+ # check reconciliation tool output
+ self.assertEqual(len(pr.get("invoices")), 1)
+ self.assertEqual(len(pr.get("payments")), 1)
+ self.assertEqual(pr.get("invoices")[0].outstanding_amount, 20)
+ self.assertEqual(pr.get("payments")[0].amount, 20)
diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
index 53b1c64..5a86376 100644
--- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
+++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
@@ -54,8 +54,8 @@
pce = frappe.db.sql(
"""select name from `tabPeriod Closing Voucher`
- where posting_date > %s and fiscal_year = %s and docstatus = 1""",
- (self.posting_date, self.fiscal_year),
+ where posting_date > %s and fiscal_year = %s and docstatus = 1 and company = %s""",
+ (self.posting_date, self.fiscal_year, self.company),
)
if pce and pce[0][0]:
frappe.throw(
diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.py b/erpnext/accounts/doctype/pos_profile/pos_profile.py
index e83dc0f..e8aee73 100644
--- a/erpnext/accounts/doctype/pos_profile/pos_profile.py
+++ b/erpnext/accounts/doctype/pos_profile/pos_profile.py
@@ -173,7 +173,7 @@
where
pfu.parent = pf.name and pfu.user = %(user)s and pf.company = %(company)s
and (pf.name like %(txt)s)
- and pf.disabled = 0 limit %(start)s, %(page_len)s""",
+ and pf.disabled = 0 limit %(page_len)s offset %(start)s""",
args,
)
diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
index 2438f4b..98e0a9b 100644
--- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
+++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
@@ -36,8 +36,12 @@
def validate_duplicate_apply_on(self):
if self.apply_on != "Transaction":
- field = apply_on_dict.get(self.apply_on)
- values = [d.get(frappe.scrub(self.apply_on)) for d in self.get(field) if field]
+ apply_on_table = apply_on_dict.get(self.apply_on)
+ if not apply_on_table:
+ return
+
+ apply_on_field = frappe.scrub(self.apply_on)
+ values = [d.get(apply_on_field) for d in self.get(apply_on_table) if d.get(apply_on_field)]
if len(values) != len(set(values)):
frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on))
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index 23ad223..4e0d1c9 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -165,17 +165,6 @@
super(PurchaseInvoice, self).set_missing_values(for_validate)
- def check_conversion_rate(self):
- default_currency = erpnext.get_company_currency(self.company)
- if not default_currency:
- throw(_("Please enter default currency in Company Master"))
- if (
- (self.currency == default_currency and flt(self.conversion_rate) != 1.00)
- or not self.conversion_rate
- or (self.currency != default_currency and flt(self.conversion_rate) == 1.00)
- ):
- throw(_("Conversion rate cannot be 0 or 1"))
-
def validate_credit_to_acc(self):
if not self.credit_to:
self.credit_to = get_party_account("Supplier", self.supplier, self.company)
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index 3c70e24..6412da7 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -1616,6 +1616,26 @@
company.enable_provisional_accounting_for_non_stock_items = 0
company.save()
+ def test_item_less_defaults(self):
+
+ pi = frappe.new_doc("Purchase Invoice")
+ pi.supplier = "_Test Supplier"
+ pi.company = "_Test Company"
+ pi.append(
+ "items",
+ {
+ "item_name": "Opening item",
+ "qty": 1,
+ "uom": "Tonne",
+ "stock_uom": "Kg",
+ "rate": 1000,
+ "expense_account": "Stock Received But Not Billed - _TC",
+ },
+ )
+
+ pi.save()
+ self.assertEqual(pi.items[0].conversion_factor, 1000)
+
def check_gl_entries(doc, voucher_no, expected_gle, posting_date):
gl_entries = frappe.db.sql(
diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json
index 6651195..1f79d47 100644
--- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json
+++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json
@@ -195,6 +195,7 @@
"label": "Rejected Qty"
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
@@ -214,6 +215,7 @@
"reqd": 1
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
@@ -222,6 +224,7 @@
"reqd": 1
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "stock_qty",
"fieldtype": "Float",
"label": "Accepted Qty in Stock UOM",
@@ -871,7 +874,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2021-11-15 17:04:07.191013",
+ "modified": "2022-06-17 05:31:10.520171",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice Item",
@@ -879,5 +882,6 @@
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
index 80b95db..327545a 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json
@@ -1790,6 +1790,8 @@
"width": "50%"
},
{
+ "fetch_from": "sales_partner.commission_rate",
+ "fetch_if_empty": 1,
"fieldname": "commission_rate",
"fieldtype": "Float",
"hide_days": 1,
@@ -2038,7 +2040,7 @@
"link_fieldname": "consolidated_invoice"
}
],
- "modified": "2022-03-08 16:08:53.517903",
+ "modified": "2022-06-10 03:52:51.409913",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index a580d45..1a3164b 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -114,6 +114,7 @@
self.set_income_account_for_fixed_assets()
self.validate_item_cost_centers()
self.validate_income_account()
+ self.check_conversion_rate()
validate_inter_company_party(
self.doctype, self.customer, self.company, self.inter_company_invoice_reference
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index b8154dd..448ec54 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -792,6 +792,54 @@
jv.cancel()
self.assertEqual(frappe.db.get_value("Sales Invoice", w.name, "outstanding_amount"), 562.0)
+ def test_outstanding_on_cost_center_allocation(self):
+ # setup cost centers
+ from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
+ from erpnext.accounts.doctype.cost_center_allocation.test_cost_center_allocation import (
+ create_cost_center_allocation,
+ )
+
+ cost_centers = [
+ "Main Cost Center 1",
+ "Sub Cost Center 1",
+ "Sub Cost Center 2",
+ ]
+ for cc in cost_centers:
+ create_cost_center(cost_center_name=cc, company="_Test Company")
+
+ cca = create_cost_center_allocation(
+ "_Test Company",
+ "Main Cost Center 1 - _TC",
+ {"Sub Cost Center 1 - _TC": 60, "Sub Cost Center 2 - _TC": 40},
+ )
+
+ # make invoice
+ si = frappe.copy_doc(test_records[0])
+ si.is_pos = 0
+ si.insert()
+ si.submit()
+
+ from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
+
+ # make payment - fully paid
+ pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC")
+ pe.reference_no = "1"
+ pe.reference_date = nowdate()
+ pe.paid_from_account_currency = si.currency
+ pe.paid_to_account_currency = si.currency
+ pe.source_exchange_rate = 1
+ pe.target_exchange_rate = 1
+ pe.paid_amount = si.outstanding_amount
+ pe.cost_center = cca.main_cost_center
+ pe.insert()
+ pe.submit()
+
+ # cancel cost center allocation
+ cca.cancel()
+
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 0)
+
def test_sales_invoice_gl_entry_without_perpetual_inventory(self):
si = frappe.copy_doc(test_records[1])
si.insert()
@@ -1583,6 +1631,17 @@
self.assertTrue(gle)
+ def test_invoice_exchange_rate(self):
+ si = create_sales_invoice(
+ customer="_Test Customer USD",
+ debit_to="_Test Receivable USD - _TC",
+ currency="USD",
+ conversion_rate=1,
+ do_not_save=1,
+ )
+
+ self.assertRaises(frappe.ValidationError, si.save)
+
def test_invalid_currency(self):
# Customer currency = USD
diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json
index b3ba119..b417c7d 100644
--- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json
+++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json
@@ -182,6 +182,7 @@
"oldfieldtype": "Currency"
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
@@ -200,6 +201,7 @@
"reqd": 1
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
@@ -207,6 +209,7 @@
"reqd": 1
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "stock_qty",
"fieldtype": "Float",
"label": "Qty as per Stock UOM",
@@ -843,7 +846,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2022-03-23 08:18:04.928287",
+ "modified": "2022-06-17 05:33:15.335912",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",
diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py
index 2243b19..9dab4e9 100644
--- a/erpnext/accounts/doctype/subscription/subscription.py
+++ b/erpnext/accounts/doctype/subscription/subscription.py
@@ -145,13 +145,14 @@
You shouldn't need to call this directly. Use `get_billing_cycle` instead.
"""
plan_names = [plan.plan for plan in self.plans]
- billing_info = frappe.db.sql(
- "select distinct `billing_interval`, `billing_interval_count` "
- "from `tabSubscription Plan` "
- "where name in %s",
- (plan_names,),
- as_dict=1,
- )
+
+ subscription_plan = frappe.qb.DocType("Subscription Plan")
+ billing_info = (
+ frappe.qb.from_(subscription_plan)
+ .select(subscription_plan.billing_interval, subscription_plan.billing_interval_count)
+ .distinct()
+ .where(subscription_plan.name.isin(plan_names))
+ ).run(as_dict=1)
return billing_info
diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py
index b0513f1..76ef3ab 100644
--- a/erpnext/accounts/general_ledger.py
+++ b/erpnext/accounts/general_ledger.py
@@ -35,7 +35,13 @@
validate_disabled_accounts(gl_map)
gl_map = process_gl_map(gl_map, merge_entries)
if gl_map and len(gl_map) > 1:
- create_payment_ledger_entry(gl_map)
+ create_payment_ledger_entry(
+ gl_map,
+ cancel=0,
+ adv_adj=adv_adj,
+ update_outstanding=update_outstanding,
+ from_repost=from_repost,
+ )
save_entries(gl_map, adv_adj, update_outstanding, from_repost)
# Post GL Map proccess there may no be any GL Entries
elif gl_map:
@@ -126,7 +132,7 @@
for sub_cost_center, percentage in cost_center_allocation.get(cost_center, {}).items():
gle = copy.deepcopy(d)
gle.cost_center = sub_cost_center
- for field in ("debit", "credit", "debit_in_account_currency", "credit_in_company_currency"):
+ for field in ("debit", "credit", "debit_in_account_currency", "credit_in_account_currency"):
gle[field] = flt(flt(d.get(field)) * percentage / 100, precision)
new_gl_map.append(gle)
else:
@@ -482,6 +488,9 @@
if gl_entries:
create_payment_ledger_entry(gl_entries, cancel=1)
+ create_payment_ledger_entry(
+ gl_entries, cancel=1, adv_adj=adv_adj, update_outstanding=update_outstanding
+ )
validate_accounting_period(gl_entries)
check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"])
diff --git a/erpnext/accounts/module_onboarding/accounts/accounts.json b/erpnext/accounts/module_onboarding/accounts/accounts.json
index b9040e3..9916d16 100644
--- a/erpnext/accounts/module_onboarding/accounts/accounts.json
+++ b/erpnext/accounts/module_onboarding/accounts/accounts.json
@@ -13,7 +13,7 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/accounts",
"idx": 0,
"is_complete": 0,
- "modified": "2022-06-07 14:29:21.352132",
+ "modified": "2022-06-14 17:38:24.967834",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts",
diff --git a/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json b/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json
index b6e9f5c..e323f6c 100644
--- a/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json
+++ b/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json
@@ -2,14 +2,14 @@
"action": "Create Entry",
"action_label": "Manage Sales Tax Templates",
"creation": "2020-05-13 19:29:43.844463",
- "description": "# Setting up Taxes\n\nERPNext lets you configure your taxes so that they are automatically applied in your buying and selling transactions. You can configure them globally or even on Items. ERPNext taxes are pre-configured for most regions.\n\n[Checkout pre-configured taxes](/app/sales-taxes-and-charges-template)\n",
+ "description": "# Setting up Taxes\n\nERPNext lets you configure your taxes so that they are automatically applied in your buying and selling transactions. You can configure them globally or even on Items. ERPNext taxes are pre-configured for most regions.",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
- "modified": "2022-06-07 14:27:15.906286",
+ "modified": "2022-06-14 17:37:56.694261",
"modified_by": "Administrator",
"name": "Setup Taxes",
"owner": "Administrator",
diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py
index f4a44bd..e39f22b 100644
--- a/erpnext/accounts/party.py
+++ b/erpnext/accounts/party.py
@@ -211,7 +211,7 @@
else:
party_details.update(get_company_address(company))
- if doctype and doctype in ["Delivery Note", "Sales Invoice", "Sales Order"]:
+ if doctype and doctype in ["Delivery Note", "Sales Invoice", "Sales Order", "Quotation"]:
if party_details.company_address:
party_details.update(
get_fetch_values(doctype, "company_address", party_details.company_address)
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.html b/erpnext/accounts/report/accounts_receivable/accounts_receivable.html
index f4fd06b..f2bf942 100644
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.html
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.html
@@ -42,7 +42,7 @@
{% if(filters.show_future_payments) { %}
{% var balance_row = data.slice(-1).pop();
- var start = filters.based_on_payment_terms ? 13 : 11;
+ var start = report.columns.findIndex((elem) => (elem.fieldname == 'age'));
var range1 = report.columns[start].label;
var range2 = report.columns[start+1].label;
var range3 = report.columns[start+2].label;
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
index 748bcde..0238711 100644
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
@@ -173,11 +173,6 @@
"fieldtype": "Check",
},
{
- "fieldname": "show_remarks",
- "label": __("Show Remarks"),
- "fieldtype": "Check",
- },
- {
"fieldname": "tax_id",
"label": __("Tax Id"),
"fieldtype": "Data",
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
index de9d63d..1911152 100755
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
@@ -5,7 +5,9 @@
from collections import OrderedDict
import frappe
-from frappe import _, scrub
+from frappe import _, qb, scrub
+from frappe.query_builder import Criterion
+from frappe.query_builder.functions import Date
from frappe.utils import cint, cstr, flt, getdate, nowdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
@@ -41,6 +43,8 @@
class ReceivablePayableReport(object):
def __init__(self, filters=None):
self.filters = frappe._dict(filters or {})
+ self.qb_selection_filter = []
+ self.ple = qb.DocType("Payment Ledger Entry")
self.filters.report_date = getdate(self.filters.report_date or nowdate())
self.age_as_on = (
getdate(nowdate())
@@ -78,7 +82,7 @@
self.skip_total_row = 1
def get_data(self):
- self.get_gl_entries()
+ self.get_ple_entries()
self.get_sales_invoices_or_customers_based_on_sales_person()
self.voucher_balance = OrderedDict()
self.init_voucher_balance() # invoiced, paid, credit_note, outstanding
@@ -96,25 +100,25 @@
self.get_return_entries()
self.data = []
- for gle in self.gl_entries:
- self.update_voucher_balance(gle)
+
+ for ple in self.ple_entries:
+ self.update_voucher_balance(ple)
self.build_data()
def init_voucher_balance(self):
# build all keys, since we want to exclude vouchers beyond the report date
- for gle in self.gl_entries:
+ for ple in self.ple_entries:
# get the balance object for voucher_type
- key = (gle.voucher_type, gle.voucher_no, gle.party)
+ key = (ple.voucher_type, ple.voucher_no, ple.party)
if not key in self.voucher_balance:
self.voucher_balance[key] = frappe._dict(
- voucher_type=gle.voucher_type,
- voucher_no=gle.voucher_no,
- party=gle.party,
- party_account=gle.account,
- posting_date=gle.posting_date,
- account_currency=gle.account_currency,
- remarks=gle.remarks if self.filters.get("show_remarks") else None,
+ voucher_type=ple.voucher_type,
+ voucher_no=ple.voucher_no,
+ party=ple.party,
+ party_account=ple.account,
+ posting_date=ple.posting_date,
+ account_currency=ple.account_currency,
invoiced=0.0,
paid=0.0,
credit_note=0.0,
@@ -124,23 +128,22 @@
credit_note_in_account_currency=0.0,
outstanding_in_account_currency=0.0,
)
- self.get_invoices(gle)
if self.filters.get("group_by_party"):
- self.init_subtotal_row(gle.party)
+ self.init_subtotal_row(ple.party)
if self.filters.get("group_by_party"):
self.init_subtotal_row("Total")
- def get_invoices(self, gle):
- if gle.voucher_type in ("Sales Invoice", "Purchase Invoice"):
+ def get_invoices(self, ple):
+ if ple.voucher_type in ("Sales Invoice", "Purchase Invoice"):
if self.filters.get("sales_person"):
- if gle.voucher_no in self.sales_person_records.get(
+ if ple.voucher_no in self.sales_person_records.get(
"Sales Invoice", []
- ) or gle.party in self.sales_person_records.get("Customer", []):
- self.invoices.add(gle.voucher_no)
+ ) or ple.party in self.sales_person_records.get("Customer", []):
+ self.invoices.add(ple.voucher_no)
else:
- self.invoices.add(gle.voucher_no)
+ self.invoices.add(ple.voucher_no)
def init_subtotal_row(self, party):
if not self.total_row_map.get(party):
@@ -162,39 +165,49 @@
"range5",
]
- def update_voucher_balance(self, gle):
+ def get_voucher_balance(self, ple):
+ if self.filters.get("sales_person"):
+ if not (
+ ple.party in self.sales_person_records.get("Customer", [])
+ or ple.against_voucher_no in self.sales_person_records.get("Sales Invoice", [])
+ ):
+ return
+
+ key = (ple.against_voucher_type, ple.against_voucher_no, ple.party)
+ row = self.voucher_balance.get(key)
+ return row
+
+ def update_voucher_balance(self, ple):
# get the row where this balance needs to be updated
# if its a payment, it will return the linked invoice or will be considered as advance
- row = self.get_voucher_balance(gle)
+ row = self.get_voucher_balance(ple)
if not row:
return
- # gle_balance will be the total "debit - credit" for receivable type reports and
- # and vice-versa for payable type reports
- gle_balance = self.get_gle_balance(gle)
- gle_balance_in_account_currency = self.get_gle_balance_in_account_currency(gle)
- if gle_balance > 0:
- if gle.voucher_type in ("Journal Entry", "Payment Entry") and gle.against_voucher:
- # debit against sales / purchase invoice
- row.paid -= gle_balance
- row.paid_in_account_currency -= gle_balance_in_account_currency
+ amount = ple.amount
+ amount_in_account_currency = ple.amount_in_account_currency
+
+ # update voucher
+ if ple.amount > 0:
+ if (
+ ple.voucher_type in ["Journal Entry", "Payment Entry"]
+ and ple.voucher_no != ple.against_voucher_no
+ ):
+ row.paid -= amount
+ row.paid_in_account_currency -= amount_in_account_currency
else:
- # invoice
- row.invoiced += gle_balance
- row.invoiced_in_account_currency += gle_balance_in_account_currency
+ row.invoiced += amount
+ row.invoiced_in_account_currency += amount_in_account_currency
else:
- # payment or credit note for receivables
- if self.is_invoice(gle):
- # stand alone debit / credit note
- row.credit_note -= gle_balance
- row.credit_note_in_account_currency -= gle_balance_in_account_currency
+ if self.is_invoice(ple):
+ row.credit_note -= amount
+ row.credit_note_in_account_currency -= amount_in_account_currency
else:
- # advance / unlinked payment or other adjustment
- row.paid -= gle_balance
- row.paid_in_account_currency -= gle_balance_in_account_currency
+ row.paid -= amount
+ row.paid_in_account_currency -= amount_in_account_currency
- if gle.cost_center:
- row.cost_center = str(gle.cost_center)
+ if ple.cost_center:
+ row.cost_center = str(ple.cost_center)
def update_sub_total_row(self, row, party):
total_row = self.total_row_map.get(party)
@@ -210,39 +223,6 @@
self.data.append({})
self.update_sub_total_row(sub_total_row, "Total")
- def get_voucher_balance(self, gle):
- if self.filters.get("sales_person"):
- against_voucher = gle.against_voucher or gle.voucher_no
- if not (
- gle.party in self.sales_person_records.get("Customer", [])
- or against_voucher in self.sales_person_records.get("Sales Invoice", [])
- ):
- return
-
- voucher_balance = None
- if gle.against_voucher:
- # find invoice
- against_voucher = gle.against_voucher
-
- # If payment is made against credit note
- # and credit note is made against a Sales Invoice
- # then consider the payment against original sales invoice.
- if gle.against_voucher_type in ("Sales Invoice", "Purchase Invoice"):
- if gle.against_voucher in self.return_entries:
- return_against = self.return_entries.get(gle.against_voucher)
- if return_against:
- against_voucher = return_against
-
- voucher_balance = self.voucher_balance.get(
- (gle.against_voucher_type, against_voucher, gle.party)
- )
-
- if not voucher_balance:
- # no invoice, this is an invoice / stand-alone payment / credit note
- voucher_balance = self.voucher_balance.get((gle.voucher_type, gle.voucher_no, gle.party))
-
- return voucher_balance
-
def build_data(self):
# set outstanding for all the accumulated balances
# as we can use this to filter out invoices without outstanding
@@ -260,6 +240,7 @@
if (abs(row.outstanding) > 1.0 / 10**self.currency_precision) and (
abs(row.outstanding_in_account_currency) > 1.0 / 10**self.currency_precision
):
+
# non-zero oustanding, we must consider this row
if self.is_invoice(row) and self.filters.based_on_payment_terms:
@@ -669,48 +650,53 @@
index = 4
row["range" + str(index + 1)] = row.outstanding
- def get_gl_entries(self):
+ def get_ple_entries(self):
# get all the GL entries filtered by the given filters
- conditions, values = self.prepare_conditions()
- order_by = self.get_order_by_condition()
+ self.prepare_conditions()
if self.filters.show_future_payments:
- values.insert(2, self.filters.report_date)
-
- date_condition = """AND (posting_date <= %s
- OR (against_voucher IS NULL AND DATE(creation) <= %s))"""
+ self.qb_selection_filter.append(
+ (
+ self.ple.posting_date.lte(self.filters.report_date)
+ | (
+ (self.ple.voucher_no == self.ple.against_voucher_no)
+ & (Date(self.ple.creation).lte(self.filters.report_date))
+ )
+ )
+ )
else:
- date_condition = "AND posting_date <=%s"
+ self.qb_selection_filter.append(self.ple.posting_date.lte(self.filters.report_date))
- if self.filters.get(scrub(self.party_type)):
- select_fields = "debit_in_account_currency as debit, credit_in_account_currency as credit"
- else:
- select_fields = "debit, credit"
-
- doc_currency_fields = "debit_in_account_currency, credit_in_account_currency"
-
- remarks = ", remarks" if self.filters.get("show_remarks") else ""
-
- self.gl_entries = frappe.db.sql(
- """
- select
- name, posting_date, account, party_type, party, voucher_type, voucher_no, cost_center,
- against_voucher_type, against_voucher, account_currency, {0}, {1} {remarks}
- from
- `tabGL Entry`
- where
- docstatus < 2
- and is_cancelled = 0
- and party_type=%s
- and (party is not null and party != '')
- {2} {3} {4}""".format(
- select_fields, doc_currency_fields, date_condition, conditions, order_by, remarks=remarks
- ),
- values,
- as_dict=True,
+ ple = qb.DocType("Payment Ledger Entry")
+ query = (
+ qb.from_(ple)
+ .select(
+ ple.account,
+ ple.voucher_type,
+ ple.voucher_no,
+ ple.against_voucher_type,
+ ple.against_voucher_no,
+ ple.party_type,
+ ple.cost_center,
+ ple.party,
+ ple.posting_date,
+ ple.due_date,
+ ple.account_currency.as_("currency"),
+ ple.amount,
+ ple.amount_in_account_currency,
+ )
+ .where(ple.delinked == 0)
+ .where(Criterion.all(self.qb_selection_filter))
)
+ if self.filters.get("group_by_party"):
+ query = query.orderby(self.ple.party, self.ple.posting_date)
+ else:
+ query = query.orderby(self.ple.posting_date, self.ple.party)
+
+ self.ple_entries = query.run(as_dict=True)
+
def get_sales_invoices_or_customers_based_on_sales_person(self):
if self.filters.get("sales_person"):
lft, rgt = frappe.db.get_value("Sales Person", self.filters.get("sales_person"), ["lft", "rgt"])
@@ -731,23 +717,21 @@
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
def prepare_conditions(self):
- conditions = [""]
- values = [self.party_type, self.filters.report_date]
+ self.qb_selection_filter = []
party_type_field = scrub(self.party_type)
- self.add_common_filters(conditions, values, party_type_field)
+ self.add_common_filters(party_type_field=party_type_field)
if party_type_field == "customer":
- self.add_customer_filters(conditions, values)
+ self.add_customer_filters()
elif party_type_field == "supplier":
- self.add_supplier_filters(conditions, values)
+ self.add_supplier_filters()
if self.filters.cost_center:
- self.get_cost_center_conditions(conditions)
+ self.get_cost_center_conditions()
- self.add_accounting_dimensions_filters(conditions, values)
- return " and ".join(conditions), values
+ self.add_accounting_dimensions_filters()
def get_cost_center_conditions(self, conditions):
lft, rgt = frappe.db.get_value("Cost Center", self.filters.cost_center, ["lft", "rgt"])
@@ -755,32 +739,20 @@
center.name
for center in frappe.get_list("Cost Center", filters={"lft": (">=", lft), "rgt": ("<=", rgt)})
]
+ self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list))
- cost_center_string = '", "'.join(cost_center_list)
- conditions.append('cost_center in ("{0}")'.format(cost_center_string))
-
- def get_order_by_condition(self):
- if self.filters.get("group_by_party"):
- return "order by party, posting_date"
- else:
- return "order by posting_date, party"
-
- def add_common_filters(self, conditions, values, party_type_field):
+ def add_common_filters(self, party_type_field):
if self.filters.company:
- conditions.append("company=%s")
- values.append(self.filters.company)
+ self.qb_selection_filter.append(self.ple.company == self.filters.company)
if self.filters.finance_book:
- conditions.append("ifnull(finance_book, '') in (%s, '')")
- values.append(self.filters.finance_book)
+ self.qb_selection_filter.append(self.ple.finance_book == self.filters.finance_book)
if self.filters.get(party_type_field):
- conditions.append("party=%s")
- values.append(self.filters.get(party_type_field))
+ self.qb_selection_filter.append(self.ple.party == self.filters.get(party_type_field))
if self.filters.party_account:
- conditions.append("account =%s")
- values.append(self.filters.party_account)
+ self.qb_selection_filter.append(self.ple.account == self.filters.party_account)
else:
# get GL with "receivable" or "payable" account_type
account_type = "Receivable" if self.party_type == "Customer" else "Payable"
@@ -792,46 +764,68 @@
]
if accounts:
- conditions.append("account in (%s)" % ",".join(["%s"] * len(accounts)))
- values += accounts
+ self.qb_selection_filter.append(self.ple.account.isin(accounts))
- def add_customer_filters(self, conditions, values):
+ def add_customer_filters(
+ self,
+ ):
+ self.customter = qb.DocType("Customer")
+
if self.filters.get("customer_group"):
- conditions.append(self.get_hierarchical_filters("Customer Group", "customer_group"))
+ self.get_hierarchical_filters("Customer Group", "customer_group")
if self.filters.get("territory"):
- conditions.append(self.get_hierarchical_filters("Territory", "territory"))
+ self.get_hierarchical_filters("Territory", "territory")
if self.filters.get("payment_terms_template"):
- conditions.append("party in (select name from tabCustomer where payment_terms=%s)")
- values.append(self.filters.get("payment_terms_template"))
+ self.qb_selection_filter.append(
+ self.ple.party_isin(
+ qb.from_(self.customer).where(
+ self.customer.payment_terms == self.filters.get("payment_terms_template")
+ )
+ )
+ )
if self.filters.get("sales_partner"):
- conditions.append("party in (select name from tabCustomer where default_sales_partner=%s)")
- values.append(self.filters.get("sales_partner"))
-
- def add_supplier_filters(self, conditions, values):
- if self.filters.get("supplier_group"):
- conditions.append(
- """party in (select name from tabSupplier
- where supplier_group=%s)"""
+ self.qb_selection_filter.append(
+ self.ple.party_isin(
+ qb.from_(self.customer).where(
+ self.customer.default_sales_partner == self.filters.get("payment_terms_template")
+ )
+ )
)
- values.append(self.filters.get("supplier_group"))
+
+ def add_supplier_filters(self):
+ supplier = qb.DocType("Supplier")
+ if self.filters.get("supplier_group"):
+ self.qb_selection_filter.append(
+ self.ple.party.isin(
+ qb.from_(supplier)
+ .select(supplier.name)
+ .where(supplier.supplier_group == self.filters.get("supplier_group"))
+ )
+ )
if self.filters.get("payment_terms_template"):
- conditions.append("party in (select name from tabSupplier where payment_terms=%s)")
- values.append(self.filters.get("payment_terms_template"))
+ self.qb_selection_filter.append(
+ self.ple.party.isin(
+ qb.from_(supplier)
+ .select(supplier.name)
+ .where(supplier.payment_terms == self.filters.get("supplier_group"))
+ )
+ )
def get_hierarchical_filters(self, doctype, key):
lft, rgt = frappe.db.get_value(doctype, self.filters.get(key), ["lft", "rgt"])
- return """party in (select name from tabCustomer
- where exists(select name from `tab{doctype}` where lft >= {lft} and rgt <= {rgt}
- and name=tabCustomer.{key}))""".format(
- doctype=doctype, lft=lft, rgt=rgt, key=key
- )
+ doc = qb.DocType(doctype)
+ ple = self.ple
+ customer = self.customer
+ groups = qb.from_(doc).select(doc.name).where((doc.lft >= lft) & (doc.rgt <= rgt))
+ customers = qb.from_(customer).select(customer.name).where(customer[key].isin(groups))
+ self.qb_selection_filter.append(ple.isin(ple.party.isin(customers)))
- def add_accounting_dimensions_filters(self, conditions, values):
+ def add_accounting_dimensions_filters(self):
accounting_dimensions = get_accounting_dimensions(as_list=False)
if accounting_dimensions:
@@ -841,30 +835,16 @@
self.filters[dimension.fieldname] = get_dimension_with_children(
dimension.document_type, self.filters.get(dimension.fieldname)
)
- conditions.append("{0} in %s".format(dimension.fieldname))
- values.append(tuple(self.filters.get(dimension.fieldname)))
+ self.qb_selection_filter.append(
+ self.ple[dimension.fieldname].isin(self.filters[dimension.fieldname])
+ )
+ else:
+ self.qb_selection_filter.append(
+ self.ple[dimension.fieldname] == self.filters[dimension.fieldname]
+ )
- def get_gle_balance(self, gle):
- # get the balance of the GL (debit - credit) or reverse balance based on report type
- return gle.get(self.dr_or_cr) - self.get_reverse_balance(gle)
-
- def get_gle_balance_in_account_currency(self, gle):
- # get the balance of the GL (debit - credit) or reverse balance based on report type
- return gle.get(
- self.dr_or_cr + "_in_account_currency"
- ) - self.get_reverse_balance_in_account_currency(gle)
-
- def get_reverse_balance_in_account_currency(self, gle):
- return gle.get(
- "debit_in_account_currency" if self.dr_or_cr == "credit" else "credit_in_account_currency"
- )
-
- def get_reverse_balance(self, gle):
- # get "credit" balance if report type is "debit" and vice versa
- return gle.get("debit" if self.dr_or_cr == "credit" else "credit")
-
- def is_invoice(self, gle):
- if gle.voucher_type in ("Sales Invoice", "Purchase Invoice"):
+ def is_invoice(self, ple):
+ if ple.voucher_type in ("Sales Invoice", "Purchase Invoice"):
return True
def get_party_details(self, party):
@@ -926,9 +906,6 @@
width=180,
)
- if self.filters.show_remarks:
- self.add_column(label=_("Remarks"), fieldname="remarks", fieldtype="Text", width=200),
-
self.add_column(label="Due Date", fieldtype="Date")
if self.party_type == "Supplier":
diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
index f38890e..edddbbc 100644
--- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
@@ -12,6 +12,7 @@
def test_accounts_receivable(self):
frappe.db.sql("delete from `tabSales Invoice` where company='_Test Company 2'")
frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 2'")
+ frappe.db.sql("delete from `tabPayment Ledger Entry` where company='_Test Company 2'")
filters = {
"company": "_Test Company 2",
diff --git a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py
index 20f7643..9d2deea 100644
--- a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py
+++ b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py
@@ -43,7 +43,7 @@
"options": "Account",
"width": 170,
},
- {"label": _("Amount"), "fieldname": "amount", "width": 120},
+ {"label": _("Amount"), "fieldname": "amount", "fieldtype": "Currency", "width": 120},
]
return columns
diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js
index d3e836a..dd965a9 100644
--- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js
+++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js
@@ -50,7 +50,15 @@
"fieldtype": "Link",
"options": "Fiscal Year",
"default": frappe.defaults.get_user_default("fiscal_year"),
- "reqd": 1
+ "reqd": 1,
+ on_change: () => {
+ frappe.model.with_doc("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year'), function(r) {
+ let year_start_date = frappe.model.get_value("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year'), "year_start_date");
+ frappe.query_report.set_filter_value({
+ period_start_date: year_start_date
+ });
+ });
+ }
},
{
"fieldname":"to_fiscal_year",
@@ -58,7 +66,15 @@
"fieldtype": "Link",
"options": "Fiscal Year",
"default": frappe.defaults.get_user_default("fiscal_year"),
- "reqd": 1
+ "reqd": 1,
+ on_change: () => {
+ frappe.model.with_doc("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year'), function(r) {
+ let year_end_date = frappe.model.get_value("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year'), "year_end_date");
+ frappe.query_report.set_filter_value({
+ period_end_date: year_end_date
+ });
+ });
+ }
},
{
"fieldname":"finance_book",
diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py
index e4b561e..e77e828 100644
--- a/erpnext/accounts/report/general_ledger/general_ledger.py
+++ b/erpnext/accounts/report/general_ledger/general_ledger.py
@@ -425,7 +425,7 @@
update_value_in_dict(totals, "opening", gle)
update_value_in_dict(totals, "closing", gle)
- elif gle.posting_date <= to_date:
+ elif gle.posting_date <= to_date or (cstr(gle.is_opening) == "Yes" and show_opening_entries):
if not group_by_voucher_consolidated:
update_value_in_dict(gle_map[group_by_value].totals, "total", gle)
update_value_in_dict(gle_map[group_by_value].totals, "closing", gle)
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.js b/erpnext/accounts/report/gross_profit/gross_profit.js
index 158ff4d..3d37b58 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.js
+++ b/erpnext/accounts/report/gross_profit/gross_profit.js
@@ -35,7 +35,7 @@
"fieldname":"group_by",
"label": __("Group By"),
"fieldtype": "Select",
- "options": "Invoice\nItem Code\nItem Group\nBrand\nWarehouse\nCustomer\nCustomer Group\nTerritory\nSales Person\nProject",
+ "options": "Invoice\nItem Code\nItem Group\nBrand\nWarehouse\nCustomer\nCustomer Group\nTerritory\nSales Person\nProject\nMonthly\nPayment Term",
"default": "Invoice"
},
],
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py
index 9668992..526ea9d 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.py
+++ b/erpnext/accounts/report/gross_profit/gross_profit.py
@@ -4,7 +4,7 @@
import frappe
from frappe import _, scrub
-from frappe.utils import cint, flt
+from frappe.utils import cint, flt, formatdate
from erpnext.controllers.queries import get_match_cond
from erpnext.stock.utils import get_incoming_rate
@@ -124,6 +124,23 @@
"gross_profit",
"gross_profit_percent",
],
+ "monthly": [
+ "monthly",
+ "qty",
+ "base_rate",
+ "buying_rate",
+ "base_amount",
+ "buying_amount",
+ "gross_profit",
+ "gross_profit_percent",
+ ],
+ "payment_term": [
+ "payment_term",
+ "base_amount",
+ "buying_amount",
+ "gross_profit",
+ "gross_profit_percent",
+ ],
}
)
@@ -317,6 +334,19 @@
"options": "territory",
"width": 100,
},
+ "monthly": {
+ "label": _("Monthly"),
+ "fieldname": "monthly",
+ "fieldtype": "Data",
+ "width": 100,
+ },
+ "payment_term": {
+ "label": _("Payment Term"),
+ "fieldname": "payment_term",
+ "fieldtype": "Link",
+ "options": "Payment Term",
+ "width": 170,
+ },
}
)
@@ -390,6 +420,9 @@
buying_amount = 0
for row in reversed(self.si_list):
+ if self.filters.get("group_by") == "Monthly":
+ row.monthly = formatdate(row.posting_date, "MMM YYYY")
+
if self.skip_row(row):
continue
@@ -445,17 +478,7 @@
def get_average_rate_based_on_group_by(self):
for key in list(self.grouped):
- if self.filters.get("group_by") != "Invoice":
- for i, row in enumerate(self.grouped[key]):
- if i == 0:
- new_row = row
- else:
- new_row.qty += flt(row.qty)
- new_row.buying_amount += flt(row.buying_amount, self.currency_precision)
- new_row.base_amount += flt(row.base_amount, self.currency_precision)
- new_row = self.set_average_rate(new_row)
- self.grouped_data.append(new_row)
- else:
+ if self.filters.get("group_by") == "Invoice":
for i, row in enumerate(self.grouped[key]):
if row.indent == 1.0:
if (
@@ -469,6 +492,44 @@
if flt(row.qty) or row.base_amount:
row = self.set_average_rate(row)
self.grouped_data.append(row)
+ elif self.filters.get("group_by") == "Payment Term":
+ for i, row in enumerate(self.grouped[key]):
+ invoice_portion = 0
+
+ if row.is_return:
+ invoice_portion = 100
+ elif row.invoice_portion:
+ invoice_portion = row.invoice_portion
+ else:
+ invoice_portion = row.payment_amount * 100 / row.base_net_amount
+
+ if i == 0:
+ new_row = row
+ self.set_average_based_on_payment_term_portion(new_row, row, invoice_portion)
+ else:
+ new_row.qty += flt(row.qty)
+ self.set_average_based_on_payment_term_portion(new_row, row, invoice_portion, True)
+
+ new_row = self.set_average_rate(new_row)
+ self.grouped_data.append(new_row)
+ else:
+ for i, row in enumerate(self.grouped[key]):
+ if i == 0:
+ new_row = row
+ else:
+ new_row.qty += flt(row.qty)
+ new_row.buying_amount += flt(row.buying_amount, self.currency_precision)
+ new_row.base_amount += flt(row.base_amount, self.currency_precision)
+ new_row = self.set_average_rate(new_row)
+ self.grouped_data.append(new_row)
+
+ def set_average_based_on_payment_term_portion(self, new_row, row, invoice_portion, aggr=False):
+ cols = ["base_amount", "buying_amount", "gross_profit"]
+ for col in cols:
+ if aggr:
+ new_row[col] += row[col] * invoice_portion / 100
+ else:
+ new_row[col] = row[col] * invoice_portion / 100
def is_not_invoice_row(self, row):
return (self.filters.get("group_by") == "Invoice" and row.indent != 0.0) or self.filters.get(
@@ -622,6 +683,20 @@
sales_person_cols = ""
sales_team_table = ""
+ if self.filters.group_by == "Payment Term":
+ payment_term_cols = """,if(`tabSales Invoice`.is_return = 1,
+ '{0}',
+ coalesce(schedule.payment_term, '{1}')) as payment_term,
+ schedule.invoice_portion,
+ schedule.payment_amount """.format(
+ _("Sales Return"), _("No Terms")
+ )
+ payment_term_table = """ left join `tabPayment Schedule` schedule on schedule.parent = `tabSales Invoice`.name and
+ `tabSales Invoice`.is_return = 0 """
+ else:
+ payment_term_cols = ""
+ payment_term_table = ""
+
if self.filters.get("sales_invoice"):
conditions += " and `tabSales Invoice`.name = %(sales_invoice)s"
@@ -644,10 +719,12 @@
`tabSales Invoice Item`.name as "item_row", `tabSales Invoice`.is_return,
`tabSales Invoice Item`.cost_center
{sales_person_cols}
+ {payment_term_cols}
from
`tabSales Invoice` inner join `tabSales Invoice Item`
on `tabSales Invoice Item`.parent = `tabSales Invoice`.name
{sales_team_table}
+ {payment_term_table}
where
`tabSales Invoice`.docstatus=1 and `tabSales Invoice`.is_opening!='Yes' {conditions} {match_cond}
order by
@@ -655,6 +732,8 @@
conditions=conditions,
sales_person_cols=sales_person_cols,
sales_team_table=sales_team_table,
+ payment_term_cols=payment_term_cols,
+ payment_term_table=payment_term_table,
match_cond=get_match_cond("Sales Invoice"),
),
self.filters,
diff --git a/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py b/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py
index 1a00399..230b18c 100644
--- a/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py
+++ b/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py
@@ -100,7 +100,7 @@
sales_data = frappe.db.sql(
"""
select s.territory, s.customer, si.item_group, si.item_code, si.qty, {date_field} as last_order_date,
- DATEDIFF(CURDATE(), {date_field}) as days_since_last_order
+ DATEDIFF(CURRENT_DATE, {date_field}) as days_since_last_order
from `tab{doctype}` s, `tab{doctype} Item` si
where s.name = si.parent and s.docstatus = 1
order by days_since_last_order """.format( # nosec
diff --git a/erpnext/accounts/report/sales_payment_summary/sales_payment_summary.py b/erpnext/accounts/report/sales_payment_summary/sales_payment_summary.py
index 4eef307..0577214 100644
--- a/erpnext/accounts/report/sales_payment_summary/sales_payment_summary.py
+++ b/erpnext/accounts/report/sales_payment_summary/sales_payment_summary.py
@@ -179,7 +179,7 @@
def get_mode_of_payments(filters):
mode_of_payments = {}
invoice_list = get_invoices(filters)
- invoice_list_names = ",".join('"' + invoice["name"] + '"' for invoice in invoice_list)
+ invoice_list_names = ",".join("'" + invoice["name"] + "'" for invoice in invoice_list)
if invoice_list:
inv_mop = frappe.db.sql(
"""select a.owner,a.posting_date, ifnull(b.mode_of_payment, '') as mode_of_payment
@@ -200,7 +200,7 @@
from `tabJournal Entry` a, `tabJournal Entry Account` b
where a.name = b.parent
and a.docstatus = 1
- and b.reference_type = "Sales Invoice"
+ and b.reference_type = 'Sales Invoice'
and b.reference_name in ({invoice_list_names})
""".format(
invoice_list_names=invoice_list_names
@@ -228,7 +228,7 @@
def get_mode_of_payment_details(filters):
mode_of_payment_details = {}
invoice_list = get_invoices(filters)
- invoice_list_names = ",".join('"' + invoice["name"] + '"' for invoice in invoice_list)
+ invoice_list_names = ",".join("'" + invoice["name"] + "'" for invoice in invoice_list)
if invoice_list:
inv_mop_detail = frappe.db.sql(
"""
@@ -259,7 +259,7 @@
from `tabJournal Entry` a, `tabJournal Entry Account` b
where a.name = b.parent
and a.docstatus = 1
- and b.reference_type = "Sales Invoice"
+ and b.reference_type = 'Sales Invoice'
and b.reference_name in ({invoice_list_names})
group by a.owner, a.posting_date, mode_of_payment
) t
diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py
index e5a4ed2..6bd08ad 100644
--- a/erpnext/accounts/report/trial_balance/trial_balance.py
+++ b/erpnext/accounts/report/trial_balance/trial_balance.py
@@ -160,14 +160,12 @@
if filters.project:
additional_conditions += " and project = %(project)s"
- if filters.finance_book:
- fb_conditions = " AND finance_book = %(finance_book)s"
- if filters.include_default_book_entries:
- fb_conditions = (
- " AND (finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)"
- )
-
- additional_conditions += fb_conditions
+ if filters.get("include_default_book_entries"):
+ additional_conditions += (
+ " AND (finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)"
+ )
+ else:
+ additional_conditions += " AND (finance_book in (%(finance_book)s, '') OR finance_book IS NULL)"
accounting_dimensions = get_accounting_dimensions(as_list=False)
diff --git a/erpnext/accounts/test/test_utils.py b/erpnext/accounts/test/test_utils.py
index 77c40ba..882cd69 100644
--- a/erpnext/accounts/test/test_utils.py
+++ b/erpnext/accounts/test/test_utils.py
@@ -62,8 +62,8 @@
stock_entry = {"item": item, "to_warehouse": "_Test Warehouse - _TC", "qty": 1, "rate": 10}
se1 = make_stock_entry(posting_date="2022-01-01", **stock_entry)
- se2 = make_stock_entry(posting_date="2022-02-01", **stock_entry)
se3 = make_stock_entry(posting_date="2022-03-01", **stock_entry)
+ se2 = make_stock_entry(posting_date="2022-02-01", **stock_entry)
for doc in (se1, se2, se3):
vouchers.append((doc.doctype, doc.name))
diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py
index 1869cc7..8daff9d 100644
--- a/erpnext/accounts/utils.py
+++ b/erpnext/accounts/utils.py
@@ -3,13 +3,28 @@
from json import loads
-from typing import List, Tuple
+from typing import TYPE_CHECKING, List, Optional, Tuple
import frappe
import frappe.defaults
from frappe import _, qb, throw
from frappe.model.meta import get_field_precision
-from frappe.utils import cint, cstr, flt, formatdate, get_number_format_info, getdate, now, nowdate
+from frappe.query_builder import AliasedQuery, Criterion, Table
+from frappe.query_builder.functions import Sum
+from frappe.query_builder.utils import DocType
+from frappe.utils import (
+ cint,
+ create_batch,
+ cstr,
+ flt,
+ formatdate,
+ get_number_format_info,
+ getdate,
+ now,
+ nowdate,
+)
+from pypika import Order
+from pypika.terms import ExistsCriterion
import erpnext
@@ -19,6 +34,9 @@
from erpnext.stock import get_warehouse_account_map
from erpnext.stock.utils import get_stock_value_on
+if TYPE_CHECKING:
+ from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import RepostItemValuation
+
class FiscalYearError(frappe.ValidationError):
pass
@@ -28,6 +46,9 @@
pass
+GL_REPOSTING_CHUNK = 100
+
+
@frappe.whitelist()
def get_fiscal_year(
date=None, fiscal_year=None, label="Date", verbose=1, company=None, as_dict=False
@@ -42,37 +63,32 @@
if not fiscal_years:
# if year start date is 2012-04-01, year end date should be 2013-03-31 (hence subdate)
- cond = ""
- if fiscal_year:
- cond += " and fy.name = {0}".format(frappe.db.escape(fiscal_year))
- if company:
- cond += """
- and (not exists (select name
- from `tabFiscal Year Company` fyc
- where fyc.parent = fy.name)
- or exists(select company
- from `tabFiscal Year Company` fyc
- where fyc.parent = fy.name
- and fyc.company=%(company)s)
- )
- """
+ FY = DocType("Fiscal Year")
- fiscal_years = frappe.db.sql(
- """
- select
- fy.name, fy.year_start_date, fy.year_end_date
- from
- `tabFiscal Year` fy
- where
- disabled = 0 {0}
- order by
- fy.year_start_date desc""".format(
- cond
- ),
- {"company": company},
- as_dict=True,
+ query = (
+ frappe.qb.from_(FY)
+ .select(FY.name, FY.year_start_date, FY.year_end_date)
+ .where(FY.disabled == 0)
)
+ if fiscal_year:
+ query = query.where(FY.name == fiscal_year)
+
+ if company:
+ FYC = DocType("Fiscal Year Company")
+ query = query.where(
+ ExistsCriterion(frappe.qb.from_(FYC).select(FYC.name).where(FYC.parent == FY.name)).negate()
+ | ExistsCriterion(
+ frappe.qb.from_(FYC)
+ .select(FYC.company)
+ .where(FYC.parent == FY.name)
+ .where(FYC.company == company)
+ )
+ )
+
+ query = query.orderby(FY.year_start_date, Order.desc)
+ fiscal_years = query.run(as_dict=True)
+
frappe.cache().hset("fiscal_years", company, fiscal_years)
if not transaction_date and not fiscal_year:
@@ -423,7 +439,8 @@
# cancel advance entry
doc = frappe.get_doc(voucher_type, voucher_no)
frappe.flags.ignore_party_validation = True
- doc.make_gl_entries(cancel=1, adv_adj=1)
+ gl_map = doc.build_gl_map()
+ create_payment_ledger_entry(gl_map, cancel=1, adv_adj=1)
for entry in entries:
check_if_advance_entry_modified(entry)
@@ -438,7 +455,9 @@
doc.save(ignore_permissions=True)
# re-submit advance entry
doc = frappe.get_doc(entry.voucher_type, entry.voucher_no)
- doc.make_gl_entries(cancel=0, adv_adj=1)
+ gl_map = doc.build_gl_map()
+ create_payment_ledger_entry(gl_map, cancel=0, adv_adj=1)
+
frappe.flags.ignore_party_validation = False
if entry.voucher_type in ("Payment Entry", "Journal Entry"):
@@ -461,7 +480,7 @@
select t2.{dr_or_cr} from `tabJournal Entry` t1, `tabJournal Entry Account` t2
where t1.name = t2.parent and t2.account = %(account)s
and t2.party_type = %(party_type)s and t2.party = %(party)s
- and (t2.reference_type is null or t2.reference_type in ("", "Sales Order", "Purchase Order"))
+ and (t2.reference_type is null or t2.reference_type in ('', 'Sales Order', 'Purchase Order'))
and t1.name = %(voucher_no)s and t2.name = %(voucher_detail_no)s
and t1.docstatus=1 """.format(
dr_or_cr=args.get("dr_or_cr")
@@ -481,7 +500,7 @@
t1.name = t2.parent and t1.docstatus = 1
and t1.name = %(voucher_no)s and t2.name = %(voucher_detail_no)s
and t1.party_type = %(party_type)s and t1.party = %(party)s and t1.{0} = %(account)s
- and t2.reference_doctype in ("", "Sales Order", "Purchase Order")
+ and t2.reference_doctype in ('', 'Sales Order', 'Purchase Order')
and t2.allocated_amount = %(unreconciled_amount)s
""".format(
party_account_field
@@ -802,7 +821,11 @@
return held_invoices
-def get_outstanding_invoices(party_type, party, account, condition=None, filters=None):
+def get_outstanding_invoices(
+ party_type, party, account, common_filter=None, min_outstanding=None, max_outstanding=None
+):
+
+ ple = qb.DocType("Payment Ledger Entry")
outstanding_invoices = []
precision = frappe.get_precision("Sales Invoice", "outstanding_amount") or 2
@@ -815,76 +838,30 @@
else:
party_account_type = erpnext.get_party_account_type(party_type)
- if party_account_type == "Receivable":
- dr_or_cr = "debit_in_account_currency - credit_in_account_currency"
- payment_dr_or_cr = "credit_in_account_currency - debit_in_account_currency"
- else:
- dr_or_cr = "credit_in_account_currency - debit_in_account_currency"
- payment_dr_or_cr = "debit_in_account_currency - credit_in_account_currency"
-
held_invoices = get_held_invoices(party_type, party)
- invoice_list = frappe.db.sql(
- """
- select
- voucher_no, voucher_type, posting_date, due_date,
- ifnull(sum({dr_or_cr}), 0) as invoice_amount,
- account_currency as currency
- from
- `tabGL Entry`
- where
- party_type = %(party_type)s and party = %(party)s
- and account = %(account)s and {dr_or_cr} > 0
- and is_cancelled=0
- {condition}
- and ((voucher_type = 'Journal Entry'
- and (against_voucher = '' or against_voucher is null))
- or (voucher_type not in ('Journal Entry', 'Payment Entry')))
- group by voucher_type, voucher_no
- order by posting_date, name""".format(
- dr_or_cr=dr_or_cr, condition=condition or ""
- ),
- {
- "party_type": party_type,
- "party": party,
- "account": account,
- },
- as_dict=True,
- )
+ common_filter = common_filter or []
+ common_filter.append(ple.account_type == party_account_type)
+ common_filter.append(ple.account == account)
+ common_filter.append(ple.party_type == party_type)
+ common_filter.append(ple.party == party)
- payment_entries = frappe.db.sql(
- """
- select against_voucher_type, against_voucher,
- ifnull(sum({payment_dr_or_cr}), 0) as payment_amount
- from `tabGL Entry`
- where party_type = %(party_type)s and party = %(party)s
- and account = %(account)s
- and {payment_dr_or_cr} > 0
- and against_voucher is not null and against_voucher != ''
- and is_cancelled=0
- group by against_voucher_type, against_voucher
- """.format(
- payment_dr_or_cr=payment_dr_or_cr
- ),
- {"party_type": party_type, "party": party, "account": account},
- as_dict=True,
+ ple_query = QueryPaymentLedger()
+ invoice_list = ple_query.get_voucher_outstandings(
+ common_filter=common_filter,
+ min_outstanding=min_outstanding,
+ max_outstanding=max_outstanding,
+ get_invoices=True,
)
- pe_map = frappe._dict()
- for d in payment_entries:
- pe_map.setdefault((d.against_voucher_type, d.against_voucher), d.payment_amount)
-
for d in invoice_list:
- payment_amount = pe_map.get((d.voucher_type, d.voucher_no), 0)
- outstanding_amount = flt(d.invoice_amount - payment_amount, precision)
+ payment_amount = d.invoice_amount - d.outstanding
+ outstanding_amount = d.outstanding
if outstanding_amount > 0.5 / (10**precision):
if (
- filters
- and filters.get("outstanding_amt_greater_than")
- and not (
- outstanding_amount >= filters.get("outstanding_amt_greater_than")
- and outstanding_amount <= filters.get("outstanding_amt_less_than")
- )
+ min_outstanding
+ and max_outstanding
+ and not (outstanding_amount >= min_outstanding and outstanding_amount <= max_outstanding)
):
continue
@@ -1122,38 +1099,62 @@
def repost_gle_for_stock_vouchers(
- stock_vouchers, posting_date, company=None, warehouse_account=None
+ stock_vouchers: List[Tuple[str, str]],
+ posting_date: str,
+ company: Optional[str] = None,
+ warehouse_account=None,
+ repost_doc: Optional["RepostItemValuation"] = None,
):
+
+ from erpnext.accounts.general_ledger import toggle_debit_credit_if_negative
+
if not stock_vouchers:
return
- def _delete_gl_entries(voucher_type, voucher_no):
- frappe.db.sql(
- """delete from `tabGL Entry`
- where voucher_type=%s and voucher_no=%s""",
- (voucher_type, voucher_no),
- )
-
- stock_vouchers = sort_stock_vouchers_by_posting_date(stock_vouchers)
-
if not warehouse_account:
warehouse_account = get_warehouse_account_map(company)
+ stock_vouchers = sort_stock_vouchers_by_posting_date(stock_vouchers)
+ if repost_doc and repost_doc.gl_reposting_index:
+ # Restore progress
+ stock_vouchers = stock_vouchers[cint(repost_doc.gl_reposting_index) :]
+
precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit")) or 2
- gle = get_voucherwise_gl_entries(stock_vouchers, posting_date)
- for voucher_type, voucher_no in stock_vouchers:
- existing_gle = gle.get((voucher_type, voucher_no), [])
- voucher_obj = frappe.get_cached_doc(voucher_type, voucher_no)
- expected_gle = voucher_obj.get_gl_entries(warehouse_account)
- if expected_gle:
- if not existing_gle or not compare_existing_and_expected_gle(
- existing_gle, expected_gle, precision
- ):
+ for stock_vouchers_chunk in create_batch(stock_vouchers, GL_REPOSTING_CHUNK):
+ gle = get_voucherwise_gl_entries(stock_vouchers_chunk, posting_date)
+
+ for voucher_type, voucher_no in stock_vouchers_chunk:
+ existing_gle = gle.get((voucher_type, voucher_no), [])
+ voucher_obj = frappe.get_doc(voucher_type, voucher_no)
+ # Some transactions post credit as negative debit, this is handled while posting GLE
+ # but while comparing we need to make sure it's flipped so comparisons are accurate
+ expected_gle = toggle_debit_credit_if_negative(voucher_obj.get_gl_entries(warehouse_account))
+ if expected_gle:
+ if not existing_gle or not compare_existing_and_expected_gle(
+ existing_gle, expected_gle, precision
+ ):
+ _delete_gl_entries(voucher_type, voucher_no)
+ voucher_obj.make_gl_entries(gl_entries=expected_gle, from_repost=True)
+ else:
_delete_gl_entries(voucher_type, voucher_no)
- voucher_obj.make_gl_entries(gl_entries=expected_gle, from_repost=True)
- else:
- _delete_gl_entries(voucher_type, voucher_no)
+
+ if not frappe.flags.in_test:
+ frappe.db.commit()
+
+ if repost_doc:
+ repost_doc.db_set(
+ "gl_reposting_index",
+ cint(repost_doc.gl_reposting_index) + len(stock_vouchers_chunk),
+ )
+
+
+def _delete_gl_entries(voucher_type, voucher_no):
+ frappe.db.sql(
+ """delete from `tabGL Entry`
+ where voucher_type=%s and voucher_no=%s""",
+ (voucher_type, voucher_no),
+ )
def sort_stock_vouchers_by_posting_date(
@@ -1167,6 +1168,9 @@
.select(sle.voucher_type, sle.voucher_no, sle.posting_date, sle.posting_time, sle.creation)
.where((sle.is_cancelled == 0) & (sle.voucher_no.isin(voucher_nos)))
.groupby(sle.voucher_type, sle.voucher_no)
+ .orderby(sle.posting_date)
+ .orderby(sle.posting_time)
+ .orderby(sle.creation)
).run(as_dict=True)
sorted_vouchers = [(sle.voucher_type, sle.voucher_no) for sle in sles]
@@ -1348,7 +1352,9 @@
frappe.delete_doc("Desktop Icon", icon)
-def create_payment_ledger_entry(gl_entries, cancel=0):
+def create_payment_ledger_entry(
+ gl_entries, cancel=0, adv_adj=0, update_outstanding="Yes", from_repost=0
+):
if gl_entries:
ple = None
@@ -1421,9 +1427,42 @@
if cancel:
delink_original_entry(ple)
ple.flags.ignore_permissions = 1
+ ple.flags.adv_adj = adv_adj
+ ple.flags.from_repost = from_repost
+ ple.flags.update_outstanding = update_outstanding
ple.submit()
+def update_voucher_outstanding(voucher_type, voucher_no, account, party_type, party):
+ ple = frappe.qb.DocType("Payment Ledger Entry")
+ vouchers = [frappe._dict({"voucher_type": voucher_type, "voucher_no": voucher_no})]
+ common_filter = []
+ if account:
+ common_filter.append(ple.account == account)
+
+ if party_type:
+ common_filter.append(ple.party_type == party_type)
+
+ if party:
+ common_filter.append(ple.party == party)
+
+ ple_query = QueryPaymentLedger()
+
+ # on cancellation outstanding can be an empty list
+ voucher_outstanding = ple_query.get_voucher_outstandings(vouchers, common_filter=common_filter)
+ if voucher_type in ["Sales Invoice", "Purchase Invoice", "Fees"] and voucher_outstanding:
+ outstanding = voucher_outstanding[0]
+ ref_doc = frappe.get_doc(voucher_type, voucher_no)
+
+ # Didn't use db_set for optimisation purpose
+ ref_doc.outstanding_amount = outstanding["outstanding_in_account_currency"]
+ frappe.db.set_value(
+ voucher_type, voucher_no, "outstanding_amount", outstanding["outstanding_in_account_currency"]
+ )
+
+ ref_doc.set_status(update=True)
+
+
def delink_original_entry(pl_entry):
if pl_entry:
ple = qb.DocType("Payment Ledger Entry")
@@ -1445,3 +1484,196 @@
)
)
query.run()
+
+
+class QueryPaymentLedger(object):
+ """
+ Helper Class for Querying Payment Ledger Entry
+ """
+
+ def __init__(self):
+ self.ple = qb.DocType("Payment Ledger Entry")
+
+ # query result
+ self.voucher_outstandings = []
+
+ # query filters
+ self.vouchers = []
+ self.common_filter = []
+ self.min_outstanding = None
+ self.max_outstanding = None
+
+ def reset(self):
+ # clear filters
+ self.vouchers.clear()
+ self.common_filter.clear()
+ self.min_outstanding = self.max_outstanding = None
+
+ # clear result
+ self.voucher_outstandings.clear()
+
+ def query_for_outstanding(self):
+ """
+ Database query to fetch voucher amount and voucher outstanding using Common Table Expression
+ """
+
+ ple = self.ple
+
+ filter_on_voucher_no = []
+ filter_on_against_voucher_no = []
+ if self.vouchers:
+ voucher_types = set([x.voucher_type for x in self.vouchers])
+ voucher_nos = set([x.voucher_no for x in self.vouchers])
+
+ filter_on_voucher_no.append(ple.voucher_type.isin(voucher_types))
+ filter_on_voucher_no.append(ple.voucher_no.isin(voucher_nos))
+
+ filter_on_against_voucher_no.append(ple.against_voucher_type.isin(voucher_types))
+ filter_on_against_voucher_no.append(ple.against_voucher_no.isin(voucher_nos))
+
+ # build outstanding amount filter
+ filter_on_outstanding_amount = []
+ if self.min_outstanding:
+ if self.min_outstanding > 0:
+ filter_on_outstanding_amount.append(
+ Table("outstanding").amount_in_account_currency >= self.min_outstanding
+ )
+ else:
+ filter_on_outstanding_amount.append(
+ Table("outstanding").amount_in_account_currency <= self.min_outstanding
+ )
+ if self.max_outstanding:
+ if self.max_outstanding > 0:
+ filter_on_outstanding_amount.append(
+ Table("outstanding").amount_in_account_currency <= self.max_outstanding
+ )
+ else:
+ filter_on_outstanding_amount.append(
+ Table("outstanding").amount_in_account_currency >= self.max_outstanding
+ )
+
+ # build query for voucher amount
+ query_voucher_amount = (
+ qb.from_(ple)
+ .select(
+ ple.account,
+ ple.voucher_type,
+ ple.voucher_no,
+ ple.party_type,
+ ple.party,
+ ple.posting_date,
+ ple.due_date,
+ ple.account_currency.as_("currency"),
+ Sum(ple.amount).as_("amount"),
+ Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
+ )
+ .where(ple.delinked == 0)
+ .where(Criterion.all(filter_on_voucher_no))
+ .where(Criterion.all(self.common_filter))
+ .groupby(ple.voucher_type, ple.voucher_no, ple.party_type, ple.party)
+ )
+
+ # build query for voucher outstanding
+ query_voucher_outstanding = (
+ qb.from_(ple)
+ .select(
+ ple.account,
+ ple.against_voucher_type.as_("voucher_type"),
+ ple.against_voucher_no.as_("voucher_no"),
+ ple.party_type,
+ ple.party,
+ ple.posting_date,
+ ple.due_date,
+ ple.account_currency.as_("currency"),
+ Sum(ple.amount).as_("amount"),
+ Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
+ )
+ .where(ple.delinked == 0)
+ .where(Criterion.all(filter_on_against_voucher_no))
+ .where(Criterion.all(self.common_filter))
+ .groupby(ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
+ )
+
+ # build CTE for combining voucher amount and outstanding
+ self.cte_query_voucher_amount_and_outstanding = (
+ qb.with_(query_voucher_amount, "vouchers")
+ .with_(query_voucher_outstanding, "outstanding")
+ .from_(AliasedQuery("vouchers"))
+ .left_join(AliasedQuery("outstanding"))
+ .on(
+ (AliasedQuery("vouchers").account == AliasedQuery("outstanding").account)
+ & (AliasedQuery("vouchers").voucher_type == AliasedQuery("outstanding").voucher_type)
+ & (AliasedQuery("vouchers").voucher_no == AliasedQuery("outstanding").voucher_no)
+ & (AliasedQuery("vouchers").party_type == AliasedQuery("outstanding").party_type)
+ & (AliasedQuery("vouchers").party == AliasedQuery("outstanding").party)
+ )
+ .select(
+ Table("vouchers").account,
+ Table("vouchers").voucher_type,
+ Table("vouchers").voucher_no,
+ Table("vouchers").party_type,
+ Table("vouchers").party,
+ Table("vouchers").posting_date,
+ Table("vouchers").amount.as_("invoice_amount"),
+ Table("vouchers").amount_in_account_currency.as_("invoice_amount_in_account_currency"),
+ Table("outstanding").amount.as_("outstanding"),
+ Table("outstanding").amount_in_account_currency.as_("outstanding_in_account_currency"),
+ (Table("vouchers").amount - Table("outstanding").amount).as_("paid_amount"),
+ (
+ Table("vouchers").amount_in_account_currency - Table("outstanding").amount_in_account_currency
+ ).as_("paid_amount_in_account_currency"),
+ Table("vouchers").due_date,
+ Table("vouchers").currency,
+ )
+ .where(Criterion.all(filter_on_outstanding_amount))
+ )
+
+ # build CTE filter
+ # only fetch invoices
+ if self.get_invoices:
+ self.cte_query_voucher_amount_and_outstanding = (
+ self.cte_query_voucher_amount_and_outstanding.having(
+ qb.Field("outstanding_in_account_currency") > 0
+ )
+ )
+ # only fetch payments
+ elif self.get_payments:
+ self.cte_query_voucher_amount_and_outstanding = (
+ self.cte_query_voucher_amount_and_outstanding.having(
+ qb.Field("outstanding_in_account_currency") < 0
+ )
+ )
+
+ # execute SQL
+ self.voucher_outstandings = self.cte_query_voucher_amount_and_outstanding.run(as_dict=True)
+
+ def get_voucher_outstandings(
+ self,
+ vouchers=None,
+ common_filter=None,
+ min_outstanding=None,
+ max_outstanding=None,
+ get_payments=False,
+ get_invoices=False,
+ ):
+ """
+ Fetch voucher amount and outstanding amount from Payment Ledger using Database CTE
+
+ vouchers - dict of vouchers to get
+ common_filter - array of criterions
+ min_outstanding - filter on minimum total outstanding amount
+ max_outstanding - filter on maximum total outstanding amount
+ get_invoices - only fetch vouchers(ledger entries with +ve outstanding)
+ get_payments - only fetch payments(ledger entries with -ve outstanding)
+ """
+
+ self.reset()
+ self.vouchers = vouchers
+ self.common_filter = common_filter or []
+ self.min_outstanding = min_outstanding
+ self.max_outstanding = max_outstanding
+ self.get_payments = get_payments
+ self.get_invoices = get_invoices
+ self.query_for_outstanding()
+
+ return self.voucher_outstandings
diff --git a/erpnext/accounts/workspace/accounting/accounting.json b/erpnext/accounts/workspace/accounting/accounting.json
index a456c7f..61f6225 100644
--- a/erpnext/accounts/workspace/accounting/accounting.json
+++ b/erpnext/accounts/workspace/accounting/accounting.json
@@ -508,18 +508,6 @@
"dependencies": "GL Entry",
"hidden": 0,
"is_query_report": 1,
- "label": "DATEV Export",
- "link_count": 0,
- "link_to": "DATEV",
- "link_type": "Report",
- "onboard": 0,
- "only_for": "Germany",
- "type": "Link"
- },
- {
- "dependencies": "GL Entry",
- "hidden": 0,
- "is_query_report": 1,
"label": "UAE VAT 201",
"link_count": 0,
"link_to": "UAE VAT 201",
@@ -1024,16 +1012,16 @@
"type": "Link"
},
{
- "dependencies": "Cost Center",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Cost Center Allocation",
- "link_count": 0,
- "link_to": "Cost Center Allocation",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
+ "dependencies": "Cost Center",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Cost Center Allocation",
+ "link_count": 0,
+ "link_to": "Cost Center Allocation",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
{
"dependencies": "Cost Center",
"hidden": 0,
@@ -1235,13 +1223,14 @@
"type": "Link"
}
],
- "modified": "2022-01-13 17:25:09.835345",
+ "modified": "2022-06-10 15:49:42.990860",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounting",
"owner": "Administrator",
"parent_page": "",
"public": 1,
+ "quick_lists": [],
"restrict_to_domain": "",
"roles": [],
"sequence_id": 2.0,
diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py
index 257488d..a880c2f 100644
--- a/erpnext/assets/doctype/asset/asset.py
+++ b/erpnext/assets/doctype/asset/asset.py
@@ -252,6 +252,7 @@
number_of_pending_depreciations += 1
skip_row = False
+ should_get_last_day = is_last_day_of_the_month(finance_book.depreciation_start_date)
for n in range(start[finance_book.idx - 1], number_of_pending_depreciations):
# If depreciation is already completed (for double declining balance)
@@ -265,6 +266,9 @@
finance_book.depreciation_start_date, n * cint(finance_book.frequency_of_depreciation)
)
+ if should_get_last_day:
+ schedule_date = get_last_day(schedule_date)
+
# schedule date will be a year later from start date
# so monthly schedule date is calculated by removing 11 months from it
monthly_schedule_date = add_months(schedule_date, -finance_book.frequency_of_depreciation + 1)
@@ -849,14 +853,9 @@
if args.get("rate_of_depreciation") and on_validate:
return args.get("rate_of_depreciation")
- no_of_years = (
- flt(args.get("total_number_of_depreciations") * flt(args.get("frequency_of_depreciation")))
- / 12
- )
value = flt(args.get("expected_value_after_useful_life")) / flt(self.gross_purchase_amount)
- # square root of flt(salvage_value) / flt(asset_cost)
- depreciation_rate = math.pow(value, 1.0 / flt(no_of_years, 2))
+ depreciation_rate = math.pow(value, 1.0 / flt(args.get("total_number_of_depreciations"), 2))
return 100 * (1 - flt(depreciation_rate, float_precision))
@@ -1105,9 +1104,18 @@
def get_total_days(date, frequency):
period_start_date = add_months(date, cint(frequency) * -1)
+ if is_last_day_of_the_month(date):
+ period_start_date = get_last_day(period_start_date)
+
return date_diff(date, period_start_date)
+def is_last_day_of_the_month(date):
+ last_day_of_the_month = get_last_day(date)
+
+ return getdate(last_day_of_the_month) == getdate(date)
+
+
@erpnext.allow_regional
def get_depreciation_amount(asset, depreciable_value, row):
if row.depreciation_method in ("Straight Line", "Manual"):
diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py
index e759ad0..f8a8fc5 100644
--- a/erpnext/assets/doctype/asset/test_asset.py
+++ b/erpnext/assets/doctype/asset/test_asset.py
@@ -707,6 +707,39 @@
self.assertEqual(schedules, expected_schedules)
+ def test_monthly_depreciation_by_wdv_method(self):
+ asset = create_asset(
+ calculate_depreciation=1,
+ available_for_use_date="2022-02-15",
+ purchase_date="2022-02-15",
+ depreciation_method="Written Down Value",
+ gross_purchase_amount=10000,
+ expected_value_after_useful_life=5000,
+ depreciation_start_date="2022-02-28",
+ total_number_of_depreciations=5,
+ frequency_of_depreciation=1,
+ )
+
+ expected_schedules = [
+ ["2022-02-28", 645.0, 645.0],
+ ["2022-03-31", 1206.8, 1851.8],
+ ["2022-04-30", 1051.12, 2902.92],
+ ["2022-05-31", 915.52, 3818.44],
+ ["2022-06-30", 797.42, 4615.86],
+ ["2022-07-15", 384.14, 5000.0],
+ ]
+
+ schedules = [
+ [
+ cstr(d.schedule_date),
+ flt(d.depreciation_amount, 2),
+ flt(d.accumulated_depreciation_amount, 2),
+ ]
+ for d in asset.get("schedules")
+ ]
+
+ self.assertEqual(schedules, expected_schedules)
+
def test_discounted_wdv_depreciation_rate_for_indian_region(self):
# set indian company
company_flag = frappe.flags.company
@@ -838,7 +871,7 @@
expected_values = [["2020-12-31", 30000.0], ["2021-12-31", 30000.0], ["2022-12-31", 30000.0]]
for i, schedule in enumerate(asset.schedules):
- self.assertEqual(expected_values[i][0], schedule.schedule_date)
+ self.assertEqual(getdate(expected_values[i][0]), schedule.schedule_date)
self.assertEqual(expected_values[i][1], schedule.depreciation_amount)
def test_set_accumulated_depreciation(self):
@@ -1333,6 +1366,32 @@
asset.cost_center = "Main - _TC"
asset.submit()
+ def test_depreciation_on_final_day_of_the_month(self):
+ """Tests if final day of the month is picked each time, if the depreciation start date is the last day of the month."""
+
+ asset = create_asset(
+ item_code="Macbook Pro",
+ calculate_depreciation=1,
+ purchase_date="2020-01-30",
+ available_for_use_date="2020-02-15",
+ depreciation_start_date="2020-02-29",
+ frequency_of_depreciation=1,
+ total_number_of_depreciations=5,
+ submit=1,
+ )
+
+ expected_dates = [
+ "2020-02-29",
+ "2020-03-31",
+ "2020-04-30",
+ "2020-05-31",
+ "2020-06-30",
+ "2020-07-15",
+ ]
+
+ for i, schedule in enumerate(asset.schedules):
+ self.assertEqual(getdate(expected_dates[i]), getdate(schedule.schedule_date))
+
def create_asset_data():
if not frappe.db.exists("Asset Category", "Computers"):
diff --git a/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py b/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py
index e603d34..0028d84 100644
--- a/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py
+++ b/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py
@@ -47,17 +47,19 @@
team_member = frappe.db.get_value("User", assign_to_member, "email")
args = {
"doctype": "Asset Maintenance",
- "assign_to": [team_member],
+ "assign_to": team_member,
"name": asset_maintenance_name,
"description": maintenance_task,
"date": next_due_date,
}
if not frappe.db.sql(
"""select owner from `tabToDo`
- where reference_type=%(doctype)s and reference_name=%(name)s and status="Open"
+ where reference_type=%(doctype)s and reference_name=%(name)s and status='Open'
and owner=%(assign_to)s""",
args,
):
+ # assign_to function expects a list
+ args["assign_to"] = [args["assign_to"]]
assign_to.add(args)
diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
index 1a7f2dd..5f84de6 100644
--- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
@@ -140,6 +140,43 @@
# ordered qty decreases as ordered qty is 0 (deleted row)
self.assertEqual(get_ordered_qty(), existing_ordered_qty - 10) # 0
+ def test_supplied_items_validations_on_po_update_after_submit(self):
+ po = create_purchase_order(item_code="_Test FG Item", is_subcontracted=1, qty=5, rate=100)
+ item = po.items[0]
+
+ original_supplied_items = {po.name: po.required_qty for po in po.supplied_items}
+
+ # Just update rate
+ trans_item = [
+ {
+ "item_code": "_Test FG Item",
+ "rate": 20,
+ "qty": 5,
+ "conversion_factor": 1.0,
+ "docname": item.name,
+ }
+ ]
+ update_child_qty_rate("Purchase Order", json.dumps(trans_item), po.name)
+ po.reload()
+
+ new_supplied_items = {po.name: po.required_qty for po in po.supplied_items}
+ self.assertEqual(set(original_supplied_items.keys()), set(new_supplied_items.keys()))
+
+ # Update qty to 2x
+ trans_item[0]["qty"] *= 2
+ update_child_qty_rate("Purchase Order", json.dumps(trans_item), po.name)
+ po.reload()
+
+ new_supplied_items = {po.name: po.required_qty for po in po.supplied_items}
+ self.assertEqual(2 * sum(original_supplied_items.values()), sum(new_supplied_items.values()))
+
+ # Set transfer qty and attempt to update qty, shouldn't be allowed
+ po.supplied_items[0].supplied_qty = 2
+ po.supplied_items[0].db_update()
+ trans_item[0]["qty"] *= 2
+ with self.assertRaises(frappe.ValidationError):
+ update_child_qty_rate("Purchase Order", json.dumps(trans_item), po.name)
+
def test_update_child(self):
mr = make_material_request(qty=10)
po = make_purchase_order(mr.name)
@@ -330,7 +367,7 @@
else:
# update valid from
frappe.db.sql(
- """UPDATE `tabItem Tax` set valid_from = CURDATE()
+ """UPDATE `tabItem Tax` set valid_from = CURRENT_DATE
where parent = %(item)s and item_tax_template = %(tax)s""",
{"item": item, "tax": tax_template},
)
diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
index f72c598..7994b08 100644
--- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
+++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
@@ -213,6 +213,7 @@
"width": "60px"
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
@@ -242,6 +243,7 @@
"reqd": 1
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
@@ -593,6 +595,7 @@
"label": "Billed, Received & Returned"
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "stock_qty",
"fieldtype": "Float",
"label": "Qty in Stock UOM",
@@ -851,7 +854,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-02-02 13:10:18.398976",
+ "modified": "2022-06-17 05:29:40.602349",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",
diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
index d39aec1..67affe7 100644
--- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
+++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
@@ -285,7 +285,7 @@
"""select `tabContact`.name from `tabContact`, `tabDynamic Link`
where `tabDynamic Link`.link_doctype = 'Supplier' and (`tabDynamic Link`.link_name=%(name)s
and `tabDynamic Link`.link_name like %(txt)s) and `tabContact`.name = `tabDynamic Link`.parent
- limit %(start)s, %(page_len)s""",
+ limit %(page_len)s offset %(start)s""",
{"start": start, "page_len": page_len, "txt": "%%%s%%" % txt, "name": filters.get("supplier")},
)
diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py
index 97d0ba0..43152e8 100644
--- a/erpnext/buying/doctype/supplier/supplier.py
+++ b/erpnext/buying/doctype/supplier/supplier.py
@@ -84,6 +84,9 @@
self.save()
def validate_internal_supplier(self):
+ if not self.is_internal_supplier:
+ self.represents_company = ""
+
internal_supplier = frappe.db.get_value(
"Supplier",
{
diff --git a/erpnext/buying/report/procurement_tracker/procurement_tracker.py b/erpnext/buying/report/procurement_tracker/procurement_tracker.py
index e0b02ee..d70ac46 100644
--- a/erpnext/buying/report/procurement_tracker/procurement_tracker.py
+++ b/erpnext/buying/report/procurement_tracker/procurement_tracker.py
@@ -252,7 +252,7 @@
ON pi_item.`purchase_order` = po.`name`
WHERE
pi_item.docstatus = 1
- AND po.status not in ("Closed","Completed","Cancelled")
+ AND po.status not in ('Closed','Completed','Cancelled')
AND pi_item.po_detail IS NOT NULL
"""
)
@@ -271,7 +271,7 @@
pr.docstatus=1
AND pr.name=pr_item.parent
AND pr_item.purchase_order_item IS NOT NULL
- AND pr.status not in ("Closed","Completed","Cancelled")
+ AND pr.status not in ('Closed','Completed','Cancelled')
"""
)
)
@@ -302,7 +302,7 @@
WHERE
parent.docstatus = 1
AND parent.name = child.parent
- AND parent.status not in ("Closed","Completed","Cancelled")
+ AND parent.status not in ('Closed','Completed','Cancelled')
{conditions}
GROUP BY
parent.name, child.item_code
diff --git a/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.js b/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.js
index ca3be03..721e54e 100644
--- a/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.js
+++ b/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.js
@@ -59,6 +59,7 @@
for (let option of status){
options.push({
"value": option,
+ "label": __(option),
"description": ""
})
}
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 854c0d0..ceac815 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -46,6 +46,7 @@
from erpnext.controllers.sales_and_purchase_return import validate_return
from erpnext.exceptions import InvalidCurrency
from erpnext.setup.utils import get_exchange_rate
+from erpnext.stock.doctype.item.item import get_uom_conv_factor
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
from erpnext.stock.get_item_details import (
_get_item_tax_template,
@@ -548,6 +549,15 @@
if ret.get("pricing_rules"):
self.apply_pricing_rule_on_items(item, ret)
self.set_pricing_rule_details(item, ret)
+ else:
+ # Transactions line item without item code
+
+ uom = item.get("uom")
+ stock_uom = item.get("stock_uom")
+ if bool(uom) != bool(stock_uom): # xor
+ item.stock_uom = item.uom = uom or stock_uom
+
+ item.conversion_factor = get_uom_conv_factor(item.get("uom"), item.get("stock_uom"))
if self.doctype == "Purchase Invoice":
self.set_expense_account(for_validate)
@@ -1838,6 +1848,17 @@
jv.save()
jv.submit()
+ def check_conversion_rate(self):
+ default_currency = erpnext.get_company_currency(self.company)
+ if not default_currency:
+ throw(_("Please enter default currency in Company Master"))
+ if (
+ (self.currency == default_currency and flt(self.conversion_rate) != 1.00)
+ or not self.conversion_rate
+ or (self.currency != default_currency and flt(self.conversion_rate) == 1.00)
+ ):
+ throw(_("Conversion rate cannot be 0 or 1"))
+
@frappe.whitelist()
def get_tax_rate(account_head):
@@ -2039,7 +2060,7 @@
journal_entries = frappe.db.sql(
"""
select
- "Journal Entry" as reference_type, t1.name as reference_name,
+ 'Journal Entry' as reference_type, t1.name as reference_name,
t1.remark as remarks, t2.{0} as amount, t2.name as reference_row,
t2.reference_name as against_order, t2.exchange_rate
from
@@ -2094,7 +2115,7 @@
payment_entries_against_order = frappe.db.sql(
"""
select
- "Payment Entry" as reference_type, t1.name as reference_name,
+ 'Payment Entry' as reference_type, t1.name as reference_name,
t1.remarks, t2.allocated_amount as amount, t2.name as reference_row,
t2.reference_name as against_order, t1.posting_date,
t1.{0} as currency, t1.{4} as exchange_rate
@@ -2114,7 +2135,7 @@
if include_unallocated:
unallocated_payment_entries = frappe.db.sql(
"""
- select "Payment Entry" as reference_type, name as reference_name, posting_date,
+ select 'Payment Entry' as reference_type, name as reference_name, posting_date,
remarks, unallocated_amount as amount, {2} as exchange_rate, {3} as currency
from `tabPayment Entry`
where
@@ -2419,7 +2440,7 @@
update_bin_qty(row.item_code, row.warehouse, qty_dict)
-def validate_and_delete_children(parent, data):
+def validate_and_delete_children(parent, data) -> bool:
deleted_children = []
updated_item_names = [d.get("docname") for d in data]
for item in parent.items:
@@ -2438,6 +2459,8 @@
for d in deleted_children:
update_bin_on_delete(d, parent.doctype)
+ return bool(deleted_children)
+
@frappe.whitelist()
def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"):
@@ -2501,13 +2524,38 @@
):
frappe.throw(_("Cannot set quantity less than received quantity"))
+ def should_update_supplied_items(doc) -> bool:
+ """Subcontracted PO can allow following changes *after submit*:
+
+ 1. Change rate of subcontracting - regardless of other changes.
+ 2. Change qty and/or add new items and/or remove items
+ Exception: Transfer/Consumption is already made, qty change not allowed.
+ """
+
+ supplied_items_processed = any(
+ item.supplied_qty or item.consumed_qty or item.returned_qty for item in doc.supplied_items
+ )
+
+ update_supplied_items = (
+ any_qty_changed or items_added_or_removed or any_conversion_factor_changed
+ )
+ if update_supplied_items and supplied_items_processed:
+ frappe.throw(_("Item qty can not be updated as raw materials are already processed."))
+
+ return update_supplied_items
+
data = json.loads(trans_items)
+ any_qty_changed = False # updated to true if any item's qty changes
+ items_added_or_removed = False # updated to true if any new item is added or removed
+ any_conversion_factor_changed = False
+
sales_doctypes = ["Sales Order", "Sales Invoice", "Delivery Note", "Quotation"]
parent = frappe.get_doc(parent_doctype, parent_doctype_name)
check_doc_permissions(parent, "write")
- validate_and_delete_children(parent, data)
+ _removed_items = validate_and_delete_children(parent, data)
+ items_added_or_removed |= _removed_items
for d in data:
new_child_flag = False
@@ -2518,6 +2566,7 @@
if not d.get("docname"):
new_child_flag = True
+ items_added_or_removed = True
check_doc_permissions(parent, "create")
child_item = get_new_child_item(d)
else:
@@ -2540,6 +2589,7 @@
qty_unchanged = prev_qty == new_qty
uom_unchanged = prev_uom == new_uom
conversion_factor_unchanged = prev_con_fac == new_con_fac
+ any_conversion_factor_changed |= not conversion_factor_unchanged
date_unchanged = (
prev_date == getdate(new_date) if prev_date and new_date else False
) # in case of delivery note etc
@@ -2553,6 +2603,8 @@
continue
validate_quantity(child_item, d)
+ if flt(child_item.get("qty")) != flt(d.get("qty")):
+ any_qty_changed = True
child_item.qty = flt(d.get("qty"))
rate_precision = child_item.precision("rate") or 2
@@ -2658,8 +2710,9 @@
parent.update_ordered_and_reserved_qty()
parent.update_receiving_percentage()
if parent.is_subcontracted:
- parent.update_reserved_qty_for_subcontract()
- parent.create_raw_materials_supplied("supplied_items")
+ if should_update_supplied_items(parent):
+ parent.update_reserved_qty_for_subcontract()
+ parent.create_raw_materials_supplied("supplied_items")
parent.save()
else: # Sales Order
parent.validate_warehouse()
diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py
index eeb5a7f..243ebb6 100644
--- a/erpnext/controllers/queries.py
+++ b/erpnext/controllers/queries.py
@@ -29,11 +29,11 @@
or employee_name like %(txt)s)
{fcond} {mcond}
order by
- if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999),
- if(locate(%(_txt)s, employee_name), locate(%(_txt)s, employee_name), 99999),
+ (case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end),
+ (case when locate(%(_txt)s, employee_name) > 0 then locate(%(_txt)s, employee_name) else 99999 end),
idx desc,
name, employee_name
- limit %(start)s, %(page_len)s""".format(
+ limit %(page_len)s offset %(start)s""".format(
**{
"fields": ", ".join(fields),
"key": searchfield,
@@ -60,12 +60,12 @@
or company_name like %(txt)s)
{mcond}
order by
- if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999),
- if(locate(%(_txt)s, lead_name), locate(%(_txt)s, lead_name), 99999),
- if(locate(%(_txt)s, company_name), locate(%(_txt)s, company_name), 99999),
+ (case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end),
+ (case when locate(%(_txt)s, lead_name) > 0 then locate(%(_txt)s, lead_name) else 99999 end),
+ (case when locate(%(_txt)s, company_name) > 0 then locate(%(_txt)s, company_name) else 99999 end),
idx desc,
name, lead_name
- limit %(start)s, %(page_len)s""".format(
+ limit %(page_len)s offset %(start)s""".format(
**{"fields": ", ".join(fields), "key": searchfield, "mcond": get_match_cond(doctype)}
),
{"txt": "%%%s%%" % txt, "_txt": txt.replace("%", ""), "start": start, "page_len": page_len},
@@ -96,11 +96,11 @@
and ({scond}) and disabled=0
{fcond} {mcond}
order by
- if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999),
- if(locate(%(_txt)s, customer_name), locate(%(_txt)s, customer_name), 99999),
+ (case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end),
+ (case when locate(%(_txt)s, customer_name) > 0 then locate(%(_txt)s, customer_name) else 99999 end),
idx desc,
name, customer_name
- limit %(start)s, %(page_len)s""".format(
+ limit %(page_len)s offset %(start)s""".format(
**{
"fields": ", ".join(fields),
"scond": searchfields,
@@ -130,14 +130,14 @@
where docstatus < 2
and ({key} like %(txt)s
or supplier_name like %(txt)s) and disabled=0
- and (on_hold = 0 or (on_hold = 1 and CURDATE() > release_date))
+ and (on_hold = 0 or (on_hold = 1 and CURRENT_DATE > release_date))
{mcond}
order by
- if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999),
- if(locate(%(_txt)s, supplier_name), locate(%(_txt)s, supplier_name), 99999),
+ (case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end),
+ (case when locate(%(_txt)s, supplier_name) > 0 then locate(%(_txt)s, supplier_name) else 99999 end),
idx desc,
name, supplier_name
- limit %(start)s, %(page_len)s """.format(
+ limit %(page_len)s offset %(start)s""".format(
**{"field": ", ".join(fields), "key": searchfield, "mcond": get_match_cond(doctype)}
),
{"txt": "%%%s%%" % txt, "_txt": txt.replace("%", ""), "start": start, "page_len": page_len},
@@ -167,7 +167,7 @@
AND `{searchfield}` LIKE %(txt)s
{mcond}
ORDER BY idx DESC, name
- LIMIT %(offset)s, %(limit)s
+ LIMIT %(limit)s offset %(offset)s
""".format(
account_type_condition=account_type_condition,
searchfield=searchfield,
@@ -305,15 +305,15 @@
return frappe.db.sql(
"""select {fields}
- from tabBOM
- where tabBOM.docstatus=1
- and tabBOM.is_active=1
- and tabBOM.`{key}` like %(txt)s
+ from `tabBOM`
+ where `tabBOM`.docstatus=1
+ and `tabBOM`.is_active=1
+ and `tabBOM`.`{key}` like %(txt)s
{fcond} {mcond}
order by
- if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999),
+ (case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end),
idx desc, name
- limit %(start)s, %(page_len)s """.format(
+ limit %(page_len)s offset %(start)s""".format(
fields=", ".join(fields),
fcond=get_filters_cond(doctype, filters, conditions).replace("%", "%%"),
mcond=get_match_cond(doctype).replace("%", "%%"),
@@ -340,18 +340,18 @@
fields = get_fields("Project", ["name", "project_name"])
searchfields = frappe.get_meta("Project").get_search_fields()
- searchfields = " or ".join([field + " like %(txt)s" for field in searchfields])
+ searchfields = " or ".join(["`tabProject`." + field + " like %(txt)s" for field in searchfields])
return frappe.db.sql(
"""select {fields} from `tabProject`
where
- `tabProject`.status not in ("Completed", "Cancelled")
+ `tabProject`.status not in ('Completed', 'Cancelled')
and {cond} {scond} {match_cond}
order by
- if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999),
- idx desc,
+ (case when locate(%(_txt)s, `tabProject`.name) > 0 then locate(%(_txt)s, `tabProject`.name) else 99999 end),
+ `tabProject`.idx desc,
`tabProject`.name asc
- limit {start}, {page_len}""".format(
+ limit {page_len} offset {start}""".format(
fields=", ".join(["`tabProject`.{0}".format(f) for f in fields]),
cond=cond,
scond=searchfields,
@@ -374,7 +374,7 @@
from `tabDelivery Note`
where `tabDelivery Note`.`%(key)s` like %(txt)s and
`tabDelivery Note`.docstatus = 1
- and status not in ("Stopped", "Closed") %(fcond)s
+ and status not in ('Stopped', 'Closed') %(fcond)s
and (
(`tabDelivery Note`.is_return = 0 and `tabDelivery Note`.per_billed < 100)
or (`tabDelivery Note`.grand_total = 0 and `tabDelivery Note`.per_billed < 100)
@@ -383,7 +383,7 @@
and return_against in (select name from `tabDelivery Note` where per_billed < 100)
)
)
- %(mcond)s order by `tabDelivery Note`.`%(key)s` asc limit %(start)s, %(page_len)s
+ %(mcond)s order by `tabDelivery Note`.`%(key)s` asc limit %(page_len)s offset %(start)s
"""
% {
"fields": ", ".join(["`tabDelivery Note`.{0}".format(f) for f in fields]),
@@ -456,7 +456,7 @@
{match_conditions}
group by batch_no {having_clause}
order by batch.expiry_date, sle.batch_no desc
- limit %(start)s, %(page_len)s""".format(
+ limit %(page_len)s offset %(start)s""".format(
search_columns=search_columns,
cond=cond,
match_conditions=get_match_cond(doctype),
@@ -483,7 +483,7 @@
{match_conditions}
order by expiry_date, name desc
- limit %(start)s, %(page_len)s""".format(
+ limit %(page_len)s offset %(start)s""".format(
cond,
search_columns=search_columns,
search_cond=search_cond,
@@ -654,7 +654,7 @@
filter_dict = get_doctype_wise_filters(filters)
query = """select `tabWarehouse`.name,
- CONCAT_WS(" : ", "Actual Qty", ifnull(round(`tabBin`.actual_qty, 2), 0 )) actual_qty
+ CONCAT_WS(' : ', 'Actual Qty', ifnull(round(`tabBin`.actual_qty, 2), 0 )) actual_qty
from `tabWarehouse` left join `tabBin`
on `tabBin`.warehouse = `tabWarehouse`.name {bin_conditions}
where
@@ -662,7 +662,7 @@
{fcond} {mcond}
order by ifnull(`tabBin`.actual_qty, 0) desc
limit
- {start}, {page_len}
+ {page_len} offset {start}
""".format(
bin_conditions=get_filters_cond(
doctype, filter_dict.get("Bin"), bin_conditions, ignore_permissions=True
@@ -691,7 +691,7 @@
def get_batch_numbers(doctype, txt, searchfield, start, page_len, filters):
query = """select batch_id from `tabBatch`
where disabled = 0
- and (expiry_date >= CURDATE() or expiry_date IS NULL)
+ and (expiry_date >= CURRENT_DATE or expiry_date IS NULL)
and name like {txt}""".format(
txt=frappe.db.escape("%{0}%".format(txt))
)
diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py
index 3c0a10e..197d2ba 100644
--- a/erpnext/controllers/status_updater.py
+++ b/erpnext/controllers/status_updater.py
@@ -35,7 +35,8 @@
["Draft", None],
["Open", "eval:self.docstatus==1"],
["Lost", "eval:self.status=='Lost'"],
- ["Ordered", "has_sales_order"],
+ ["Partially Ordered", "is_partially_ordered"],
+ ["Ordered", "is_fully_ordered"],
["Cancelled", "eval:self.docstatus==2"],
],
"Sales Order": [
@@ -351,9 +352,9 @@
for args in self.status_updater:
# condition to include current record (if submit or no if cancel)
if self.docstatus == 1:
- args["cond"] = ' or parent="%s"' % self.name.replace('"', '"')
+ args["cond"] = " or parent='%s'" % self.name.replace('"', '"')
else:
- args["cond"] = ' and parent!="%s"' % self.name.replace('"', '"')
+ args["cond"] = " and parent!='%s'" % self.name.replace('"', '"')
self._update_children(args, update_modified)
@@ -383,7 +384,7 @@
args["second_source_condition"] = frappe.db.sql(
""" select ifnull((select sum(%(second_source_field)s)
from `tab%(second_source_dt)s`
- where `%(second_join_field)s`="%(detail_id)s"
+ where `%(second_join_field)s`='%(detail_id)s'
and (`tab%(second_source_dt)s`.docstatus=1)
%(second_source_extra_cond)s), 0) """
% args
@@ -397,7 +398,7 @@
frappe.db.sql(
"""
(select ifnull(sum(%(source_field)s), 0)
- from `tab%(source_dt)s` where `%(join_field)s`="%(detail_id)s"
+ from `tab%(source_dt)s` where `%(join_field)s`='%(detail_id)s'
and (docstatus=1 %(cond)s) %(extra_cond)s)
"""
% args
@@ -442,9 +443,9 @@
"""update `tab%(target_parent_dt)s`
set %(target_parent_field)s = round(
ifnull((select
- ifnull(sum(if(abs(%(target_ref_field)s) > abs(%(target_field)s), abs(%(target_field)s), abs(%(target_ref_field)s))), 0)
+ ifnull(sum(case when abs(%(target_ref_field)s) > abs(%(target_field)s) then abs(%(target_field)s) else abs(%(target_ref_field)s) end), 0)
/ sum(abs(%(target_ref_field)s)) * 100
- from `tab%(target_dt)s` where parent="%(name)s" having sum(abs(%(target_ref_field)s)) > 0), 0), 6)
+ from `tab%(target_dt)s` where parent='%(name)s' having sum(abs(%(target_ref_field)s)) > 0), 0), 6)
%(update_modified)s
where name='%(name)s'"""
% args
@@ -454,9 +455,9 @@
if args.get("status_field"):
frappe.db.sql(
"""update `tab%(target_parent_dt)s`
- set %(status_field)s = if(%(target_parent_field)s<0.001,
- 'Not %(keyword)s', if(%(target_parent_field)s>=99.999999,
- 'Fully %(keyword)s', 'Partly %(keyword)s'))
+ set %(status_field)s = (case when %(target_parent_field)s<0.001 then 'Not %(keyword)s'
+ else case when %(target_parent_field)s>=99.999999 then 'Fully %(keyword)s'
+ else 'Partly %(keyword)s' end end)
where name='%(name)s'"""
% args
)
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index feec42f..e90a4f6 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -166,7 +166,7 @@
"against": warehouse_account[sle.warehouse]["account"],
"cost_center": item_row.cost_center,
"remarks": self.get("remarks") or _("Accounting Entry for Stock"),
- "credit": flt(sle.stock_value_difference, precision),
+ "debit": -1 * flt(sle.stock_value_difference, precision),
"project": item_row.get("project") or self.get("project"),
"is_opening": item_row.get("is_opening") or self.get("is_opening") or "No",
},
diff --git a/erpnext/erpnext_integrations/data_migration_mapping/__init__.py b/erpnext/crm/doctype/crm_note/__init__.py
similarity index 100%
rename from erpnext/erpnext_integrations/data_migration_mapping/__init__.py
rename to erpnext/crm/doctype/crm_note/__init__.py
diff --git a/erpnext/crm/doctype/crm_note/crm_note.json b/erpnext/crm/doctype/crm_note/crm_note.json
new file mode 100644
index 0000000..fc2a4d1
--- /dev/null
+++ b/erpnext/crm/doctype/crm_note/crm_note.json
@@ -0,0 +1,48 @@
+{
+ "actions": [],
+ "autoname": "autoincrement",
+ "creation": "2022-06-04 15:49:23.416644",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "note",
+ "added_by",
+ "added_on"
+ ],
+ "fields": [
+ {
+ "columns": 5,
+ "fieldname": "note",
+ "fieldtype": "Text Editor",
+ "in_list_view": 1,
+ "label": "Note"
+ },
+ {
+ "fieldname": "added_by",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Added By",
+ "options": "User"
+ },
+ {
+ "fieldname": "added_on",
+ "fieldtype": "Datetime",
+ "in_list_view": 1,
+ "label": "Added On"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2022-06-04 16:29:07.807252",
+ "modified_by": "Administrator",
+ "module": "CRM",
+ "name": "CRM Note",
+ "naming_rule": "Autoincrement",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/crm/doctype/crm_note/crm_note.py b/erpnext/crm/doctype/crm_note/crm_note.py
new file mode 100644
index 0000000..6c7eeb4
--- /dev/null
+++ b/erpnext/crm/doctype/crm_note/crm_note.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class CRMNote(Document):
+ pass
diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.json b/erpnext/crm/doctype/crm_settings/crm_settings.json
index a2a19b9..26a07d2 100644
--- a/erpnext/crm/doctype/crm_settings/crm_settings.json
+++ b/erpnext/crm/doctype/crm_settings/crm_settings.json
@@ -10,12 +10,10 @@
"campaign_naming_by",
"allow_lead_duplication_based_on_emails",
"column_break_4",
- "create_event_on_next_contact_date",
"auto_creation_of_contact",
"opportunity_section",
"close_opportunity_after_days",
"column_break_9",
- "create_event_on_next_contact_date_opportunity",
"quotation_section",
"default_valid_till",
"section_break_13",
@@ -56,12 +54,6 @@
"label": "Auto Creation of Contact"
},
{
- "default": "1",
- "fieldname": "create_event_on_next_contact_date",
- "fieldtype": "Check",
- "label": "Create Event on Next Contact Date"
- },
- {
"fieldname": "opportunity_section",
"fieldtype": "Section Break",
"label": "Opportunity"
@@ -74,12 +66,6 @@
"label": "Close Replied Opportunity After Days"
},
{
- "default": "1",
- "fieldname": "create_event_on_next_contact_date_opportunity",
- "fieldtype": "Check",
- "label": "Create Event on Next Contact Date"
- },
- {
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
@@ -105,7 +91,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2021-12-20 12:51:38.894252",
+ "modified": "2022-06-06 11:22:08.464253",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM Settings",
@@ -143,5 +129,6 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/crm/doctype/lead/lead.js b/erpnext/crm/doctype/lead/lead.js
index 999599c..37fb350 100644
--- a/erpnext/crm/doctype/lead/lead.js
+++ b/erpnext/crm/doctype/lead/lead.js
@@ -24,31 +24,39 @@
this.frm.set_query("lead_owner", function (doc, cdt, cdn) {
return { query: "frappe.core.doctype.user.user.user_query" }
});
-
- this.frm.set_query("contact_by", function (doc, cdt, cdn) {
- return { query: "frappe.core.doctype.user.user.user_query" }
- });
}
refresh () {
+ var me = this;
let doc = this.frm.doc;
erpnext.toggle_naming_series();
- frappe.dynamic_link = { doc: doc, fieldname: 'name', doctype: 'Lead' }
+ frappe.dynamic_link = {
+ doc: doc,
+ fieldname: 'name',
+ doctype: 'Lead'
+ };
if (!this.frm.is_new() && doc.__onload && !doc.__onload.is_customer) {
this.frm.add_custom_button(__("Customer"), this.make_customer, __("Create"));
- this.frm.add_custom_button(__("Opportunity"), this.make_opportunity, __("Create"));
+ this.frm.add_custom_button(__("Opportunity"), function() {
+ me.frm.trigger("make_opportunity");
+ }, __("Create"));
this.frm.add_custom_button(__("Quotation"), this.make_quotation, __("Create"));
- this.frm.add_custom_button(__("Prospect"), this.make_prospect, __("Create"));
- this.frm.add_custom_button(__('Add to Prospect'), this.add_lead_to_prospect, __('Action'));
+ if (!doc.__onload.linked_prospects.length) {
+ this.frm.add_custom_button(__("Prospect"), this.make_prospect, __("Create"));
+ this.frm.add_custom_button(__('Add to Prospect'), this.add_lead_to_prospect, __('Action'));
+ }
}
if (!this.frm.is_new()) {
frappe.contacts.render_address_and_contact(this.frm);
- cur_frm.trigger('render_contact_day_html');
} else {
frappe.contacts.clear_address_and_contact(this.frm);
}
+
+ this.frm.dashboard.links_area.hide();
+ this.show_notes();
+ this.show_activities();
}
add_lead_to_prospect () {
@@ -74,7 +82,7 @@
}
},
freeze: true,
- freeze_message: __('...Adding Lead to Prospect')
+ freeze_message: __('Adding Lead to Prospect...')
});
}, __('Add Lead to Prospect'), __('Add'));
}
@@ -86,13 +94,6 @@
})
}
- make_opportunity () {
- frappe.model.open_mapped_doc({
- method: "erpnext.crm.doctype.lead.lead.make_opportunity",
- frm: cur_frm
- })
- }
-
make_quotation () {
frappe.model.open_mapped_doc({
method: "erpnext.crm.doctype.lead.lead.make_quotation",
@@ -111,9 +112,10 @@
prospect.fax = cur_frm.doc.fax;
prospect.website = cur_frm.doc.website;
prospect.prospect_owner = cur_frm.doc.lead_owner;
+ prospect.notes = cur_frm.doc.notes;
- let lead_prospect_row = frappe.model.add_child(prospect, 'prospect_lead');
- lead_prospect_row.lead = cur_frm.doc.name;
+ let leads_row = frappe.model.add_child(prospect, 'leads');
+ leads_row.lead = cur_frm.doc.name;
frappe.set_route("Form", "Prospect", prospect.name);
});
@@ -125,26 +127,109 @@
}
}
- contact_date () {
- if (this.frm.doc.contact_date) {
- let d = moment(this.frm.doc.contact_date);
- d.add(1, "day");
- this.frm.set_value("ends_on", d.format(frappe.defaultDatetimeFormat));
- }
+ show_notes() {
+ if (this.frm.doc.docstatus == 1) return;
+
+ const crm_notes = new erpnext.utils.CRMNotes({
+ frm: this.frm,
+ notes_wrapper: $(this.frm.fields_dict.notes_html.wrapper),
+ });
+ crm_notes.refresh();
}
- render_contact_day_html() {
- if (cur_frm.doc.contact_date) {
- let contact_date = frappe.datetime.obj_to_str(cur_frm.doc.contact_date);
- let diff_days = frappe.datetime.get_day_diff(contact_date, frappe.datetime.get_today());
- let color = diff_days > 0 ? "orange" : "green";
- let message = diff_days > 0 ? __("Next Contact Date") : __("Last Contact Date");
- let html = `<div class="col-xs-12">
- <span class="indicator whitespace-nowrap ${color}"><span> ${message} : ${frappe.datetime.global_date_format(contact_date)}</span></span>
- </div>` ;
- cur_frm.dashboard.set_headline_alert(html);
- }
+ show_activities() {
+ if (this.frm.doc.docstatus == 1) return;
+
+ const crm_activities = new erpnext.utils.CRMActivities({
+ frm: this.frm,
+ open_activities_wrapper: $(this.frm.fields_dict.open_activities_html.wrapper),
+ all_activities_wrapper: $(this.frm.fields_dict.all_activities_html.wrapper),
+ form_wrapper: $(this.frm.wrapper),
+ });
+ crm_activities.refresh();
}
};
+
extend_cscript(cur_frm.cscript, new erpnext.LeadController({ frm: cur_frm }));
+
+frappe.ui.form.on("Lead", {
+ make_opportunity: async function(frm) {
+ let existing_prospect = (await frappe.db.get_value("Prospect Lead",
+ {
+ "lead": frm.doc.name
+ },
+ "name", null, "Prospect"
+ )).message.name;
+
+ if (!existing_prospect) {
+ var fields = [
+ {
+ "label": "Create Prospect",
+ "fieldname": "create_prospect",
+ "fieldtype": "Check",
+ "default": 1
+ },
+ {
+ "label": "Prospect Name",
+ "fieldname": "prospect_name",
+ "fieldtype": "Data",
+ "default": frm.doc.company_name,
+ "depends_on": "create_prospect"
+ }
+ ];
+ }
+ let existing_contact = (await frappe.db.get_value("Contact",
+ {
+ "first_name": frm.doc.first_name || frm.doc.lead_name,
+ "last_name": frm.doc.last_name
+ },
+ "name"
+ )).message.name;
+
+ if (!existing_contact) {
+ fields.push(
+ {
+ "label": "Create Contact",
+ "fieldname": "create_contact",
+ "fieldtype": "Check",
+ "default": "1"
+ }
+ );
+ }
+
+ if (fields) {
+ var d = new frappe.ui.Dialog({
+ title: __('Create Opportunity'),
+ fields: fields,
+ primary_action: function() {
+ var data = d.get_values();
+ frappe.call({
+ method: 'create_prospect_and_contact',
+ doc: frm.doc,
+ args: {
+ data: data,
+ },
+ freeze: true,
+ callback: function(r) {
+ if (!r.exc) {
+ frappe.model.open_mapped_doc({
+ method: "erpnext.crm.doctype.lead.lead.make_opportunity",
+ frm: frm
+ });
+ }
+ d.hide();
+ }
+ });
+ },
+ primary_action_label: __('Create')
+ });
+ d.show();
+ } else {
+ frappe.model.open_mapped_doc({
+ method: "erpnext.crm.doctype.lead.lead.make_opportunity",
+ frm: frm
+ });
+ }
+ }
+});
\ No newline at end of file
diff --git a/erpnext/crm/doctype/lead/lead.json b/erpnext/crm/doctype/lead/lead.json
index 542977e..d47373f 100644
--- a/erpnext/crm/doctype/lead/lead.json
+++ b/erpnext/crm/doctype/lead/lead.json
@@ -3,79 +3,81 @@
"allow_events_in_timeline": 1,
"allow_import": 1,
"autoname": "naming_series:",
- "creation": "2013-04-10 11:45:37",
+ "creation": "2022-02-08 13:14:41.083327",
"doctype": "DocType",
"document_type": "Document",
"email_append_to": 1,
"engine": "InnoDB",
"field_order": [
- "lead_details",
"naming_series",
"salutation",
"first_name",
"middle_name",
"last_name",
+ "column_break_1",
"lead_name",
- "col_break123",
- "status",
- "company_name",
- "designation",
+ "job_title",
"gender",
- "contact_details_section",
+ "source",
+ "col_break123",
+ "lead_owner",
+ "status",
+ "customer",
+ "type",
+ "request_type",
+ "contact_info_tab",
"email_id",
+ "website",
+ "column_break_20",
"mobile_no",
"whatsapp_no",
"column_break_16",
"phone",
"phone_ext",
- "additional_information_section",
+ "organization_section",
+ "company_name",
"no_of_employees",
+ "column_break_28",
+ "annual_revenue",
"industry",
"market_segment",
- "column_break_22",
+ "column_break_31",
+ "territory",
"fax",
- "website",
- "type",
- "request_type",
"address_section",
"address_html",
+ "column_break_38",
"city",
- "pincode",
- "county",
- "column_break2",
- "contact_html",
"state",
"country",
- "section_break_12",
- "lead_owner",
- "ends_on",
- "column_break_14",
- "contact_by",
- "contact_date",
- "lead_source_details_section",
- "company",
- "territory",
- "language",
- "column_break_50",
- "source",
+ "column_break2",
+ "contact_html",
+ "qualification_tab",
+ "qualification_status",
+ "column_break_64",
+ "qualified_by",
+ "qualified_on",
+ "other_info_tab",
"campaign_name",
+ "company",
+ "column_break_22",
+ "language",
+ "image",
+ "title",
+ "column_break_50",
+ "disabled",
"unsubscribed",
"blog_subscriber",
- "notes_section",
- "notes",
- "other_information_section",
- "customer",
- "image",
- "title"
+ "activities_tab",
+ "open_activities_html",
+ "all_activities_section",
+ "all_activities_html",
+ "notes_tab",
+ "notes_html",
+ "notes"
],
"fields": [
{
- "fieldname": "lead_details",
- "fieldtype": "Section Break",
- "label": "Lead Details",
- "options": "fa fa-user"
- },
- {
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Series",
@@ -86,6 +88,7 @@
"set_only_once": 1
},
{
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "lead_name",
"fieldtype": "Data",
"in_global_search": 1,
@@ -108,7 +111,7 @@
{
"fieldname": "email_id",
"fieldtype": "Data",
- "label": "Email Address",
+ "label": "Email",
"oldfieldname": "email_id",
"oldfieldtype": "Data",
"options": "Email",
@@ -189,50 +192,9 @@
"print_hide": 1
},
{
- "fieldname": "section_break_12",
+ "fieldname": "contact_info_tab",
"fieldtype": "Section Break",
- "label": "Follow Up"
- },
- {
- "fieldname": "contact_by",
- "fieldtype": "Link",
- "label": "Next Contact By",
- "oldfieldname": "contact_by",
- "oldfieldtype": "Link",
- "options": "User",
- "width": "100px"
- },
- {
- "fieldname": "column_break_14",
- "fieldtype": "Column Break"
- },
- {
- "bold": 1,
- "fieldname": "contact_date",
- "fieldtype": "Datetime",
- "label": "Next Contact Date",
- "no_copy": 1,
- "oldfieldname": "contact_date",
- "oldfieldtype": "Date",
- "width": "100px"
- },
- {
- "bold": 1,
- "fieldname": "ends_on",
- "fieldtype": "Datetime",
- "label": "Ends On",
- "no_copy": 1
- },
- {
- "collapsible": 1,
- "fieldname": "notes_section",
- "fieldtype": "Section Break",
- "label": "Notes"
- },
- {
- "fieldname": "notes",
- "fieldtype": "Text Editor",
- "label": "Notes"
+ "label": "Contact Info"
},
{
"fieldname": "address_html",
@@ -241,34 +203,6 @@
"read_only": 1
},
{
- "fieldname": "city",
- "fieldtype": "Data",
- "label": "City/Town",
- "mandatory_depends_on": "eval: doc.address_title && doc.address_type"
- },
- {
- "fieldname": "county",
- "fieldtype": "Data",
- "label": "County"
- },
- {
- "fieldname": "state",
- "fieldtype": "Data",
- "label": "State"
- },
- {
- "fieldname": "country",
- "fieldtype": "Link",
- "label": "Country",
- "mandatory_depends_on": "eval: doc.address_title && doc.address_type",
- "options": "Country"
- },
- {
- "fieldname": "pincode",
- "fieldtype": "Data",
- "label": "Postal Code"
- },
- {
"fieldname": "column_break2",
"fieldtype": "Column Break"
},
@@ -289,7 +223,7 @@
{
"fieldname": "mobile_no",
"fieldtype": "Data",
- "label": "Mobile No.",
+ "label": "Mobile No",
"oldfieldname": "mobile_no",
"oldfieldtype": "Data",
"options": "Phone"
@@ -347,8 +281,7 @@
"fieldtype": "Data",
"label": "Website",
"oldfieldname": "website",
- "oldfieldtype": "Data",
- "options": "URL"
+ "oldfieldtype": "Data"
},
{
"fieldname": "territory",
@@ -381,14 +314,6 @@
"print_hide": 1
},
{
- "fieldname": "designation",
- "fieldtype": "Link",
- "in_list_view": 1,
- "in_standard_filter": 1,
- "label": "Designation",
- "options": "Designation"
- },
- {
"fieldname": "language",
"fieldtype": "Link",
"label": "Print Language",
@@ -411,12 +336,6 @@
"label": "Last Name"
},
{
- "collapsible": 1,
- "fieldname": "additional_information_section",
- "fieldtype": "Section Break",
- "label": "Additional Information"
- },
- {
"fieldname": "no_of_employees",
"fieldtype": "Int",
"label": "No. of Employees"
@@ -428,36 +347,14 @@
{
"fieldname": "whatsapp_no",
"fieldtype": "Data",
- "label": "WhatsApp No.",
+ "label": "WhatsApp",
"options": "Phone"
},
{
- "collapsible": 1,
- "depends_on": "eval: !doc.__islocal",
- "fieldname": "address_section",
- "fieldtype": "Section Break",
- "label": "Address"
- },
- {
- "fieldname": "lead_source_details_section",
- "fieldtype": "Section Break",
- "label": "Lead Source Details"
- },
- {
"fieldname": "column_break_50",
"fieldtype": "Column Break"
},
{
- "fieldname": "other_information_section",
- "fieldtype": "Section Break",
- "label": "Other Information"
- },
- {
- "fieldname": "contact_details_section",
- "fieldtype": "Section Break",
- "label": "Contact Details"
- },
- {
"fieldname": "column_break_16",
"fieldtype": "Column Break"
},
@@ -465,17 +362,156 @@
"fieldname": "phone_ext",
"fieldtype": "Data",
"label": "Phone Ext."
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "qualification_tab",
+ "fieldtype": "Section Break",
+ "label": "Qualification"
+ },
+ {
+ "depends_on": "eval:!doc.__islocal",
+ "fieldname": "notes_tab",
+ "fieldtype": "Tab Break",
+ "label": "Notes"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "other_info_tab",
+ "fieldtype": "Section Break",
+ "label": "Additional Information"
+ },
+ {
+ "fieldname": "column_break_1",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "qualified_by",
+ "fieldtype": "Link",
+ "label": "Qualified By",
+ "options": "User"
+ },
+ {
+ "fieldname": "qualified_on",
+ "fieldtype": "Date",
+ "label": "Qualified on"
+ },
+ {
+ "fieldname": "qualification_status",
+ "fieldtype": "Select",
+ "label": "Qualification Status",
+ "options": "Unqualified\nIn Process\nQualified"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "address_section",
+ "fieldtype": "Section Break",
+ "label": "Address & Contacts"
+ },
+ {
+ "fieldname": "column_break_64",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_20",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "job_title",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Job Title"
+ },
+ {
+ "fieldname": "annual_revenue",
+ "fieldtype": "Currency",
+ "label": "Annual Revenue"
+ },
+ {
+ "depends_on": "eval:!doc.__islocal",
+ "fieldname": "activities_tab",
+ "fieldtype": "Tab Break",
+ "label": "Activities"
+ },
+ {
+ "fieldname": "organization_section",
+ "fieldtype": "Section Break",
+ "label": "Organization"
+ },
+ {
+ "fieldname": "column_break_28",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_31",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "notes_html",
+ "fieldtype": "HTML",
+ "label": "Notes HTML"
+ },
+ {
+ "fieldname": "open_activities_html",
+ "fieldtype": "HTML",
+ "label": "Open Activities HTML"
+ },
+ {
+ "fieldname": "all_activities_section",
+ "fieldtype": "Section Break",
+ "label": "All Activities"
+ },
+ {
+ "fieldname": "all_activities_html",
+ "fieldtype": "HTML",
+ "label": "All Activities HTML"
+ },
+ {
+ "fieldname": "notes",
+ "fieldtype": "Table",
+ "hidden": 1,
+ "label": "Notes",
+ "no_copy": 1,
+ "options": "CRM Note"
+ },
+ {
+ "default": "0",
+ "fieldname": "disabled",
+ "fieldtype": "Check",
+ "label": "Disabled"
+ },
+ {
+ "fieldname": "column_break_38",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "city",
+ "fieldtype": "Data",
+ "label": "City"
+ },
+ {
+ "fieldname": "state",
+ "fieldtype": "Data",
+ "label": "State"
+ },
+ {
+ "fieldname": "country",
+ "fieldtype": "Link",
+ "label": "Country",
+ "options": "Country"
}
],
"icon": "fa fa-user",
"idx": 5,
"image_field": "image",
"links": [],
- "modified": "2021-08-04 00:24:57.208590",
+ "modified": "2022-06-27 21:56:17.392756",
"modified_by": "Administrator",
"module": "CRM",
"name": "Lead",
"name_case": "Title Case",
+ "naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
@@ -535,6 +571,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"subject_field": "title",
"title_field": "title"
}
\ No newline at end of file
diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py
index c9a64ff..0d12499 100644
--- a/erpnext/crm/doctype/lead/lead.py
+++ b/erpnext/crm/doctype/lead/lead.py
@@ -1,27 +1,19 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
-
import frappe
from frappe import _
from frappe.contacts.address_and_contact import load_address_and_contact
from frappe.email.inbox import link_communication_to_document
from frappe.model.mapper import get_mapped_doc
-from frappe.utils import (
- comma_and,
- cstr,
- get_link_to_form,
- getdate,
- has_gravatar,
- nowdate,
- validate_email_address,
-)
+from frappe.utils import comma_and, get_link_to_form, has_gravatar, validate_email_address
from erpnext.accounts.party import set_taxes
from erpnext.controllers.selling_controller import SellingController
+from erpnext.crm.utils import CRMNote, copy_comments, link_communications, link_open_events
-class Lead(SellingController):
+class Lead(SellingController, CRMNote):
def get_feed(self):
return "{0}: {1}".format(_(self.status), self.lead_name)
@@ -29,6 +21,7 @@
customer = frappe.db.get_value("Customer", {"lead_name": self.name})
self.get("__onload").is_customer = customer
load_address_and_contact(self)
+ self.set_onload("linked_prospects", self.get_linked_prospects())
def validate(self):
self.set_full_name()
@@ -37,79 +30,42 @@
self.set_status()
self.check_email_id_is_unique()
self.validate_email_id()
- self.validate_contact_date()
- self.set_prev()
+
+ def before_insert(self):
+ self.contact_doc = None
+ if frappe.db.get_single_value("CRM Settings", "auto_creation_of_contact"):
+ self.contact_doc = self.create_contact()
+
+ def after_insert(self):
+ self.link_to_contact()
+
+ def on_update(self):
+ self.update_prospect()
+
+ def on_trash(self):
+ frappe.db.sql("""update `tabIssue` set lead='' where lead=%s""", self.name)
+
+ self.unlink_dynamic_links()
+ self.remove_link_from_prospect()
def set_full_name(self):
if self.first_name:
- self.lead_name = " ".join(filter(None, [self.first_name, self.middle_name, self.last_name]))
-
- def validate_email_id(self):
- if self.email_id:
- if not self.flags.ignore_email_validation:
- validate_email_address(self.email_id, throw=True)
-
- if self.email_id == self.lead_owner:
- frappe.throw(_("Lead Owner cannot be same as the Lead"))
-
- if self.email_id == self.contact_by:
- frappe.throw(_("Next Contact By cannot be same as the Lead Email Address"))
-
- if self.is_new() or not self.image:
- self.image = has_gravatar(self.email_id)
-
- def validate_contact_date(self):
- if self.contact_date and getdate(self.contact_date) < getdate(nowdate()):
- frappe.throw(_("Next Contact Date cannot be in the past"))
-
- if self.ends_on and self.contact_date and (getdate(self.ends_on) < getdate(self.contact_date)):
- frappe.throw(_("Ends On date cannot be before Next Contact Date."))
-
- def on_update(self):
- self.add_calendar_event()
- self.update_prospects()
-
- def set_prev(self):
- if self.is_new():
- self._prev = frappe._dict({"contact_date": None, "ends_on": None, "contact_by": None})
- else:
- self._prev = frappe.db.get_value(
- "Lead", self.name, ["contact_date", "ends_on", "contact_by"], as_dict=1
+ self.lead_name = " ".join(
+ filter(None, [self.salutation, self.first_name, self.middle_name, self.last_name])
)
- def before_insert(self):
- self.contact_doc = self.create_contact()
+ def set_lead_name(self):
+ if not self.lead_name:
+ # Check for leads being created through data import
+ if not self.company_name and not self.email_id and not self.flags.ignore_mandatory:
+ frappe.throw(_("A Lead requires either a person's name or an organization's name"))
+ elif self.company_name:
+ self.lead_name = self.company_name
+ else:
+ self.lead_name = self.email_id.split("@")[0]
- def after_insert(self):
- self.update_links()
-
- def update_links(self):
- # update contact links
- if self.contact_doc:
- self.contact_doc.append(
- "links", {"link_doctype": "Lead", "link_name": self.name, "link_title": self.lead_name}
- )
- self.contact_doc.save()
-
- def add_calendar_event(self, opts=None, force=False):
- if frappe.db.get_single_value("CRM Settings", "create_event_on_next_contact_date"):
- super(Lead, self).add_calendar_event(
- {
- "owner": self.lead_owner,
- "starts_on": self.contact_date,
- "ends_on": self.ends_on or "",
- "subject": ("Contact " + cstr(self.lead_name)),
- "description": ("Contact " + cstr(self.lead_name))
- + (self.contact_by and (". By : " + cstr(self.contact_by)) or ""),
- },
- force,
- )
-
- def update_prospects(self):
- prospects = frappe.get_all("Prospect Lead", filters={"lead": self.name}, fields=["parent"])
- for row in prospects:
- prospect = frappe.get_doc("Prospect", row.parent)
- prospect.save(ignore_permissions=True)
+ def set_title(self):
+ self.title = self.company_name or self.lead_name
def check_email_id_is_unique(self):
if self.email_id:
@@ -124,15 +80,47 @@
if duplicate_leads:
frappe.throw(
- _("Email Address must be unique, already exists for {0}").format(comma_and(duplicate_leads)),
+ _("Email Address must be unique, it is already used in {0}").format(
+ comma_and(duplicate_leads)
+ ),
frappe.DuplicateEntryError,
)
- def on_trash(self):
- frappe.db.sql("""update `tabIssue` set lead='' where lead=%s""", self.name)
+ def validate_email_id(self):
+ if self.email_id:
+ if not self.flags.ignore_email_validation:
+ validate_email_address(self.email_id, throw=True)
- self.unlink_dynamic_links()
- self.delete_events()
+ if self.email_id == self.lead_owner:
+ frappe.throw(_("Lead Owner cannot be same as the Lead Email Address"))
+
+ if self.is_new() or not self.image:
+ self.image = has_gravatar(self.email_id)
+
+ def link_to_contact(self):
+ # update contact links
+ if self.contact_doc:
+ self.contact_doc.append(
+ "links", {"link_doctype": "Lead", "link_name": self.name, "link_title": self.lead_name}
+ )
+ self.contact_doc.save()
+
+ def update_prospect(self):
+ lead_row_name = frappe.db.get_value(
+ "Prospect Lead", filters={"lead": self.name}, fieldname="name"
+ )
+ if lead_row_name:
+ lead_row = frappe.get_doc("Prospect Lead", lead_row_name)
+ lead_row.update(
+ {
+ "lead_name": self.lead_name,
+ "email": self.email_id,
+ "mobile_no": self.mobile_no,
+ "lead_owner": self.lead_owner,
+ "status": self.status,
+ }
+ )
+ lead_row.db_update()
def unlink_dynamic_links(self):
links = frappe.get_all(
@@ -155,6 +143,30 @@
linked_doc.remove(to_remove)
linked_doc.save(ignore_permissions=True)
+ def remove_link_from_prospect(self):
+ prospects = self.get_linked_prospects()
+
+ for d in prospects:
+ prospect = frappe.get_doc("Prospect", d.parent)
+ if len(prospect.get("leads")) == 1:
+ prospect.delete(ignore_permissions=True)
+ else:
+ to_remove = None
+ for d in prospect.get("leads"):
+ if d.lead == self.name:
+ to_remove = d
+
+ if to_remove:
+ prospect.remove(to_remove)
+ prospect.save(ignore_permissions=True)
+
+ def get_linked_prospects(self):
+ return frappe.get_all(
+ "Prospect Lead",
+ filters={"lead": self.name},
+ fields=["parent"],
+ )
+
def has_customer(self):
return frappe.db.get_value("Customer", {"lead_name": self.name})
@@ -171,50 +183,78 @@
"Quotation", {"party_name": self.name, "docstatus": 1, "status": "Lost"}
)
- def set_lead_name(self):
- if not self.lead_name:
- # Check for leads being created through data import
- if not self.company_name and not self.email_id and not self.flags.ignore_mandatory:
- frappe.throw(_("A Lead requires either a person's name or an organization's name"))
- elif self.company_name:
- self.lead_name = self.company_name
- else:
- self.lead_name = self.email_id.split("@")[0]
+ @frappe.whitelist()
+ def create_prospect_and_contact(self, data):
+ data = frappe._dict(data)
+ if data.create_contact:
+ self.create_contact()
- def set_title(self):
- self.title = self.company_name or self.lead_name
+ if data.create_prospect:
+ self.create_prospect(data.prospect_name)
def create_contact(self):
- if frappe.db.get_single_value("CRM Settings", "auto_creation_of_contact"):
- if not self.lead_name:
- self.set_full_name()
- self.set_lead_name()
+ if not self.lead_name:
+ self.set_full_name()
+ self.set_lead_name()
- contact = frappe.new_doc("Contact")
- contact.update(
+ contact = frappe.new_doc("Contact")
+ contact.update(
+ {
+ "first_name": self.first_name or self.lead_name,
+ "last_name": self.last_name,
+ "salutation": self.salutation,
+ "gender": self.gender,
+ "job_title": self.job_title,
+ "company_name": self.company_name,
+ }
+ )
+
+ if self.email_id:
+ contact.append("email_ids", {"email_id": self.email_id, "is_primary": 1})
+
+ if self.phone:
+ contact.append("phone_nos", {"phone": self.phone, "is_primary_phone": 1})
+
+ if self.mobile_no:
+ contact.append("phone_nos", {"phone": self.mobile_no, "is_primary_mobile_no": 1})
+
+ contact.insert(ignore_permissions=True)
+ contact.reload() # load changes by hooks on contact
+
+ return contact
+
+ def create_prospect(self, company_name):
+ try:
+ prospect = frappe.new_doc("Prospect")
+
+ prospect.company_name = company_name or self.company_name
+ prospect.no_of_employees = self.no_of_employees
+ prospect.industry = self.industry
+ prospect.market_segment = self.market_segment
+ prospect.annual_revenue = self.annual_revenue
+ prospect.territory = self.territory
+ prospect.fax = self.fax
+ prospect.website = self.website
+ prospect.prospect_owner = self.lead_owner
+ prospect.company = self.company
+ prospect.notes = self.notes
+
+ prospect.append(
+ "leads",
{
- "first_name": self.first_name or self.lead_name,
- "last_name": self.last_name,
- "salutation": self.salutation,
- "gender": self.gender,
- "designation": self.designation,
- "company_name": self.company_name,
- }
+ "lead": self.name,
+ "lead_name": self.lead_name,
+ "email": self.email_id,
+ "mobile_no": self.mobile_no,
+ "lead_owner": self.lead_owner,
+ "status": self.status,
+ },
)
-
- if self.email_id:
- contact.append("email_ids", {"email_id": self.email_id, "is_primary": 1})
-
- if self.phone:
- contact.append("phone_nos", {"phone": self.phone, "is_primary_phone": 1})
-
- if self.mobile_no:
- contact.append("phone_nos", {"phone": self.mobile_no, "is_primary_mobile_no": 1})
-
- contact.insert(ignore_permissions=True)
- contact.reload() # load changes by hooks on contact
-
- return contact
+ prospect.flags.ignore_permissions = True
+ prospect.flags.ignore_mandatory = True
+ prospect.save()
+ except frappe.DuplicateEntryError:
+ frappe.throw(_("Prospect {0} already exists").format(company_name or self.company_name))
@frappe.whitelist()
@@ -274,6 +314,8 @@
"company_name": "customer_name",
"email_id": "contact_email",
"mobile_no": "contact_mobile",
+ "lead_owner": "opportunity_owner",
+ "notes": "notes",
},
}
},
@@ -422,21 +464,25 @@
return lead
-def daily_open_lead():
- leads = frappe.get_all("Lead", filters=[["contact_date", "Between", [nowdate(), nowdate()]]])
- for lead in leads:
- frappe.db.set_value("Lead", lead.name, "status", "Open")
-
-
@frappe.whitelist()
def add_lead_to_prospect(lead, prospect):
prospect = frappe.get_doc("Prospect", prospect)
- prospect.append("prospect_lead", {"lead": lead})
+ prospect.append("leads", {"lead": lead})
prospect.save(ignore_permissions=True)
+
+ carry_forward_communication_and_comments = frappe.db.get_single_value(
+ "CRM Settings", "carry_forward_communication_and_comments"
+ )
+
+ if carry_forward_communication_and_comments:
+ copy_comments("Lead", lead, prospect)
+ link_communications("Lead", lead, prospect)
+ link_open_events("Lead", lead, prospect)
+
frappe.msgprint(
_("Lead {0} has been added to prospect {1}.").format(
frappe.bold(lead), frappe.bold(prospect.name)
),
- title=_("Lead Added"),
+ title=_("Lead -> Prospect"),
indicator="green",
)
diff --git a/erpnext/crm/doctype/lead/lead_list.js b/erpnext/crm/doctype/lead/lead_list.js
index 75208fa..dbeaf60 100644
--- a/erpnext/crm/doctype/lead/lead_list.js
+++ b/erpnext/crm/doctype/lead/lead_list.js
@@ -16,7 +16,7 @@
prospect.prospect_owner = r.lead_owner;
leads.forEach(function(lead) {
- let lead_prospect_row = frappe.model.add_child(prospect, 'prospect_lead');
+ let lead_prospect_row = frappe.model.add_child(prospect, 'leads');
lead_prospect_row.lead = lead.name;
});
frappe.set_route("Form", "Prospect", prospect.name);
diff --git a/erpnext/crm/doctype/lead/test_lead.py b/erpnext/crm/doctype/lead/test_lead.py
index 166ae2c..8fe688d 100644
--- a/erpnext/crm/doctype/lead/test_lead.py
+++ b/erpnext/crm/doctype/lead/test_lead.py
@@ -5,7 +5,10 @@
import unittest
import frappe
-from frappe.utils import random_string
+from frappe.utils import random_string, today
+
+from erpnext.crm.doctype.lead.lead import make_opportunity
+from erpnext.crm.utils import get_linked_prospect
test_records = frappe.get_test_records("Lead")
@@ -83,6 +86,105 @@
self.assertEqual(frappe.db.exists("Lead", lead_doc.name), None)
self.assertEqual(len(address_1.get("links")), 1)
+ def test_prospect_creation_from_lead(self):
+ frappe.db.sql("delete from `tabLead` where lead_name='Rahul Tripathi'")
+ frappe.db.sql("delete from `tabProspect` where name='Prospect Company'")
+
+ lead = make_lead(
+ first_name="Rahul",
+ last_name="Tripathi",
+ email_id="rahul@gmail.com",
+ company_name="Prospect Company",
+ )
+
+ event = create_event("Meeting 1", today(), "Lead", lead.name)
+
+ lead.create_prospect(lead.company_name)
+
+ prospect = get_linked_prospect("Lead", lead.name)
+ self.assertEqual(prospect, "Prospect Company")
+
+ event.reload()
+ self.assertEqual(event.event_participants[1].reference_doctype, "Prospect")
+ self.assertEqual(event.event_participants[1].reference_docname, prospect)
+
+ def test_opportunity_from_lead(self):
+ frappe.db.sql("delete from `tabLead` where lead_name='Rahul Tripathi'")
+ frappe.db.sql("delete from `tabOpportunity` where party_name='Rahul Tripathi'")
+
+ lead = make_lead(
+ first_name="Rahul",
+ last_name="Tripathi",
+ email_id="rahul@gmail.com",
+ company_name="Prospect Company",
+ )
+
+ lead.add_note("test note")
+ event = create_event("Meeting 1", today(), "Lead", lead.name)
+ create_todo("followup", "Lead", lead.name)
+
+ opportunity = make_opportunity(lead.name)
+ opportunity.save()
+
+ self.assertEqual(opportunity.get("party_name"), lead.name)
+ self.assertEqual(opportunity.notes[0].note, "test note")
+
+ event.reload()
+ self.assertEqual(event.event_participants[1].reference_doctype, "Opportunity")
+ self.assertEqual(event.event_participants[1].reference_docname, opportunity.name)
+
+ self.assertTrue(
+ frappe.db.get_value(
+ "ToDo", {"reference_type": "Opportunity", "reference_name": opportunity.name}
+ )
+ )
+
+ def test_copy_events_from_lead_to_prospect(self):
+ frappe.db.sql("delete from `tabLead` where lead_name='Rahul Tripathi'")
+ frappe.db.sql("delete from `tabProspect` where name='Prospect Company'")
+
+ lead = make_lead(
+ first_name="Rahul",
+ last_name="Tripathi",
+ email_id="rahul@gmail.com",
+ company_name="Prospect Company",
+ )
+
+ lead.create_prospect(lead.company_name)
+ prospect = get_linked_prospect("Lead", lead.name)
+
+ event = create_event("Meeting", today(), "Lead", lead.name)
+
+ self.assertEqual(len(event.event_participants), 2)
+ self.assertEqual(event.event_participants[1].reference_doctype, "Prospect")
+ self.assertEqual(event.event_participants[1].reference_docname, prospect)
+
+
+def create_event(subject, starts_on, reference_type, reference_name):
+ event = frappe.new_doc("Event")
+ event.subject = subject
+ event.starts_on = starts_on
+ event.event_type = "Private"
+ event.all_day = 1
+ event.owner = "Administrator"
+ event.append(
+ "event_participants", {"reference_doctype": reference_type, "reference_docname": reference_name}
+ )
+ event.reference_type = reference_type
+ event.reference_name = reference_name
+ event.insert()
+ return event
+
+
+def create_todo(description, reference_type, reference_name):
+ todo = frappe.new_doc("ToDo")
+ todo.description = description
+ todo.owner = "Administrator"
+ todo.reference_type = reference_type
+ todo.reference_name = reference_name
+ todo.insert()
+ return todo
+
def make_lead(**args):
args = frappe._dict(args)
@@ -93,6 +195,7 @@
"first_name": args.first_name or "_Test",
"last_name": args.last_name or "Lead",
"email_id": args.email_id or "new_lead_{}@example.com".format(random_string(5)),
+ "company_name": args.company_name or "_Test Company",
}
).insert()
diff --git a/erpnext/crm/doctype/opportunity/opportunity.js b/erpnext/crm/doctype/opportunity/opportunity.js
index 8e7d67e..c53ea9d 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.js
+++ b/erpnext/crm/doctype/opportunity/opportunity.js
@@ -32,13 +32,6 @@
}
},
- contact_date: function(frm) {
- if(frm.doc.contact_date < frappe.datetime.now_datetime()){
- frm.set_value("contact_date", "");
- frappe.throw(__("Next follow up date should be greater than now."))
- }
- },
-
onload_post_render: function(frm) {
frm.get_field("items").grid.set_multiple_add("item_code", "qty");
},
@@ -130,6 +123,13 @@
});
}
}
+
+ if (!frm.is_new()) {
+ frappe.contacts.render_address_and_contact(frm);
+ // frm.trigger('render_contact_day_html');
+ } else {
+ frappe.contacts.clear_address_and_contact(frm);
+ }
},
set_contact_link: function(frm) {
@@ -227,8 +227,7 @@
'total': flt(total),
'base_total': flt(base_total)
});
- }
-
+ },
});
frappe.ui.form.on("Opportunity Item", {
calculate: function(frm, cdt, cdn) {
@@ -264,13 +263,14 @@
this.frm.trigger('currency');
}
+ refresh() {
+ this.show_notes();
+ this.show_activities();
+ }
+
setup_queries() {
var me = this;
- if(this.frm.fields_dict.contact_by.df.options.match(/^User/)) {
- this.frm.set_query("contact_by", erpnext.queries.user);
- }
-
me.frm.set_query('customer_address', erpnext.queries.address_query);
this.frm.set_query("item_code", "items", function() {
@@ -287,6 +287,14 @@
}
else if (me.frm.doc.opportunity_from == "Customer") {
me.frm.set_query('party_name', erpnext.queries['customer']);
+ } else if (me.frm.doc.opportunity_from == "Prospect") {
+ me.frm.set_query('party_name', function() {
+ return {
+ filters: {
+ "company": me.frm.doc.company
+ }
+ };
+ });
}
}
@@ -303,6 +311,24 @@
frm: cur_frm
})
}
+
+ show_notes() {
+ const crm_notes = new erpnext.utils.CRMNotes({
+ frm: this.frm,
+ notes_wrapper: $(this.frm.fields_dict.notes_html.wrapper),
+ });
+ crm_notes.refresh();
+ }
+
+ show_activities() {
+ const crm_activities = new erpnext.utils.CRMActivities({
+ frm: this.frm,
+ open_activities_wrapper: $(this.frm.fields_dict.open_activities_html.wrapper),
+ all_activities_wrapper: $(this.frm.fields_dict.all_activities_html.wrapper),
+ form_wrapper: $(this.frm.wrapper),
+ });
+ crm_activities.refresh();
+ }
};
extend_cscript(cur_frm.cscript, new erpnext.crm.Opportunity({frm: cur_frm}));
diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json
index 089f2d2..1a6f23b 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.json
+++ b/erpnext/crm/doctype/opportunity/opportunity.json
@@ -1,5 +1,6 @@
{
"actions": [],
+ "allow_events_in_timeline": 1,
"allow_import": 1,
"allow_rename": 1,
"autoname": "naming_series:",
@@ -11,69 +12,88 @@
"email_append_to": 1,
"engine": "InnoDB",
"field_order": [
- "from_section",
"naming_series",
"opportunity_from",
"party_name",
"customer_name",
- "source",
- "column_break0",
- "title",
- "opportunity_type",
"status",
- "converted_by",
+ "column_break0",
+ "opportunity_type",
+ "source",
+ "opportunity_owner",
+ "column_break_10",
"sales_stage",
- "first_response_time",
"expected_closing",
- "next_contact",
- "contact_by",
- "contact_date",
- "column_break2",
- "to_discuss",
+ "probability",
+ "organization_details_section",
+ "no_of_employees",
+ "annual_revenue",
+ "customer_group",
+ "column_break_23",
+ "industry",
+ "market_segment",
+ "website",
+ "column_break_31",
+ "city",
+ "state",
+ "country",
+ "territory",
"section_break_14",
"currency",
+ "column_break_36",
"conversion_rate",
- "base_opportunity_amount",
- "with_items",
"column_break_17",
- "probability",
"opportunity_amount",
+ "base_opportunity_amount",
+ "more_info",
+ "company",
+ "campaign",
+ "transaction_date",
+ "column_break1",
+ "language",
+ "amended_from",
+ "title",
+ "first_response_time",
+ "lost_detail_section",
+ "lost_reasons",
+ "order_lost_reason",
+ "column_break_56",
+ "competitors",
+ "contact_info",
+ "primary_contact_section",
+ "contact_person",
+ "job_title",
+ "column_break_54",
+ "contact_email",
+ "contact_mobile",
+ "column_break_22",
+ "whatsapp",
+ "phone",
+ "phone_ext",
+ "address_contact_section",
+ "address_html",
+ "customer_address",
+ "address_display",
+ "column_break3",
+ "contact_html",
+ "contact_display",
"items_section",
"items",
"section_break_32",
"base_total",
"column_break_33",
"total",
- "contact_info",
- "customer_address",
- "address_display",
- "territory",
- "customer_group",
- "column_break3",
- "contact_person",
- "contact_display",
- "contact_email",
- "contact_mobile",
- "more_info",
- "company",
- "campaign",
- "column_break1",
- "transaction_date",
- "language",
- "amended_from",
- "lost_detail_section",
- "lost_reasons",
- "order_lost_reason",
- "column_break_56",
- "competitors"
+ "activities_tab",
+ "open_activities_html",
+ "all_activities_section",
+ "all_activities_html",
+ "notes_tab",
+ "notes_html",
+ "notes",
+ "dashboard_tab"
],
"fields": [
{
- "fieldname": "from_section",
- "fieldtype": "Section Break",
- "options": "fa fa-user"
- },
- {
"fieldname": "naming_series",
"fieldtype": "Select",
"in_list_view": 1,
@@ -113,8 +133,9 @@
"bold": 1,
"fieldname": "customer_name",
"fieldtype": "Data",
+ "hidden": 1,
"in_global_search": 1,
- "label": "Customer / Lead Name",
+ "label": "Customer Name",
"read_only": 1
},
{
@@ -167,47 +188,9 @@
"label": "Expected Closing Date"
},
{
- "collapsible": 1,
- "collapsible_depends_on": "contact_by",
- "fieldname": "next_contact",
- "fieldtype": "Section Break",
- "label": "Follow Up"
- },
- {
- "fieldname": "contact_by",
- "fieldtype": "Link",
- "in_standard_filter": 1,
- "label": "Next Contact By",
- "oldfieldname": "contact_by",
- "oldfieldtype": "Link",
- "options": "User",
- "width": "75px"
- },
- {
- "fieldname": "contact_date",
- "fieldtype": "Datetime",
- "label": "Next Contact Date",
- "oldfieldname": "contact_date",
- "oldfieldtype": "Date"
- },
- {
- "fieldname": "column_break2",
- "fieldtype": "Column Break",
- "oldfieldtype": "Column Break",
- "width": "50%"
- },
- {
- "fieldname": "to_discuss",
- "fieldtype": "Small Text",
- "label": "To Discuss",
- "no_copy": 1,
- "oldfieldname": "to_discuss",
- "oldfieldtype": "Small Text"
- },
- {
"fieldname": "section_break_14",
"fieldtype": "Section Break",
- "label": "Sales"
+ "label": "Opportunity Value"
},
{
"fieldname": "currency",
@@ -222,12 +205,6 @@
"options": "currency"
},
{
- "default": "0",
- "fieldname": "with_items",
- "fieldtype": "Check",
- "label": "With Items"
- },
- {
"fieldname": "column_break_17",
"fieldtype": "Column Break"
},
@@ -245,9 +222,8 @@
"label": "Probability (%)"
},
{
- "depends_on": "with_items",
"fieldname": "items_section",
- "fieldtype": "Section Break",
+ "fieldtype": "Tab Break",
"label": "Items",
"oldfieldtype": "Section Break",
"options": "fa fa-shopping-cart"
@@ -262,18 +238,16 @@
"options": "Opportunity Item"
},
{
- "collapsible": 1,
- "collapsible_depends_on": "next_contact_by",
- "depends_on": "eval:doc.party_name",
"fieldname": "contact_info",
- "fieldtype": "Section Break",
- "label": "Contact Info",
+ "fieldtype": "Tab Break",
+ "label": "Contacts",
"options": "fa fa-bullhorn"
},
{
"depends_on": "eval:doc.party_name",
"fieldname": "customer_address",
"fieldtype": "Link",
+ "hidden": 1,
"label": "Customer / Lead Address",
"options": "Address",
"print_hide": 1
@@ -327,19 +301,16 @@
"read_only": 1
},
{
- "depends_on": "eval:doc.party_name",
"fieldname": "contact_email",
"fieldtype": "Data",
"label": "Contact Email",
- "options": "Email",
- "read_only": 1
+ "options": "Email"
},
{
- "depends_on": "eval:doc.party_name",
"fieldname": "contact_mobile",
- "fieldtype": "Small Text",
- "label": "Contact Mobile No",
- "read_only": 1
+ "fieldtype": "Data",
+ "label": "Contact Mobile",
+ "options": "Phone"
},
{
"collapsible": 1,
@@ -417,12 +388,6 @@
"read_only": 1
},
{
- "fieldname": "converted_by",
- "fieldtype": "Link",
- "label": "Converted By",
- "options": "User"
- },
- {
"bold": 1,
"fieldname": "first_response_time",
"fieldtype": "Duration",
@@ -474,6 +439,7 @@
"fieldtype": "Column Break"
},
{
+ "depends_on": "eval:doc.status===\"Lost\"",
"fieldname": "lost_detail_section",
"fieldtype": "Section Break",
"label": "Lost Reasons"
@@ -488,12 +454,179 @@
"label": "Competitors",
"options": "Competitor Detail",
"read_only": 1
+ },
+ {
+ "fieldname": "column_break_10",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "organization_details_section",
+ "fieldtype": "Section Break",
+ "label": "Organization"
+ },
+ {
+ "fieldname": "no_of_employees",
+ "fieldtype": "Int",
+ "label": "No of Employees"
+ },
+ {
+ "fieldname": "annual_revenue",
+ "fieldtype": "Currency",
+ "label": "Annual Revenue"
+ },
+ {
+ "fieldname": "industry",
+ "fieldtype": "Link",
+ "label": "Industry",
+ "options": "Industry Type"
+ },
+ {
+ "fieldname": "market_segment",
+ "fieldtype": "Link",
+ "label": "Market Segment",
+ "options": "Market Segment"
+ },
+ {
+ "fieldname": "column_break_23",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "address_contact_section",
+ "fieldtype": "Section Break",
+ "label": "Address & Contact"
+ },
+ {
+ "fieldname": "column_break_36",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "opportunity_owner",
+ "fieldtype": "Link",
+ "label": "Opportunity Owner",
+ "options": "User"
+ },
+ {
+ "fieldname": "website",
+ "fieldtype": "Data",
+ "label": "Website"
+ },
+ {
+ "fieldname": "column_break_22",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "whatsapp",
+ "fieldtype": "Data",
+ "label": "WhatsApp",
+ "options": "Phone"
+ },
+ {
+ "fieldname": "phone",
+ "fieldtype": "Data",
+ "label": "Phone",
+ "options": "Phone"
+ },
+ {
+ "fieldname": "phone_ext",
+ "fieldtype": "Data",
+ "label": "Phone Ext."
+ },
+ {
+ "fieldname": "column_break_31",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "primary_contact_section",
+ "fieldtype": "Section Break",
+ "label": "Primary Contact"
+ },
+ {
+ "fieldname": "column_break_54",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "eval:!doc.__islocal",
+ "fieldname": "dashboard_tab",
+ "fieldtype": "Tab Break",
+ "label": "Dashboard",
+ "show_dashboard": 1
+ },
+ {
+ "depends_on": "eval:!doc.__islocal",
+ "fieldname": "notes_tab",
+ "fieldtype": "Tab Break",
+ "label": "Notes"
+ },
+ {
+ "fieldname": "notes_html",
+ "fieldtype": "HTML",
+ "label": "Notes HTML"
+ },
+ {
+ "depends_on": "eval:!doc.__islocal",
+ "fieldname": "activities_tab",
+ "fieldtype": "Tab Break",
+ "label": "Activities"
+ },
+ {
+ "fieldname": "job_title",
+ "fieldtype": "Data",
+ "label": "Job Title"
+ },
+ {
+ "fieldname": "address_html",
+ "fieldtype": "HTML",
+ "label": "Address HTML"
+ },
+ {
+ "fieldname": "contact_html",
+ "fieldtype": "HTML",
+ "label": "Contact HTML"
+ },
+ {
+ "fieldname": "open_activities_html",
+ "fieldtype": "HTML",
+ "label": "Open Activities HTML"
+ },
+ {
+ "fieldname": "all_activities_section",
+ "fieldtype": "Section Break",
+ "label": "All Activities"
+ },
+ {
+ "fieldname": "all_activities_html",
+ "fieldtype": "HTML",
+ "label": "All Activities HTML"
+ },
+ {
+ "fieldname": "notes",
+ "fieldtype": "Table",
+ "hidden": 1,
+ "label": "Notes",
+ "no_copy": 1,
+ "options": "CRM Note"
+ },
+ {
+ "fieldname": "city",
+ "fieldtype": "Data",
+ "label": "City"
+ },
+ {
+ "fieldname": "state",
+ "fieldtype": "Data",
+ "label": "State"
+ },
+ {
+ "fieldname": "country",
+ "fieldtype": "Link",
+ "label": "Country",
+ "options": "Country"
}
],
"icon": "fa fa-info-sign",
"idx": 195,
"links": [],
- "modified": "2022-01-29 19:32:26.382896",
+ "modified": "2022-06-27 18:44:32.858696",
"modified_by": "Administrator",
"module": "CRM",
"name": "Opportunity",
diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py
index b590177..08eb472 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.py
+++ b/erpnext/crm/doctype/opportunity/opportunity.py
@@ -6,52 +6,54 @@
import frappe
from frappe import _
+from frappe.contacts.address_and_contact import load_address_and_contact
from frappe.email.inbox import link_communication_to_document
from frappe.model.mapper import get_mapped_doc
-from frappe.query_builder import DocType
-from frappe.utils import cint, flt, get_fullname
+from frappe.query_builder import DocType, Interval
+from frappe.query_builder.functions import Now
+from frappe.utils import flt, get_fullname
-from erpnext.crm.utils import add_link_in_communication, copy_comments
+from erpnext.crm.utils import (
+ CRMNote,
+ copy_comments,
+ link_communications,
+ link_open_events,
+ link_open_tasks,
+)
from erpnext.setup.utils import get_exchange_rate
from erpnext.utilities.transaction_base import TransactionBase
-class Opportunity(TransactionBase):
+class Opportunity(TransactionBase, CRMNote):
+ def onload(self):
+ ref_doc = frappe.get_doc(self.opportunity_from, self.party_name)
+ load_address_and_contact(ref_doc)
+ self.set("__onload", ref_doc.get("__onload"))
+
def after_insert(self):
if self.opportunity_from == "Lead":
frappe.get_doc("Lead", self.party_name).set_status(update=True)
+ self.disable_lead()
- if self.opportunity_from in ["Lead", "Prospect"]:
+ link_open_tasks(self.opportunity_from, self.party_name, self)
+ link_open_events(self.opportunity_from, self.party_name, self)
if frappe.db.get_single_value("CRM Settings", "carry_forward_communication_and_comments"):
copy_comments(self.opportunity_from, self.party_name, self)
- add_link_in_communication(self.opportunity_from, self.party_name, self)
+ link_communications(self.opportunity_from, self.party_name, self)
def validate(self):
- self._prev = frappe._dict(
- {
- "contact_date": frappe.db.get_value("Opportunity", self.name, "contact_date")
- if (not cint(self.get("__islocal")))
- else None,
- "contact_by": frappe.db.get_value("Opportunity", self.name, "contact_by")
- if (not cint(self.get("__islocal")))
- else None,
- }
- )
-
self.make_new_lead_if_required()
self.validate_item_details()
self.validate_uom_is_integer("uom", "qty")
self.validate_cust_name()
self.map_fields()
+ self.set_exchange_rate()
if not self.title:
self.title = self.customer_name
- if not self.with_items:
- self.items = []
-
- else:
- self.calculate_totals()
+ self.calculate_totals()
+ self.update_prospect()
def map_fields(self):
for field in self.meta.get_valid_columns():
@@ -62,18 +64,65 @@
except Exception:
continue
+ def set_exchange_rate(self):
+ company_currency = frappe.get_cached_value("Company", self.company, "default_currency")
+ if self.currency == company_currency:
+ self.conversion_rate = 1.0
+ return
+
+ if not self.conversion_rate or self.conversion_rate == 1.0:
+ self.conversion_rate = get_exchange_rate(self.currency, company_currency, self.transaction_date)
+
def calculate_totals(self):
total = base_total = 0
for item in self.get("items"):
item.amount = flt(item.rate) * flt(item.qty)
- item.base_rate = flt(self.conversion_rate * item.rate)
- item.base_amount = flt(self.conversion_rate * item.amount)
+ item.base_rate = flt(self.conversion_rate) * flt(item.rate)
+ item.base_amount = flt(self.conversion_rate) * flt(item.amount)
total += item.amount
base_total += item.base_amount
self.total = flt(total)
self.base_total = flt(base_total)
+ def update_prospect(self):
+ prospect_name = None
+ if self.opportunity_from == "Prospect" and self.party_name:
+ prospect_name = self.party_name
+ elif self.opportunity_from == "Lead":
+ prospect_name = frappe.db.get_value("Prospect Lead", {"lead": self.party_name}, "parent")
+
+ if prospect_name:
+ prospect = frappe.get_doc("Prospect", prospect_name)
+
+ opportunity_values = {
+ "opportunity": self.name,
+ "amount": self.opportunity_amount,
+ "stage": self.sales_stage,
+ "deal_owner": self.opportunity_owner,
+ "probability": self.probability,
+ "expected_closing": self.expected_closing,
+ "currency": self.currency,
+ "contact_person": self.contact_person,
+ }
+
+ opportunity_already_added = False
+ for d in prospect.get("opportunities", []):
+ if d.opportunity == self.name:
+ opportunity_already_added = True
+ d.update(opportunity_values)
+ d.db_update()
+
+ if not opportunity_already_added:
+ prospect.append("opportunities", opportunity_values)
+ prospect.flags.ignore_permissions = True
+ prospect.flags.ignore_mandatory = True
+ prospect.save()
+
+ def disable_lead(self):
+ if self.opportunity_from == "Lead":
+ frappe.db.set_value("Lead", self.party_name, {"disabled": 1, "docstatus": 1})
+
def make_new_lead_if_required(self):
"""Set lead against new opportunity"""
if (not self.get("party_name")) and self.contact_email:
@@ -143,11 +192,8 @@
else:
frappe.throw(_("Cannot declare as lost, because Quotation has been made."))
- def on_trash(self):
- self.delete_events()
-
def has_active_quotation(self):
- if not self.with_items:
+ if not self.get("items", []):
return frappe.get_all(
"Quotation",
{"opportunity": self.name, "status": ("not in", ["Lost", "Closed"]), "docstatus": 1},
@@ -164,7 +210,7 @@
)
def has_ordered_quotation(self):
- if not self.with_items:
+ if not self.get("items", []):
return frappe.get_all(
"Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name"
)
@@ -194,43 +240,20 @@
return True
def validate_cust_name(self):
- if self.party_name and self.opportunity_from == "Customer":
- self.customer_name = frappe.db.get_value("Customer", self.party_name, "customer_name")
- elif self.party_name and self.opportunity_from == "Lead":
- lead_name, company_name = frappe.db.get_value(
- "Lead", self.party_name, ["lead_name", "company_name"]
- )
- self.customer_name = company_name or lead_name
+ if self.party_name:
+ if self.opportunity_from == "Customer":
+ self.customer_name = frappe.db.get_value("Customer", self.party_name, "customer_name")
+ elif self.opportunity_from == "Lead":
+ customer_name = frappe.db.get_value("Prospect Lead", {"lead": self.party_name}, "parent")
+ if not customer_name:
+ lead_name, company_name = frappe.db.get_value(
+ "Lead", self.party_name, ["lead_name", "company_name"]
+ )
+ customer_name = company_name or lead_name
- def on_update(self):
- self.add_calendar_event()
-
- def add_calendar_event(self, opts=None, force=False):
- if frappe.db.get_single_value("CRM Settings", "create_event_on_next_contact_date_opportunity"):
- if not opts:
- opts = frappe._dict()
-
- opts.description = ""
- opts.contact_date = self.contact_date
-
- if self.party_name and self.opportunity_from == "Customer":
- if self.contact_person:
- opts.description = f"Contact {self.contact_person}"
- else:
- opts.description = f"Contact customer {self.party_name}"
- elif self.party_name and self.opportunity_from == "Lead":
- if self.contact_display:
- opts.description = f"Contact {self.contact_display}"
- else:
- opts.description = f"Contact lead {self.party_name}"
-
- opts.subject = opts.description
- opts.description += f". By : {self.contact_by}"
-
- if self.to_discuss:
- opts.description += f" To Discuss : {frappe.render_template(self.to_discuss, {'doc': self})}"
-
- super(Opportunity, self).add_calendar_event(opts, force)
+ self.customer_name = customer_name
+ elif self.opportunity_from == "Prospect":
+ self.customer_name = self.party_name
def validate_item_details(self):
if not self.get("items"):
@@ -294,7 +317,7 @@
quotation.run_method("set_missing_values")
quotation.run_method("calculate_taxes_and_totals")
- if not source.with_items:
+ if not source.get("items", []):
quotation.opportunity = source.name
doclist = get_mapped_doc(
@@ -398,15 +421,17 @@
frappe.db.get_single_value("CRM Settings", "close_opportunity_after_days") or 15
)
- opportunities = frappe.db.sql(
- """ select name from tabOpportunity where status='Replied' and
- modified<DATE_SUB(CURDATE(), INTERVAL %s DAY) """,
- (auto_close_after_days),
- as_dict=True,
- )
+ table = frappe.qb.DocType("Opportunity")
+ opportunities = (
+ frappe.qb.from_(table)
+ .select(table.name)
+ .where(
+ (table.modified < (Now() - Interval(days=auto_close_after_days))) & (table.status == "Replied")
+ )
+ ).run(pluck=True)
for opportunity in opportunities:
- doc = frappe.get_doc("Opportunity", opportunity.get("name"))
+ doc = frappe.get_doc("Opportunity", opportunity)
doc.status = "Closed"
doc.flags.ignore_permissions = True
doc.flags.ignore_mandatory = True
@@ -437,34 +462,3 @@
link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links)
return opportunity.name
-
-
-@frappe.whitelist()
-def get_events(start, end, filters=None):
- """Returns events for Gantt / Calendar view rendering.
- :param start: Start date-time.
- :param end: End date-time.
- :param filters: Filters (JSON).
- """
- from frappe.desk.calendar import get_event_conditions
-
- conditions = get_event_conditions("Opportunity", filters)
-
- data = frappe.db.sql(
- """
- select
- distinct `tabOpportunity`.name, `tabOpportunity`.customer_name, `tabOpportunity`.opportunity_amount,
- `tabOpportunity`.title, `tabOpportunity`.contact_date
- from
- `tabOpportunity`
- where
- (`tabOpportunity`.contact_date between %(start)s and %(end)s)
- {conditions}
- """.format(
- conditions=conditions
- ),
- {"start": start, "end": end},
- as_dict=True,
- update={"allDay": 0},
- )
- return data
diff --git a/erpnext/crm/doctype/opportunity/opportunity_calendar.js b/erpnext/crm/doctype/opportunity/opportunity_calendar.js
deleted file mode 100644
index 58fa2b8..0000000
--- a/erpnext/crm/doctype/opportunity/opportunity_calendar.js
+++ /dev/null
@@ -1,19 +0,0 @@
-// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
-// License: GNU General Public License v3. See license.txt
-frappe.views.calendar["Opportunity"] = {
- field_map: {
- "start": "contact_date",
- "end": "contact_date",
- "id": "name",
- "title": "customer_name",
- "allDay": "allDay"
- },
- options: {
- header: {
- left: 'prev,next today',
- center: 'title',
- right: 'month'
- }
- },
- get_events_method: 'erpnext.crm.doctype.opportunity.opportunity.get_events'
-}
diff --git a/erpnext/crm/doctype/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py
index 4a18e94..1ff3267 100644
--- a/erpnext/crm/doctype/opportunity/test_opportunity.py
+++ b/erpnext/crm/doctype/opportunity/test_opportunity.py
@@ -77,42 +77,6 @@
create_communication(opp_doc.doctype, opp_doc.name, opp_doc.contact_email)
create_communication(opp_doc.doctype, opp_doc.name, opp_doc.contact_email)
- quotation_doc = make_quotation(opp_doc.name)
- quotation_doc.append("items", {"item_code": "_Test Item", "qty": 1})
- quotation_doc.run_method("set_missing_values")
- quotation_doc.run_method("calculate_taxes_and_totals")
- quotation_doc.save()
-
- quotation_comment_count = frappe.db.count(
- "Comment",
- {
- "reference_doctype": quotation_doc.doctype,
- "reference_name": quotation_doc.name,
- "comment_type": "Comment",
- },
- )
- quotation_communication_count = len(
- get_linked_communication_list(quotation_doc.doctype, quotation_doc.name)
- )
- self.assertEqual(quotation_comment_count, 4)
- self.assertEqual(quotation_communication_count, 4)
-
- def test_render_template_for_to_discuss(self):
- doc = make_opportunity(with_items=0, opportunity_from="Lead")
- doc.contact_by = "test@example.com"
- doc.contact_date = add_days(today(), days=2)
- doc.to_discuss = "{{ doc.name }} test data"
- doc.save()
-
- event = frappe.get_all(
- "Event Participants",
- fields=["parent"],
- filters={"reference_doctype": doc.doctype, "reference_docname": doc.name},
- )
-
- event_description = frappe.db.get_value("Event", event[0].parent, "description")
- self.assertTrue(doc.name in event_description)
-
def make_opportunity_from_lead():
new_lead_email_id = "new{}@example.com".format(random_string(5))
@@ -139,7 +103,6 @@
"opportunity_from": args.opportunity_from or "Customer",
"opportunity_type": "Sales",
"conversion_rate": 1.0,
- "with_items": args.with_items or 0,
"transaction_date": today(),
}
)
diff --git a/erpnext/crm/doctype/opportunity/test_records.json b/erpnext/crm/doctype/opportunity/test_records.json
index a1e0ad9..f7e8350 100644
--- a/erpnext/crm/doctype/opportunity/test_records.json
+++ b/erpnext/crm/doctype/opportunity/test_records.json
@@ -8,7 +8,9 @@
"transaction_date": "2013-12-12",
"items": [{
"item_name": "Test Item",
- "description": "Some description"
+ "description": "Some description",
+ "qty": 5,
+ "rate": 100
}]
}
]
diff --git a/erpnext/crm/doctype/prospect/prospect.js b/erpnext/crm/doctype/prospect/prospect.js
index 8721a5b..495ed29 100644
--- a/erpnext/crm/doctype/prospect/prospect.js
+++ b/erpnext/crm/doctype/prospect/prospect.js
@@ -27,5 +27,26 @@
} else {
frappe.contacts.clear_address_and_contact(frm);
}
+ frm.trigger("show_notes");
+ frm.trigger("show_activities");
+ },
+
+ show_notes (frm) {
+ const crm_notes = new erpnext.utils.CRMNotes({
+ frm: frm,
+ notes_wrapper: $(frm.fields_dict.notes_html.wrapper),
+ });
+ crm_notes.refresh();
+ },
+
+ show_activities (frm) {
+ const crm_activities = new erpnext.utils.CRMActivities({
+ frm: frm,
+ open_activities_wrapper: $(frm.fields_dict.open_activities_html.wrapper),
+ all_activities_wrapper: $(frm.fields_dict.all_activities_html.wrapper),
+ form_wrapper: $(frm.wrapper),
+ });
+ crm_activities.refresh();
}
+
});
diff --git a/erpnext/crm/doctype/prospect/prospect.json b/erpnext/crm/doctype/prospect/prospect.json
index c9554ba..afc6c1d 100644
--- a/erpnext/crm/doctype/prospect/prospect.json
+++ b/erpnext/crm/doctype/prospect/prospect.json
@@ -1,33 +1,42 @@
{
"actions": [],
+ "allow_events_in_timeline": 1,
"autoname": "field:company_name",
"creation": "2021-08-19 00:21:06.995448",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
+ "overview_tab",
"company_name",
- "industry",
- "market_segment",
"customer_group",
+ "no_of_employees",
+ "annual_revenue",
+ "column_break_4",
+ "market_segment",
+ "industry",
"territory",
"column_break_6",
- "no_of_employees",
- "currency",
- "annual_revenue",
- "more_details_section",
- "fax",
- "website",
- "column_break_13",
"prospect_owner",
+ "website",
+ "fax",
"company",
- "leads_section",
- "prospect_lead",
"address_and_contact_section",
+ "column_break_16",
+ "contacts_tab",
"address_html",
- "column_break_17",
+ "column_break_18",
"contact_html",
+ "leads_section",
+ "leads",
+ "opportunities_tab",
+ "opportunities",
+ "activities_tab",
+ "open_activities_html",
+ "all_activities_section",
+ "all_activities_html",
"notes_section",
+ "notes_html",
"notes"
],
"fields": [
@@ -71,15 +80,9 @@
},
{
"fieldname": "no_of_employees",
- "fieldtype": "Int",
- "label": "No. of Employees"
- },
- {
- "fieldname": "currency",
- "fieldtype": "Link",
- "in_list_view": 1,
- "label": "Currency",
- "options": "Currency"
+ "fieldtype": "Select",
+ "label": "No. of Employees",
+ "options": "1-10\n11-20\n21-30\n31-100\n11-50\n51-200\n201-500\n101-500\n500-1000\n501-1000\n>1000\n1000+"
},
{
"fieldname": "annual_revenue",
@@ -97,8 +100,7 @@
{
"fieldname": "website",
"fieldtype": "Data",
- "label": "Website",
- "options": "URL"
+ "label": "Website"
},
{
"fieldname": "prospect_owner",
@@ -108,52 +110,31 @@
},
{
"fieldname": "leads_section",
- "fieldtype": "Section Break",
+ "fieldtype": "Tab Break",
"label": "Leads"
},
{
- "fieldname": "prospect_lead",
- "fieldtype": "Table",
- "options": "Prospect Lead"
- },
- {
"fieldname": "address_html",
"fieldtype": "HTML",
"label": "Address HTML"
},
{
- "fieldname": "column_break_17",
- "fieldtype": "Column Break"
- },
- {
"fieldname": "contact_html",
"fieldtype": "HTML",
"label": "Contact HTML"
},
{
"collapsible": 1,
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "notes_section",
- "fieldtype": "Section Break",
+ "fieldtype": "Tab Break",
"label": "Notes"
},
{
- "fieldname": "notes",
- "fieldtype": "Text Editor"
- },
- {
- "fieldname": "more_details_section",
- "fieldtype": "Section Break",
- "label": "More Details"
- },
- {
- "fieldname": "column_break_13",
- "fieldtype": "Column Break"
- },
- {
"depends_on": "eval: !doc.__islocal",
"fieldname": "address_and_contact_section",
"fieldtype": "Section Break",
- "label": "Address and Contact"
+ "label": "Address"
},
{
"fieldname": "company",
@@ -161,11 +142,83 @@
"label": "Company",
"options": "Company",
"reqd": 1
+ },
+ {
+ "fieldname": "column_break_4",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "opportunities_tab",
+ "fieldtype": "Tab Break",
+ "label": "Opportunities"
+ },
+ {
+ "depends_on": "eval:!doc.__islocal",
+ "fieldname": "activities_tab",
+ "fieldtype": "Tab Break",
+ "label": "Activities"
+ },
+ {
+ "fieldname": "notes_html",
+ "fieldtype": "HTML",
+ "label": "Notes HTML"
+ },
+ {
+ "fieldname": "opportunities",
+ "fieldtype": "Table",
+ "label": "Opportunities",
+ "options": "Prospect Opportunity"
+ },
+ {
+ "fieldname": "contacts_tab",
+ "fieldtype": "Tab Break",
+ "label": "Address & Contact"
+ },
+ {
+ "fieldname": "column_break_18",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "leads",
+ "fieldtype": "Table",
+ "options": "Prospect Lead"
+ },
+ {
+ "fieldname": "column_break_16",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "overview_tab",
+ "fieldtype": "Tab Break",
+ "label": "Overview"
+ },
+ {
+ "fieldname": "open_activities_html",
+ "fieldtype": "HTML",
+ "label": "Open Activities HTML"
+ },
+ {
+ "fieldname": "all_activities_section",
+ "fieldtype": "Section Break",
+ "label": "All Activities"
+ },
+ {
+ "fieldname": "all_activities_html",
+ "fieldtype": "HTML",
+ "label": "All Activities HTML"
+ },
+ {
+ "fieldname": "notes",
+ "fieldtype": "Table",
+ "hidden": 1,
+ "label": "Notes",
+ "no_copy": 1,
+ "options": "CRM Note"
}
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2021-11-01 13:10:36.759249",
+ "modified": "2022-06-21 15:10:26.887502",
"modified_by": "Administrator",
"module": "CRM",
"name": "Prospect",
@@ -207,6 +260,7 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"title_field": "company_name",
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/crm/doctype/prospect/prospect.py b/erpnext/crm/doctype/prospect/prospect.py
index 39436f5..fbb1158 100644
--- a/erpnext/crm/doctype/prospect/prospect.py
+++ b/erpnext/crm/doctype/prospect/prospect.py
@@ -3,19 +3,15 @@
import frappe
from frappe.contacts.address_and_contact import load_address_and_contact
-from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
-from erpnext.crm.utils import add_link_in_communication, copy_comments
+from erpnext.crm.utils import CRMNote, copy_comments, link_communications, link_open_events
-class Prospect(Document):
+class Prospect(CRMNote):
def onload(self):
load_address_and_contact(self)
- def validate(self):
- self.update_lead_details()
-
def on_update(self):
self.link_with_lead_contact_and_address()
@@ -23,23 +19,24 @@
self.unlink_dynamic_links()
def after_insert(self):
- if frappe.db.get_single_value("CRM Settings", "carry_forward_communication_and_comments"):
- for row in self.get("prospect_lead"):
- copy_comments("Lead", row.lead, self)
- add_link_in_communication("Lead", row.lead, self)
+ carry_forward_communication_and_comments = frappe.db.get_single_value(
+ "CRM Settings", "carry_forward_communication_and_comments"
+ )
- def update_lead_details(self):
- for row in self.get("prospect_lead"):
- lead = frappe.get_value(
- "Lead", row.lead, ["lead_name", "status", "email_id", "mobile_no"], as_dict=True
- )
- row.lead_name = lead.lead_name
- row.status = lead.status
- row.email = lead.email_id
- row.mobile_no = lead.mobile_no
+ for row in self.get("leads"):
+ if carry_forward_communication_and_comments:
+ copy_comments("Lead", row.lead, self)
+ link_communications("Lead", row.lead, self)
+ link_open_events("Lead", row.lead, self)
+
+ for row in self.get("opportunities"):
+ if carry_forward_communication_and_comments:
+ copy_comments("Opportunity", row.opportunity, self)
+ link_communications("Opportunity", row.opportunity, self)
+ link_open_events("Opportunity", row.opportunity, self)
def link_with_lead_contact_and_address(self):
- for row in self.prospect_lead:
+ for row in self.leads:
links = frappe.get_all(
"Dynamic Link",
filters={"link_doctype": "Lead", "link_name": row.lead},
@@ -116,9 +113,7 @@
{
"Prospect": {
"doctype": "Opportunity",
- "field_map": {
- "name": "party_name",
- },
+ "field_map": {"name": "party_name", "prospect_owner": "opportunity_owner"},
}
},
target_doc,
@@ -127,3 +122,25 @@
)
return doclist
+
+
+@frappe.whitelist()
+def get_opportunities(prospect):
+ return frappe.get_all(
+ "Opportunity",
+ filters={"opportunity_from": "Prospect", "party_name": prospect},
+ fields=[
+ "opportunity_owner",
+ "sales_stage",
+ "status",
+ "expected_closing",
+ "probability",
+ "opportunity_amount",
+ "currency",
+ "contact_person",
+ "contact_email",
+ "contact_mobile",
+ "creation",
+ "name",
+ ],
+ )
diff --git a/erpnext/crm/doctype/prospect/test_prospect.py b/erpnext/crm/doctype/prospect/test_prospect.py
index ddd7b93..874f84c 100644
--- a/erpnext/crm/doctype/prospect/test_prospect.py
+++ b/erpnext/crm/doctype/prospect/test_prospect.py
@@ -20,7 +20,7 @@
add_lead_to_prospect(lead_doc.name, prospect_doc.name)
prospect_doc.reload()
lead_exists_in_prosoect = False
- for rec in prospect_doc.get("prospect_lead"):
+ for rec in prospect_doc.get("leads"):
if rec.lead == lead_doc.name:
lead_exists_in_prosoect = True
self.assertEqual(lead_exists_in_prosoect, True)
diff --git a/erpnext/crm/doctype/prospect_lead/prospect_lead.json b/erpnext/crm/doctype/prospect_lead/prospect_lead.json
index 3c160d9..075c0f9 100644
--- a/erpnext/crm/doctype/prospect_lead/prospect_lead.json
+++ b/erpnext/crm/doctype/prospect_lead/prospect_lead.json
@@ -7,12 +7,15 @@
"field_order": [
"lead",
"lead_name",
- "status",
"email",
- "mobile_no"
+ "column_break_4",
+ "mobile_no",
+ "lead_owner",
+ "status"
],
"fields": [
{
+ "columns": 2,
"fieldname": "lead",
"fieldtype": "Link",
"in_list_view": 1,
@@ -21,6 +24,8 @@
"reqd": 1
},
{
+ "columns": 2,
+ "fetch_from": "lead.lead_name",
"fieldname": "lead_name",
"fieldtype": "Data",
"in_list_view": 1,
@@ -28,14 +33,17 @@
"read_only": 1
},
{
+ "columns": 1,
+ "fetch_from": "lead.status",
"fieldname": "status",
- "fieldtype": "Select",
+ "fieldtype": "Data",
"in_list_view": 1,
"label": "Status",
- "options": "Lead\nOpen\nReplied\nOpportunity\nQuotation\nLost Quotation\nInterested\nConverted\nDo Not Contact",
"read_only": 1
},
{
+ "columns": 2,
+ "fetch_from": "lead.email_id",
"fieldname": "email",
"fieldtype": "Data",
"in_list_view": 1,
@@ -44,18 +52,32 @@
"read_only": 1
},
{
+ "columns": 2,
+ "fetch_from": "lead.mobile_no",
"fieldname": "mobile_no",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Mobile No",
"options": "Phone",
"read_only": 1
+ },
+ {
+ "fieldname": "column_break_4",
+ "fieldtype": "Column Break"
+ },
+ {
+ "columns": 1,
+ "fetch_from": "lead.lead_owner",
+ "fieldname": "lead_owner",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Lead Owner"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-08-25 12:58:24.638054",
+ "modified": "2022-04-28 20:27:58.805970",
"modified_by": "Administrator",
"module": "CRM",
"name": "Prospect Lead",
@@ -63,5 +85,6 @@
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/erpnext_integrations/data_migration_mapping/__init__.py b/erpnext/crm/doctype/prospect_opportunity/__init__.py
similarity index 100%
copy from erpnext/erpnext_integrations/data_migration_mapping/__init__.py
copy to erpnext/crm/doctype/prospect_opportunity/__init__.py
diff --git a/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.json b/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.json
new file mode 100644
index 0000000..d8c2520
--- /dev/null
+++ b/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.json
@@ -0,0 +1,101 @@
+{
+ "actions": [],
+ "autoname": "autoincrement",
+ "creation": "2022-04-27 17:40:37.965161",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "opportunity",
+ "amount",
+ "stage",
+ "deal_owner",
+ "column_break_4",
+ "probability",
+ "expected_closing",
+ "currency",
+ "contact_person"
+ ],
+ "fields": [
+ {
+ "columns": 2,
+ "fieldname": "opportunity",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Opportunity",
+ "options": "Opportunity"
+ },
+ {
+ "columns": 2,
+ "fetch_from": "opportunity.opportunity_amount",
+ "fieldname": "amount",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Amount",
+ "options": "currency"
+ },
+ {
+ "columns": 2,
+ "fetch_from": "opportunity.sales_stage",
+ "fieldname": "stage",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Stage"
+ },
+ {
+ "columns": 1,
+ "fetch_from": "opportunity.probability",
+ "fieldname": "probability",
+ "fieldtype": "Percent",
+ "in_list_view": 1,
+ "label": "Probability"
+ },
+ {
+ "columns": 1,
+ "fetch_from": "opportunity.expected_closing",
+ "fieldname": "expected_closing",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "Closing"
+ },
+ {
+ "fetch_from": "opportunity.currency",
+ "fieldname": "currency",
+ "fieldtype": "Link",
+ "label": "Currency",
+ "options": "Currency"
+ },
+ {
+ "fieldname": "column_break_4",
+ "fieldtype": "Column Break"
+ },
+ {
+ "columns": 2,
+ "fetch_from": "opportunity.opportunity_owner",
+ "fieldname": "deal_owner",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Deal Owner"
+ },
+ {
+ "fetch_from": "opportunity.contact_person",
+ "fieldname": "contact_person",
+ "fieldtype": "Link",
+ "label": "Contact Person",
+ "options": "Contact"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2022-04-28 10:05:38.730368",
+ "modified_by": "Administrator",
+ "module": "CRM",
+ "name": "Prospect Opportunity",
+ "naming_rule": "Autoincrement",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.py b/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.py
new file mode 100644
index 0000000..8f5d19a
--- /dev/null
+++ b/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class ProspectOpportunity(Document):
+ pass
diff --git a/erpnext/crm/report/lost_opportunity/lost_opportunity.js b/erpnext/crm/report/lost_opportunity/lost_opportunity.js
index 97c56f8..927c54d 100644
--- a/erpnext/crm/report/lost_opportunity/lost_opportunity.js
+++ b/erpnext/crm/report/lost_opportunity/lost_opportunity.js
@@ -57,11 +57,5 @@
"fieldtype": "Dynamic Link",
"options": "opportunity_from"
},
- {
- "fieldname":"contact_by",
- "label": __("Next Contact By"),
- "fieldtype": "Link",
- "options": "User"
- },
]
};
diff --git a/erpnext/crm/report/lost_opportunity/lost_opportunity.json b/erpnext/crm/report/lost_opportunity/lost_opportunity.json
index e7a8e12..f6f36bd 100644
--- a/erpnext/crm/report/lost_opportunity/lost_opportunity.json
+++ b/erpnext/crm/report/lost_opportunity/lost_opportunity.json
@@ -7,8 +7,8 @@
"doctype": "Report",
"idx": 0,
"is_standard": "Yes",
- "json": "{\"order_by\": \"`tabOpportunity`.`modified` desc\", \"filters\": [[\"Opportunity\", \"status\", \"=\", \"Lost\"]], \"fields\": [[\"name\", \"Opportunity\"], [\"opportunity_from\", \"Opportunity\"], [\"party_name\", \"Opportunity\"], [\"customer_name\", \"Opportunity\"], [\"opportunity_type\", \"Opportunity\"], [\"status\", \"Opportunity\"], [\"contact_by\", \"Opportunity\"], [\"docstatus\", \"Opportunity\"], [\"lost_reason\", \"Lost Reason Detail\"]], \"add_totals_row\": 0, \"add_total_row\": 0, \"page_length\": 20}",
- "modified": "2020-07-29 15:49:02.848845",
+ "json": "{\"order_by\": \"`tabOpportunity`.`modified` desc\", \"filters\": [[\"Opportunity\", \"status\", \"=\", \"Lost\"]], \"fields\": [[\"name\", \"Opportunity\"], [\"opportunity_from\", \"Opportunity\"], [\"party_name\", \"Opportunity\"], [\"customer_name\", \"Opportunity\"], [\"opportunity_type\", \"Opportunity\"], [\"status\", \"Opportunity\"], [\"docstatus\", \"Opportunity\"], [\"lost_reason\", \"Lost Reason Detail\"]], \"add_totals_row\": 0, \"add_total_row\": 0, \"page_length\": 20}",
+ "modified": "2022-06-04 15:49:02.848845",
"modified_by": "Administrator",
"module": "CRM",
"name": "Lost Opportunity",
diff --git a/erpnext/crm/report/lost_opportunity/lost_opportunity.py b/erpnext/crm/report/lost_opportunity/lost_opportunity.py
index a57b44b..254511c 100644
--- a/erpnext/crm/report/lost_opportunity/lost_opportunity.py
+++ b/erpnext/crm/report/lost_opportunity/lost_opportunity.py
@@ -61,13 +61,6 @@
"options": "Territory",
"width": 150,
},
- {
- "label": _("Next Contact By"),
- "fieldname": "contact_by",
- "fieldtype": "Link",
- "options": "User",
- "width": 150,
- },
]
return columns
@@ -81,7 +74,6 @@
`tabOpportunity`.party_name,
`tabOpportunity`.customer_name,
`tabOpportunity`.opportunity_type,
- `tabOpportunity`.contact_by,
GROUP_CONCAT(`tabOpportunity Lost Reason Detail`.lost_reason separator ', ') lost_reason,
`tabOpportunity`.sales_stage,
`tabOpportunity`.territory
@@ -115,9 +107,6 @@
if filters.get("party_name"):
conditions.append(" and `tabOpportunity`.party_name=%(party_name)s")
- if filters.get("contact_by"):
- conditions.append(" and `tabOpportunity`.contact_by=%(contact_by)s")
-
return " ".join(conditions) if conditions else ""
diff --git a/erpnext/crm/utils.py b/erpnext/crm/utils.py
index 5783b2c..a2528c3 100644
--- a/erpnext/crm/utils.py
+++ b/erpnext/crm/utils.py
@@ -1,4 +1,7 @@
import frappe
+from frappe.model.document import Document
+from frappe.utils import cstr, now, today
+from pypika import functions
def update_lead_phone_numbers(contact, method):
@@ -41,7 +44,7 @@
comment.insert()
-def add_link_in_communication(doctype, docname, doc):
+def link_communications(doctype, docname, doc):
communication_list = get_linked_communication_list(doctype, docname)
for communication in communication_list:
@@ -60,3 +63,159 @@
)
return communications + communication_links
+
+
+def link_communications_with_prospect(communication, method):
+ prospect = get_linked_prospect(communication.reference_doctype, communication.reference_name)
+
+ if prospect:
+ already_linked = any(
+ [
+ d.name
+ for d in communication.get("timeline_links")
+ if d.link_doctype == "Prospect" and d.link_name == prospect
+ ]
+ )
+ if not already_linked:
+ row = communication.append("timeline_links")
+ row.link_doctype = "Prospect"
+ row.link_name = prospect
+ row.db_update()
+
+
+def get_linked_prospect(reference_doctype, reference_name):
+ prospect = None
+ if reference_doctype == "Lead":
+ prospect = frappe.db.get_value("Prospect Lead", {"lead": reference_name}, "parent")
+
+ elif reference_doctype == "Opportunity":
+ opportunity_from, party_name = frappe.db.get_value(
+ "Opportunity", reference_name, ["opportunity_from", "party_name"]
+ )
+ if opportunity_from == "Lead":
+ prospect = frappe.db.get_value(
+ "Prospect Opportunity", {"opportunity": reference_name}, "parent"
+ )
+ if opportunity_from == "Prospect":
+ prospect = party_name
+
+ return prospect
+
+
+def link_events_with_prospect(event, method):
+ if event.event_participants:
+ ref_doctype = event.event_participants[0].reference_doctype
+ ref_docname = event.event_participants[0].reference_docname
+ prospect = get_linked_prospect(ref_doctype, ref_docname)
+ if prospect:
+ event.add_participant("Prospect", prospect)
+ event.save()
+
+
+def link_open_tasks(ref_doctype, ref_docname, doc):
+ todos = get_open_todos(ref_doctype, ref_docname)
+
+ for todo in todos:
+ todo_doc = frappe.get_doc("ToDo", todo.name)
+ todo_doc.reference_type = doc.doctype
+ todo_doc.reference_name = doc.name
+ todo_doc.db_update()
+
+
+def link_open_events(ref_doctype, ref_docname, doc):
+ events = get_open_events(ref_doctype, ref_docname)
+ for event in events:
+ event_doc = frappe.get_doc("Event", event.name)
+ event_doc.add_participant(doc.doctype, doc.name)
+ event_doc.save()
+
+
+@frappe.whitelist()
+def get_open_activities(ref_doctype, ref_docname):
+ tasks = get_open_todos(ref_doctype, ref_docname)
+ events = get_open_events(ref_doctype, ref_docname)
+
+ return {"tasks": tasks, "events": events}
+
+
+def get_open_todos(ref_doctype, ref_docname):
+ return frappe.get_all(
+ "ToDo",
+ filters={"reference_type": ref_doctype, "reference_name": ref_docname, "status": "Open"},
+ fields=[
+ "name",
+ "description",
+ "allocated_to",
+ "date",
+ ],
+ )
+
+
+def get_open_events(ref_doctype, ref_docname):
+ event = frappe.qb.DocType("Event")
+ event_link = frappe.qb.DocType("Event Participants")
+
+ query = (
+ frappe.qb.from_(event)
+ .join(event_link)
+ .on(event_link.parent == event.name)
+ .select(
+ event.name,
+ event.subject,
+ event.event_category,
+ event.starts_on,
+ event.ends_on,
+ event.description,
+ )
+ .where(
+ (event_link.reference_doctype == ref_doctype)
+ & (event_link.reference_docname == ref_docname)
+ & (event.status == "Open")
+ )
+ )
+ data = query.run(as_dict=True)
+
+ return data
+
+
+def open_leads_opportunities_based_on_todays_event():
+ event = frappe.qb.DocType("Event")
+ event_link = frappe.qb.DocType("Event Participants")
+
+ query = (
+ frappe.qb.from_(event)
+ .join(event_link)
+ .on(event_link.parent == event.name)
+ .select(event_link.reference_doctype, event_link.reference_docname)
+ .where(
+ (event_link.reference_doctype.isin(["Lead", "Opportunity"]))
+ & (event.status == "Open")
+ & (functions.Date(event.starts_on) == today())
+ )
+ )
+ data = query.run(as_dict=True)
+
+ for d in data:
+ frappe.db.set_value(d.reference_doctype, d.reference_docname, "status", "Open")
+
+
+class CRMNote(Document):
+ @frappe.whitelist()
+ def add_note(self, note):
+ self.append("notes", {"note": note, "added_by": frappe.session.user, "added_on": now()})
+ self.save()
+
+ @frappe.whitelist()
+ def edit_note(self, note, row_id):
+ for d in self.notes:
+ if cstr(d.name) == row_id:
+ d.note = note
+ d.db_update()
+
+ @frappe.whitelist()
+ def delete_note(self, row_id):
+ for d in self.notes:
+ if cstr(d.name) == row_id:
+ self.remove(d)
+ break
+ self.save()
diff --git a/erpnext/erpnext_integrations/connectors/github_connection.py b/erpnext/erpnext_integrations/connectors/github_connection.py
deleted file mode 100644
index f28065e..0000000
--- a/erpnext/erpnext_integrations/connectors/github_connection.py
+++ /dev/null
@@ -1,44 +0,0 @@
-import frappe
-from frappe.data_migration.doctype.data_migration_connector.connectors.base import BaseConnection
-from github import Github
-
-class GithubConnection(BaseConnection):
- def __init__(self, connector):
- self.connector = connector
-
- try:
- password = self.get_password()
- except frappe.AuthenticationError:
- password = None
-
- if self.connector.username and password:
- self.connection = Github(self.connector.username, self.get_password())
- else:
- self.connection = Github()
-
- self.name_field = 'id'
-
- def insert(self, doctype, doc):
- pass
-
- def update(self, doctype, doc, migration_id):
- pass
-
- def delete(self, doctype, migration_id):
- pass
-
- def get(self, remote_objectname, fields=None, filters=None, start=0, page_length=10):
- repo = filters.get('repo')
-
- if remote_objectname == 'Milestone':
- return self.get_milestones(repo, start, page_length)
- if remote_objectname == 'Issue':
- return self.get_issues(repo, start, page_length)
-
- def get_milestones(self, repo, start=0, page_length=10):
- _repo = self.connection.get_repo(repo)
- return list(_repo.get_milestones()[start:start+page_length])
-
- def get_issues(self, repo, start=0, page_length=10):
- _repo = self.connection.get_repo(repo)
- return list(_repo.get_issues()[start:start+page_length])
diff --git a/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/__init__.py b/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/__init__.py
deleted file mode 100644
index 616ecfb..0000000
--- a/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/__init__.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import frappe
-
-
-def pre_process(issue):
-
- project = frappe.db.get_value("Project", filters={"project_name": issue.milestone})
- return {
- "title": issue.title,
- "body": frappe.utils.md_to_html(issue.body or ""),
- "state": issue.state.title(),
- "project": project or "",
- }
diff --git a/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/issue_to_task.json b/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/issue_to_task.json
deleted file mode 100644
index e945ba2..0000000
--- a/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/issue_to_task.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "condition": "{\"repo\":\"frappe/erpnext\"}",
- "creation": "2017-10-16 16:03:32.772191",
- "docstatus": 0,
- "doctype": "Data Migration Mapping",
- "fields": [
- {
- "is_child_table": 0,
- "local_fieldname": "subject",
- "remote_fieldname": "title"
- },
- {
- "is_child_table": 0,
- "local_fieldname": "description",
- "remote_fieldname": "body"
- },
- {
- "is_child_table": 0,
- "local_fieldname": "status",
- "remote_fieldname": "state"
- }
- ],
- "idx": 0,
- "local_doctype": "Task",
- "local_primary_key": "name",
- "mapping_name": "Issue to Task",
- "mapping_type": "Pull",
- "migration_id_field": "github_sync_id",
- "modified": "2017-10-20 11:48:54.575993",
- "modified_by": "Administrator",
- "name": "Issue to Task",
- "owner": "Administrator",
- "page_length": 10,
- "remote_objectname": "Issue",
- "remote_primary_key": "id"
-}
\ No newline at end of file
diff --git a/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/__init__.py b/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/__init__.py
deleted file mode 100644
index d44fc04..0000000
--- a/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-def pre_process(milestone):
- return {
- "title": milestone.title,
- "description": milestone.description,
- "state": milestone.state.title(),
- }
diff --git a/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/milestone_to_project.json b/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/milestone_to_project.json
deleted file mode 100644
index 5a3e07e..0000000
--- a/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/milestone_to_project.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "condition": "{\"repo\": \"frappe/erpnext\"}",
- "creation": "2017-10-13 11:16:49.664925",
- "docstatus": 0,
- "doctype": "Data Migration Mapping",
- "fields": [
- {
- "is_child_table": 0,
- "local_fieldname": "project_name",
- "remote_fieldname": "title"
- },
- {
- "is_child_table": 0,
- "local_fieldname": "notes",
- "remote_fieldname": "description"
- },
- {
- "is_child_table": 0,
- "local_fieldname": "status",
- "remote_fieldname": "state"
- }
- ],
- "idx": 0,
- "local_doctype": "Project",
- "local_primary_key": "project_name",
- "mapping_name": "Milestone to Project",
- "mapping_type": "Pull",
- "migration_id_field": "github_sync_id",
- "modified": "2017-10-20 11:48:54.552305",
- "modified_by": "Administrator",
- "name": "Milestone to Project",
- "owner": "Administrator",
- "page_length": 10,
- "remote_objectname": "Milestone",
- "remote_primary_key": "id"
-}
\ No newline at end of file
diff --git a/erpnext/erpnext_integrations/data_migration_plan/github_sync/github_sync.json b/erpnext/erpnext_integrations/data_migration_plan/github_sync/github_sync.json
deleted file mode 100644
index 20eb387..0000000
--- a/erpnext/erpnext_integrations/data_migration_plan/github_sync/github_sync.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "creation": "2017-10-13 11:16:53.600026",
- "docstatus": 0,
- "doctype": "Data Migration Plan",
- "idx": 0,
- "mappings": [
- {
- "enabled": 1,
- "mapping": "Milestone to Project"
- },
- {
- "enabled": 1,
- "mapping": "Issue to Task"
- }
- ],
- "modified": "2017-10-20 11:48:54.496123",
- "modified_by": "Administrator",
- "module": "ERPNext Integrations",
- "name": "GitHub Sync",
- "owner": "Administrator",
- "plan_name": "GitHub Sync"
-}
\ No newline at end of file
diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py
index 17e332c..b526624 100644
--- a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py
+++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py
@@ -23,7 +23,7 @@
def tearDown(self):
frappe.db.sql("delete from `tabMpesa Settings`")
- frappe.db.sql('delete from `tabIntegration Request` where integration_request_service = "Mpesa"')
+ frappe.db.sql("delete from `tabIntegration Request` where integration_request_service = 'Mpesa'")
def test_creation_of_payment_gateway(self):
mode_of_payment = create_mode_of_payment("Mpesa-_Test", payment_type="Phone")
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index a2dc205..bc5a59a 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -300,7 +300,11 @@
"on_update": [
"erpnext.support.doctype.service_level_agreement.service_level_agreement.on_communication_update",
"erpnext.support.doctype.issue.issue.set_first_response_time",
- ]
+ ],
+ "after_insert": "erpnext.crm.utils.link_communications_with_prospect",
+ },
+ "Event": {
+ "after_insert": "erpnext.crm.utils.link_events_with_prospect",
},
"Sales Taxes and Charges Template": {
"on_update": "erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings.validate_cart_settings"
@@ -391,9 +395,12 @@
scheduler_events = {
"cron": {
+ "0/5 * * * *": [
+ "erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs",
+ ],
"0/30 * * * *": [
"erpnext.utilities.doctype.video.video.update_youtube_data",
- ]
+ ],
},
"all": [
"erpnext.projects.doctype.project.project.project_status_update_reminder",
@@ -439,7 +446,7 @@
"erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.auto_update_latest_price_in_all_boms",
"erpnext.loan_management.doctype.process_loan_security_shortfall.process_loan_security_shortfall.create_process_loan_security_shortfall",
"erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual.process_loan_interest_accrual_for_term_loans",
- "erpnext.crm.doctype.lead.lead.daily_open_lead",
+ "erpnext.crm.utils.open_leads_opportunities_based_on_todays_event",
],
"monthly_long": [
"erpnext.accounts.deferred_revenue.process_deferred_accounting",
diff --git a/erpnext/loan_management/doctype/loan/loan.py b/erpnext/loan_management/doctype/loan/loan.py
index a795668..d84eef6 100644
--- a/erpnext/loan_management/doctype/loan/loan.py
+++ b/erpnext/loan_management/doctype/loan/loan.py
@@ -70,7 +70,7 @@
def on_cancel(self):
self.unlink_loan_security_pledge()
- self.ignore_linked_doctypes = ["GL Entry"]
+ self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
def set_missing_fields(self):
if not self.company:
diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
index 10174e5..0c2042b 100644
--- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
+++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
@@ -29,7 +29,7 @@
def on_cancel(self):
self.set_status_and_amounts(cancel=1)
self.make_gl_entries(cancel=1)
- self.ignore_linked_doctypes = ["GL Entry"]
+ self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
def set_missing_values(self):
if not self.disbursement_date:
diff --git a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py
index 3a4c651..0aeb448 100644
--- a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py
+++ b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py
@@ -32,7 +32,7 @@
self.update_is_accrued()
self.make_gl_entries(cancel=1)
- self.ignore_linked_doctypes = ["GL Entry"]
+ self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
def update_is_accrued(self):
frappe.db.set_value("Repayment Schedule", self.repayment_schedule_name, "is_accrued", 0)
diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
index 262545b..baeb2fb 100644
--- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
+++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
@@ -41,7 +41,7 @@
self.check_future_accruals()
self.update_repayment_schedule(cancel=1)
self.mark_as_unpaid()
- self.ignore_linked_doctypes = ["GL Entry"]
+ self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
self.make_gl_entries(cancel=1)
def set_missing_values(self, amounts):
diff --git a/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py b/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py
index e19fd15..25aecf6 100644
--- a/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py
+++ b/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py
@@ -42,7 +42,7 @@
def on_cancel(self):
self.update_outstanding_amount(cancel=1)
- self.ignore_linked_doctypes = ["GL Entry"]
+ self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
self.make_gl_entries(cancel=1)
def update_outstanding_amount(self, cancel=0):
diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js
index d743798..ecad41f 100644
--- a/erpnext/manufacturing/doctype/bom/bom.js
+++ b/erpnext/manufacturing/doctype/bom/bom.js
@@ -81,7 +81,7 @@
}
)
- if (!frm.doc.__islocal && frm.doc.docstatus<2) {
+ if (!frm.is_new() && frm.doc.docstatus<2) {
frm.add_custom_button(__("Update Cost"), function() {
frm.events.update_cost(frm, true);
});
@@ -93,10 +93,12 @@
});
}
- frm.add_custom_button(__("New Version"), function() {
- let new_bom = frappe.model.copy_doc(frm.doc);
- frappe.set_route("Form", "BOM", new_bom.name);
- });
+ if (!frm.is_new() && !frm.doc.docstatus == 0) {
+ frm.add_custom_button(__("New Version"), function() {
+ let new_bom = frappe.model.copy_doc(frm.doc);
+ frappe.set_route("Form", "BOM", new_bom.name);
+ });
+ }
if(frm.doc.docstatus==1) {
frm.add_custom_button(__("Work Order"), function() {
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index 6376359..b29f671 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -1,11 +1,11 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import functools
import re
from collections import deque
from operator import itemgetter
-from typing import List
+from typing import Dict, List
import frappe
from frappe import _
@@ -189,6 +189,7 @@
self.validate_transfer_against()
self.set_routing_operations()
self.validate_operations()
+ self.update_exploded_items(save=False)
self.calculate_cost()
self.update_stock_qty()
self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False)
@@ -386,40 +387,14 @@
existing_bom_cost = self.total_cost
- for d in self.get("items"):
- if not d.item_code:
- continue
-
- rate = self.get_rm_rate(
- {
- "company": self.company,
- "item_code": d.item_code,
- "bom_no": d.bom_no,
- "qty": d.qty,
- "uom": d.uom,
- "stock_uom": d.stock_uom,
- "conversion_factor": d.conversion_factor,
- "sourced_by_supplier": d.sourced_by_supplier,
- }
- )
-
- if rate:
- d.rate = rate
- d.amount = flt(d.rate) * flt(d.qty)
- d.base_rate = flt(d.rate) * flt(self.conversion_rate)
- d.base_amount = flt(d.amount) * flt(self.conversion_rate)
-
- if save:
- d.db_update()
-
if self.docstatus == 1:
self.flags.ignore_validate_update_after_submit = True
- self.calculate_cost(update_hour_rate)
+
+ self.calculate_cost(save_updates=save, update_hour_rate=update_hour_rate)
+
if save:
self.db_update()
- self.update_exploded_items(save=save)
-
# update parent BOMs
if self.total_cost != existing_bom_cost and update_parent:
parent_boms = frappe.db.sql_list(
@@ -470,6 +445,7 @@
and self.is_active
):
frappe.db.set(self, "is_default", 1)
+ frappe.db.set_value("Item", self.item, "default_bom", self.name)
else:
frappe.db.set(self, "is_default", 0)
item = frappe.get_doc("Item", self.item)
@@ -608,11 +584,15 @@
bom_list.reverse()
return bom_list
- def calculate_cost(self, update_hour_rate=False):
+ def calculate_cost(self, save_updates=False, update_hour_rate=False):
"""Calculate bom totals"""
self.calculate_op_cost(update_hour_rate)
- self.calculate_rm_cost()
- self.calculate_sm_cost()
+ self.calculate_rm_cost(save=save_updates)
+ self.calculate_sm_cost(save=save_updates)
+ if save_updates:
+ # not via doc event, table is not regenerated and needs updation
+ self.calculate_exploded_cost()
+
self.total_cost = self.operating_cost + self.raw_material_cost - self.scrap_material_cost
self.base_total_cost = (
self.base_operating_cost + self.base_raw_material_cost - self.base_scrap_material_cost
@@ -654,12 +634,26 @@
if update_hour_rate:
row.db_update()
- def calculate_rm_cost(self):
+ def calculate_rm_cost(self, save=False):
"""Fetch RM rate as per today's valuation rate and calculate totals"""
total_rm_cost = 0
base_total_rm_cost = 0
for d in self.get("items"):
+ old_rate = d.rate
+ d.rate = self.get_rm_rate(
+ {
+ "company": self.company,
+ "item_code": d.item_code,
+ "bom_no": d.bom_no,
+ "qty": d.qty,
+ "uom": d.uom,
+ "stock_uom": d.stock_uom,
+ "conversion_factor": d.conversion_factor,
+ "sourced_by_supplier": d.sourced_by_supplier,
+ }
+ )
+
d.base_rate = flt(d.rate) * flt(self.conversion_rate)
d.amount = flt(d.rate, d.precision("rate")) * flt(d.qty, d.precision("qty"))
d.base_amount = d.amount * flt(self.conversion_rate)
@@ -669,11 +663,13 @@
total_rm_cost += d.amount
base_total_rm_cost += d.base_amount
+ if save and (old_rate != d.rate):
+ d.db_update()
self.raw_material_cost = total_rm_cost
self.base_raw_material_cost = base_total_rm_cost
- def calculate_sm_cost(self):
+ def calculate_sm_cost(self, save=False):
"""Fetch RM rate as per today's valuation rate and calculate totals"""
total_sm_cost = 0
base_total_sm_cost = 0
@@ -688,10 +684,45 @@
)
total_sm_cost += d.amount
base_total_sm_cost += d.base_amount
+ if save:
+ d.db_update()
self.scrap_material_cost = total_sm_cost
self.base_scrap_material_cost = base_total_sm_cost
+ def calculate_exploded_cost(self):
+ "Set exploded row cost from it's parent BOM."
+ rm_rate_map = self.get_rm_rate_map()
+
+ for row in self.get("exploded_items"):
+ old_rate = flt(row.rate)
+ row.rate = rm_rate_map.get(row.item_code)
+ row.amount = flt(row.stock_qty) * flt(row.rate)
+
+ if old_rate != row.rate:
+ # Only db_update if changed
+ row.db_update()
+
+ def get_rm_rate_map(self) -> Dict[str, float]:
+ "Create Raw Material-Rate map for Exploded Items. Fetch rate from Items table or Subassembly BOM."
+ rm_rate_map = {}
+
+ for item in self.get("items"):
+ if item.bom_no:
+ # Get Item-Rate from Subassembly BOM
+ explosion_items = frappe.get_all(
+ "BOM Explosion Item",
+ filters={"parent": item.bom_no},
+ fields=["item_code", "rate"],
+ order_by=None, # to avoid sort index creation at db level (granular change)
+ )
+ explosion_item_rate = {item.item_code: flt(item.rate) for item in explosion_items}
+ rm_rate_map.update(explosion_item_rate)
+ else:
+ rm_rate_map[item.item_code] = flt(item.base_rate) / flt(item.conversion_factor or 1.0)
+
+ return rm_rate_map
+
def update_exploded_items(self, save=True):
"""Update Flat BOM, following will be correct data"""
self.get_exploded_items()
@@ -902,44 +933,46 @@
return flt(rate)
-def get_valuation_rate(args):
- """Get weighted average of valuation rate from all warehouses"""
+def get_valuation_rate(data):
+ """
+ 1) Get average valuation rate from all warehouses
+ 2) If no value, get last valuation rate from SLE
+ 3) If no value, get valuation rate from Item
+ """
+ from frappe.query_builder.functions import Sum
- total_qty, total_value, valuation_rate = 0.0, 0.0, 0.0
- item_bins = frappe.db.sql(
- """
- select
- bin.actual_qty, bin.stock_value
- from
- `tabBin` bin, `tabWarehouse` warehouse
- where
- bin.item_code=%(item)s
- and bin.warehouse = warehouse.name
- and warehouse.company=%(company)s""",
- {"item": args["item_code"], "company": args["company"]},
- as_dict=1,
- )
+ item_code, company = data.get("item_code"), data.get("company")
+ valuation_rate = 0.0
- for d in item_bins:
- total_qty += flt(d.actual_qty)
- total_value += flt(d.stock_value)
+ bin_table = frappe.qb.DocType("Bin")
+ wh_table = frappe.qb.DocType("Warehouse")
+ item_valuation = (
+ frappe.qb.from_(bin_table)
+ .join(wh_table)
+ .on(bin_table.warehouse == wh_table.name)
+ .select((Sum(bin_table.stock_value) / Sum(bin_table.actual_qty)).as_("valuation_rate"))
+ .where((bin_table.item_code == item_code) & (wh_table.company == company))
+ ).run(as_dict=True)[0]
- if total_qty:
- valuation_rate = total_value / total_qty
+ valuation_rate = item_valuation.get("valuation_rate")
- if valuation_rate <= 0:
- last_valuation_rate = frappe.db.sql(
- """select valuation_rate
- from `tabStock Ledger Entry`
- where item_code = %s and valuation_rate > 0 and is_cancelled = 0
- order by posting_date desc, posting_time desc, creation desc limit 1""",
- args["item_code"],
- )
+ if (valuation_rate is not None) and valuation_rate <= 0:
+ # Explicit null value check. If None, Bins don't exist, neither does SLE
+ sle = frappe.qb.DocType("Stock Ledger Entry")
+ last_val_rate = (
+ frappe.qb.from_(sle)
+ .select(sle.valuation_rate)
+ .where((sle.item_code == item_code) & (sle.valuation_rate > 0) & (sle.is_cancelled == 0))
+ .orderby(sle.posting_date, order=frappe.qb.desc)
+ .orderby(sle.posting_time, order=frappe.qb.desc)
+ .orderby(sle.creation, order=frappe.qb.desc)
+ .limit(1)
+ ).run(as_dict=True)
- valuation_rate = flt(last_valuation_rate[0][0]) if last_valuation_rate else 0
+ valuation_rate = flt(last_val_rate[0].get("valuation_rate")) if last_val_rate else 0
if not valuation_rate:
- valuation_rate = frappe.db.get_value("Item", args["item_code"], "valuation_rate")
+ valuation_rate = frappe.db.get_value("Item", item_code, "valuation_rate")
return flt(valuation_rate)
@@ -1125,39 +1158,6 @@
return bom_items
-def get_boms_in_bottom_up_order(bom_no=None):
- def _get_parent(bom_no):
- return frappe.db.sql_list(
- """
- select distinct bom_item.parent from `tabBOM Item` bom_item
- where bom_item.bom_no = %s and bom_item.docstatus=1 and bom_item.parenttype='BOM'
- and exists(select bom.name from `tabBOM` bom where bom.name=bom_item.parent and bom.is_active=1)
- """,
- bom_no,
- )
-
- count = 0
- bom_list = []
- if bom_no:
- bom_list.append(bom_no)
- else:
- # get all leaf BOMs
- bom_list = frappe.db.sql_list(
- """select name from `tabBOM` bom
- where docstatus=1 and is_active=1
- and not exists(select bom_no from `tabBOM Item`
- where parent=bom.name and ifnull(bom_no, '')!='')"""
- )
-
- while count < len(bom_list):
- for child_bom in _get_parent(bom_list[count]):
- if child_bom not in bom_list:
- bom_list.append(child_bom)
- count += 1
-
- return bom_list
-
-
def add_additional_cost(stock_entry, work_order):
# Add non stock items cost in the additional cost
stock_entry.additional_costs = []
@@ -1306,7 +1306,7 @@
if not field in searchfields
]
- query_filters = {"disabled": 0, "ifnull(end_of_life, '5050-50-50')": (">", today())}
+ query_filters = {"disabled": 0, "end_of_life": (">", today())}
or_cond_filters = {}
if txt:
diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py
index f235e44..860512c 100644
--- a/erpnext/manufacturing/doctype/bom/test_bom.py
+++ b/erpnext/manufacturing/doctype/bom/test_bom.py
@@ -11,7 +11,9 @@
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.manufacturing.doctype.bom.bom import BOMRecursionError, item_query, make_variant_bom
-from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost
+from erpnext.manufacturing.doctype.bom_update_log.test_bom_update_log import (
+ update_cost_in_all_boms_in_test,
+)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
@@ -69,26 +71,31 @@
def test_update_bom_cost_in_all_boms(self):
# get current rate for '_Test Item 2'
- rm_rate = frappe.db.sql(
- """select rate from `tabBOM Item`
- where parent='BOM-_Test Item Home Desktop Manufactured-001'
- and item_code='_Test Item 2' and docstatus=1 and parenttype='BOM'"""
+ bom_rates = frappe.db.get_values(
+ "BOM Item",
+ {
+ "parent": "BOM-_Test Item Home Desktop Manufactured-001",
+ "item_code": "_Test Item 2",
+ "docstatus": 1,
+ },
+ fieldname=["rate", "base_rate"],
+ as_dict=True,
)
- rm_rate = rm_rate[0][0] if rm_rate else 0
+ rm_base_rate = bom_rates[0].get("base_rate") if bom_rates else 0
# Reset item valuation rate
- reset_item_valuation_rate(item_code="_Test Item 2", qty=200, rate=rm_rate + 10)
+ reset_item_valuation_rate(item_code="_Test Item 2", qty=200, rate=rm_base_rate + 10)
# update cost of all BOMs based on latest valuation rate
- update_cost()
+ update_cost_in_all_boms_in_test()
# check if new valuation rate updated in all BOMs
for d in frappe.db.sql(
- """select rate from `tabBOM Item`
+ """select base_rate from `tabBOM Item`
where item_code='_Test Item 2' and docstatus=1 and parenttype='BOM'""",
as_dict=1,
):
- self.assertEqual(d.rate, rm_rate + 10)
+ self.assertEqual(d.base_rate, rm_base_rate + 10)
def test_bom_cost(self):
bom = frappe.copy_doc(test_records[2])
@@ -552,6 +559,42 @@
bom.submit()
self.assertEqual(bom.items[0].rate, 42)
+ def test_set_default_bom_for_item_having_single_bom(self):
+ from erpnext.stock.doctype.item.test_item import make_item
+
+ fg_item = make_item(properties={"is_stock_item": 1})
+ bom_item = make_item(properties={"is_stock_item": 1})
+
+ # Step 1: Create BOM
+ bom = frappe.new_doc("BOM")
+ bom.item = fg_item.item_code
+ bom.quantity = 1
+ bom.append(
+ "items",
+ {
+ "item_code": bom_item.item_code,
+ "qty": 1,
+ "uom": bom_item.stock_uom,
+ "stock_uom": bom_item.stock_uom,
+ "rate": 100.0,
+ },
+ )
+ bom.save()
+ bom.submit()
+ self.assertEqual(frappe.get_value("Item", fg_item.item_code, "default_bom"), bom.name)
+
+ # Step 2: Uncheck is_active field
+ bom.is_active = 0
+ bom.save()
+ bom.reload()
+ self.assertIsNone(frappe.get_value("Item", fg_item.item_code, "default_bom"))
+
+ # Step 3: Check is_active field
+ bom.is_active = 1
+ bom.save()
+ bom.reload()
+ self.assertEqual(frappe.get_value("Item", fg_item.item_code, "default_bom"), bom.name)
+
def get_default_bom(item_code="_Test FG Item 2"):
return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1})
diff --git a/erpnext/manufacturing/doctype/bom/test_records.json b/erpnext/manufacturing/doctype/bom/test_records.json
index 25730f9..507d319 100644
--- a/erpnext/manufacturing/doctype/bom/test_records.json
+++ b/erpnext/manufacturing/doctype/bom/test_records.json
@@ -32,6 +32,7 @@
"is_active": 1,
"is_default": 1,
"item": "_Test Item Home Desktop Manufactured",
+ "company": "_Test Company",
"quantity": 1.0
},
{
diff --git a/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json b/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json
index f01d856..9b1db63 100644
--- a/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json
+++ b/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json
@@ -169,13 +169,15 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2020-10-08 16:21:29.386212",
+ "modified": "2022-05-27 13:42:23.305455",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Explosion Item",
+ "naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/erpnext_integrations/data_migration_mapping/__init__.py b/erpnext/manufacturing/doctype/bom_update_batch/__init__.py
similarity index 100%
copy from erpnext/erpnext_integrations/data_migration_mapping/__init__.py
copy to erpnext/manufacturing/doctype/bom_update_batch/__init__.py
diff --git a/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json
new file mode 100644
index 0000000..b867d2a
--- /dev/null
+++ b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json
@@ -0,0 +1,54 @@
+{
+ "actions": [],
+ "autoname": "hash",
+ "creation": "2022-05-31 17:34:39.825537",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "level",
+ "batch_no",
+ "boms_updated",
+ "status"
+ ],
+ "fields": [
+ {
+ "fieldname": "level",
+ "fieldtype": "Int",
+ "in_list_view": 1,
+ "label": "Level"
+ },
+ {
+ "fieldname": "batch_no",
+ "fieldtype": "Int",
+ "in_list_view": 1,
+ "label": "Batch No."
+ },
+ {
+ "fieldname": "boms_updated",
+ "fieldtype": "Long Text",
+ "hidden": 1,
+ "in_list_view": 1,
+ "label": "BOMs Updated"
+ },
+ {
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Status",
+ "options": "Pending\nCompleted",
+ "read_only": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2022-06-06 14:50:35.161062",
+ "modified_by": "Administrator",
+ "module": "Manufacturing",
+ "name": "BOM Update Batch",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.py b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.py
new file mode 100644
index 0000000..f952e43
--- /dev/null
+++ b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class BOMUpdateBatch(Document):
+ pass
diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json
index 98c1acb..a926e69 100644
--- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json
+++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json
@@ -7,12 +7,16 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
- "current_bom",
- "new_bom",
- "column_break_3",
"update_type",
"status",
+ "column_break_3",
+ "current_bom",
+ "new_bom",
"error_log",
+ "progress_section",
+ "current_level",
+ "processed_boms",
+ "bom_batches",
"amended_from"
],
"fields": [
@@ -33,6 +37,7 @@
"options": "BOM"
},
{
+ "depends_on": "eval:doc.update_type === \"Replace BOM\"",
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
@@ -63,13 +68,37 @@
"fieldtype": "Link",
"label": "Error Log",
"options": "Error Log"
+ },
+ {
+ "collapsible": 1,
+ "depends_on": "eval: doc.update_type == \"Update Cost\"",
+ "fieldname": "progress_section",
+ "fieldtype": "Section Break",
+ "label": "Progress"
+ },
+ {
+ "fieldname": "processed_boms",
+ "fieldtype": "Long Text",
+ "hidden": 1,
+ "label": "Processed BOMs"
+ },
+ {
+ "fieldname": "bom_batches",
+ "fieldtype": "Table",
+ "options": "BOM Update Batch"
+ },
+ {
+ "depends_on": "eval:doc.status !== \"Completed\"",
+ "fieldname": "current_level",
+ "fieldtype": "Int",
+ "label": "Current Level"
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-03-31 12:51:44.885102",
+ "modified": "2022-06-20 15:43:55.696388",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Update Log",
diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py
index c0770fa..c3f52d4 100644
--- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py
+++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py
@@ -1,13 +1,22 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
-from typing import Dict, List, Literal, Optional
+import json
+from typing import Any, Dict, List, Optional, Tuple, Union
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import cstr, flt
+from frappe.query_builder import DocType, Interval
+from frappe.query_builder.functions import Now
+from frappe.utils import cint, cstr
-from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost
+from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import (
+ get_leaf_boms,
+ get_next_higher_level_boms,
+ handle_exception,
+ replace_bom,
+ set_values_in_log,
+)
class BOMMissingError(frappe.ValidationError):
@@ -15,11 +24,24 @@
class BOMUpdateLog(Document):
+ @staticmethod
+ def clear_old_logs(days=None):
+ days = days or 90
+ table = DocType("BOM Update Log")
+ frappe.db.delete(
+ table,
+ filters=(
+ (table.modified < (Now() - Interval(days=days))) & (table.update_type == "Update Cost")
+ ),
+ )
+
def validate(self):
if self.update_type == "Replace BOM":
self.validate_boms_are_specified()
self.validate_same_bom()
self.validate_bom_items()
+ else:
+ self.validate_bom_cost_update_in_progress()
self.status = "Queued"
@@ -42,123 +64,197 @@
if current_bom_item != new_bom_item:
frappe.throw(_("The selected BOMs are not for the same item"))
- def on_submit(self):
- if frappe.flags.in_test:
- return
+ def validate_bom_cost_update_in_progress(self):
+ "If another Cost Updation Log is still in progress, dont make new ones."
+ wip_log = frappe.get_all(
+ "BOM Update Log",
+ {"update_type": "Update Cost", "status": ["in", ["Queued", "In Progress"]]},
+ limit_page_length=1,
+ )
+ if wip_log:
+ log_link = frappe.utils.get_link_to_form("BOM Update Log", wip_log[0].name)
+ frappe.throw(
+ _("BOM Updation already in progress. Please wait until {0} is complete.").format(log_link),
+ title=_("Note"),
+ )
+
+ def on_submit(self):
if self.update_type == "Replace BOM":
boms = {"current_bom": self.current_bom, "new_bom": self.new_bom}
frappe.enqueue(
- method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_bom_job",
+ method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_replace_bom_job",
doc=self,
boms=boms,
timeout=40000,
+ now=frappe.flags.in_test,
)
else:
frappe.enqueue(
- method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_bom_job",
- doc=self,
- update_type="Update Cost",
- timeout=40000,
+ method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.process_boms_cost_level_wise",
+ update_doc=self,
+ now=frappe.flags.in_test,
)
-def replace_bom(boms: Dict) -> None:
- """Replace current BOM with new BOM in parent BOMs."""
- current_bom = boms.get("current_bom")
- new_bom = boms.get("new_bom")
-
- unit_cost = get_new_bom_unit_cost(new_bom)
- update_new_bom_in_bom_items(unit_cost, current_bom, new_bom)
-
- frappe.cache().delete_key("bom_children")
- parent_boms = get_parent_boms(new_bom)
-
- for bom in parent_boms:
- bom_obj = frappe.get_doc("BOM", bom)
- # this is only used for versioning and we do not want
- # to make separate db calls by using load_doc_before_save
- # which proves to be expensive while doing bulk replace
- bom_obj._doc_before_save = bom_obj
- bom_obj.update_exploded_items()
- bom_obj.calculate_cost()
- bom_obj.update_parent_cost()
- bom_obj.db_update()
- if bom_obj.meta.get("track_changes") and not bom_obj.flags.ignore_version:
- bom_obj.save_version()
-
-
-def update_new_bom_in_bom_items(unit_cost: float, current_bom: str, new_bom: str) -> None:
- bom_item = frappe.qb.DocType("BOM Item")
- (
- frappe.qb.update(bom_item)
- .set(bom_item.bom_no, new_bom)
- .set(bom_item.rate, unit_cost)
- .set(bom_item.amount, (bom_item.stock_qty * unit_cost))
- .where(
- (bom_item.bom_no == current_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM")
- )
- ).run()
-
-
-def get_parent_boms(new_bom: str, bom_list: Optional[List] = None) -> List:
- bom_list = bom_list or []
- bom_item = frappe.qb.DocType("BOM Item")
-
- parents = (
- frappe.qb.from_(bom_item)
- .select(bom_item.parent)
- .where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
- .run(as_dict=True)
- )
-
- for d in parents:
- if new_bom == d.parent:
- frappe.throw(_("BOM recursion: {0} cannot be child of {1}").format(new_bom, d.parent))
-
- bom_list.append(d.parent)
- get_parent_boms(d.parent, bom_list)
-
- return list(set(bom_list))
-
-
-def get_new_bom_unit_cost(new_bom: str) -> float:
- bom = frappe.qb.DocType("BOM")
- new_bom_unitcost = (
- frappe.qb.from_(bom).select(bom.total_cost / bom.quantity).where(bom.name == new_bom).run()
- )
-
- return flt(new_bom_unitcost[0][0])
-
-
-def run_bom_job(
+def run_replace_bom_job(
doc: "BOMUpdateLog",
boms: Optional[Dict[str, str]] = None,
- update_type: Literal["Replace BOM", "Update Cost"] = "Replace BOM",
) -> None:
try:
doc.db_set("status", "In Progress")
+
if not frappe.flags.in_test:
frappe.db.commit()
frappe.db.auto_commit_on_many_writes = 1
-
boms = frappe._dict(boms or {})
-
- if update_type == "Replace BOM":
- replace_bom(boms)
- else:
- update_cost()
+ replace_bom(boms, doc.name)
doc.db_set("status", "Completed")
-
except Exception:
- frappe.db.rollback()
- error_log = doc.log_error("BOM Update Tool Error")
-
- doc.db_set("status", "Failed")
- doc.db_set("error_log", error_log.name)
-
+ handle_exception(doc)
finally:
frappe.db.auto_commit_on_many_writes = 0
- frappe.db.commit() # nosemgrep
+
+ if not frappe.flags.in_test:
+ frappe.db.commit() # nosemgrep
+
+
+def process_boms_cost_level_wise(
+ update_doc: "BOMUpdateLog", parent_boms: List[str] = None
+) -> Union[None, Tuple]:
+ "Queue jobs at the start of new BOM Level in 'Update Cost' Jobs."
+
+ current_boms = {}
+ values = {}
+
+ try:
+ if update_doc.status == "Queued":
+ # First level yet to process. On Submit.
+ current_level = 0
+ current_boms = get_leaf_boms()
+ values = {
+ "processed_boms": json.dumps({}),
+ "status": "In Progress",
+ "current_level": current_level,
+ }
+ else:
+ # Resume next level. via Cron Job.
+ if not parent_boms:
+ return
+
+ current_level = cint(update_doc.current_level) + 1
+
+ # Process the next level BOMs. Stage parents as current BOMs.
+ current_boms = parent_boms.copy()
+ values = {"current_level": current_level}
+
+ set_values_in_log(update_doc.name, values, commit=True)
+ queue_bom_cost_jobs(current_boms, update_doc, current_level)
+ except Exception:
+ handle_exception(update_doc)
+
+
+def queue_bom_cost_jobs(
+ current_boms_list: List[str], update_doc: "BOMUpdateLog", current_level: int
+) -> None:
+ "Queue batches of 20k BOMs of the same level to process parallelly"
+ batch_no = 0
+
+ while current_boms_list:
+ batch_no += 1
+ batch_size = 20_000
+ boms_to_process = current_boms_list[:batch_size] # slice out batch of 20k BOMs
+
+ # update list to exclude 20K (queued) BOMs
+ current_boms_list = current_boms_list[batch_size:] if len(current_boms_list) > batch_size else []
+
+ batch_row = update_doc.append(
+ "bom_batches", {"level": current_level, "batch_no": batch_no, "status": "Pending"}
+ )
+ batch_row.db_insert()
+
+ frappe.enqueue(
+ method="erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils.update_cost_in_level",
+ doc=update_doc,
+ bom_list=boms_to_process,
+ batch_name=batch_row.name,
+ queue="long",
+ now=frappe.flags.in_test,
+ )
+
+
+def resume_bom_cost_update_jobs():
+ """
+ 1. Checks for In Progress BOM Update Log.
+ 2. Checks if this job has completed the _current level_.
+ 3. If current level is complete, get parent BOMs and start next level.
+ 4. If no parents, mark as Complete.
+ 5. If current level is WIP, skip the Log.
+
+ Called every 5 minutes via Cron job.
+ """
+
+ in_progress_logs = frappe.db.get_all(
+ "BOM Update Log",
+ {"update_type": "Update Cost", "status": "In Progress"},
+ ["name", "processed_boms", "current_level"],
+ )
+ if not in_progress_logs:
+ return
+
+ for log in in_progress_logs:
+ # check if all log batches of current level are processed
+ bom_batches = frappe.db.get_all(
+ "BOM Update Batch",
+ {"parent": log.name, "level": log.current_level},
+ ["name", "boms_updated", "status"],
+ )
+ incomplete_level = any(row.get("status") == "Pending" for row in bom_batches)
+ if not bom_batches or incomplete_level:
+ continue
+
+ # Prep parent BOMs & updated processed BOMs for next level
+ current_boms, processed_boms = get_processed_current_boms(log, bom_batches)
+ parent_boms = get_next_higher_level_boms(child_boms=current_boms, processed_boms=processed_boms)
+
+ # Unset processed BOMs (it is used for next level BOMs) & change status if log is complete
+ status = "Completed" if not parent_boms else "In Progress"
+ processed_boms = json.dumps([] if not parent_boms else processed_boms)
+ set_values_in_log(
+ log.name,
+ values={
+ "processed_boms": processed_boms,
+ "status": status,
+ },
+ commit=True,
+ )
+
+ # clear progress section
+ if status == "Completed":
+ frappe.db.delete("BOM Update Batch", {"parent": log.name})
+
+ if parent_boms: # there is a next level to process
+ process_boms_cost_level_wise(
+ update_doc=frappe.get_doc("BOM Update Log", log.name), parent_boms=parent_boms
+ )
+
+
+def get_processed_current_boms(
+ log: Dict[str, Any], bom_batches: Dict[str, Any]
+) -> Tuple[List[str], Dict[str, Any]]:
+ """
+ Aggregate all BOMs from BOM Update Batch rows into 'processed_boms' field
+ and into current boms list.
+ """
+ processed_boms = json.loads(log.processed_boms) if log.processed_boms else {}
+ current_boms = []
+
+ for row in bom_batches:
+ boms_updated = json.loads(row.boms_updated)
+ current_boms.extend(boms_updated)
+ boms_updated_dict = {bom: True for bom in boms_updated}
+ processed_boms.update(boms_updated_dict)
+
+ return current_boms, processed_boms
diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js
index e39b563..bc709d8 100644
--- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js
+++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js
@@ -1,6 +1,6 @@
frappe.listview_settings['BOM Update Log'] = {
add_fields: ["status"],
- get_indicator: function(doc) {
+ get_indicator: (doc) => {
let status_map = {
"Queued": "orange",
"In Progress": "blue",
@@ -9,5 +9,22 @@
};
return [__(doc.status), status_map[doc.status], "status,=," + doc.status];
- }
+ },
+ onload: () => {
+ if (!frappe.model.can_write("Log Settings")) {
+ return;
+ }
+
+ let sidebar_entry = $(
+ '<ul class="list-unstyled sidebar-menu log-retention-note"></ul>'
+ ).appendTo(cur_list.page.sidebar);
+ let message = __("Note: Automatic log deletion only applies to logs of type <i>Update Cost</i>");
+ $(`<hr><div class='text-muted'>${message}</div>`).appendTo(sidebar_entry);
+
+ frappe.require("logtypes.bundle.js", () => {
+ frappe.utils.logtypes.show_log_retention_message(cur_list.doctype);
+ });
+
+
+ },
};
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py
new file mode 100644
index 0000000..af115e3
--- /dev/null
+++ b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py
@@ -0,0 +1,225 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import copy
+import json
+from collections import defaultdict
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
+
+if TYPE_CHECKING:
+ from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import BOMUpdateLog
+
+import frappe
+from frappe import _
+
+
+def replace_bom(boms: Dict, log_name: str) -> None:
+ "Replace current BOM with new BOM in parent BOMs."
+
+ current_bom = boms.get("current_bom")
+ new_bom = boms.get("new_bom")
+
+ unit_cost = get_bom_unit_cost(new_bom)
+ update_new_bom_in_bom_items(unit_cost, current_bom, new_bom)
+
+ frappe.cache().delete_key("bom_children")
+ parent_boms = get_ancestor_boms(new_bom)
+
+ for bom in parent_boms:
+ bom_obj = frappe.get_doc("BOM", bom)
+ # this is only used for versioning and we do not want
+ # to make separate db calls by using load_doc_before_save
+ # which proves to be expensive while doing bulk replace
+ bom_obj._doc_before_save = copy.deepcopy(bom_obj)
+ bom_obj.update_exploded_items()
+ bom_obj.calculate_cost()
+ bom_obj.update_parent_cost()
+ bom_obj.db_update()
+ bom_obj.flags.updater_reference = {
+ "doctype": "BOM Update Log",
+ "docname": log_name,
+ "label": _("via BOM Update Tool"),
+ }
+ bom_obj.save_version()
+
+
+def update_cost_in_level(
+ doc: "BOMUpdateLog", bom_list: List[str], batch_name: Union[int, str]
+) -> None:
+ "Updates Cost for BOMs within a given level. Runs via background jobs."
+
+ try:
+ status = frappe.db.get_value("BOM Update Log", doc.name, "status")
+ if status == "Failed":
+ return
+
+ update_cost_in_boms(bom_list=bom_list) # main updation logic
+
+ bom_batch = frappe.qb.DocType("BOM Update Batch")
+ (
+ frappe.qb.update(bom_batch)
+ .set(bom_batch.boms_updated, json.dumps(bom_list))
+ .set(bom_batch.status, "Completed")
+ .where(bom_batch.name == batch_name)
+ ).run()
+ except Exception:
+ handle_exception(doc)
+ finally:
+ if not frappe.flags.in_test:
+ frappe.db.commit() # nosemgrep
+
+
+def get_ancestor_boms(new_bom: str, bom_list: Optional[List] = None) -> List:
+ "Recursively get all ancestors of BOM."
+
+ bom_list = bom_list or []
+ bom_item = frappe.qb.DocType("BOM Item")
+
+ parents = (
+ frappe.qb.from_(bom_item)
+ .select(bom_item.parent)
+ .where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
+ .run(as_dict=True)
+ )
+
+ for d in parents:
+ if new_bom == d.parent:
+ frappe.throw(_("BOM recursion: {0} cannot be child of {1}").format(new_bom, d.parent))
+
+ bom_list.append(d.parent)
+ get_ancestor_boms(d.parent, bom_list)
+
+ return list(set(bom_list))
+
+
+def update_new_bom_in_bom_items(unit_cost: float, current_bom: str, new_bom: str) -> None:
+ bom_item = frappe.qb.DocType("BOM Item")
+ (
+ frappe.qb.update(bom_item)
+ .set(bom_item.bom_no, new_bom)
+ .set(bom_item.rate, unit_cost)
+ .set(bom_item.amount, (bom_item.stock_qty * unit_cost))
+ .where(
+ (bom_item.bom_no == current_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM")
+ )
+ ).run()
+
+
+def get_bom_unit_cost(bom_name: str) -> float:
+ bom = frappe.qb.DocType("BOM")
+ new_bom_unitcost = (
+ frappe.qb.from_(bom).select(bom.total_cost / bom.quantity).where(bom.name == bom_name).run()
+ )
+
+ return frappe.utils.flt(new_bom_unitcost[0][0])
+
+
+def update_cost_in_boms(bom_list: List[str]) -> None:
+ "Updates cost in given BOMs. Returns current and total updated BOMs."
+
+ for index, bom in enumerate(bom_list):
+ bom_doc = frappe.get_doc("BOM", bom, for_update=True)
+ bom_doc.calculate_cost(save_updates=True, update_hour_rate=True)
+ bom_doc.db_update()
+
+ if (index % 50 == 0) and not frappe.flags.in_test:
+ frappe.db.commit() # nosemgrep
+
+
+def get_next_higher_level_boms(
+ child_boms: List[str], processed_boms: Dict[str, bool]
+) -> List[str]:
+ "Generate immediate higher level dependants with no unresolved dependencies (children)."
+
+ def _all_children_are_processed(parent_bom):
+ child_boms = dependency_map.get(parent_bom)
+ return all(processed_boms.get(bom) for bom in child_boms)
+
+ dependants_map, dependency_map = _generate_dependence_map()
+
+ dependants = []
+ for bom in child_boms:
+ # generate list of immediate dependants
+ parents = dependants_map.get(bom) or []
+ dependants.extend(parents)
+
+ dependants = set(dependants) # remove duplicates
+ resolved_dependants = set()
+
+ # consider only if children are all resolved
+ for parent_bom in dependants:
+ if _all_children_are_processed(parent_bom):
+ resolved_dependants.add(parent_bom)
+
+ return list(resolved_dependants)
+
+
+def get_leaf_boms() -> List[str]:
+ "Get BOMs that have no dependencies."
+
+ return frappe.db.sql_list(
+ """select name from `tabBOM` bom
+ where docstatus=1 and is_active=1
+ and not exists(select bom_no from `tabBOM Item`
+ where parent=bom.name and ifnull(bom_no, '')!='')"""
+ )
+
+
+def _generate_dependence_map() -> defaultdict:
+ """
+ Generate maps such as: { BOM-1: [Dependant-BOM-1, Dependant-BOM-2, ..] }.
+ Here BOM-1 is the leaf/lower level node/dependency.
+ The list contains one level higher nodes/dependants that depend on BOM-1.
+
+ Generate and return the reverse as well.
+ """
+
+ bom = frappe.qb.DocType("BOM")
+ bom_item = frappe.qb.DocType("BOM Item")
+
+ bom_items = (
+ frappe.qb.from_(bom_item)
+ .join(bom)
+ .on(bom_item.parent == bom.name)
+ .select(bom_item.bom_no, bom_item.parent)
+ .where(
+ (bom_item.bom_no.isnotnull())
+ & (bom_item.bom_no != "")
+ & (bom.docstatus == 1)
+ & (bom.is_active == 1)
+ & (bom_item.parenttype == "BOM")
+ )
+ ).run(as_dict=True)
+
+ child_parent_map = defaultdict(list)
+ parent_child_map = defaultdict(list)
+ for row in bom_items:
+ child_parent_map[row.bom_no].append(row.parent)
+ parent_child_map[row.parent].append(row.bom_no)
+
+ return child_parent_map, parent_child_map
+
+
+def set_values_in_log(log_name: str, values: Dict[str, Any], commit: bool = False) -> None:
+ "Update BOM Update Log record."
+
+ if not values:
+ return
+
+ bom_update_log = frappe.qb.DocType("BOM Update Log")
+ query = frappe.qb.update(bom_update_log).where(bom_update_log.name == log_name)
+
+ for key, value in values.items():
+ query = query.set(key, value)
+ query.run()
+
+ if commit and not frappe.flags.in_test:
+ frappe.db.commit() # nosemgrep
+
+
+def handle_exception(doc: "BOMUpdateLog") -> None:
+ "Rolls back and fails BOM Update Log."
+
+ frappe.db.rollback()
+ error_log = doc.log_error("BOM Update Tool Error")
+ set_values_in_log(doc.name, {"status": "Failed", "error_log": error_log.name})
diff --git a/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py
index 47efea9..b38fc89 100644
--- a/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py
+++ b/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py
@@ -6,9 +6,12 @@
from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import (
BOMMissingError,
- run_bom_job,
+ resume_bom_cost_update_jobs,
)
-from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import enqueue_replace_bom
+from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import (
+ enqueue_replace_bom,
+ enqueue_update_cost,
+)
test_records = frappe.get_test_records("BOM")
@@ -31,17 +34,12 @@
def tearDown(self):
frappe.db.rollback()
- if self._testMethodName == "test_bom_update_log_completion":
- # clear logs and delete BOM created via setUp
- frappe.db.delete("BOM Update Log")
- self.new_bom_doc.cancel()
- self.new_bom_doc.delete()
-
- # explicitly commit and restore to original state
- frappe.db.commit() # nosemgrep
-
def test_bom_update_log_validate(self):
- "Test if BOM presence is validated."
+ """
+ 1) Test if BOM presence is validated.
+ 2) Test if same BOMs are validated.
+ 3) Test of non-existent BOM is validated.
+ """
with self.assertRaises(BOMMissingError):
enqueue_replace_bom(boms={})
@@ -52,45 +50,22 @@
with self.assertRaises(frappe.ValidationError):
enqueue_replace_bom(boms=frappe._dict(current_bom=self.boms.new_bom, new_bom="Dummy BOM"))
- def test_bom_update_log_queueing(self):
- "Test if BOM Update Log is created and queued."
-
- log = enqueue_replace_bom(
- boms=self.boms,
- )
-
- self.assertEqual(log.docstatus, 1)
- self.assertEqual(log.status, "Queued")
-
def test_bom_update_log_completion(self):
"Test if BOM Update Log handles job completion correctly."
- log = enqueue_replace_bom(
- boms=self.boms,
- )
-
- # Explicitly commits log, new bom (setUp) and replacement impact.
- # Is run via background jobs IRL
- run_bom_job(
- doc=log,
- boms=self.boms,
- update_type="Replace BOM",
- )
+ log = enqueue_replace_bom(boms=self.boms)
log.reload()
-
self.assertEqual(log.status, "Completed")
- # teardown (undo replace impact) due to commit
- boms = frappe._dict(
- current_bom=self.boms.new_bom,
- new_bom=self.boms.current_bom,
- )
- log2 = enqueue_replace_bom(
- boms=self.boms,
- )
- run_bom_job( # Explicitly commits
- doc=log2,
- boms=boms,
- update_type="Replace BOM",
- )
- self.assertEqual(log2.status, "Completed")
+
+def update_cost_in_all_boms_in_test():
+ """
+ Utility to run 'Update Cost' job in tests without Cron job until fully complete.
+ """
+ log = enqueue_update_cost() # create BOM Update Log
+
+ while log.status != "Completed":
+ resume_bom_cost_update_jobs() # run cron job until complete
+ log.reload()
+
+ return log
diff --git a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py
index b0e7da1..d16fcd0 100644
--- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py
+++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py
@@ -10,8 +10,6 @@
import frappe
from frappe.model.document import Document
-from erpnext.manufacturing.doctype.bom.bom import get_boms_in_bottom_up_order
-
class BOMUpdateTool(Document):
pass
@@ -40,14 +38,13 @@
def auto_update_latest_price_in_all_boms() -> None:
"""Called via hooks.py."""
if frappe.db.get_single_value("Manufacturing Settings", "update_bom_costs_automatically"):
- update_cost()
-
-
-def update_cost() -> None:
- """Updates Cost for all BOMs from bottom to top."""
- bom_list = get_boms_in_bottom_up_order()
- for bom in bom_list:
- frappe.get_doc("BOM", bom).update_cost(update_parent=False, from_child_bom=True)
+ wip_log = frappe.get_all(
+ "BOM Update Log",
+ {"update_type": "Update Cost", "status": ["in", ["Queued", "In Progress"]]},
+ limit_page_length=1,
+ )
+ if not wip_log:
+ create_bom_update_log(update_type="Update Cost")
def create_bom_update_log(
diff --git a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py
index fae72a0..5dd557f 100644
--- a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py
+++ b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py
@@ -1,11 +1,13 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.tests.utils import FrappeTestCase
-from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import replace_bom
-from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost
+from erpnext.manufacturing.doctype.bom_update_log.test_bom_update_log import (
+ update_cost_in_all_boms_in_test,
+)
+from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import enqueue_replace_bom
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.stock.doctype.item.test_item import create_item
@@ -15,6 +17,9 @@
class TestBOMUpdateTool(FrappeTestCase):
"Test major functions run via BOM Update Tool."
+ def tearDown(self):
+ frappe.db.rollback()
+
def test_replace_bom(self):
current_bom = "BOM-_Test Item Home Desktop Manufactured-001"
@@ -23,15 +28,10 @@
bom_doc.insert()
boms = frappe._dict(current_bom=current_bom, new_bom=bom_doc.name)
- replace_bom(boms)
+ enqueue_replace_bom(boms=boms)
- self.assertFalse(frappe.db.sql("select name from `tabBOM Item` where bom_no=%s", current_bom))
- self.assertTrue(frappe.db.sql("select name from `tabBOM Item` where bom_no=%s", bom_doc.name))
-
- # reverse, as it affects other testcases
- boms.current_bom = bom_doc.name
- boms.new_bom = current_bom
- replace_bom(boms)
+ self.assertFalse(frappe.db.exists("BOM Item", {"bom_no": current_bom, "docstatus": 1}))
+ self.assertTrue(frappe.db.exists("BOM Item", {"bom_no": bom_doc.name, "docstatus": 1}))
def test_bom_cost(self):
for item in ["BOM Cost Test Item 1", "BOM Cost Test Item 2", "BOM Cost Test Item 3"]:
@@ -52,13 +52,13 @@
self.assertEqual(doc.total_cost, 200)
frappe.db.set_value("Item", "BOM Cost Test Item 2", "valuation_rate", 200)
- update_cost()
+ update_cost_in_all_boms_in_test()
doc.load_from_db()
self.assertEqual(doc.total_cost, 300)
frappe.db.set_value("Item", "BOM Cost Test Item 2", "valuation_rate", 100)
- update_cost()
+ update_cost_in_all_boms_in_test()
doc.load_from_db()
self.assertEqual(doc.total_cost, 200)
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py
index 0199a5c..ed45106 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/job_card.py
@@ -626,14 +626,15 @@
self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]
- if self.for_quantity <= self.transferred_qty:
- self.status = "Material Transferred"
+ if self.docstatus < 2:
+ if self.for_quantity <= self.transferred_qty:
+ self.status = "Material Transferred"
- if self.time_logs:
- self.status = "Work In Progress"
+ if self.time_logs:
+ self.status = "Work In Progress"
- if self.docstatus == 1 and (self.for_quantity <= self.total_completed_qty or not self.items):
- self.status = "Completed"
+ if self.docstatus == 1 and (self.for_quantity <= self.total_completed_qty or not self.items):
+ self.status = "Completed"
if update_status:
self.db_set("status", self.status)
diff --git a/erpnext/manufacturing/doctype/job_card/job_card_list.js b/erpnext/manufacturing/doctype/job_card/job_card_list.js
index 7f60bdc..5d883bf 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card_list.js
+++ b/erpnext/manufacturing/doctype/job_card/job_card_list.js
@@ -1,16 +1,17 @@
frappe.listview_settings['Job Card'] = {
has_indicator_for_draft: true,
+
get_indicator: function(doc) {
- if (doc.status === "Work In Progress") {
- return [__("Work In Progress"), "orange", "status,=,Work In Progress"];
- } else if (doc.status === "Completed") {
- return [__("Completed"), "green", "status,=,Completed"];
- } else if (doc.docstatus == 2) {
- return [__("Cancelled"), "red", "status,=,Cancelled"];
- } else if (doc.status === "Material Transferred") {
- return [__('Material Transferred'), "blue", "status,=,Material Transferred"];
- } else {
- return [__("Open"), "red", "status,=,Open"];
- }
+ const status_colors = {
+ "Work In Progress": "orange",
+ "Completed": "green",
+ "Cancelled": "red",
+ "Material Transferred": "blue",
+ "Open": "red",
+ };
+ const status = doc.status || "Open";
+ const color = status_colors[status] || "blue";
+
+ return [__(status), color, `status,=,${status}`];
}
};
diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py
index 7f3c7fe..ac71141 100644
--- a/erpnext/manufacturing/doctype/job_card/test_job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py
@@ -344,6 +344,30 @@
cost_after_cancel = self.work_order.total_operating_cost
self.assertEqual(cost_after_cancel, original_cost)
+ def test_job_card_statuses(self):
+ def assertStatus(status):
+ jc.set_status()
+ self.assertEqual(jc.status, status)
+
+ jc = frappe.new_doc("Job Card")
+ jc.for_quantity = 2
+ jc.transferred_qty = 1
+ jc.total_completed_qty = 0
+ assertStatus("Open")
+
+ jc.transferred_qty = jc.for_quantity
+ assertStatus("Material Transferred")
+
+ jc.append("time_logs", {})
+ assertStatus("Work In Progress")
+
+ jc.docstatus = 1
+ jc.total_completed_qty = jc.for_quantity
+ assertStatus("Completed")
+
+ jc.docstatus = 2
+ assertStatus("Cancelled")
+
def create_bom_with_multiple_operations():
"Create a BOM with multiple operations and Material Transfer against Job Card"
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py
index 9ca05b9..a73b9bc 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py
@@ -25,6 +25,7 @@
from erpnext.manufacturing.doctype.bom.bom import validate_bom_no
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
+from erpnext.utilities.transaction_base import validate_uom_is_integer
class ProductionPlan(Document):
@@ -33,6 +34,7 @@
self.calculate_total_planned_qty()
self.set_status()
self._rename_temporary_references()
+ validate_uom_is_integer(self, "stock_uom", "planned_qty")
def set_pending_qty_in_row_without_reference(self):
"Set Pending Qty in independent rows (not from SO or MR)."
@@ -849,7 +851,7 @@
FROM
`tabBOM Item` bom_item
JOIN `tabBOM` bom ON bom.name = bom_item.parent
- JOIN tabItem item ON bom_item.item_code = item.name
+ JOIN `tabItem` item ON bom_item.item_code = item.name
LEFT JOIN `tabItem Default` item_default
ON item.name = item_default.parent and item_default.company = %(company)s
LEFT JOIN `tabUOM Conversion Detail` item_uom
@@ -979,7 +981,7 @@
select distinct so.name, so.transaction_date, so.customer, so.base_grand_total
from `tabSales Order` so, `tabSales Order Item` so_item
where so_item.parent = so.name
- and so.docstatus = 1 and so.status not in ("Stopped", "Closed")
+ and so.docstatus = 1 and so.status not in ('Stopped', 'Closed')
and so.company = %(company)s
and so_item.qty > so_item.work_order_qty {so_filter} {item_filter}
and (exists (select name from `tabBOM` bom where {bom_item}
diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
index 891a497..040e791 100644
--- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
@@ -679,15 +679,23 @@
self.assertFalse(pp.all_items_completed())
def test_production_plan_planned_qty(self):
- pln = create_production_plan(item_code="_Test FG Item", planned_qty=0.55)
- pln.make_work_order()
- work_order = frappe.db.get_value("Work Order", {"production_plan": pln.name}, "name")
- wo_doc = frappe.get_doc("Work Order", work_order)
- wo_doc.update(
- {"wip_warehouse": "Work In Progress - _TC", "fg_warehouse": "Finished Goods - _TC"}
+ # Case 1: When Planned Qty is non-integer and UOM is integer.
+ from erpnext.utilities.transaction_base import UOMMustBeIntegerError
+
+ self.assertRaises(
+ UOMMustBeIntegerError, create_production_plan, item_code="_Test FG Item", planned_qty=0.55
)
- wo_doc.submit()
- self.assertEqual(wo_doc.qty, 0.55)
+
+ # Case 2: When Planned Qty is non-integer and UOM is also non-integer.
+ from erpnext.stock.doctype.item.test_item import make_item
+
+ fg_item = make_item(properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1"}).name
+ bom_item = make_item().name
+
+ make_bom(item=fg_item, raw_materials=[bom_item], source_warehouse="_Test Warehouse - _TC")
+
+ pln = create_production_plan(item_code=fg_item, planned_qty=0.55, stock_uom="_Test UOM 1")
+ self.assertEqual(pln.po_items[0].planned_qty, 0.55)
def test_temporary_name_relinking(self):
@@ -751,6 +759,7 @@
"bom_no": frappe.db.get_value("Item", args.item_code, "default_bom"),
"planned_qty": args.planned_qty or 1,
"planned_start_date": args.planned_start_date or now_datetime(),
+ "stock_uom": args.stock_uom or "Nos",
},
)
@@ -798,7 +807,6 @@
for item in args.raw_materials:
item_doc = frappe.get_doc("Item", item)
-
bom.append(
"items",
{
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index 2aba482..6bb4cfc 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -1,6 +1,8 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
+import copy
+
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings, timeout
from frappe.utils import add_days, add_months, cint, flt, now, today
@@ -19,6 +21,7 @@
)
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import create_item, make_item
+from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_entry import test_stock_entry
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.utils import get_bin
@@ -28,6 +31,7 @@
def setUp(self):
self.warehouse = "_Test Warehouse 2 - _TC"
self.item = "_Test Item"
+ prepare_data_for_backflush_based_on_materials_transferred()
def tearDown(self):
frappe.db.rollback()
@@ -417,7 +421,7 @@
"doctype": "Item Price",
"item_code": "_Test FG Non Stock Item",
"price_list_rate": 1000,
- "price_list": "Standard Buying",
+ "price_list": "_Test Price List India",
}
).insert(ignore_permissions=True)
@@ -426,8 +430,17 @@
item_code="_Test FG Item", target="_Test Warehouse - _TC", qty=1, basic_rate=100
)
- if not frappe.db.get_value("BOM", {"item": fg_item}):
- make_bom(item=fg_item, rate=1000, raw_materials=["_Test FG Item", "_Test FG Non Stock Item"])
+ if not frappe.db.get_value("BOM", {"item": fg_item, "docstatus": 1}):
+ bom = make_bom(
+ item=fg_item,
+ rate=1000,
+ raw_materials=["_Test FG Item", "_Test FG Non Stock Item"],
+ do_not_save=True,
+ )
+ bom.rm_cost_as_per = "Price List" # non stock item won't have valuation rate
+ bom.buying_price_list = "_Test Price List India"
+ bom.currency = "INR"
+ bom.save()
wo = make_wo_order_test_record(production_item=fg_item)
@@ -518,6 +531,8 @@
work_order.cancel()
def test_work_order_with_non_transfer_item(self):
+ frappe.db.set_value("Manufacturing Settings", None, "backflush_raw_materials_based_on", "BOM")
+
items = {"Finished Good Transfer Item": 1, "_Test FG Item": 1, "_Test FG Item 1": 0}
for item, allow_transfer in items.items():
make_item(item, {"include_item_in_manufacturing": allow_transfer})
@@ -1062,7 +1077,7 @@
sm = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 100))
for row in sm.get("items"):
if row.get("item_code") == "_Test Item":
- row.qty = 110
+ row.qty = 120
sm.submit()
cancel_stock_entry.append(sm.name)
@@ -1070,21 +1085,21 @@
s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 90))
for row in s.get("items"):
if row.get("item_code") == "_Test Item":
- self.assertEqual(row.get("qty"), 100)
+ self.assertEqual(row.get("qty"), 108)
s.submit()
cancel_stock_entry.append(s.name)
s1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5))
for row in s1.get("items"):
if row.get("item_code") == "_Test Item":
- self.assertEqual(row.get("qty"), 5)
+ self.assertEqual(row.get("qty"), 6)
s1.submit()
cancel_stock_entry.append(s1.name)
s2 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5))
for row in s2.get("items"):
if row.get("item_code") == "_Test Item":
- self.assertEqual(row.get("qty"), 5)
+ self.assertEqual(row.get("qty"), 6)
cancel_stock_entry.reverse()
for ste in cancel_stock_entry:
@@ -1194,6 +1209,269 @@
self.assertEqual(work_order.required_items[0].transferred_qty, 1)
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
+ def test_backflushed_batch_raw_materials_based_on_transferred(self):
+ frappe.db.set_value(
+ "Manufacturing Settings",
+ None,
+ "backflush_raw_materials_based_on",
+ "Material Transferred for Manufacture",
+ )
+
+ batch_item = "Test Batch MCC Keyboard"
+ fg_item = "Test FG Item with Batch Raw Materials"
+
+ ste_doc = test_stock_entry.make_stock_entry(
+ item_code=batch_item, target="Stores - _TC", qty=2, basic_rate=100, do_not_save=True
+ )
+
+ ste_doc.append(
+ "items",
+ {
+ "item_code": batch_item,
+ "item_name": batch_item,
+ "description": batch_item,
+ "basic_rate": 100,
+ "t_warehouse": "Stores - _TC",
+ "qty": 2,
+ "uom": "Nos",
+ "stock_uom": "Nos",
+ "conversion_factor": 1,
+ },
+ )
+
+ # Inward raw materials in Stores warehouse
+ ste_doc.insert()
+ ste_doc.submit()
+
+ batch_list = [row.batch_no for row in ste_doc.items]
+
+ wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4)
+ transferred_ste_doc = frappe.get_doc(
+ make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4)
+ )
+
+ transferred_ste_doc.items[0].qty = 2
+ transferred_ste_doc.items[0].batch_no = batch_list[0]
+
+ new_row = copy.deepcopy(transferred_ste_doc.items[0])
+ new_row.name = ""
+ new_row.batch_no = batch_list[1]
+
+ # Transferred two batches from Stores to WIP Warehouse
+ transferred_ste_doc.append("items", new_row)
+ transferred_ste_doc.submit()
+
+ # First Manufacture stock entry
+ manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1))
+
+ # Batch no should be same as transferred Batch no
+ self.assertEqual(manufacture_ste_doc1.items[0].batch_no, batch_list[0])
+ self.assertEqual(manufacture_ste_doc1.items[0].qty, 1)
+
+ manufacture_ste_doc1.submit()
+
+ # Second Manufacture stock entry
+ manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 2))
+
+ # Batch no should be same as transferred Batch no
+ self.assertEqual(manufacture_ste_doc2.items[0].batch_no, batch_list[0])
+ self.assertEqual(manufacture_ste_doc2.items[0].qty, 1)
+ self.assertEqual(manufacture_ste_doc2.items[1].batch_no, batch_list[1])
+ self.assertEqual(manufacture_ste_doc2.items[1].qty, 1)
+
+ def test_backflushed_serial_no_raw_materials_based_on_transferred(self):
+ frappe.db.set_value(
+ "Manufacturing Settings",
+ None,
+ "backflush_raw_materials_based_on",
+ "Material Transferred for Manufacture",
+ )
+
+ sn_item = "Test Serial No BTT Headphone"
+ fg_item = "Test FG Item with Serial No Raw Materials"
+
+ ste_doc = test_stock_entry.make_stock_entry(
+ item_code=sn_item, target="Stores - _TC", qty=4, basic_rate=100, do_not_save=True
+ )
+
+ # Inward raw materials in Stores warehouse
+ ste_doc.submit()
+
+ serial_nos_list = sorted(get_serial_nos(ste_doc.items[0].serial_no))
+
+ wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4)
+ transferred_ste_doc = frappe.get_doc(
+ make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4)
+ )
+
+ transferred_ste_doc.items[0].serial_no = "\n".join(serial_nos_list)
+ transferred_ste_doc.submit()
+
+ # First Manufacture stock entry
+ manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1))
+
+ # Serial nos should be same as transferred Serial nos
+ self.assertEqual(get_serial_nos(manufacture_ste_doc1.items[0].serial_no), serial_nos_list[0:1])
+ self.assertEqual(manufacture_ste_doc1.items[0].qty, 1)
+
+ manufacture_ste_doc1.submit()
+
+ # Second Manufacture stock entry
+ manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 2))
+
+ # Serial nos should be same as transferred Serial nos
+ self.assertEqual(get_serial_nos(manufacture_ste_doc2.items[0].serial_no), serial_nos_list[1:3])
+ self.assertEqual(manufacture_ste_doc2.items[0].qty, 2)
+
+ def test_backflushed_serial_no_batch_raw_materials_based_on_transferred(self):
+ frappe.db.set_value(
+ "Manufacturing Settings",
+ None,
+ "backflush_raw_materials_based_on",
+ "Material Transferred for Manufacture",
+ )
+
+ sn_batch_item = "Test Batch Serial No WebCam"
+ fg_item = "Test FG Item with Serial & Batch No Raw Materials"
+
+ ste_doc = test_stock_entry.make_stock_entry(
+ item_code=sn_batch_item, target="Stores - _TC", qty=2, basic_rate=100, do_not_save=True
+ )
+
+ ste_doc.append(
+ "items",
+ {
+ "item_code": sn_batch_item,
+ "item_name": sn_batch_item,
+ "description": sn_batch_item,
+ "basic_rate": 100,
+ "t_warehouse": "Stores - _TC",
+ "qty": 2,
+ "uom": "Nos",
+ "stock_uom": "Nos",
+ "conversion_factor": 1,
+ },
+ )
+
+ # Inward raw materials in Stores warehouse
+ ste_doc.insert()
+ ste_doc.submit()
+
+ batch_dict = {row.batch_no: get_serial_nos(row.serial_no) for row in ste_doc.items}
+ batches = list(batch_dict.keys())
+
+ wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4)
+ transferred_ste_doc = frappe.get_doc(
+ make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4)
+ )
+
+ transferred_ste_doc.items[0].qty = 2
+ transferred_ste_doc.items[0].batch_no = batches[0]
+ transferred_ste_doc.items[0].serial_no = "\n".join(batch_dict.get(batches[0]))
+
+ new_row = copy.deepcopy(transferred_ste_doc.items[0])
+ new_row.name = ""
+ new_row.batch_no = batches[1]
+ new_row.serial_no = "\n".join(batch_dict.get(batches[1]))
+
+ # Transferred two batches from Stores to WIP Warehouse
+ transferred_ste_doc.append("items", new_row)
+ transferred_ste_doc.submit()
+
+ # First Manufacture stock entry
+ manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1))
+
+ # Batch no & Serial Nos should be same as transferred Batch no & Serial Nos
+ batch_no = manufacture_ste_doc1.items[0].batch_no
+ self.assertEqual(
+ get_serial_nos(manufacture_ste_doc1.items[0].serial_no)[0], batch_dict.get(batch_no)[0]
+ )
+ self.assertEqual(manufacture_ste_doc1.items[0].qty, 1)
+
+ manufacture_ste_doc1.submit()
+
+ # Second Manufacture stock entry
+ manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 2))
+
+ # Batch no & Serial Nos should be same as transferred Batch no & Serial Nos
+ batch_no = manufacture_ste_doc2.items[0].batch_no
+ self.assertEqual(
+ get_serial_nos(manufacture_ste_doc2.items[0].serial_no)[0], batch_dict.get(batch_no)[1]
+ )
+ self.assertEqual(manufacture_ste_doc2.items[0].qty, 1)
+
+ batch_no = manufacture_ste_doc2.items[1].batch_no
+ self.assertEqual(
+ get_serial_nos(manufacture_ste_doc2.items[1].serial_no)[0], batch_dict.get(batch_no)[0]
+ )
+ self.assertEqual(manufacture_ste_doc2.items[1].qty, 1)
+
+
+def prepare_data_for_backflush_based_on_materials_transferred():
+ batch_item_doc = make_item(
+ "Test Batch MCC Keyboard",
+ {
+ "is_stock_item": 1,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "TBMK.#####",
+ "valuation_rate": 100,
+ "stock_uom": "Nos",
+ },
+ )
+
+ item = make_item(
+ "Test FG Item with Batch Raw Materials",
+ {
+ "is_stock_item": 1,
+ },
+ )
+
+ make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[batch_item_doc.name])
+
+ sn_item_doc = make_item(
+ "Test Serial No BTT Headphone",
+ {
+ "is_stock_item": 1,
+ "has_serial_no": 1,
+ "serial_no_series": "TSBH.#####",
+ "valuation_rate": 100,
+ "stock_uom": "Nos",
+ },
+ )
+
+ item = make_item(
+ "Test FG Item with Serial No Raw Materials",
+ {
+ "is_stock_item": 1,
+ },
+ )
+
+ make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[sn_item_doc.name])
+
+ sn_batch_item_doc = make_item(
+ "Test Batch Serial No WebCam",
+ {
+ "is_stock_item": 1,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "TBSW.#####",
+ "has_serial_no": 1,
+ "serial_no_series": "TBSWC.#####",
+ "valuation_rate": 100,
+ "stock_uom": "Nos",
+ },
+ )
+
+ item = make_item(
+ "Test FG Item with Serial & Batch No Raw Materials",
+ {
+ "is_stock_item": 1,
+ },
+ )
+
+ make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[sn_batch_item_doc.name])
+
def update_job_card(job_card, jc_qty=None):
employee = frappe.db.get_value("Employee", {"status": "Active"}, "name")
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 2802310..7b86253 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -939,7 +939,7 @@
from `tabStock Entry` entry, `tabStock Entry Detail` detail
where
entry.work_order = %(name)s
- and entry.purpose = "Material Transfer for Manufacture"
+ and entry.purpose = 'Material Transfer for Manufacture'
and entry.docstatus = 1
and detail.parent = entry.name
and (detail.item_code = %(item)s or detail.original_item = %(item)s)""",
diff --git a/erpnext/manufacturing/report/bom_variance_report/bom_variance_report.py b/erpnext/manufacturing/report/bom_variance_report/bom_variance_report.py
index 3fe2198..da28343 100644
--- a/erpnext/manufacturing/report/bom_variance_report/bom_variance_report.py
+++ b/erpnext/manufacturing/report/bom_variance_report/bom_variance_report.py
@@ -102,7 +102,7 @@
return frappe.db.sql(
"""select name from `tabWork Order`
where name like %(name)s and {0} and produced_qty > qty and docstatus = 1
- order by name limit {1}, {2}""".format(
+ order by name limit {2} offset {1}""".format(
cond, start, page_len
),
{"name": "%%%s%%" % txt},
diff --git a/erpnext/manufacturing/workspace/manufacturing/manufacturing.json b/erpnext/manufacturing/workspace/manufacturing/manufacturing.json
index 9829a96..549f5af 100644
--- a/erpnext/manufacturing/workspace/manufacturing/manufacturing.json
+++ b/erpnext/manufacturing/workspace/manufacturing/manufacturing.json
@@ -1,6 +1,6 @@
{
"charts": [],
- "content": "[{\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Your Shortcuts</b></span>\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Item\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BOM\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Work Order\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Production Plan\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Forecasting\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Work Order Summary\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BOM Stock Report\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Production Planning Report\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Reports & Masters</b></span>\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Production\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Bill of Materials\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Tools\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}}]",
+ "content": "[{\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Your Shortcuts</b></span>\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BOM\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Production Plan\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Work Order\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Job Card\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Forecasting\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BOM Stock Report\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Production Planning Report\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Reports & Masters</b></span>\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Production\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Bill of Materials\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Tools\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}}]",
"creation": "2020-03-02 17:11:37.032604",
"docstatus": 0,
"doctype": "Workspace",
@@ -402,7 +402,7 @@
"type": "Link"
}
],
- "modified": "2022-05-31 22:08:19.408223",
+ "modified": "2022-06-15 15:18:57.062935",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Manufacturing",
@@ -415,39 +415,35 @@
"sequence_id": 17.0,
"shortcuts": [
{
- "color": "Green",
- "format": "{} Active",
- "label": "Item",
- "link_to": "Item",
- "restrict_to_domain": "Manufacturing",
- "stats_filter": "{\n \"disabled\": 0\n}",
- "type": "DocType"
- },
- {
- "color": "Green",
- "format": "{} Active",
+ "color": "Grey",
+ "doc_view": "List",
"label": "BOM",
"link_to": "BOM",
- "restrict_to_domain": "Manufacturing",
- "stats_filter": "{\n \"is_active\": 1\n}",
+ "stats_filter": "{\"is_active\":[\"=\",1]}",
"type": "DocType"
},
{
- "color": "Yellow",
- "format": "{} Open",
- "label": "Work Order",
- "link_to": "Work Order",
- "restrict_to_domain": "Manufacturing",
- "stats_filter": "{ \n \"status\": [\"in\", \n [\"Draft\", \"Not Started\", \"In Process\"]\n ]\n}",
- "type": "DocType"
- },
- {
- "color": "Yellow",
- "format": "{} Open",
+ "color": "Grey",
+ "doc_view": "List",
"label": "Production Plan",
"link_to": "Production Plan",
- "restrict_to_domain": "Manufacturing",
- "stats_filter": "{ \n \"status\": [\"not in\", [\"Completed\"]]\n}",
+ "stats_filter": "{\"status\":[\"not in\",[\"Closed\",\"Cancelled\",\"Completed\"]]}",
+ "type": "DocType"
+ },
+ {
+ "color": "Grey",
+ "doc_view": "List",
+ "label": "Work Order",
+ "link_to": "Work Order",
+ "stats_filter": "{\"status\":[\"not in\",[\"Closed\",\"Cancelled\",\"Completed\"]]}",
+ "type": "DocType"
+ },
+ {
+ "color": "Grey",
+ "doc_view": "List",
+ "label": "Job Card",
+ "link_to": "Job Card",
+ "stats_filter": "{\"status\":[\"not in\",[\"Cancelled\",\"Completed\",null]]}",
"type": "DocType"
},
{
@@ -456,12 +452,6 @@
"type": "Report"
},
{
- "label": "Work Order Summary",
- "link_to": "Work Order Summary",
- "restrict_to_domain": "Manufacturing",
- "type": "Report"
- },
- {
"label": "BOM Stock Report",
"link_to": "BOM Stock Report",
"type": "Report"
@@ -470,12 +460,6 @@
"label": "Production Planning Report",
"link_to": "Production Planning Report",
"type": "Report"
- },
- {
- "label": "Dashboard",
- "link_to": "Manufacturing",
- "restrict_to_domain": "Manufacturing",
- "type": "Dashboard"
}
],
"title": "Manufacturing"
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index e57fdf9..65412d7 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -303,7 +303,7 @@
erpnext.patches.v14_0.delete_healthcare_doctypes
erpnext.patches.v14_0.delete_hub_doctypes
erpnext.patches.v14_0.delete_hospitality_doctypes # 20-01-2022
-erpnext.patches.v14_0.delete_agriculture_doctypes
+erpnext.patches.v14_0.delete_agriculture_doctypes # 15-06-2022
erpnext.patches.v14_0.delete_education_doctypes
erpnext.patches.v14_0.delete_datev_doctypes
erpnext.patches.v14_0.rearrange_company_fields
@@ -333,4 +333,6 @@
erpnext.patches.v13_0.set_per_billed_in_return_delivery_note
execute:frappe.delete_doc("DocType", "Naming Series")
erpnext.patches.v13_0.job_card_status_on_hold
-erpnext.patches.v14_0.delete_hr_payroll_doctypes
\ No newline at end of file
+erpnext.patches.v14_0.migrate_gl_to_payment_ledger
+erpnext.patches.v14_0.crm_ux_cleanup
+erpnext.patches.v14_0.delete_hr_payroll_doctypes
diff --git a/erpnext/patches/v12_0/update_healthcare_refactored_changes.py b/erpnext/patches/v12_0/update_healthcare_refactored_changes.py
deleted file mode 100644
index 5ca0d5d..0000000
--- a/erpnext/patches/v12_0/update_healthcare_refactored_changes.py
+++ /dev/null
@@ -1,131 +0,0 @@
-import frappe
-from frappe.model.utils.rename_field import rename_field
-from frappe.modules import get_doctype_module, scrub
-
-field_rename_map = {
- "Healthcare Settings": [
- ["patient_master_name", "patient_name_by"],
- ["max_visit", "max_visits"],
- ["reg_sms", "send_registration_msg"],
- ["reg_msg", "registration_msg"],
- ["app_con", "send_appointment_confirmation"],
- ["app_con_msg", "appointment_confirmation_msg"],
- ["no_con", "avoid_confirmation"],
- ["app_rem", "send_appointment_reminder"],
- ["app_rem_msg", "appointment_reminder_msg"],
- ["rem_before", "remind_before"],
- ["manage_customer", "link_customer_to_patient"],
- ["create_test_on_si_submit", "create_lab_test_on_si_submit"],
- ["require_sample_collection", "create_sample_collection_for_lab_test"],
- ["require_test_result_approval", "lab_test_approval_required"],
- ["manage_appointment_invoice_automatically", "automate_appointment_invoicing"],
- ],
- "Drug Prescription": [["use_interval", "usage_interval"], ["in_every", "interval_uom"]],
- "Lab Test Template": [
- ["sample_quantity", "sample_qty"],
- ["sample_collection_details", "sample_details"],
- ],
- "Sample Collection": [
- ["sample_quantity", "sample_qty"],
- ["sample_collection_details", "sample_details"],
- ],
- "Fee Validity": [["max_visit", "max_visits"]],
-}
-
-
-def execute():
- for dn in field_rename_map:
- if frappe.db.exists("DocType", dn):
- if dn == "Healthcare Settings":
- frappe.reload_doctype("Healthcare Settings")
- else:
- frappe.reload_doc(get_doctype_module(dn), "doctype", scrub(dn))
-
- for dt, field_list in field_rename_map.items():
- if frappe.db.exists("DocType", dt):
- for field in field_list:
- if dt == "Healthcare Settings":
- rename_field(dt, field[0], field[1])
- elif frappe.db.has_column(dt, field[0]):
- rename_field(dt, field[0], field[1])
-
- # first name mandatory in Patient
- if frappe.db.exists("DocType", "Patient"):
- patients = frappe.db.sql("select name, patient_name from `tabPatient`", as_dict=1)
- frappe.reload_doc("healthcare", "doctype", "patient")
- for entry in patients:
- name = entry.patient_name.split(" ")
- frappe.db.set_value("Patient", entry.name, "first_name", name[0])
-
- # mark Healthcare Practitioner status as Disabled
- if frappe.db.exists("DocType", "Healthcare Practitioner"):
- practitioners = frappe.db.sql(
- "select name from `tabHealthcare Practitioner` where 'active'= 0", as_dict=1
- )
- practitioners_lst = [p.name for p in practitioners]
- frappe.reload_doc("healthcare", "doctype", "healthcare_practitioner")
- if practitioners_lst:
- frappe.db.sql(
- "update `tabHealthcare Practitioner` set status = 'Disabled' where name IN %(practitioners)s"
- "",
- {"practitioners": practitioners_lst},
- )
-
- # set Clinical Procedure status
- if frappe.db.exists("DocType", "Clinical Procedure"):
- frappe.reload_doc("healthcare", "doctype", "clinical_procedure")
- frappe.db.sql(
- """
- UPDATE
- `tabClinical Procedure`
- SET
- docstatus = (CASE WHEN status = 'Cancelled' THEN 2
- WHEN status = 'Draft' THEN 0
- ELSE 1
- END)
- """
- )
-
- # set complaints and diagnosis in table multiselect in Patient Encounter
- if frappe.db.exists("DocType", "Patient Encounter"):
- field_list = [["visit_department", "medical_department"], ["type", "appointment_type"]]
- encounter_details = frappe.db.sql(
- """select symptoms, diagnosis, name from `tabPatient Encounter`""", as_dict=True
- )
- frappe.reload_doc("healthcare", "doctype", "patient_encounter")
- frappe.reload_doc("healthcare", "doctype", "patient_encounter_symptom")
- frappe.reload_doc("healthcare", "doctype", "patient_encounter_diagnosis")
-
- for field in field_list:
- if frappe.db.has_column(dt, field[0]):
- rename_field(dt, field[0], field[1])
-
- for entry in encounter_details:
- doc = frappe.get_doc("Patient Encounter", entry.name)
- symptoms = entry.symptoms.split("\n") if entry.symptoms else []
- for symptom in symptoms:
- if not frappe.db.exists("Complaint", symptom):
- frappe.get_doc({"doctype": "Complaint", "complaints": symptom}).insert()
- row = doc.append("symptoms", {"complaint": symptom})
- row.db_update()
-
- diagnosis = entry.diagnosis.split("\n") if entry.diagnosis else []
- for d in diagnosis:
- if not frappe.db.exists("Diagnosis", d):
- frappe.get_doc({"doctype": "Diagnosis", "diagnosis": d}).insert()
- row = doc.append("diagnosis", {"diagnosis": d})
- row.db_update()
- doc.db_update()
-
- if frappe.db.exists("DocType", "Fee Validity"):
- # update fee validity status
- frappe.db.sql(
- """
- UPDATE
- `tabFee Validity`
- SET
- status = (CASE WHEN visited >= max_visits THEN 'Completed'
- ELSE 'Pending'
- END)
- """
- )
diff --git a/erpnext/patches/v13_0/healthcare_lab_module_rename_doctypes.py b/erpnext/patches/v13_0/healthcare_lab_module_rename_doctypes.py
deleted file mode 100644
index 30b84ac..0000000
--- a/erpnext/patches/v13_0/healthcare_lab_module_rename_doctypes.py
+++ /dev/null
@@ -1,94 +0,0 @@
-import frappe
-from frappe.model.utils.rename_field import rename_field
-
-
-def execute():
- if frappe.db.exists("DocType", "Lab Test") and frappe.db.exists("DocType", "Lab Test Template"):
- # rename child doctypes
- doctypes = {
- "Lab Test Groups": "Lab Test Group Template",
- "Normal Test Items": "Normal Test Result",
- "Sensitivity Test Items": "Sensitivity Test Result",
- "Special Test Items": "Descriptive Test Result",
- "Special Test Template": "Descriptive Test Template",
- }
-
- frappe.reload_doc("healthcare", "doctype", "lab_test")
- frappe.reload_doc("healthcare", "doctype", "lab_test_template")
-
- for old_dt, new_dt in doctypes.items():
- frappe.flags.link_fields = {}
- should_rename = frappe.db.table_exists(old_dt) and not frappe.db.table_exists(new_dt)
- if should_rename:
- frappe.reload_doc("healthcare", "doctype", frappe.scrub(old_dt))
- frappe.rename_doc("DocType", old_dt, new_dt, force=True)
- frappe.reload_doc("healthcare", "doctype", frappe.scrub(new_dt))
- frappe.delete_doc_if_exists("DocType", old_dt)
-
- parent_fields = {
- "Lab Test Group Template": "lab_test_groups",
- "Descriptive Test Template": "descriptive_test_templates",
- "Normal Test Result": "normal_test_items",
- "Sensitivity Test Result": "sensitivity_test_items",
- "Descriptive Test Result": "descriptive_test_items",
- }
-
- for doctype, parentfield in parent_fields.items():
- frappe.db.sql(
- """
- UPDATE `tab{0}`
- SET parentfield = %(parentfield)s
- """.format(
- doctype
- ),
- {"parentfield": parentfield},
- )
-
- # copy renamed child table fields (fields were already renamed in old doctype json, hence sql)
- rename_fields = {
- "lab_test_name": "test_name",
- "lab_test_event": "test_event",
- "lab_test_uom": "test_uom",
- "lab_test_comment": "test_comment",
- }
-
- for new, old in rename_fields.items():
- if frappe.db.has_column("Normal Test Result", old):
- frappe.db.sql("""UPDATE `tabNormal Test Result` SET {} = {}""".format(new, old))
-
- if frappe.db.has_column("Normal Test Template", "test_event"):
- frappe.db.sql("""UPDATE `tabNormal Test Template` SET lab_test_event = test_event""")
-
- if frappe.db.has_column("Normal Test Template", "test_uom"):
- frappe.db.sql("""UPDATE `tabNormal Test Template` SET lab_test_uom = test_uom""")
-
- if frappe.db.has_column("Descriptive Test Result", "test_particulars"):
- frappe.db.sql(
- """UPDATE `tabDescriptive Test Result` SET lab_test_particulars = test_particulars"""
- )
-
- rename_fields = {
- "lab_test_template": "test_template",
- "lab_test_description": "test_description",
- "lab_test_rate": "test_rate",
- }
-
- for new, old in rename_fields.items():
- if frappe.db.has_column("Lab Test Group Template", old):
- frappe.db.sql("""UPDATE `tabLab Test Group Template` SET {} = {}""".format(new, old))
-
- # rename field
- frappe.reload_doc("healthcare", "doctype", "lab_test")
- if frappe.db.has_column("Lab Test", "special_toggle"):
- rename_field("Lab Test", "special_toggle", "descriptive_toggle")
-
- if frappe.db.exists("DocType", "Lab Test Group Template"):
- # fix select field option
- frappe.reload_doc("healthcare", "doctype", "lab_test_group_template")
- frappe.db.sql(
- """
- UPDATE `tabLab Test Group Template`
- SET template_or_new_line = 'Add New Line'
- WHERE template_or_new_line = 'Add new line'
- """
- )
diff --git a/erpnext/patches/v13_0/print_uom_after_quantity_patch.py b/erpnext/patches/v13_0/print_uom_after_quantity_patch.py
deleted file mode 100644
index a16f909..0000000
--- a/erpnext/patches/v13_0/print_uom_after_quantity_patch.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (c) 2019, Frappe and Contributors
-# License: GNU General Public License v3. See license.txt
-
-
-from erpnext.setup.install import create_print_uom_after_qty_custom_field
-
-
-def execute():
- create_print_uom_after_qty_custom_field()
diff --git a/erpnext/patches/v13_0/rename_discharge_date_in_ip_record.py b/erpnext/patches/v13_0/rename_discharge_date_in_ip_record.py
deleted file mode 100644
index 3bd717d..0000000
--- a/erpnext/patches/v13_0/rename_discharge_date_in_ip_record.py
+++ /dev/null
@@ -1,8 +0,0 @@
-import frappe
-from frappe.model.utils.rename_field import rename_field
-
-
-def execute():
- frappe.reload_doc("Healthcare", "doctype", "Inpatient Record")
- if frappe.db.has_column("Inpatient Record", "discharge_date"):
- rename_field("Inpatient Record", "discharge_date", "discharge_datetime")
diff --git a/erpnext/patches/v13_0/set_company_field_in_healthcare_doctypes.py b/erpnext/patches/v13_0/set_company_field_in_healthcare_doctypes.py
deleted file mode 100644
index bc2d1b9..0000000
--- a/erpnext/patches/v13_0/set_company_field_in_healthcare_doctypes.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import frappe
-
-
-def execute():
- company = frappe.db.get_single_value("Global Defaults", "default_company")
- doctypes = [
- "Clinical Procedure",
- "Inpatient Record",
- "Lab Test",
- "Sample Collection",
- "Patient Appointment",
- "Patient Encounter",
- "Vital Signs",
- "Therapy Session",
- "Therapy Plan",
- "Patient Assessment",
- ]
- for entry in doctypes:
- if frappe.db.exists("DocType", entry):
- frappe.reload_doc("Healthcare", "doctype", entry)
- frappe.db.sql(
- "update `tab{dt}` set company = {company} where ifnull(company, '') = ''".format(
- dt=entry, company=frappe.db.escape(company)
- )
- )
diff --git a/erpnext/patches/v14_0/crm_ux_cleanup.py b/erpnext/patches/v14_0/crm_ux_cleanup.py
new file mode 100644
index 0000000..b2df36f
--- /dev/null
+++ b/erpnext/patches/v14_0/crm_ux_cleanup.py
@@ -0,0 +1,94 @@
+import frappe
+from frappe.model.utils.rename_field import rename_field
+from frappe.utils import add_months, cstr, today
+
+
+def execute():
+ for doctype in ("CRM Note", "Lead", "Opportunity", "Prospect", "Prospect Lead"):
+ frappe.reload_doc("crm", "doctype", doctype)
+
+ try:
+ rename_field("Lead", "designation", "job_title")
+ rename_field("Opportunity", "converted_by", "opportunity_owner")
+
+ frappe.db.sql(
+ """
+ update `tabProspect Lead`
+ set parentfield='leads'
+ where parentfield='partner_lead'
+ """
+ )
+ except Exception as e:
+ if e.args[0] != 1054:
+ raise
+
+ add_calendar_event_for_leads()
+ add_calendar_event_for_opportunities()
+
+
+def add_calendar_event_for_leads():
+ # create events based on next contact date
+ leads = frappe.db.sql(
+ """
+ select name, contact_date, contact_by, ends_on, lead_name, lead_owner
+ from tabLead
+ where contact_date >= %s
+ """,
+ add_months(today(), -1),
+ as_dict=1,
+ )
+
+ for d in leads:
+ event = frappe.get_doc(
+ {
+ "doctype": "Event",
+ "owner": d.lead_owner,
+ "subject": ("Contact " + cstr(d.lead_name)),
+ "description": (
+ ("Contact " + cstr(d.lead_name)) + (("<br>By: " + cstr(d.contact_by)) if d.contact_by else "")
+ ),
+ "starts_on": d.contact_date,
+ "ends_on": d.ends_on,
+ "event_type": "Private",
+ }
+ )
+
+ event.append("event_participants", {"reference_doctype": "Lead", "reference_docname": d.name})
+
+ event.insert(ignore_permissions=True)
+
+
+def add_calendar_event_for_opportunities():
+ # create events based on next contact date
+ opportunities = frappe.db.sql(
+ """
+ select name, contact_date, contact_by, to_discuss,
+ party_name, opportunity_owner, contact_person
+ from tabOpportunity
+ where contact_date >= %s
+ """,
+ add_months(today(), -1),
+ as_dict=1,
+ )
+
+ for d in opportunities:
+ event = frappe.get_doc(
+ {
+ "doctype": "Event",
+ "owner": d.opportunity_owner,
+ "subject": ("Contact " + cstr(d.contact_person or d.party_name)),
+ "description": (
+ ("Contact " + cstr(d.contact_person or d.party_name))
+ + (("<br>By: " + cstr(d.contact_by)) if d.contact_by else "")
+ + (("<br>Agenda: " + cstr(d.to_discuss)) if d.to_discuss else "")
+ ),
+ "starts_on": d.contact_date,
+ "event_type": "Private",
+ }
+ )
+
+ event.append(
+ "event_participants", {"reference_doctype": "Opportunity", "reference_docname": d.name}
+ )
+
+ event.insert(ignore_permissions=True)
diff --git a/erpnext/patches/v14_0/delete_agriculture_doctypes.py b/erpnext/patches/v14_0/delete_agriculture_doctypes.py
index e0b12a2..8ec0c33 100644
--- a/erpnext/patches/v14_0/delete_agriculture_doctypes.py
+++ b/erpnext/patches/v14_0/delete_agriculture_doctypes.py
@@ -2,6 +2,9 @@
def execute():
+ if "agriculture" in frappe.get_installed_apps():
+ return
+
frappe.delete_doc("Module Def", "Agriculture", ignore_missing=True, force=True)
frappe.delete_doc("Workspace", "Agriculture", ignore_missing=True, force=True)
@@ -19,3 +22,5 @@
doctypes = frappe.get_all("DocType", {"module": "agriculture", "custom": 0}, pluck="name")
for doctype in doctypes:
frappe.delete_doc("DocType", doctype, ignore_missing=True)
+
+ frappe.delete_doc("Module Def", "Agriculture", ignore_missing=True, force=True)
diff --git a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py
index c2267aa..e15aa4a 100644
--- a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py
+++ b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py
@@ -1,11 +1,13 @@
import frappe
from frappe import qb
+from frappe.query_builder import Case, CustomFunction
+from frappe.query_builder.custom import ConstantColumn
+from frappe.query_builder.functions import IfNull
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_dimensions,
make_dimension_in_accounting_doctypes,
)
-from erpnext.accounts.utils import create_payment_ledger_entry
def create_accounting_dimension_fields():
@@ -15,24 +17,124 @@
make_dimension_in_accounting_doctypes(dimension, ["Payment Ledger Entry"])
-def execute():
- # create accounting dimension fields in Payment Ledger
- create_accounting_dimension_fields()
+def generate_name_for_payment_ledger_entries(gl_entries):
+ for index, entry in enumerate(gl_entries, 1):
+ entry.name = index
- gl = qb.DocType("GL Entry")
- accounts = frappe.db.get_list(
- "Account", "name", filters={"account_type": ["in", ["Receivable", "Payable"]]}, as_list=True
- )
- gl_entries = []
- if accounts:
- # get all gl entries on receivable/payable accounts
+
+def get_columns():
+ columns = [
+ "name",
+ "creation",
+ "modified",
+ "modified_by",
+ "owner",
+ "docstatus",
+ "posting_date",
+ "account_type",
+ "account",
+ "party_type",
+ "party",
+ "voucher_type",
+ "voucher_no",
+ "against_voucher_type",
+ "against_voucher_no",
+ "amount",
+ "amount_in_account_currency",
+ "account_currency",
+ "company",
+ "cost_center",
+ "due_date",
+ "finance_book",
+ ]
+
+ dimensions_and_defaults = get_dimensions()
+ if dimensions_and_defaults:
+ for dimension in dimensions_and_defaults[0]:
+ columns.append(dimension.fieldname)
+
+ return columns
+
+
+def build_insert_query():
+ ple = qb.DocType("Payment Ledger Entry")
+ columns = get_columns()
+ insert_query = qb.into(ple)
+
+ # build 'insert' columns in query
+ insert_query = insert_query.columns(tuple(columns))
+
+ return insert_query
+
+
+def insert_chunk_into_payment_ledger(insert_query, gl_entries):
+ if gl_entries:
+ columns = get_columns()
+
+ # build tuple of data with same column order
+ for entry in gl_entries:
+ data = ()
+ for column in columns:
+ data += (entry[column],)
+ insert_query = insert_query.insert(data)
+ insert_query.run()
+
+
+def execute():
+ if frappe.reload_doc("accounts", "doctype", "payment_ledger_entry"):
+ # create accounting dimension fields in Payment Ledger
+ create_accounting_dimension_fields()
+
+ gl = qb.DocType("GL Entry")
+ account = qb.DocType("Account")
+ ifelse = CustomFunction("IF", ["condition", "then", "else"])
+
gl_entries = (
qb.from_(gl)
- .select("*")
- .where(gl.account.isin(accounts))
+ .inner_join(account)
+ .on((gl.account == account.name) & (account.account_type.isin(["Receivable", "Payable"])))
+ .select(
+ gl.star,
+ ConstantColumn(1).as_("docstatus"),
+ account.account_type.as_("account_type"),
+ IfNull(
+ ifelse(gl.against_voucher_type == "", None, gl.against_voucher_type), gl.voucher_type
+ ).as_("against_voucher_type"),
+ IfNull(ifelse(gl.against_voucher == "", None, gl.against_voucher), gl.voucher_no).as_(
+ "against_voucher_no"
+ ),
+ # convert debit/credit to amount
+ Case()
+ .when(account.account_type == "Receivable", gl.debit - gl.credit)
+ .else_(gl.credit - gl.debit)
+ .as_("amount"),
+ # convert debit/credit in account currency to amount in account currency
+ Case()
+ .when(
+ account.account_type == "Receivable",
+ gl.debit_in_account_currency - gl.credit_in_account_currency,
+ )
+ .else_(gl.credit_in_account_currency - gl.debit_in_account_currency)
+ .as_("amount_in_account_currency"),
+ )
.where(gl.is_cancelled == 0)
+ .orderby(gl.creation)
.run(as_dict=True)
)
- if gl_entries:
- # create payment ledger entries for the accounts receivable/payable
- create_payment_ledger_entry(gl_entries, 0)
+
+ # primary key(name) for payment ledger records
+ generate_name_for_payment_ledger_entries(gl_entries)
+
+ # split data into chunks
+ chunk_size = 1000
+ try:
+ for i in range(0, len(gl_entries), chunk_size):
+ insert_query = build_insert_query()
+ insert_chunk_into_payment_ledger(insert_query, gl_entries[i : i + chunk_size])
+ frappe.db.commit()
+ except Exception as err:
+ frappe.db.rollback()
+ ple = qb.DocType("Payment Ledger Entry")
+ qb.from_(ple).delete().where(ple.docstatus >= 0).run()
+ frappe.db.commit()
+ raise err
diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py
index 7646078..8ca3de5 100644
--- a/erpnext/projects/doctype/project/project.py
+++ b/erpnext/projects/doctype/project/project.py
@@ -379,11 +379,11 @@
or full_name like %(txt)s)
{fcond} {mcond}
order by
- if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999),
- if(locate(%(_txt)s, full_name), locate(%(_txt)s, full_name), 99999),
+ (case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end),
+ (case when locate(%(_txt)s, full_name) > 0 then locate(%(_txt)s, full_name) else 99999 end)
idx desc,
name, full_name
- limit %(start)s, %(page_len)s""".format(
+ limit %(page_len)s offset %(start)s""".format(
**{
"key": searchfield,
"fcond": get_filters_cond(doctype, filters, conditions),
diff --git a/erpnext/projects/doctype/project_update/project_update.py b/erpnext/projects/doctype/project_update/project_update.py
index 5a29fb6..175f787 100644
--- a/erpnext/projects/doctype/project_update/project_update.py
+++ b/erpnext/projects/doctype/project_update/project_update.py
@@ -28,7 +28,7 @@
for drafts in draft:
number_of_drafts = drafts[0]
update = frappe.db.sql(
- """SELECT name,date,time,progress,progress_details FROM `tabProject Update` WHERE `tabProject Update`.project = %s AND date = DATE_ADD(CURDATE(), INTERVAL -1 DAY);""",
+ """SELECT name,date,time,progress,progress_details FROM `tabProject Update` WHERE `tabProject Update`.project = %s AND date = DATE_ADD(CURRENT_DATE, INTERVAL -1 DAY);""",
project_name,
)
email_sending(project_name, frequency, date_start, date_end, progress, number_of_drafts, update)
@@ -39,7 +39,7 @@
):
holiday = frappe.db.sql(
- """SELECT holiday_date FROM `tabHoliday` where holiday_date = CURDATE();"""
+ """SELECT holiday_date FROM `tabHoliday` where holiday_date = CURRENT_DATE;"""
)
msg = (
"<p>Project Name: "
diff --git a/erpnext/projects/doctype/task/task.py b/erpnext/projects/doctype/task/task.py
index f2600d3..fa50785 100755
--- a/erpnext/projects/doctype/task/task.py
+++ b/erpnext/projects/doctype/task/task.py
@@ -281,7 +281,7 @@
%(mcond)s
{search_condition}
order by name
- limit %(start)s, %(page_len)s""".format(
+ limit %(page_len)s offset %(start)s""".format(
search_columns=search_columns, search_condition=search_cond
),
{
diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py
index 83db223..e098c3e 100644
--- a/erpnext/projects/doctype/timesheet/test_timesheet.py
+++ b/erpnext/projects/doctype/timesheet/test_timesheet.py
@@ -40,7 +40,9 @@
emp = make_employee("test_employee_6@salary.com")
timesheet = make_timesheet(emp, simulate=True, is_billable=1)
- sales_invoice = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer")
+ sales_invoice = make_sales_invoice(
+ timesheet.name, "_Test Item", "_Test Customer", currency="INR"
+ )
sales_invoice.due_date = nowdate()
sales_invoice.submit()
timesheet = frappe.get_doc("Timesheet", timesheet.name)
diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py
index abff910..b9bb37a 100644
--- a/erpnext/projects/doctype/timesheet/timesheet.py
+++ b/erpnext/projects/doctype/timesheet/timesheet.py
@@ -317,7 +317,7 @@
ts.status in ('Submitted', 'Payslip') and tsd.parent = ts.name and
tsd.docstatus = 1 and ts.total_billable_amount > 0
and tsd.parent LIKE %(txt)s {condition}
- order by tsd.parent limit %(start)s, %(page_len)s""".format(
+ order by tsd.parent limit %(page_len)s offset %(start)s""".format(
condition=condition
),
{
@@ -483,7 +483,7 @@
tsd.project IN %(projects)s
)
ORDER BY `end_date` ASC
- LIMIT {0}, {1}
+ LIMIT {1} offset {0}
""".format(
limit_start, limit_page_length
),
diff --git a/erpnext/projects/utils.py b/erpnext/projects/utils.py
index 000ea66..3cc4da4 100644
--- a/erpnext/projects/utils.py
+++ b/erpnext/projects/utils.py
@@ -25,7 +25,7 @@
case when `%s` like %s then 0 else 1 end,
`%s`,
subject
- limit %s, %s"""
+ limit %s offset %s"""
% (searchfield, "%s", "%s", match_conditions, "%s", searchfield, "%s", searchfield, "%s", "%s"),
- (search_string, search_string, order_by_string, order_by_string, start, page_len),
+ (search_string, search_string, order_by_string, order_by_string, page_len, start),
)
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index de93c82..01f72ad 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -453,7 +453,6 @@
is_pos: cint(me.frm.doc.is_pos),
is_return: cint(me.frm.doc.is_return),
is_subcontracted: me.frm.doc.is_subcontracted,
- transaction_date: me.frm.doc.transaction_date || me.frm.doc.posting_date,
ignore_pricing_rule: me.frm.doc.ignore_pricing_rule,
doctype: me.frm.doc.doctype,
name: me.frm.doc.name,
diff --git a/erpnext/public/js/erpnext.bundle.js b/erpnext/public/js/erpnext.bundle.js
index d58b827..14a088e 100644
--- a/erpnext/public/js/erpnext.bundle.js
+++ b/erpnext/public/js/erpnext.bundle.js
@@ -21,5 +21,8 @@
import "./telephony";
import "./templates/call_link.html";
import "./bulk_transaction_processing";
+import "./utils/crm_activities";
+import "./templates/crm_activities.html";
+import "./templates/crm_notes.html";
// import { sum } from 'frappe/public/utils/util.js'
diff --git a/erpnext/public/js/templates/crm_activities.html b/erpnext/public/js/templates/crm_activities.html
new file mode 100644
index 0000000..4260319
--- /dev/null
+++ b/erpnext/public/js/templates/crm_activities.html
@@ -0,0 +1,176 @@
+<div class="open-activities">
+ <div class="new-btn pb-3">
+ <span>
+ <button class="btn btn-sm small new-task-btn mr-1">
+ <svg class="icon icon-sm">
+ <use href="#icon-small-message"></use>
+ </svg>
+ {{ __("New Task") }}
+ </button>
+ <button class="btn btn-sm small new-event-btn">
+ <svg class="icon icon-sm">
+ <use href="#icon-calendar"></use>
+ </svg>
+ {{ __("New Event") }}
+ </button>
+ </span>
+ </div>
+ <div class="section-body">
+ <div class="open-tasks pr-1">
+ <div class="open-section-head">
+ <span class="ml-2">{{ __("Open Tasks") }}</span>
+ </div>
+ {% if (tasks.length) { %}
+ {% for(var i=0, l=tasks.length; i<l; i++) { %}
+ <div class="single-activity">
+ <div class="flex justify-between mb-2">
+ <div class="row label-area font-md ml-1">
+ <span class="mr-2">
+ <svg class="icon icon-sm">
+ <use href="#icon-small-message"></use>
+ </svg>
+ </span>
+ <a href="/app/todo/{{ tasks[i].name }}" title="{{ __('Open Task') }}">
+ {%= tasks[i].description %}
+ </a>
+ </div>
+ <div class="checkbox">
+ <input type="checkbox" class="completion-checkbox"
+ name="{{tasks[i].name}}" title="{{ __('Mark As Closed') }}">
+ </div>
+ </div>
+ {% if(tasks[i].date) { %}
+ <div class="text-muted ml-1">
+ {%= frappe.datetime.global_date_format(tasks[i].date) %}
+ </div>
+ {% } %}
+ {% if(tasks[i].allocated_to) { %}
+ <div class="text-muted ml-1">
+ {{ __("Allocated To:") }}
+ {%= tasks[i].allocated_to %}
+ </div>
+ {% } %}
+ </div>
+ {% } %}
+ {% } else { %}
+ <div class="single-activity no-activity text-muted">
+ {{ __("No open task") }}
+ </div>
+ {% } %}
+ </div>
+ <div class="open-events pl-1">
+ <div class="open-section-head">
+ <span class="ml-2">{{ __("Open Events") }}</span>
+ </div>
+ {% if (events.length) { %}
+ {% let icon_set = {"Sent/Received Email": "mail", "Call": "call", "Meeting": "share-people"}; %}
+ {% for(var i=0, l=events.length; i<l; i++) { %}
+ <div class="single-activity">
+ <div class="flex justify-between mb-2">
+ <div class="row label-area font-md ml-1 title">
+ <span class="mr-2">
+ <svg class="icon icon-sm">
+ <use href="#icon-{{ icon_set[events[i].event_category] || 'calendar' }}"></use>
+ </svg>
+ </span>
+ <a href="/app/event/{{ events[i].name }}" title="{{ __('Open Event') }}">
+ {%= events[i].subject %}
+ </a>
+ </div>
+ <div class="checkbox">
+ <input type="checkbox" class="completion-checkbox"
+ name="{{ events[i].name }}" title="{{ __('Mark As Closed') }}">
+ </div>
+ </div>
+ <div class="text-muted ml-1">
+ {%= frappe.datetime.global_date_format(events[i].starts_on) %}
+
+ {% if (events[i].ends_on) { %}
+ {% if (frappe.datetime.obj_to_user(events[i].starts_on) != frappe.datetime.obj_to_user(events[i].ends_on)) %}
+ -
+ {%= frappe.datetime.global_date_format(frappe.datetime.obj_to_user(events[i].ends_on)) %}
+ {%= frappe.datetime.get_time(events[i].ends_on) %}
+ {% } else if (events[i].ends_on) { %}
+ -
+ {%= frappe.datetime.get_time(events[i].ends_on) %}
+ {% } %}
+ {% } %}
+
+ </div>
+ </div>
+ {% } %}
+ {% } else { %}
+ <div class="single-activity no-activity text-muted">
+ {{ __("No open event") }}
+ </div>
+ {% } %}
+ </div>
+ </div>
+</div>
+
+
+<style>
+.open-activities {
+ min-height: 50px;
+ padding-left: 0px;
+ padding-bottom: 15px !important;
+}
+
+.open-activities .new-btn {
+ text-align: right;
+}
+
+.single-activity {
+ min-height: 90px;
+ border: 1px solid var(--border-color);
+ padding: 10px;
+ border-bottom: 0;
+ padding-right: 0;
+}
+
+.single-activity:last-child {
+ border-bottom: 1px solid var(--border-color);
+}
+
+.single-activity:hover .completion-checkbox{
+ display: block;
+}
+
+.completion-checkbox {
+ vertical-align: middle;
+ display: none;
+}
+
+.checkbox {
+ min-width: 22px;
+}
+
+.open-tasks {
+ width: 50%;
+}
+
+.open-tasks:first-child {
+ border-right: 0;
+}
+
+.open-events {
+ width: 50%;
+}
+
+.open-section-head {
+ background-color: var(--bg-color);
+ min-height: 30px;
+ border-bottom: 1px solid var(--border-color);
+ padding: 10px;
+ font-weight: bold;
+}
+
+.no-activity {
+ text-align: center;
+ padding-top: 30px;
+}
+
+.form-footer {
+ background-color: var(--bg-color);
+}
+</style>
\ No newline at end of file
diff --git a/erpnext/public/js/templates/crm_notes.html b/erpnext/public/js/templates/crm_notes.html
new file mode 100644
index 0000000..fddeb1c
--- /dev/null
+++ b/erpnext/public/js/templates/crm_notes.html
@@ -0,0 +1,74 @@
+<div class="notes-section col-xs-12">
+ <div class="new-btn pb-3">
+ <button class="btn btn-sm small new-note-btn mr-1">
+ <svg class="icon icon-sm">
+ <use href="#icon-add"></use>
+ </svg>
+ {{ __("New Note") }}
+ </button>
+ </div>
+ <div class="all-notes">
+ {% if (notes.length) { %}
+ {% for(var i=0, l=notes.length; i<l; i++) { %}
+ <div class="comment-content p-3 row" name="{{ notes[i].name }}">
+ <div class="mb-2 head col-xs-3">
+ <div class="row">
+ <div class="col-xs-2">
+ {{ frappe.avatar(notes[i].added_by) }}
+ </div>
+ <div class="col-xs-10">
+ <div class="mr-2 title font-weight-bold">
+ {{ strip_html(notes[i].added_by) }}
+ </div>
+ <div class="time small text-muted">
+ {{ frappe.datetime.global_date_format(notes[i].added_on) }}
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="content col-xs-8">
+ {{ notes[i].note }}
+ </div>
+ <div class="col-xs-1 text-right">
+ <span class="edit-note-btn btn btn-link">
+ <svg class="icon icon-sm"><use xlink:href="#icon-edit"></use></svg>
+ </span>
+ <span class="delete-note-btn btn btn-link pl-2">
+ <svg class="icon icon-xs"><use xlink:href="#icon-delete"></use></svg>
+ </span>
+ </div>
+ </div>
+ {% } %}
+ {% } else { %}
+ <div class="no-activity text-muted pt-6">
+ {{ __("No Notes") }}
+ </div>
+ {% } %}
+ </div>
+</div>
+
+<style>
+
+.comment-content {
+ border: 1px solid var(--border-color);
+ border-bottom: none;
+}
+
+.comment-content:last-child {
+ border-bottom: 1px solid var(--border-color);
+}
+
+.new-btn {
+ text-align: right;
+}
+
+.notes-section .no-activity {
+ min-height: 100px;
+ text-align: center;
+}
+
+.notes-section .btn {
+ padding: 0.2rem 0.2rem;
+}
+
+</style>
\ No newline at end of file
diff --git a/erpnext/public/js/utils/crm_activities.js b/erpnext/public/js/utils/crm_activities.js
new file mode 100644
index 0000000..bbd9ded
--- /dev/null
+++ b/erpnext/public/js/utils/crm_activities.js
@@ -0,0 +1,234 @@
+erpnext.utils.CRMActivities = class CRMActivities {
+ constructor(opts) {
+ $.extend(this, opts);
+ }
+
+ refresh() {
+ var me = this;
+ $(this.open_activities_wrapper).empty();
+ let cur_form_footer = this.form_wrapper.find('.form-footer');
+
+ // all activities
+ if (!$(this.all_activities_wrapper).find('.form-footer').length) {
+ this.all_activities_wrapper.empty();
+ $(cur_form_footer).appendTo(this.all_activities_wrapper);
+
+ // remove frappe-control class to avoid absolute position for action-btn
+ $(this.all_activities_wrapper).removeClass('frappe-control');
+ // hide new event button
+ $('.timeline-actions').find('.btn-default').hide();
+ // hide new comment box
+ $(".comment-box").hide();
+ // show only communications by default
+ $($('.timeline-content').find('.nav-link')[0]).tab('show');
+ }
+
+ // open activities
+ frappe.call({
+ method: "erpnext.crm.utils.get_open_activities",
+ args: {
+ ref_doctype: this.frm.doc.doctype,
+ ref_docname: this.frm.doc.name
+ },
+ callback: (r) => {
+ if (!r.exc) {
+ var activities_html = frappe.render_template('crm_activities', {
+ tasks: r.message.tasks,
+ events: r.message.events
+ });
+
+ $(activities_html).appendTo(me.open_activities_wrapper);
+
+ $(".open-tasks").find(".completion-checkbox").on("click", function() {
+ me.update_status(this, "ToDo");
+ });
+
+ $(".open-events").find(".completion-checkbox").on("click", function() {
+ me.update_status(this, "Event");
+ });
+
+ me.create_task();
+ me.create_event();
+ }
+ }
+ });
+ }
+
+ create_task () {
+ let me = this;
+ let _create_task = () => {
+ const args = {
+ doc: me.frm.doc,
+ frm: me.frm,
+ title: __("New Task")
+ };
+ let composer = new frappe.views.InteractionComposer(args);
+ composer.dialog.get_field('interaction_type').set_value("ToDo");
+ // hide column having interaction type field
+ $(composer.dialog.get_field('interaction_type').wrapper).closest('.form-column').hide();
+ // hide summary field
+ $(composer.dialog.get_field('summary').wrapper).closest('.form-section').hide();
+ };
+ $(".new-task-btn").click(_create_task);
+ }
+
+ create_event () {
+ let me = this;
+ let _create_event = () => {
+ const args = {
+ doc: me.frm.doc,
+ frm: me.frm,
+ title: __("New Event")
+ };
+ let composer = new frappe.views.InteractionComposer(args);
+ composer.dialog.get_field('interaction_type').set_value("Event");
+ $(composer.dialog.get_field('interaction_type').wrapper).hide();
+ };
+ $(".new-event-btn").click(_create_event);
+ }
+
+ async update_status (input_field, doctype) {
+ let completed = $(input_field).prop("checked") ? 1 : 0;
+ let docname = $(input_field).attr("name");
+ if (completed) {
+ await frappe.db.set_value(doctype, docname, "status", "Closed");
+ this.refresh();
+ }
+ }
+};
+
+erpnext.utils.CRMNotes = class CRMNotes {
+ constructor(opts) {
+ $.extend(this, opts);
+ }
+
+ refresh() {
+ var me = this;
+ this.notes_wrapper.find('.notes-section').remove();
+
+ let notes = this.frm.doc.notes || [];
+ notes.sort(
+ function(a, b) {
+ return new Date(b.added_on) - new Date(a.added_on);
+ }
+ );
+
+ let notes_html = frappe.render_template(
+ 'crm_notes',
+ {
+ notes: notes
+ }
+ );
+ $(notes_html).appendTo(this.notes_wrapper);
+
+ this.add_note();
+
+ $(".notes-section").find(".edit-note-btn").on("click", function() {
+ me.edit_note(this);
+ });
+
+ $(".notes-section").find(".delete-note-btn").on("click", function() {
+ me.delete_note(this);
+ });
+ }
+
+
+ add_note () {
+ let me = this;
+ let _add_note = () => {
+ var d = new frappe.ui.Dialog({
+ title: __('Add a Note'),
+ fields: [
+ {
+ "label": "Note",
+ "fieldname": "note",
+ "fieldtype": "Text Editor",
+ "reqd": 1
+ }
+ ],
+ primary_action: function() {
+ var data = d.get_values();
+ frappe.call({
+ method: "add_note",
+ doc: me.frm.doc,
+ args: {
+ note: data.note
+ },
+ freeze: true,
+ callback: function(r) {
+ if (!r.exc) {
+ me.frm.refresh_field("notes");
+ me.refresh();
+ }
+ d.hide();
+ }
+ });
+ },
+ primary_action_label: __('Add')
+ });
+ d.show();
+ };
+ $(".new-note-btn").click(_add_note);
+ }
+
+ edit_note (edit_btn) {
+ var me = this;
+ let row = $(edit_btn).closest('.comment-content');
+ let row_id = row.attr("name");
+ let row_content = $(row).find(".content").html();
+ if (row_content) {
+ var d = new frappe.ui.Dialog({
+ title: __('Edit Note'),
+ fields: [
+ {
+ "label": "Note",
+ "fieldname": "note",
+ "fieldtype": "Text Editor",
+ "default": row_content
+ }
+ ],
+ primary_action: function() {
+ var data = d.get_values();
+ frappe.call({
+ method: "edit_note",
+ doc: me.frm.doc,
+ args: {
+ note: data.note,
+ row_id: row_id
+ },
+ freeze: true,
+ callback: function(r) {
+ if (!r.exc) {
+ me.frm.refresh_field("notes");
+ me.refresh();
+ d.hide();
+ }
+
+ }
+ });
+ },
+ primary_action_label: __('Done')
+ });
+ d.show();
+ }
+ }
+
+ delete_note (delete_btn) {
+ var me = this;
+ let row_id = $(delete_btn).closest('.comment-content').attr("name");
+ frappe.call({
+ method: "delete_note",
+ doc: me.frm.doc,
+ args: {
+ row_id: row_id
+ },
+ freeze: true,
+ callback: function(r) {
+ if (!r.exc) {
+ me.frm.refresh_field("notes");
+ me.refresh();
+ }
+ }
+ });
+ }
+};
diff --git a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py
index 91fccfa..090697b 100644
--- a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py
+++ b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py
@@ -244,11 +244,10 @@
)
for d in item_details:
- if d.item_code not in self.invoice_items.get(d.parent, {}):
- self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, 0.0)
- self.invoice_items[d.parent][d.item_code] += d.get("taxable_value", 0) or d.get(
- "base_net_amount", 0
- )
+ self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, 0.0)
+ self.invoice_items[d.parent][d.item_code] += d.get("taxable_value", 0) or d.get(
+ "base_net_amount", 0
+ )
if d.is_nil_exempt and d.item_code not in self.is_nil_exempt:
self.is_nil_exempt.append(d.item_code)
@@ -335,7 +334,6 @@
def set_outward_taxable_supplies(self):
inter_state_supply_details = {}
-
for inv, items_based_on_rate in self.items_based_on_tax_rate.items():
gst_category = self.invoice_detail_map.get(inv, {}).get("gst_category")
place_of_supply = (
@@ -361,7 +359,6 @@
else:
self.report_dict["sup_details"]["osup_det"]["iamt"] += taxable_value * rate / 100
self.report_dict["sup_details"]["osup_det"]["txval"] += taxable_value
-
if (
gst_category in ["Unregistered", "Registered Composition", "UIN Holders"]
and self.gst_details.get("gst_state") != place_of_supply.split("-")[1]
diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py
index 049486e..a62d1af 100644
--- a/erpnext/regional/india/utils.py
+++ b/erpnext/regional/india/utils.py
@@ -275,7 +275,7 @@
return party_details
if (
- doctype in ("Sales Invoice", "Delivery Note", "Sales Order")
+ doctype in ("Sales Invoice", "Delivery Note", "Sales Order", "Quotation")
and party_details.company_gstin
and party_details.company_gstin[:2] != party_details.place_of_supply[:2]
) or (
diff --git a/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py b/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py
index 5ceb2c0..1d4f96b 100644
--- a/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py
+++ b/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py
@@ -83,7 +83,7 @@
("gst_hsn_code", " and gst_hsn_code=%(gst_hsn_code)s"),
("company_gstin", " and company_gstin=%(company_gstin)s"),
("from_date", " and posting_date >= %(from_date)s"),
- ("to_date", "and posting_date <= %(to_date)s"),
+ ("to_date", " and posting_date <= %(to_date)s"),
):
if filters.get(opts[0]):
conditions += opts[1]
diff --git a/erpnext/regional/report/irs_1099/irs_1099.py b/erpnext/regional/report/irs_1099/irs_1099.py
index 92aeb5e..66ade1f 100644
--- a/erpnext/regional/report/irs_1099/irs_1099.py
+++ b/erpnext/regional/report/irs_1099/irs_1099.py
@@ -10,7 +10,7 @@
from frappe.utils.jinja import render_template
from frappe.utils.pdf import get_pdf
from frappe.utils.print_format import read_multi_pdf
-from PyPDF2 import PdfFileWriter
+from PyPDF2 import PdfWriter
from erpnext.accounts.utils import get_fiscal_year
@@ -47,7 +47,7 @@
s.name = gl.party
AND s.irs_1099 = 1
AND gl.fiscal_year = %(fiscal_year)s
- AND gl.party_type = "Supplier"
+ AND gl.party_type = 'Supplier'
AND gl.company = %(company)s
{conditions}
@@ -106,7 +106,7 @@
columns, data = execute(filters)
template = frappe.get_doc("Print Format", "IRS 1099 Form").html
- output = PdfFileWriter()
+ output = PdfWriter()
for row in data:
row["fiscal_year"] = fiscal_year
diff --git a/erpnext/regional/report/vat_audit_report/vat_audit_report.py b/erpnext/regional/report/vat_audit_report/vat_audit_report.py
index 70f2c0a..3d486ce 100644
--- a/erpnext/regional/report/vat_audit_report/vat_audit_report.py
+++ b/erpnext/regional/report/vat_audit_report/vat_audit_report.py
@@ -65,7 +65,7 @@
`tab{doctype}`
WHERE
docstatus = 1 {where_conditions}
- and is_opening = "No"
+ and is_opening = 'No'
ORDER BY
posting_date DESC
""".format(
diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py
index 8889a5f..35e0b0d 100644
--- a/erpnext/selling/doctype/customer/customer.py
+++ b/erpnext/selling/doctype/customer/customer.py
@@ -141,6 +141,9 @@
)
def validate_internal_customer(self):
+ if not self.is_internal_customer:
+ self.represents_company = ""
+
internal_customer = frappe.db.get_value(
"Customer",
{
diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py
index 2458756..7dc3fab 100644
--- a/erpnext/selling/doctype/customer/test_customer.py
+++ b/erpnext/selling/doctype/customer/test_customer.py
@@ -375,6 +375,12 @@
if not allowed_to_interact_with:
allowed_to_interact_with = represents_company
+ exisiting_representative = frappe.db.get_value(
+ "Customer", {"represents_company": represents_company}
+ )
+ if exisiting_representative:
+ return exisiting_representative
+
if not frappe.db.exists("Customer", customer_name):
customer = frappe.get_doc(
{
diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.js b/erpnext/selling/doctype/product_bundle/product_bundle.js
index 7a04c6a..3096b69 100644
--- a/erpnext/selling/doctype/product_bundle/product_bundle.js
+++ b/erpnext/selling/doctype/product_bundle/product_bundle.js
@@ -1,19 +1,13 @@
-// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
-cur_frm.cscript.refresh = function(doc, cdt, cdn) {
- cur_frm.toggle_enable('new_item_code', doc.__islocal);
-}
-
-cur_frm.fields_dict.new_item_code.get_query = function() {
- return{
- query: "erpnext.selling.doctype.product_bundle.product_bundle.get_new_item_code"
- }
-}
-cur_frm.fields_dict.new_item_code.query_description = __('Please select Item where "Is Stock Item" is "No" and "Is Sales Item" is "Yes" and there is no other Product Bundle');
-
-cur_frm.cscript.onload = function() {
- // set add fetch for item_code's item_name and description
- cur_frm.add_fetch('item_code', 'stock_uom', 'uom');
- cur_frm.add_fetch('item_code', 'description', 'description');
-}
+frappe.ui.form.on("Product Bundle", {
+ refresh: function (frm) {
+ frm.toggle_enable("new_item_code", frm.is_new());
+ frm.set_query("new_item_code", () => {
+ return {
+ query: "erpnext.selling.doctype.product_bundle.product_bundle.get_new_item_code",
+ };
+ });
+ },
+});
diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.py b/erpnext/selling/doctype/product_bundle/product_bundle.py
index 575b956..ac83c0f 100644
--- a/erpnext/selling/doctype/product_bundle/product_bundle.py
+++ b/erpnext/selling/doctype/product_bundle/product_bundle.py
@@ -78,7 +78,7 @@
return frappe.db.sql(
"""select name, item_name, description from tabItem
where is_stock_item=0 and name not in (select name from `tabProduct Bundle`)
- and %s like %s %s limit %s, %s"""
+ and %s like %s %s limit %s offset %s"""
% (searchfield, "%s", get_match_cond(doctype), "%s", "%s"),
- ("%%%s%%" % txt, start, page_len),
+ ("%%%s%%" % txt, page_len, start),
)
diff --git a/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json b/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json
index dc071e4..fc8caeb 100644
--- a/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json
+++ b/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json
@@ -33,6 +33,8 @@
"reqd": 1
},
{
+ "fetch_from": "item_code.description",
+ "fetch_if_empty": 1,
"fieldname": "description",
"fieldtype": "Text Editor",
"in_list_view": 1,
@@ -51,6 +53,8 @@
"print_hide": 1
},
{
+ "fetch_from": "item_code.stock_uom",
+ "fetch_if_empty": 1,
"fieldname": "uom",
"fieldtype": "Link",
"in_list_view": 1,
@@ -64,7 +68,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2020-02-28 14:06:05.725655",
+ "modified": "2022-06-27 05:30:18.475150",
"modified_by": "Administrator",
"module": "Selling",
"name": "Product Bundle Item",
@@ -72,5 +76,6 @@
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js
index 34e9a52..70ae085 100644
--- a/erpnext/selling/doctype/quotation/quotation.js
+++ b/erpnext/selling/doctype/quotation/quotation.js
@@ -20,6 +20,20 @@
frm.set_df_property('packed_items', 'cannot_add_rows', true);
frm.set_df_property('packed_items', 'cannot_delete_rows', true);
+
+ frm.set_query('company_address', function(doc) {
+ if(!doc.company) {
+ frappe.throw(__('Please set Company'));
+ }
+
+ return {
+ query: 'frappe.contacts.doctype.address.address.address_query',
+ filters: {
+ link_doctype: 'Company',
+ link_name: doc.company
+ }
+ };
+ });
},
refresh: function(frm) {
@@ -70,7 +84,7 @@
}
}
- if(doc.docstatus == 1 && doc.status!=='Lost') {
+ if(doc.docstatus == 1 && !(['Lost', 'Ordered']).includes(doc.status)) {
if(!doc.valid_till || frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) {
cur_frm.add_custom_button(__('Sales Order'),
cur_frm.cscript['Make Sales Order'], __('Create'));
diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json
index 75443ab..bb2f95d 100644
--- a/erpnext/selling/doctype/quotation/quotation.json
+++ b/erpnext/selling/doctype/quotation/quotation.json
@@ -296,7 +296,7 @@
"read_only": 1
},
{
- "depends_on": "eval:doc.quotaion_to=='Customer' && doc.party_name",
+ "depends_on": "eval:doc.quotation_to=='Customer' && doc.party_name",
"fieldname": "col_break98",
"fieldtype": "Column Break",
"width": "50%"
@@ -316,7 +316,7 @@
"read_only": 1
},
{
- "depends_on": "eval:doc.quotaion_to=='Customer' && doc.party_name",
+ "depends_on": "eval:doc.quotation_to=='Customer' && doc.party_name",
"fieldname": "customer_group",
"fieldtype": "Link",
"hidden": 1,
@@ -897,7 +897,7 @@
"no_copy": 1,
"oldfieldname": "status",
"oldfieldtype": "Select",
- "options": "Draft\nOpen\nReplied\nOrdered\nLost\nCancelled\nExpired",
+ "options": "Draft\nOpen\nReplied\nPartially Ordered\nOrdered\nLost\nCancelled\nExpired",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -986,7 +986,7 @@
"idx": 82,
"is_submittable": 1,
"links": [],
- "modified": "2022-04-07 11:01:31.157084",
+ "modified": "2022-06-11 20:35:32.635804",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation",
@@ -1084,4 +1084,4 @@
"states": [],
"timeline_field": "party_name",
"title_field": "title"
-}
\ No newline at end of file
+}
diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py
index 548813d..863fbc4 100644
--- a/erpnext/selling/doctype/quotation/quotation.py
+++ b/erpnext/selling/doctype/quotation/quotation.py
@@ -8,7 +8,6 @@
from frappe.utils import flt, getdate, nowdate
from erpnext.controllers.selling_controller import SellingController
-from erpnext.crm.utils import add_link_in_communication, copy_comments
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -36,16 +35,6 @@
make_packing_list(self)
- def after_insert(self):
- if frappe.db.get_single_value("CRM Settings", "carry_forward_communication_and_comments"):
- if self.opportunity:
- copy_comments("Opportunity", self.opportunity, self)
- add_link_in_communication("Opportunity", self.opportunity, self)
-
- elif self.quotation_to == "Lead" and self.party_name:
- copy_comments("Lead", self.party_name, self)
- add_link_in_communication("Lead", self.party_name, self)
-
def validate_valid_till(self):
if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date):
frappe.throw(_("Valid till date cannot be before transaction date"))
@@ -70,8 +59,32 @@
title=_("Unpublished Item"),
)
- def has_sales_order(self):
- return frappe.db.get_value("Sales Order Item", {"prevdoc_docname": self.name, "docstatus": 1})
+ def get_ordered_status(self):
+ ordered_items = frappe._dict(
+ frappe.db.get_all(
+ "Sales Order Item",
+ {"prevdoc_docname": self.name, "docstatus": 1},
+ ["item_code", "sum(qty)"],
+ group_by="item_code",
+ as_list=1,
+ )
+ )
+
+ status = "Open"
+ if ordered_items:
+ status = "Ordered"
+
+ for item in self.get("items"):
+ if item.qty > ordered_items.get(item.item_code, 0.0):
+ status = "Partially Ordered"
+
+ return status
+
+ def is_fully_ordered(self):
+ return self.get_ordered_status() == "Ordered"
+
+ def is_partially_ordered(self):
+ return self.get_ordered_status() == "Partially Ordered"
def update_lead(self):
if self.quotation_to == "Lead" and self.party_name:
@@ -103,7 +116,7 @@
@frappe.whitelist()
def declare_enquiry_lost(self, lost_reasons_list, competitors, detailed_reason=None):
- if not self.has_sales_order():
+ if not (self.is_fully_ordered() or self.is_partially_ordered()):
get_lost_reasons = frappe.get_list("Quotation Lost Reason", fields=["name"])
lost_reasons_lst = [reason.get("name") for reason in get_lost_reasons]
frappe.db.set(self, "status", "Lost")
@@ -194,6 +207,15 @@
def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
customer = _make_customer(source_name, ignore_permissions)
+ ordered_items = frappe._dict(
+ frappe.db.get_all(
+ "Sales Order Item",
+ {"prevdoc_docname": source_name, "docstatus": 1},
+ ["item_code", "sum(qty)"],
+ group_by="item_code",
+ as_list=1,
+ )
+ )
def set_missing_values(source, target):
if customer:
@@ -209,7 +231,9 @@
target.run_method("calculate_taxes_and_totals")
def update_item(obj, target, source_parent):
- target.stock_qty = flt(obj.qty) * flt(obj.conversion_factor)
+ balance_qty = obj.qty - ordered_items.get(obj.item_code, 0.0)
+ target.qty = balance_qty if balance_qty > 0 else 0
+ target.stock_qty = flt(target.qty) * flt(obj.conversion_factor)
if obj.against_blanket_order:
target.against_blanket_order = obj.against_blanket_order
@@ -225,6 +249,7 @@
"doctype": "Sales Order Item",
"field_map": {"parent": "prevdoc_docname"},
"postprocess": update_item,
+ "condition": lambda doc: doc.qty > 0,
},
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
@@ -243,7 +268,7 @@
def set_expired_status():
# filter out submitted non expired quotations whose validity has been ended
- cond = "qo.docstatus = 1 and qo.status != 'Expired' and qo.valid_till < %s"
+ cond = "`tabQuotation`.docstatus = 1 and `tabQuotation`.status != 'Expired' and `tabQuotation`.valid_till < %s"
# check if those QUO have SO against it
so_against_quo = """
SELECT
@@ -251,13 +276,18 @@
WHERE
so_item.docstatus = 1 and so.docstatus = 1
and so_item.parent = so.name
- and so_item.prevdoc_docname = qo.name"""
+ and so_item.prevdoc_docname = `tabQuotation`.name"""
# if not exists any SO, set status as Expired
- frappe.db.sql(
- """UPDATE `tabQuotation` qo SET qo.status = 'Expired' WHERE {cond} and not exists({so_against_quo})""".format(
- cond=cond, so_against_quo=so_against_quo
- ),
+ frappe.db.multisql(
+ {
+ "mariadb": """UPDATE `tabQuotation` SET `tabQuotation`.status = 'Expired' WHERE {cond} and not exists({so_against_quo})""".format(
+ cond=cond, so_against_quo=so_against_quo
+ ),
+ "postgres": """UPDATE `tabQuotation` SET status = 'Expired' FROM `tabSales Order`, `tabSales Order Item` WHERE {cond} and not exists({so_against_quo})""".format(
+ cond=cond, so_against_quo=so_against_quo
+ ),
+ },
(nowdate()),
)
diff --git a/erpnext/selling/doctype/quotation/quotation_list.js b/erpnext/selling/doctype/quotation/quotation_list.js
index 4c8f9c4..32fce1f 100644
--- a/erpnext/selling/doctype/quotation/quotation_list.js
+++ b/erpnext/selling/doctype/quotation/quotation_list.js
@@ -25,6 +25,8 @@
get_indicator: function(doc) {
if(doc.status==="Open") {
return [__("Open"), "orange", "status,=,Open"];
+ } else if (doc.status==="Partially Ordered") {
+ return [__("Partially Ordered"), "yellow", "status,=,Partially Ordered"];
} else if(doc.status==="Ordered") {
return [__("Ordered"), "green", "status,=,Ordered"];
} else if(doc.status==="Lost") {
diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json
index ff921c7..74c5c07 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.json
+++ b/erpnext/selling/doctype/sales_order/sales_order.json
@@ -1359,6 +1359,8 @@
"width": "50%"
},
{
+ "fetch_from": "sales_partner.commission_rate",
+ "fetch_if_empty": 1,
"fieldname": "commission_rate",
"fieldtype": "Float",
"hide_days": 1,
@@ -1547,7 +1549,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
- "modified": "2022-04-26 14:38:18.350207",
+ "modified": "2022-06-10 03:52:22.212953",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index 7522e92..8c03cb5 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -25,6 +25,7 @@
from erpnext.selling.doctype.customer.customer import check_credit_limit
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.doctype.item.item import get_item_defaults
+from erpnext.stock.get_item_details import get_default_bom
from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -423,8 +424,9 @@
for table in [self.items, self.packed_items]:
for i in table:
- bom = get_default_bom_item(i.item_code)
+ bom = get_default_bom(i.item_code)
stock_qty = i.qty if i.doctype == "Packed Item" else i.stock_qty
+
if not for_raw_material_request:
total_work_order_qty = flt(
frappe.db.sql(
@@ -438,32 +440,19 @@
pending_qty = stock_qty
if pending_qty and i.item_code not in product_bundle_parents:
- if bom:
- items.append(
- dict(
- name=i.name,
- item_code=i.item_code,
- description=i.description,
- bom=bom,
- warehouse=i.warehouse,
- pending_qty=pending_qty,
- required_qty=pending_qty if for_raw_material_request else 0,
- sales_order_item=i.name,
- )
+ items.append(
+ dict(
+ name=i.name,
+ item_code=i.item_code,
+ description=i.description,
+ bom=bom or "",
+ warehouse=i.warehouse,
+ pending_qty=pending_qty,
+ required_qty=pending_qty if for_raw_material_request else 0,
+ sales_order_item=i.name,
)
- else:
- items.append(
- dict(
- name=i.name,
- item_code=i.item_code,
- description=i.description,
- bom="",
- warehouse=i.warehouse,
- pending_qty=pending_qty,
- required_qty=pending_qty if for_raw_material_request else 0,
- sales_order_item=i.name,
- )
- )
+ )
+
return items
def on_recurring(self, reference_doc, auto_repeat_doc):
@@ -1167,13 +1156,6 @@
so.update_status(status)
-def get_default_bom_item(item_code):
- bom = frappe.get_all("BOM", dict(item=item_code, is_active=True), order_by="is_default desc")
- bom = bom[0].name if bom else None
-
- return bom
-
-
@frappe.whitelist()
def make_raw_material_request(items, company, sales_order, project=None):
if not frappe.has_permission("Sales Order", "write"):
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index 96308f0..e5e317c 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -329,7 +329,7 @@
def test_sales_order_on_hold(self):
so = make_sales_order(item_code="_Test Product Bundle Item")
- so.db_set("Status", "On Hold")
+ so.db_set("status", "On Hold")
si = make_sales_invoice(so.name)
self.assertRaises(frappe.ValidationError, create_dn_against_so, so.name)
self.assertRaises(frappe.ValidationError, si.submit)
@@ -644,7 +644,7 @@
else:
# update valid from
frappe.db.sql(
- """UPDATE `tabItem Tax` set valid_from = CURDATE()
+ """UPDATE `tabItem Tax` set valid_from = CURRENT_DATE
where parent = %(item)s and item_tax_template = %(tax)s""",
{"item": item, "tax": tax_template},
)
@@ -1380,6 +1380,59 @@
except Exception:
self.fail("Can not cancel sales order with linked cancelled payment entry")
+ def test_work_order_pop_up_from_sales_order(self):
+ "Test `get_work_order_items` in Sales Order picks the right BOM for items to manufacture."
+
+ from erpnext.controllers.item_variant import create_variant
+ from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
+
+ make_item( # template item
+ "Test-WO-Tshirt",
+ {
+ "has_variant": 1,
+ "variant_based_on": "Item Attribute",
+ "attributes": [{"attribute": "Test Colour"}],
+ },
+ )
+ make_item("Test-RM-Cotton") # RM for BOM
+
+ for colour in (
+ "Red",
+ "Green",
+ ):
+ variant = create_variant("Test-WO-Tshirt", {"Test Colour": colour})
+ variant.save()
+
+ template_bom = make_bom(item="Test-WO-Tshirt", rate=100, raw_materials=["Test-RM-Cotton"])
+ red_var_bom = make_bom(item="Test-WO-Tshirt-R", rate=100, raw_materials=["Test-RM-Cotton"])
+
+ so = make_sales_order(
+ **{
+ "item_list": [
+ {
+ "item_code": "Test-WO-Tshirt-R",
+ "qty": 1,
+ "rate": 1000,
+ "warehouse": "_Test Warehouse - _TC",
+ },
+ {
+ "item_code": "Test-WO-Tshirt-G",
+ "qty": 1,
+ "rate": 1000,
+ "warehouse": "_Test Warehouse - _TC",
+ },
+ ]
+ }
+ )
+ wo_items = so.get_work_order_items()
+
+ self.assertEqual(wo_items[0].get("item_code"), "Test-WO-Tshirt-R")
+ self.assertEqual(wo_items[0].get("bom"), red_var_bom.name)
+
+ # Must pick Template Item BOM for Test-WO-Tshirt-G as it has no BOM
+ self.assertEqual(wo_items[1].get("item_code"), "Test-WO-Tshirt-G")
+ self.assertEqual(wo_items[1].get("bom"), template_bom.name)
+
def test_request_for_raw_materials(self):
item = make_item(
"_Test Finished Item",
diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json
index 3797856..3187999 100644
--- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json
+++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json
@@ -23,7 +23,6 @@
"quantity_and_rate",
"qty",
"stock_uom",
- "picked_qty",
"col_break2",
"uom",
"conversion_factor",
@@ -87,6 +86,7 @@
"delivered_qty",
"produced_qty",
"returned_qty",
+ "picked_qty",
"shopping_cart_section",
"additional_notes",
"section_break_63",
@@ -198,6 +198,7 @@
"width": "100px"
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
@@ -220,6 +221,7 @@
"reqd": 1
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
@@ -228,6 +230,7 @@
"reqd": 1
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "stock_qty",
"fieldtype": "Float",
"label": "Qty as per Stock UOM",
@@ -811,7 +814,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2022-04-27 03:15:34.366563",
+ "modified": "2022-06-17 05:27:41.603006",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Item",
diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py
index 99afe81..13d5069 100644
--- a/erpnext/selling/page/point_of_sale/point_of_sale.py
+++ b/erpnext/selling/page/point_of_sale/point_of_sale.py
@@ -107,7 +107,7 @@
ORDER BY
item.name asc
LIMIT
- {start}, {page_length}""".format(
+ {page_length} offset {start}""".format(
start=start,
page_length=page_length,
lft=lft,
@@ -204,7 +204,7 @@
return frappe.db.sql(
""" select distinct name from `tabItem Group`
- where {condition} and (name like %(txt)s) limit {start}, {page_len}""".format(
+ where {condition} and (name like %(txt)s) limit {page_len} offset {start}""".format(
condition=cond, start=start, page_len=page_len
),
{"txt": "%%%s%%" % txt},
diff --git a/erpnext/selling/report/inactive_customers/inactive_customers.py b/erpnext/selling/report/inactive_customers/inactive_customers.py
index 1b337fc..a166085 100644
--- a/erpnext/selling/report/inactive_customers/inactive_customers.py
+++ b/erpnext/selling/report/inactive_customers/inactive_customers.py
@@ -31,13 +31,13 @@
def get_sales_details(doctype):
cond = """sum(so.base_net_total) as 'total_order_considered',
max(so.posting_date) as 'last_order_date',
- DATEDIFF(CURDATE(), max(so.posting_date)) as 'days_since_last_order' """
+ DATEDIFF(CURRENT_DATE, max(so.posting_date)) as 'days_since_last_order' """
if doctype == "Sales Order":
cond = """sum(if(so.status = "Stopped",
so.base_net_total * so.per_delivered/100,
so.base_net_total)) as 'total_order_considered',
max(so.transaction_date) as 'last_order_date',
- DATEDIFF(CURDATE(), max(so.transaction_date)) as 'days_since_last_order'"""
+ DATEDIFF(CURRENT_DATE, max(so.transaction_date)) as 'days_since_last_order'"""
return frappe.db.sql(
"""select
diff --git a/erpnext/selling/report/pending_so_items_for_purchase_request/pending_so_items_for_purchase_request.py b/erpnext/selling/report/pending_so_items_for_purchase_request/pending_so_items_for_purchase_request.py
index cc1055c..928ed80 100644
--- a/erpnext/selling/report/pending_so_items_for_purchase_request/pending_so_items_for_purchase_request.py
+++ b/erpnext/selling/report/pending_so_items_for_purchase_request/pending_so_items_for_purchase_request.py
@@ -65,7 +65,7 @@
WHERE
so.docstatus = 1
and so.name = so_item.parent
- and so.status not in ("Closed","Completed","Cancelled")
+ and so.status not in ('Closed','Completed','Cancelled')
GROUP BY
so.name,so_item.item_code
""",
diff --git a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.js b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.js
index 76a5bb5..91748bc 100644
--- a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.js
+++ b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.js
@@ -55,6 +55,7 @@
for (let option of status){
options.push({
"value": option,
+ "label": __(option),
"description": ""
})
}
diff --git a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py
index cc61594..720aa41 100644
--- a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py
+++ b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py
@@ -64,7 +64,7 @@
soi.delivery_date as delivery_date,
so.name as sales_order,
so.status, so.customer, soi.item_code,
- DATEDIFF(CURDATE(), soi.delivery_date) as delay_days,
+ DATEDIFF(CURRENT_DATE, soi.delivery_date) as delay_days,
IF(so.status in ('Completed','To Bill'), 0, (SELECT delay_days)) as delay,
soi.qty, soi.delivered_qty,
(soi.qty - soi.delivered_qty) AS pending_qty,
diff --git a/erpnext/selling/sales_common.js b/erpnext/selling/sales_common.js
index 6cb53c3..8ff01f5 100644
--- a/erpnext/selling/sales_common.js
+++ b/erpnext/selling/sales_common.js
@@ -12,8 +12,6 @@
erpnext.selling.SellingController = class SellingController extends erpnext.TransactionController {
setup() {
super.setup();
- this.frm.add_fetch("sales_partner", "commission_rate", "commission_rate");
- this.frm.add_fetch("sales_person", "commission_rate", "commission_rate");
}
onload() {
@@ -514,4 +512,4 @@
dialog.show();
}
-})
\ No newline at end of file
+})
diff --git a/erpnext/setup/doctype/authorization_control/authorization_control.py b/erpnext/setup/doctype/authorization_control/authorization_control.py
index 309658d..cfe3d62 100644
--- a/erpnext/setup/doctype/authorization_control/authorization_control.py
+++ b/erpnext/setup/doctype/authorization_control/authorization_control.py
@@ -135,8 +135,8 @@
price_list_rate, base_rate = 0, 0
for d in doc_obj.get("items"):
if d.base_rate:
- price_list_rate += flt(d.base_price_list_rate) or flt(d.base_rate)
- base_rate += flt(d.base_rate)
+ price_list_rate += (flt(d.base_price_list_rate) or flt(d.base_rate)) * flt(d.qty)
+ base_rate += flt(d.base_rate) * flt(d.qty)
if doc_obj.get("discount_amount"):
base_rate -= flt(doc_obj.discount_amount)
diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py
index 9bde6e2..9ffd6df 100644
--- a/erpnext/setup/doctype/company/company.py
+++ b/erpnext/setup/doctype/company/company.py
@@ -464,7 +464,7 @@
# reset default company
frappe.db.sql(
- """update `tabSingles` set value=""
+ """update `tabSingles` set value=''
where doctype='Global Defaults' and field='default_company'
and value=%s""",
self.name,
@@ -472,7 +472,7 @@
# reset default company
frappe.db.sql(
- """update `tabSingles` set value=""
+ """update `tabSingles` set value=''
where doctype='Chart of Accounts Importer' and field='company'
and value=%s""",
self.name,
diff --git a/erpnext/setup/doctype/email_digest/email_digest.py b/erpnext/setup/doctype/email_digest/email_digest.py
index cdfea77..4fc20e6 100644
--- a/erpnext/setup/doctype/email_digest/email_digest.py
+++ b/erpnext/setup/doctype/email_digest/email_digest.py
@@ -198,7 +198,7 @@
todo_list = frappe.db.sql(
"""select *
- from `tabToDo` where (owner=%s or assigned_by=%s) and status="Open"
+ from `tabToDo` where (owner=%s or assigned_by=%s) and status='Open'
order by field(priority, 'High', 'Medium', 'Low') asc, date asc limit 20""",
(user_id, user_id),
as_dict=True,
@@ -854,7 +854,7 @@
sql_po = """select {fields} from `tabPurchase Order Item`
left join `tabPurchase Order` on `tabPurchase Order`.name = `tabPurchase Order Item`.parent
- where status<>'Closed' and `tabPurchase Order Item`.docstatus=1 and curdate() > `tabPurchase Order Item`.schedule_date
+ where status<>'Closed' and `tabPurchase Order Item`.docstatus=1 and CURRENT_DATE > `tabPurchase Order Item`.schedule_date
and received_qty < qty order by `tabPurchase Order Item`.parent DESC,
`tabPurchase Order Item`.schedule_date DESC""".format(
fields=fields_po
@@ -862,7 +862,7 @@
sql_poi = """select {fields} from `tabPurchase Order Item`
left join `tabPurchase Order` on `tabPurchase Order`.name = `tabPurchase Order Item`.parent
- where status<>'Closed' and `tabPurchase Order Item`.docstatus=1 and curdate() > `tabPurchase Order Item`.schedule_date
+ where status<>'Closed' and `tabPurchase Order Item`.docstatus=1 and CURRENT_DATE > `tabPurchase Order Item`.schedule_date
and received_qty < qty order by `tabPurchase Order Item`.idx""".format(
fields=fields_poi
)
diff --git a/erpnext/setup/doctype/employee/employee.json b/erpnext/setup/doctype/employee/employee.json
index 37a2e33..7a806d5 100644
--- a/erpnext/setup/doctype/employee/employee.json
+++ b/erpnext/setup/doctype/employee/employee.json
@@ -734,7 +734,7 @@
"idx": 24,
"image_field": "image",
"links": [],
- "modified": "2022-06-23 22:28:55.811983",
+ "modified": "2022-06-27 01:29:32.952091",
"modified_by": "Administrator",
"module": "Setup",
"name": "Employee",
diff --git a/erpnext/setup/doctype/party_type/party_type.py b/erpnext/setup/doctype/party_type/party_type.py
index d07ab08..cf7cba8 100644
--- a/erpnext/setup/doctype/party_type/party_type.py
+++ b/erpnext/setup/doctype/party_type/party_type.py
@@ -21,7 +21,7 @@
return frappe.db.sql(
"""select name from `tabParty Type`
where `{key}` LIKE %(txt)s {cond}
- order by name limit %(start)s, %(page_len)s""".format(
+ order by name limit %(page_len)s offset %(start)s""".format(
key=searchfield, cond=cond
),
{"txt": "%" + txt + "%", "start": start, "page_len": page_len},
diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
index 78b3939..7c478bb 100644
--- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
+++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
@@ -42,7 +42,7 @@
def delete_bins(self):
frappe.db.sql(
- """delete from tabBin where warehouse in
+ """delete from `tabBin` where warehouse in
(select name from tabWarehouse where company=%s)""",
self.company,
)
@@ -64,7 +64,7 @@
addresses = ["%s" % frappe.db.escape(addr) for addr in addresses]
frappe.db.sql(
- """delete from tabAddress where name in ({addresses}) and
+ """delete from `tabAddress` where name in ({addresses}) and
name not in (select distinct dl1.parent from `tabDynamic Link` dl1
inner join `tabDynamic Link` dl2 on dl1.parent=dl2.parent
and dl1.link_doctype<>dl2.link_doctype)""".format(
@@ -80,7 +80,7 @@
)
frappe.db.sql(
- """update tabCustomer set lead_name=NULL where lead_name in ({leads})""".format(
+ """update `tabCustomer` set lead_name=NULL where lead_name in ({leads})""".format(
leads=",".join(leads)
)
)
@@ -178,7 +178,7 @@
else:
last = 0
- frappe.db.sql("""update tabSeries set current = %s where name=%s""", (last, prefix))
+ frappe.db.sql("""update `tabSeries` set current = %s where name=%s""", (last, prefix))
def delete_version_log(self, doctype, company_fieldname):
frappe.db.sql(
diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py
index 559883f..52854a0 100644
--- a/erpnext/stock/doctype/batch/batch.py
+++ b/erpnext/stock/doctype/batch/batch.py
@@ -335,7 +335,7 @@
on (`tabBatch`.batch_id = `tabStock Ledger Entry`.batch_no )
where `tabStock Ledger Entry`.item_code = %s and `tabStock Ledger Entry`.warehouse = %s
and `tabStock Ledger Entry`.is_cancelled = 0
- and (`tabBatch`.expiry_date >= CURDATE() or `tabBatch`.expiry_date IS NULL) {0}
+ and (`tabBatch`.expiry_date >= CURRENT_DATE or `tabBatch`.expiry_date IS NULL) {0}
group by batch_id
order by `tabBatch`.expiry_date ASC, `tabBatch`.creation ASC
""".format(
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json
index e3222bc..f9e9349 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.json
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.json
@@ -1192,6 +1192,8 @@
"width": "50%"
},
{
+ "fetch_from": "sales_partner.commission_rate",
+ "fetch_if_empty": 1,
"fieldname": "commission_rate",
"fieldtype": "Float",
"label": "Commission Rate (%)",
@@ -1334,7 +1336,7 @@
"idx": 146,
"is_submittable": 1,
"links": [],
- "modified": "2022-04-26 14:48:08.781837",
+ "modified": "2022-06-10 03:52:04.197415",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note",
diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py
index fffcdca..6bcab73 100644
--- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py
@@ -1064,6 +1064,33 @@
self.assertEqual(dn.items[0].rate, rate)
+ def test_internal_transfer_precision_gle(self):
+ from erpnext.selling.doctype.customer.test_customer import create_internal_customer
+
+ item = make_item(properties={"valuation_method": "Moving Average"}).name
+ company = "_Test Company with perpetual inventory"
+ warehouse = "Stores - TCP1"
+ target = "Finished Goods - TCP1"
+ customer = create_internal_customer(represents_company=company)
+
+ # average rate = 128.015
+ rates = [101.45, 150.46, 138.25, 121.9]
+
+ for rate in rates:
+ make_stock_entry(item_code=item, target=warehouse, qty=1, rate=rate)
+
+ dn = create_delivery_note(
+ item_code=item,
+ company=company,
+ customer=customer,
+ qty=4,
+ warehouse=warehouse,
+ target_warehouse=target,
+ )
+ self.assertFalse(
+ frappe.db.exists("GL Entry", {"voucher_no": dn.name, "voucher_type": dn.doctype})
+ )
+
def create_delivery_note(**args):
dn = frappe.new_doc("Delivery Note")
diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
index 2d7abc8..2de4842 100644
--- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
+++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
@@ -184,6 +184,7 @@
"width": "100px"
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
@@ -209,6 +210,7 @@
"reqd": 1
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
@@ -217,6 +219,7 @@
"reqd": 1
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "stock_qty",
"fieldtype": "Float",
"label": "Qty in Stock UOM",
@@ -780,7 +783,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-05-02 12:09:39.610075",
+ "modified": "2022-06-17 05:25:47.711177",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note Item",
diff --git a/erpnext/stock/doctype/delivery_trip/delivery_trip.py b/erpnext/stock/doctype/delivery_trip/delivery_trip.py
index 88204ef..1febbde 100644
--- a/erpnext/stock/doctype/delivery_trip/delivery_trip.py
+++ b/erpnext/stock/doctype/delivery_trip/delivery_trip.py
@@ -262,9 +262,9 @@
FROM
`tabDynamic Link` dl
WHERE
- dl.link_doctype="Customer"
+ dl.link_doctype='Customer'
AND dl.link_name=%s
- AND dl.parenttype = "Contact"
+ AND dl.parenttype = 'Contact'
""",
(name),
as_dict=1,
@@ -288,9 +288,9 @@
FROM
`tabDynamic Link` dl
WHERE
- dl.link_doctype="Customer"
+ dl.link_doctype='Customer'
AND dl.link_name=%s
- AND dl.parenttype = "Address"
+ AND dl.parenttype = 'Address'
""",
(name),
as_dict=1,
@@ -387,7 +387,7 @@
if email_recipients:
frappe.msgprint(_("Email sent to {0}").format(", ".join(email_recipients)))
- delivery_trip.db_set("email_notification_sent", True)
+ delivery_trip.db_set("email_notification_sent", 1)
else:
frappe.msgprint(_("No contacts with email IDs found."))
diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json
index 2f6d4fb..76cb31d 100644
--- a/erpnext/stock/doctype/item/item.json
+++ b/erpnext/stock/doctype/item/item.json
@@ -14,7 +14,6 @@
"details",
"naming_series",
"item_code",
- "variant_of",
"item_name",
"item_group",
"stock_uom",
@@ -22,6 +21,7 @@
"disabled",
"allow_alternative_item",
"is_stock_item",
+ "has_variants",
"include_item_in_manufacturing",
"opening_stock",
"valuation_rate",
@@ -66,7 +66,7 @@
"has_serial_no",
"serial_no_series",
"variants_section",
- "has_variants",
+ "variant_of",
"variant_based_on",
"attributes",
"accounting",
@@ -112,8 +112,8 @@
"quality_inspection_template",
"inspection_required_before_delivery",
"manufacturing",
- "default_bom",
"is_sub_contracted_item",
+ "default_bom",
"column_break_74",
"customer_code",
"default_item_manufacturer",
@@ -479,7 +479,7 @@
"collapsible_depends_on": "attributes",
"depends_on": "eval:!doc.is_fixed_asset",
"fieldname": "variants_section",
- "fieldtype": "Section Break",
+ "fieldtype": "Tab Break",
"label": "Variants"
},
{
@@ -504,7 +504,8 @@
"fieldname": "attributes",
"fieldtype": "Table",
"hidden": 1,
- "label": "Attributes",
+ "label": "Variant Attributes",
+ "mandatory_depends_on": "has_variants",
"options": "Item Variant Attribute"
},
{
@@ -909,7 +910,7 @@
"image_field": "image",
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2022-06-08 11:35:20.094546",
+ "modified": "2022-06-15 09:02:06.177691",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",
diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py
index b2f5fb7..87fa72d 100644
--- a/erpnext/stock/doctype/item/item.py
+++ b/erpnext/stock/doctype/item/item.py
@@ -1155,7 +1155,7 @@
bin_list = frappe.db.sql(
"""
- select * from tabBin where item_code = %s
+ select * from `tabBin` where item_code = %s
and (reserved_qty > 0 or ordered_qty > 0 or indented_qty > 0 or planned_qty > 0)
and stock_uom != %s
""",
@@ -1171,7 +1171,7 @@
)
# No SLE or documents against item. Bin UOM can be changed safely.
- frappe.db.sql("""update tabBin set stock_uom=%s where item_code=%s""", (stock_uom, item))
+ frappe.db.sql("""update `tabBin` set stock_uom=%s where item_code=%s""", (stock_uom, item))
def get_item_defaults(item_code, company):
diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py
index aa0a549..3366c73 100644
--- a/erpnext/stock/doctype/item/test_item.py
+++ b/erpnext/stock/doctype/item/test_item.py
@@ -381,8 +381,8 @@
frappe.delete_doc_if_exists("Item Attribute", "Test Item Length")
frappe.db.sql(
- '''delete from `tabItem Variant Attribute`
- where attribute="Test Item Length"'''
+ """delete from `tabItem Variant Attribute`
+ where attribute='Test Item Length' """
)
frappe.flags.attribute_values = None
@@ -800,6 +800,7 @@
item_code,
is_stock_item=1,
valuation_rate=0,
+ stock_uom="Nos",
warehouse="_Test Warehouse - _TC",
is_customer_provided_item=None,
customer=None,
@@ -815,6 +816,7 @@
item.item_name = item_code
item.description = item_code
item.item_group = "All Item Groups"
+ item.stock_uom = stock_uom
item.is_stock_item = is_stock_item
item.is_fixed_asset = is_fixed_asset
item.asset_category = asset_category
diff --git a/erpnext/stock/doctype/item_alternative/item_alternative.py b/erpnext/stock/doctype/item_alternative/item_alternative.py
index 0f93bb9..fb1a28d 100644
--- a/erpnext/stock/doctype/item_alternative/item_alternative.py
+++ b/erpnext/stock/doctype/item_alternative/item_alternative.py
@@ -77,7 +77,7 @@
union
(select item_code from `tabItem Alternative`
where alternative_item_code = %(item_code)s and item_code like %(txt)s
- and two_way = 1) limit {0}, {1}
+ and two_way = 1) limit {1} offset {0}
""".format(
start, page_len
),
diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
index 1af9953..1ba8011 100644
--- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
+++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
@@ -24,7 +24,7 @@
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
- supplier_warehouse="Work in Progress - TCP1",
+ supplier_warehouse="Work In Progress - TCP1",
get_multiple_items=True,
get_taxes_and_charges=True,
)
@@ -195,7 +195,7 @@
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
- supplier_warehouse="Work in Progress - TCP1",
+ supplier_warehouse="Work In Progress - TCP1",
get_multiple_items=True,
get_taxes_and_charges=True,
do_not_submit=True,
@@ -280,7 +280,7 @@
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
- supplier_warehouse="Work in Progress - TCP1",
+ supplier_warehouse="Work In Progress - TCP1",
do_not_save=True,
)
pr.items[0].cost_center = "Main - TCP1"
diff --git a/erpnext/stock/doctype/packing_slip/packing_slip.py b/erpnext/stock/doctype/packing_slip/packing_slip.py
index e9ccf5f..e5b9de8 100644
--- a/erpnext/stock/doctype/packing_slip/packing_slip.py
+++ b/erpnext/stock/doctype/packing_slip/packing_slip.py
@@ -203,7 +203,7 @@
where name in ( select item_code FROM `tabDelivery Note Item`
where parent= %s)
and %s like "%s" %s
- limit %s, %s """
+ limit %s offset %s """
% ("%s", searchfield, "%s", get_match_cond(doctype), "%s", "%s"),
- ((filters or {}).get("delivery_note"), "%%%s%%" % txt, start, page_len),
+ ((filters or {}).get("delivery_note"), "%%%s%%" % txt, page_len, start),
)
diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py
index 7dc3ba0..d31d695 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.py
+++ b/erpnext/stock/doctype/pick_list/pick_list.py
@@ -699,7 +699,7 @@
AND `company` = %(company)s
AND `name` like %(txt)s
ORDER BY
- if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), name
+ (case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end) name
LIMIT
%(start)s, %(page_length)s""",
{
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index 7fbfa62..be4f274 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
@@ -276,7 +276,7 @@
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
- supplier_warehouse="Work in Progress - TCP1",
+ supplier_warehouse="Work In Progress - TCP1",
get_multiple_items=True,
get_taxes_and_charges=True,
)
@@ -486,13 +486,13 @@
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
- supplier_warehouse="Work in Progress - TCP1",
+ supplier_warehouse="Work In Progress - TCP1",
)
return_pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
- supplier_warehouse="Work in Progress - TCP1",
+ supplier_warehouse="Work In Progress - TCP1",
is_return=1,
return_against=pr.name,
qty=-2,
@@ -573,13 +573,13 @@
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
- supplier_warehouse="Work in Progress - TCP1",
+ supplier_warehouse="Work In Progress - TCP1",
)
return_pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
- supplier_warehouse="Work in Progress - TCP1",
+ supplier_warehouse="Work In Progress - TCP1",
is_return=1,
return_against=pr.name,
qty=-5,
@@ -615,7 +615,7 @@
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
- supplier_warehouse="Work in Progress - TCP1",
+ supplier_warehouse="Work In Progress - TCP1",
qty=2,
rejected_qty=2,
rejected_warehouse=rejected_warehouse,
@@ -624,7 +624,7 @@
return_pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
- supplier_warehouse="Work in Progress - TCP1",
+ supplier_warehouse="Work In Progress - TCP1",
is_return=1,
return_against=pr.name,
qty=-2,
@@ -951,7 +951,7 @@
cost_center=cost_center,
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
- supplier_warehouse="Work in Progress - TCP1",
+ supplier_warehouse="Work In Progress - TCP1",
)
stock_in_hand_account = get_inventory_account(pr.company, pr.get("items")[0].warehouse)
@@ -975,7 +975,7 @@
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
- supplier_warehouse="Work in Progress - TCP1",
+ supplier_warehouse="Work In Progress - TCP1",
)
stock_in_hand_account = get_inventory_account(pr.company, pr.get("items")[0].warehouse)
diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
index 1c65ac8..b45d663 100644
--- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
+++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
@@ -252,6 +252,7 @@
"width": "100px"
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
@@ -265,6 +266,7 @@
"width": "100px"
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor",
@@ -547,6 +549,7 @@
"read_only": 1
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "stock_qty",
"fieldtype": "Float",
"label": "Accepted Qty in Stock UOM",
@@ -878,7 +881,7 @@
"fieldtype": "Column Break"
},
{
- "depends_on": "returned_qty",
+ "depends_on": "doc.returned_qty",
"fieldname": "returned_qty",
"fieldtype": "Float",
"label": "Returned Qty in Stock UOM",
@@ -887,6 +890,7 @@
"read_only": 1
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "received_stock_qty",
"fieldtype": "Float",
"label": "Received Qty in Stock UOM",
@@ -994,7 +998,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2022-04-11 13:07:32.061402",
+ "modified": "2022-06-17 05:32:16.483178",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt Item",
diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py
index 331d3e8..13abfad 100644
--- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py
+++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py
@@ -232,7 +232,7 @@
FROM `tab{doc}`
WHERE parent=%(parent)s and docstatus < 2 and item_code like %(txt)s
{qi_condition} {cond} {mcond}
- ORDER BY item_code limit {start}, {page_len}
+ ORDER BY item_code limit {page_len} offset {start}
""".format(
doc=filters.get("from"),
cond=cond,
@@ -252,7 +252,7 @@
WHERE name = %(reference_name)s and docstatus < 2 and production_item like %(txt)s
{qi_condition} {cond} {mcond}
ORDER BY production_item
- LIMIT {start}, {page_len}
+ limit {page_len} offset {start}
""".format(
doc=filters.get("from"),
cond=cond,
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json
index 156f77f..e093933 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json
@@ -25,7 +25,8 @@
"items_to_be_repost",
"affected_transactions",
"distinct_item_and_warehouse",
- "current_index"
+ "current_index",
+ "gl_reposting_index"
],
"fields": [
{
@@ -181,12 +182,20 @@
"label": "Affected Transactions",
"no_copy": 1,
"read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "gl_reposting_index",
+ "fieldtype": "Int",
+ "hidden": 1,
+ "label": "GL reposting index",
+ "read_only": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-04-18 14:08:08.821602",
+ "modified": "2022-06-13 12:20:22.182322",
"modified_by": "Administrator",
"module": "Stock",
"name": "Repost Item Valuation",
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
index 328afc8..b1017d2 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
@@ -87,6 +87,7 @@
self.current_index = 0
self.distinct_item_and_warehouse = None
self.items_to_be_repost = None
+ self.gl_reposting_index = 0
self.db_update()
def deduplicate_similar_repost(self):
@@ -192,6 +193,7 @@
directly_dependent_transactions + list(repost_affected_transaction),
doc.posting_date,
doc.company,
+ repost_doc=doc,
)
diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py
index 3184f69..edd2553 100644
--- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py
@@ -2,10 +2,14 @@
# See license.txt
+from unittest.mock import MagicMock, call
+
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import nowdate
+from frappe.utils.data import add_to_date, today
+from erpnext.accounts.utils import repost_gle_for_stock_vouchers
from erpnext.controllers.stock_controller import create_item_wise_repost_entries
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
@@ -13,10 +17,11 @@
in_configured_timeslot,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+from erpnext.stock.tests.test_utils import StockTestMixin
from erpnext.stock.utils import PendingRepostingError
-class TestRepostItemValuation(FrappeTestCase):
+class TestRepostItemValuation(FrappeTestCase, StockTestMixin):
def tearDown(self):
frappe.flags.dont_execute_stock_reposts = False
@@ -193,3 +198,77 @@
[["a", "b"], ["c", "d"]],
sorted(frappe.parse_json(frappe.as_json(set([("a", "b"), ("c", "d")])))),
)
+
+ def test_gl_repost_progress(self):
+ from erpnext.accounts import utils
+
+ # lower numbers to simplify test
+ orig_chunk_size = utils.GL_REPOSTING_CHUNK
+ utils.GL_REPOSTING_CHUNK = 1
+ self.addCleanup(setattr, utils, "GL_REPOSTING_CHUNK", orig_chunk_size)
+
+ doc = frappe.new_doc("Repost Item Valuation")
+ doc.db_set = MagicMock()
+
+ vouchers = []
+ company = "_Test Company with perpetual inventory"
+ posting_date = today()
+
+ for _ in range(3):
+ se = make_stock_entry(company=company, qty=1, rate=2, target="Stores - TCP1")
+ vouchers.append((se.doctype, se.name))
+
+ repost_gle_for_stock_vouchers(stock_vouchers=vouchers, posting_date=posting_date, repost_doc=doc)
+ self.assertIn(call("gl_reposting_index", 1), doc.db_set.mock_calls)
+ doc.db_set.reset_mock()
+
+ doc.gl_reposting_index = 1
+ repost_gle_for_stock_vouchers(stock_vouchers=vouchers, posting_date=posting_date, repost_doc=doc)
+
+ self.assertNotIn(call("gl_reposting_index", 1), doc.db_set.mock_calls)
+
+ def test_gl_complete_gl_reposting(self):
+ from erpnext.accounts import utils
+
+ # lower numbers to simplify test
+ orig_chunk_size = utils.GL_REPOSTING_CHUNK
+ utils.GL_REPOSTING_CHUNK = 2
+ self.addCleanup(setattr, utils, "GL_REPOSTING_CHUNK", orig_chunk_size)
+
+ item = self.make_item().name
+
+ company = "_Test Company with perpetual inventory"
+
+ for _ in range(10):
+ make_stock_entry(item=item, company=company, qty=1, rate=10, target="Stores - TCP1")
+
+ # consume
+ consumption = make_stock_entry(item=item, company=company, qty=1, source="Stores - TCP1")
+
+ self.assertGLEs(
+ consumption,
+ [{"credit": 10, "debit": 0}],
+ gle_filters={"account": "Stock In Hand - TCP1"},
+ )
+
+ # backdated receipt
+ backdated_receipt = make_stock_entry(
+ item=item,
+ company=company,
+ qty=1,
+ rate=50,
+ target="Stores - TCP1",
+ posting_date=add_to_date(today(), days=-1),
+ )
+ self.assertGLEs(
+ backdated_receipt,
+ [{"credit": 0, "debit": 50}],
+ gle_filters={"account": "Stock In Hand - TCP1"},
+ )
+
+ # check that original consumption GLe is updated
+ self.assertGLEs(
+ consumption,
+ [{"credit": 50, "debit": 0}],
+ gle_filters={"account": "Stock In Hand - TCP1"},
+ )
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index f1df54d..4b2850e 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -590,27 +590,12 @@
)
+ "<br><br>"
+ _("Available quantity is {0}, you need {1}").format(
- frappe.bold(d.actual_qty), frappe.bold(d.transfer_qty)
+ frappe.bold(flt(d.actual_qty, d.precision("actual_qty"))), frappe.bold(d.transfer_qty)
),
NegativeStockError,
title=_("Insufficient Stock"),
)
- def set_serial_nos(self, work_order):
- previous_se = frappe.db.get_value(
- "Stock Entry",
- {"work_order": work_order, "purpose": "Material Transfer for Manufacture"},
- "name",
- )
-
- for d in self.get("items"):
- transferred_serial_no = frappe.db.get_value(
- "Stock Entry Detail", {"parent": previous_se, "item_code": d.item_code}, "serial_no"
- )
-
- if transferred_serial_no:
- d.serial_no = transferred_serial_no
-
@frappe.whitelist()
def get_stock_and_rate(self):
"""
@@ -1321,7 +1306,7 @@
and not self.pro_doc.skip_transfer
and self.flags.backflush_based_on == "Material Transferred for Manufacture"
):
- self.get_transfered_raw_materials()
+ self.add_transfered_raw_materials_in_items()
elif (
self.work_order
@@ -1365,7 +1350,6 @@
# fetch the serial_no of the first stock entry for the second stock entry
if self.work_order and self.purpose == "Manufacture":
- self.set_serial_nos(self.work_order)
work_order = frappe.get_doc("Work Order", self.work_order)
add_additional_cost(self, work_order)
@@ -1655,119 +1639,78 @@
}
)
- def get_transfered_raw_materials(self):
- transferred_materials = frappe.db.sql(
- """
- select
- item_name, original_item, item_code, sum(qty) as qty, sed.t_warehouse as warehouse,
- description, stock_uom, expense_account, cost_center
- from `tabStock Entry` se,`tabStock Entry Detail` sed
- where
- se.name = sed.parent and se.docstatus=1 and se.purpose='Material Transfer for Manufacture'
- and se.work_order= %s and ifnull(sed.t_warehouse, '') != ''
- group by sed.item_code, sed.t_warehouse
- """,
+ def add_transfered_raw_materials_in_items(self) -> None:
+ available_materials = get_available_materials(self.work_order)
+
+ wo_data = frappe.db.get_value(
+ "Work Order",
self.work_order,
+ ["qty", "produced_qty", "material_transferred_for_manufacturing as trans_qty"],
as_dict=1,
)
- materials_already_backflushed = frappe.db.sql(
- """
- select
- item_code, sed.s_warehouse as warehouse, sum(qty) as qty
- from
- `tabStock Entry` se, `tabStock Entry Detail` sed
- where
- se.name = sed.parent and se.docstatus=1
- and (se.purpose='Manufacture' or se.purpose='Material Consumption for Manufacture')
- and se.work_order= %s and ifnull(sed.s_warehouse, '') != ''
- group by sed.item_code, sed.s_warehouse
- """,
- self.work_order,
- as_dict=1,
- )
+ for key, row in available_materials.items():
+ remaining_qty_to_produce = flt(wo_data.trans_qty) - flt(wo_data.produced_qty)
+ if remaining_qty_to_produce <= 0:
+ continue
- backflushed_materials = {}
- for d in materials_already_backflushed:
- backflushed_materials.setdefault(d.item_code, []).append({d.warehouse: d.qty})
+ qty = (flt(row.qty) * flt(self.fg_completed_qty)) / remaining_qty_to_produce
- po_qty = frappe.db.sql(
- """select qty, produced_qty, material_transferred_for_manufacturing from
- `tabWork Order` where name=%s""",
- self.work_order,
- as_dict=1,
- )[0]
-
- manufacturing_qty = flt(po_qty.qty) or 1
- produced_qty = flt(po_qty.produced_qty)
- trans_qty = flt(po_qty.material_transferred_for_manufacturing) or 1
-
- for item in transferred_materials:
- qty = item.qty
- item_code = item.original_item or item.item_code
- req_items = frappe.get_all(
- "Work Order Item",
- filters={"parent": self.work_order, "item_code": item_code},
- fields=["required_qty", "consumed_qty"],
- )
-
- req_qty = flt(req_items[0].required_qty) if req_items else flt(4)
- req_qty_each = flt(req_qty / manufacturing_qty)
- consumed_qty = flt(req_items[0].consumed_qty) if req_items else 0
-
- if trans_qty and manufacturing_qty > (produced_qty + flt(self.fg_completed_qty)):
- if qty >= req_qty:
- qty = (req_qty / trans_qty) * flt(self.fg_completed_qty)
- else:
- qty = qty - consumed_qty
-
- if self.purpose == "Manufacture":
- # If Material Consumption is booked, must pull only remaining components to finish product
- if consumed_qty != 0:
- remaining_qty = consumed_qty - (produced_qty * req_qty_each)
- exhaust_qty = req_qty_each * produced_qty
- if remaining_qty > exhaust_qty:
- if (remaining_qty / (req_qty_each * flt(self.fg_completed_qty))) >= 1:
- qty = 0
- else:
- qty = (req_qty_each * flt(self.fg_completed_qty)) - remaining_qty
- else:
- if self.flags.backflush_based_on == "Material Transferred for Manufacture":
- qty = (item.qty / trans_qty) * flt(self.fg_completed_qty)
- else:
- qty = req_qty_each * flt(self.fg_completed_qty)
-
- elif backflushed_materials.get(item.item_code):
- precision = frappe.get_precision("Stock Entry Detail", "qty")
- for d in backflushed_materials.get(item.item_code):
- if d.get(item.warehouse) > 0:
- if qty > req_qty:
- qty = (
- (flt(qty, precision) - flt(d.get(item.warehouse), precision))
- / (flt(trans_qty, precision) - flt(produced_qty, precision))
- ) * flt(self.fg_completed_qty)
-
- d[item.warehouse] -= qty
-
+ item = row.item_details
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(
- {
- item.item_code: {
- "from_warehouse": item.warehouse,
- "to_warehouse": "",
- "qty": qty,
- "item_name": item.item_name,
- "description": item.description,
- "stock_uom": item.stock_uom,
- "expense_account": item.expense_account,
- "cost_center": item.buying_cost_center,
- "original_item": item.original_item,
- }
- }
- )
+ if row.batch_details:
+ for batch_no, batch_qty in row.batch_details.items():
+ if qty <= 0 or batch_qty <= 0:
+ continue
+
+ if batch_qty > qty:
+ batch_qty = qty
+
+ item.batch_no = batch_no
+ self.update_item_in_stock_entry_detail(row, item, batch_qty)
+
+ row.batch_details[batch_no] -= batch_qty
+ qty -= batch_qty
+ else:
+ self.update_item_in_stock_entry_detail(row, item, qty)
+
+ def update_item_in_stock_entry_detail(self, row, item, qty) -> None:
+ ste_item_details = {
+ "from_warehouse": item.warehouse,
+ "to_warehouse": "",
+ "qty": qty,
+ "item_name": item.item_name,
+ "batch_no": item.batch_no,
+ "description": item.description,
+ "stock_uom": item.stock_uom,
+ "expense_account": item.expense_account,
+ "cost_center": item.buying_cost_center,
+ "original_item": item.original_item,
+ }
+
+ if row.serial_nos:
+ serial_nos = row.serial_nos
+ if item.batch_no:
+ serial_nos = self.get_serial_nos_based_on_transferred_batch(item.batch_no, row.serial_nos)
+
+ serial_nos = serial_nos[0 : cint(qty)]
+ ste_item_details["serial_no"] = "\n".join(serial_nos)
+
+ # remove consumed serial nos from list
+ for sn in serial_nos:
+ row.serial_nos.remove(sn)
+
+ self.add_to_stock_entry_detail({item.item_code: ste_item_details})
+
+ @staticmethod
+ def get_serial_nos_based_on_transferred_batch(batch_no, serial_nos) -> list:
+ serial_nos = frappe.get_all(
+ "Serial No", filters={"batch_no": batch_no, "name": ("in", serial_nos)}, order_by="creation"
+ )
+
+ return [d.name for d in serial_nos]
def get_pending_raw_materials(self, backflush_based_on=None):
"""
@@ -1980,23 +1923,30 @@
):
# Get PO Supplied Items Details
- item_wh = frappe._dict(
- frappe.db.sql(
- """
- select rm_item_code, reserve_warehouse
- from `tabPurchase Order` po, `tabPurchase Order Item Supplied` poitemsup
- where po.name = poitemsup.parent
- and po.name = %s""",
- self.purchase_order,
- )
+ po_supplied_items = frappe.db.get_all(
+ "Purchase Order Item Supplied",
+ filters={"parent": self.purchase_order},
+ fields=["name", "rm_item_code", "reserve_warehouse"],
)
+ # Get Items Supplied in Stock Entries against PO
supplied_items = get_supplied_items(self.purchase_order)
- for name, item in supplied_items.items():
- frappe.db.set_value("Purchase Order Item Supplied", name, item)
- # Update reserved sub contracted quantity in bin based on Supplied Item Details and
+ for row in po_supplied_items:
+ key, item = row.name, {}
+ if not supplied_items.get(key):
+ # no stock transferred against PO Supplied Items row
+ item = {"supplied_qty": 0, "returned_qty": 0, "total_supplied_qty": 0}
+ else:
+ item = supplied_items.get(key)
+
+ frappe.db.set_value("Purchase Order Item Supplied", row.name, item)
+
+ # RM Item-Reserve Warehouse Dict
+ item_wh = {x.get("rm_item_code"): x.get("reserve_warehouse") for x in po_supplied_items}
+
for d in self.get("items"):
+ # Update reserved sub contracted quantity in bin based on Supplied Item Details and
item_code = d.get("original_item") or d.get("item_code")
reserve_warehouse = item_wh.get(item_code)
if not (reserve_warehouse and item_code):
@@ -2521,3 +2471,81 @@
)
return supplied_item_details
+
+
+def get_available_materials(work_order) -> dict:
+ data = get_stock_entry_data(work_order)
+
+ available_materials = {}
+ for row in data:
+ key = (row.item_code, row.warehouse)
+ if row.purpose != "Material Transfer for Manufacture":
+ key = (row.item_code, row.s_warehouse)
+
+ if key not in available_materials:
+ available_materials.setdefault(
+ key,
+ frappe._dict(
+ {"item_details": row, "batch_details": defaultdict(float), "qty": 0, "serial_nos": []}
+ ),
+ )
+
+ item_data = available_materials[key]
+
+ if row.purpose == "Material Transfer for Manufacture":
+ item_data.qty += row.qty
+ if row.batch_no:
+ item_data.batch_details[row.batch_no] += row.qty
+
+ if row.serial_no:
+ item_data.serial_nos.extend(get_serial_nos(row.serial_no))
+ item_data.serial_nos.sort()
+ else:
+ # Consume raw material qty in case of 'Manufacture' or 'Material Consumption for Manufacture'
+
+ item_data.qty -= row.qty
+ if row.batch_no:
+ item_data.batch_details[row.batch_no] -= row.qty
+
+ if row.serial_no:
+ for serial_no in get_serial_nos(row.serial_no):
+ item_data.serial_nos.remove(serial_no)
+
+ return available_materials
+
+
+def get_stock_entry_data(work_order):
+ stock_entry = frappe.qb.DocType("Stock Entry")
+ stock_entry_detail = frappe.qb.DocType("Stock Entry Detail")
+
+ return (
+ frappe.qb.from_(stock_entry)
+ .from_(stock_entry_detail)
+ .select(
+ stock_entry_detail.item_name,
+ stock_entry_detail.original_item,
+ stock_entry_detail.item_code,
+ stock_entry_detail.qty,
+ (stock_entry_detail.t_warehouse).as_("warehouse"),
+ (stock_entry_detail.s_warehouse).as_("s_warehouse"),
+ stock_entry_detail.description,
+ stock_entry_detail.stock_uom,
+ stock_entry_detail.expense_account,
+ stock_entry_detail.cost_center,
+ stock_entry_detail.batch_no,
+ stock_entry_detail.serial_no,
+ stock_entry.purpose,
+ )
+ .where(
+ (stock_entry.name == stock_entry_detail.parent)
+ & (stock_entry.work_order == work_order)
+ & (stock_entry.docstatus == 1)
+ & (stock_entry_detail.s_warehouse.isnotnull())
+ & (
+ stock_entry.purpose.isin(
+ ["Manufacture", "Material Consumption for Manufacture", "Material Transfer for Manufacture"]
+ )
+ )
+ )
+ .orderby(stock_entry.creation, stock_entry_detail.item_code, stock_entry_detail.idx)
+ ).run(as_dict=1)
diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
index 83aed90..d758c8a 100644
--- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
+++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
@@ -233,6 +233,7 @@
"reqd": 1
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor",
@@ -242,6 +243,7 @@
"reqd": 1
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
@@ -253,6 +255,7 @@
"reqd": 1
},
{
+ "depends_on": "eval:doc.uom != doc.stock_uom",
"fieldname": "transfer_qty",
"fieldtype": "Float",
"label": "Qty as per Stock UOM",
@@ -556,7 +559,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-02-26 00:51:24.963653",
+ "modified": "2022-06-17 05:06:33.621264",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry Detail",
diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
index 55a213c..f669e90 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
+++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
@@ -42,6 +42,9 @@
"delete from `tabBin` where item_code in (%s)" % (", ".join(["%s"] * len(items))), items
)
+ def tearDown(self):
+ frappe.db.rollback()
+
def test_item_cost_reposting(self):
company = "_Test Company"
@@ -1230,6 +1233,93 @@
)
self.assertEqual(abs(sles[0].stock_value_difference), sles[1].stock_value_difference)
+ @change_settings("System Settings", {"float_precision": 4})
+ def test_negative_qty_with_precision(self):
+ "Test if system precision is respected while validating negative qty."
+ from erpnext.stock.doctype.item.test_item import create_item
+ from erpnext.stock.utils import get_stock_balance
+
+ item_code = "ItemPrecisionTest"
+ warehouse = "_Test Warehouse - _TC"
+ create_item(item_code, is_stock_item=1, stock_uom="Kg")
+
+ create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=559.8327, rate=100)
+
+ make_stock_entry(item_code=item_code, source=warehouse, qty=470.84, rate=100)
+ self.assertEqual(get_stock_balance(item_code, warehouse), 88.9927)
+
+ settings = frappe.get_doc("System Settings")
+ settings.float_precision = 3
+ settings.save()
+
+ # To deliver 100 qty we fall short of 11.0073 qty (11.007 with precision 3)
+ # Stock up with 11.007 (balance in db becomes 99.9997, on UI it will show as 100)
+ make_stock_entry(item_code=item_code, target=warehouse, qty=11.007, rate=100)
+ self.assertEqual(get_stock_balance(item_code, warehouse), 99.9997)
+
+ # See if delivery note goes through
+ # Negative qty error should not be raised as 99.9997 is 100 with precision 3 (system precision)
+ dn = create_delivery_note(
+ item_code=item_code,
+ qty=100,
+ rate=150,
+ warehouse=warehouse,
+ company="_Test Company",
+ expense_account="Cost of Goods Sold - _TC",
+ cost_center="Main - _TC",
+ do_not_submit=True,
+ )
+ dn.submit()
+
+ self.assertEqual(flt(get_stock_balance(item_code, warehouse), 3), 0.000)
+
+ @change_settings("System Settings", {"float_precision": 4})
+ def test_future_negative_qty_with_precision(self):
+ """
+ Ledger:
+ | Voucher | Qty | Balance
+ -------------------
+ | Reco | 559.8327| 559.8327
+ | SE | -470.84 | [Backdated] (new bal: 88.9927)
+ | SE | 11.007 | 570.8397 (new bal: 99.9997)
+ | DN | -100 | 470.8397 (new bal: -0.0003)
+
+ Check if future negative qty is asserted as per precision 3.
+ -0.0003 should be considered as 0.000
+ """
+ from erpnext.stock.doctype.item.test_item import create_item
+
+ item_code = "ItemPrecisionTest"
+ warehouse = "_Test Warehouse - _TC"
+ create_item(item_code, is_stock_item=1, stock_uom="Kg")
+
+ create_stock_reconciliation(
+ item_code=item_code,
+ warehouse=warehouse,
+ qty=559.8327,
+ rate=100,
+ posting_date=add_days(today(), -2),
+ )
+ make_stock_entry(item_code=item_code, target=warehouse, qty=11.007, rate=100)
+ create_delivery_note(
+ item_code=item_code,
+ qty=100,
+ rate=150,
+ warehouse=warehouse,
+ company="_Test Company",
+ expense_account="Cost of Goods Sold - _TC",
+ cost_center="Main - _TC",
+ )
+
+ settings = frappe.get_doc("System Settings")
+ settings.float_precision = 3
+ settings.save()
+
+ # Make backdated SE and make sure SE goes through as per precision (no negative qty error)
+ make_stock_entry(
+ item_code=item_code, source=warehouse, qty=470.84, rate=100, posting_date=add_days(today(), -1)
+ )
+
def create_repack_entry(**args):
args = frappe._dict(args)
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
index bd60cf0..23e0f1e 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
@@ -611,7 +611,7 @@
select
i.name as item_code, i.item_name, bin.warehouse as warehouse, i.has_serial_no, i.has_batch_no
from
- tabBin bin, tabItem i
+ `tabBin` bin, `tabItem` i
where
i.name = bin.item_code
and IFNULL(i.disabled, 0) = 0
@@ -629,7 +629,7 @@
select
i.name as item_code, i.item_name, id.default_warehouse as warehouse, i.has_serial_no, i.has_batch_no
from
- tabItem i, `tabItem Default` id
+ `tabItem` i, `tabItem Default` id
where
i.name = id.parent
and exists(
diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py
index df16643..ab784ca 100644
--- a/erpnext/stock/doctype/warehouse/warehouse.py
+++ b/erpnext/stock/doctype/warehouse/warehouse.py
@@ -161,8 +161,7 @@
fields = ["name as value", "is_group as expandable"]
filters = [
- ["docstatus", "<", "2"],
- ['ifnull(`parent_warehouse`, "")', "=", parent],
+ ["ifnull(`parent_warehouse`, '')", "=", parent],
["company", "in", (company, None, "")],
]
diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py
index c8d9f54..38ad662 100644
--- a/erpnext/stock/get_item_details.py
+++ b/erpnext/stock/get_item_details.py
@@ -63,18 +63,16 @@
item = frappe.get_cached_doc("Item", args.item_code)
validate_item_details(args, item)
- out = get_basic_details(args, item, overwrite_warehouse)
-
if isinstance(doc, str):
doc = json.loads(doc)
- if doc and doc.get("doctype") == "Purchase Invoice":
- args["bill_date"] = doc.get("bill_date")
-
if doc:
- args["posting_date"] = doc.get("posting_date")
- args["transaction_date"] = doc.get("transaction_date")
+ args["transaction_date"] = doc.get("transaction_date") or doc.get("posting_date")
+ if doc.get("doctype") == "Purchase Invoice":
+ args["bill_date"] = doc.get("bill_date")
+
+ out = get_basic_details(args, item, overwrite_warehouse)
get_item_tax_template(args, item, out)
out["item_tax_rate"] = get_item_tax_map(
args.company,
@@ -596,9 +594,7 @@
if tax.valid_from or tax.maximum_net_rate:
# In purchase Invoice first preference will be given to supplier invoice date
# if supplier date is not present then posting date
- validation_date = (
- args.get("transaction_date") or args.get("bill_date") or args.get("posting_date")
- )
+ validation_date = args.get("bill_date") or args.get("transaction_date")
if getdate(tax.valid_from) <= getdate(validation_date) and is_within_valid_range(args, tax):
taxes_with_validity.append(tax)
@@ -891,14 +887,10 @@
conditions += """ and %(transaction_date)s between
ifnull(valid_from, '2000-01-01') and ifnull(valid_upto, '2500-12-31')"""
- if args.get("posting_date"):
- conditions += """ and %(posting_date)s between
- ifnull(valid_from, '2000-01-01') and ifnull(valid_upto, '2500-12-31')"""
-
return frappe.db.sql(
""" select name, price_list_rate, uom
from `tabItem Price` {conditions}
- order by valid_from desc, batch_no desc, uom desc """.format(
+ order by valid_from desc, ifnull(batch_no, '') desc, uom desc """.format(
conditions=conditions
),
args,
@@ -921,7 +913,6 @@
"supplier": args.get("supplier"),
"uom": args.get("uom"),
"transaction_date": args.get("transaction_date"),
- "posting_date": args.get("posting_date"),
"batch_no": args.get("batch_no"),
}
@@ -1352,12 +1343,22 @@
@frappe.whitelist()
def get_default_bom(item_code=None):
- if item_code:
- bom = frappe.db.get_value(
- "BOM", {"docstatus": 1, "is_default": 1, "is_active": 1, "item": item_code}
+ def _get_bom(item):
+ bom = frappe.get_all(
+ "BOM", dict(item=item, is_active=True, is_default=True, docstatus=1), limit=1
)
- if bom:
- return bom
+ return bom[0].name if bom else None
+
+ if not item_code:
+ return
+
+ bom_name = _get_bom(item_code)
+
+ template_item = frappe.db.get_value("Item", item_code, "variant_of")
+ if not bom_name and template_item:
+ bom_name = _get_bom(template_item)
+
+ return bom_name
@frappe.whitelist()
diff --git a/erpnext/stock/reorder_item.py b/erpnext/stock/reorder_item.py
index f19c75f..136c78f 100644
--- a/erpnext/stock/reorder_item.py
+++ b/erpnext/stock/reorder_item.py
@@ -105,7 +105,7 @@
for item_code, warehouse, projected_qty in frappe.db.sql(
"""select item_code, warehouse, projected_qty
from tabBin where item_code in ({0})
- and (warehouse != "" and warehouse is not null)""".format(
+ and (warehouse != '' and warehouse is not null)""".format(
", ".join(["%s"] * len(items_to_consider))
),
items_to_consider,
diff --git a/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.py b/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.py
index bcc2139..b68db35 100644
--- a/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.py
+++ b/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.py
@@ -73,7 +73,7 @@
"Stock Ledger Entry",
fields=fields,
filters=filters,
- order_by="timestamp(posting_date, posting_time) asc, creation asc",
+ order_by="posting_date asc, posting_time asc, creation asc",
)
diff --git a/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py
index 78c6961..39d84a7 100644
--- a/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py
+++ b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py
@@ -106,7 +106,7 @@
"Stock Ledger Entry",
fields=fields,
filters=filters,
- order_by="timestamp(posting_date, posting_time) asc, creation asc",
+ order_by="posting_date asc, posting_time asc, creation asc",
)
diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py
index 409e238..ef1642e 100644
--- a/erpnext/stock/report/stock_ledger/stock_ledger.py
+++ b/erpnext/stock/report/stock_ledger/stock_ledger.py
@@ -238,7 +238,7 @@
sl_entries = frappe.db.sql(
"""
SELECT
- concat_ws(" ", posting_date, posting_time) AS date,
+ concat_ws(' ', posting_date, posting_time) AS date,
item_code,
warehouse,
actual_qty,
diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py
index e05d1c3..14cedd2 100644
--- a/erpnext/stock/stock_balance.py
+++ b/erpnext/stock/stock_balance.py
@@ -118,7 +118,7 @@
select qty, parent_detail_docname, parent, name
from `tabPacked Item` dnpi_in
where item_code = %s and warehouse = %s
- and parenttype="Sales Order"
+ and parenttype='Sales Order'
and item_code != parent_item
and exists (select * from `tabSales Order` so
where name = dnpi_in.parent and docstatus = 1 and status != 'Closed')
@@ -194,7 +194,7 @@
planned_qty = frappe.db.sql(
"""
select sum(qty - produced_qty) from `tabWork Order`
- where production_item = %s and fg_warehouse = %s and status not in ("Stopped", "Completed", "Closed")
+ where production_item = %s and fg_warehouse = %s and status not in ('Stopped', 'Completed', 'Closed')
and docstatus=1 and qty > produced_qty""",
(item_code, warehouse),
)
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 4789b52..ba2d3c1 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import copy
@@ -370,7 +370,7 @@
self.args["name"] = self.args.sle_id
self.company = frappe.get_cached_value("Warehouse", self.args.warehouse, "company")
- self.get_precision()
+ self.set_precision()
self.valuation_method = get_valuation_method(self.item_code)
self.new_items_found = False
@@ -381,10 +381,10 @@
self.initialize_previous_data(self.args)
self.build()
- def get_precision(self):
- company_base_currency = frappe.get_cached_value("Company", self.company, "default_currency")
- self.precision = get_field_precision(
- frappe.get_meta("Stock Ledger Entry").get_field("stock_value"), currency=company_base_currency
+ def set_precision(self):
+ self.flt_precision = cint(frappe.db.get_default("float_precision")) or 2
+ self.currency_precision = get_field_precision(
+ frappe.get_meta("Stock Ledger Entry").get_field("stock_value")
)
def initialize_previous_data(self, args):
@@ -581,7 +581,7 @@
self.update_queue_values(sle)
# rounding as per precision
- self.wh_data.stock_value = flt(self.wh_data.stock_value, self.precision)
+ self.wh_data.stock_value = flt(self.wh_data.stock_value, self.currency_precision)
if not self.wh_data.qty_after_transaction:
self.wh_data.stock_value = 0.0
stock_value_difference = self.wh_data.stock_value - self.wh_data.prev_stock_value
@@ -605,6 +605,7 @@
will not consider cancelled entries
"""
diff = self.wh_data.qty_after_transaction + flt(sle.actual_qty)
+ diff = flt(diff, self.flt_precision) # respect system precision
if diff < 0 and abs(diff) > 0.0001:
# negative stock!
@@ -1405,7 +1406,8 @@
return
neg_sle = get_future_sle_with_negative_qty(args)
- if neg_sle:
+
+ if is_negative_with_precision(neg_sle):
message = _(
"{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction."
).format(
@@ -1423,7 +1425,7 @@
return
neg_batch_sle = get_future_sle_with_negative_batch_qty(args)
- if neg_batch_sle:
+ if is_negative_with_precision(neg_batch_sle, is_batch=True):
message = _(
"{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction."
).format(
@@ -1437,6 +1439,22 @@
frappe.throw(message, NegativeStockError, title=_("Insufficient Stock for Batch"))
+def is_negative_with_precision(neg_sle, is_batch=False):
+ """
+ Returns whether system precision rounded qty is insufficient.
+ E.g: -0.0003 in precision 3 (0.000) is sufficient for the user.
+ """
+
+ if not neg_sle:
+ return False
+
+ field = "cumulative_total" if is_batch else "qty_after_transaction"
+ precision = cint(frappe.db.get_default("float_precision")) or 2
+ qty_deficit = flt(neg_sle[0][field], precision)
+
+ return qty_deficit < 0 and abs(qty_deficit) > 0.0001
+
+
def get_future_sle_with_negative_qty(args):
return frappe.db.sql(
"""
diff --git a/erpnext/stock/tests/test_utils.py b/erpnext/stock/tests/test_utils.py
index b046dbd..4e93ac9 100644
--- a/erpnext/stock/tests/test_utils.py
+++ b/erpnext/stock/tests/test_utils.py
@@ -26,6 +26,7 @@
filters=filters,
order_by="timestamp(posting_date, posting_time), creation",
)
+ self.assertGreaterEqual(len(sles), len(expected_sles))
for exp_sle, act_sle in zip(expected_sles, sles):
for k, v in exp_sle.items():
@@ -49,7 +50,7 @@
filters=filters,
order_by=order_by or "posting_date, creation",
)
-
+ self.assertGreaterEqual(len(actual_gles), len(expected_gles))
for exp_gle, act_gle in zip(expected_gles, actual_gles):
for k, exp_value in exp_gle.items():
act_value = act_gle[k]
diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py
index 6d8fdaa..9fb3be5 100644
--- a/erpnext/stock/utils.py
+++ b/erpnext/stock/utils.py
@@ -499,7 +499,7 @@
def get_incoming_outgoing_rate_for_cancel(item_code, voucher_type, voucher_no, voucher_detail_no):
outgoing_rate = frappe.db.sql(
- """SELECT abs(stock_value_difference / actual_qty)
+ """SELECT CASE WHEN actual_qty = 0 THEN 0 ELSE abs(stock_value_difference / actual_qty) END
FROM `tabStock Ledger Entry`
WHERE voucher_type = %s and voucher_no = %s
and item_code = %s and voucher_detail_no = %s
diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py
index 08a06b1..7f3e0cf 100644
--- a/erpnext/support/doctype/issue/issue.py
+++ b/erpnext/support/doctype/issue/issue.py
@@ -11,6 +11,8 @@
from frappe.email.inbox import link_communication_to_document
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
+from frappe.query_builder import Interval
+from frappe.query_builder.functions import Now
from frappe.utils import date_diff, get_datetime, now_datetime, time_diff_in_seconds
from frappe.utils.user import is_website_user
@@ -190,15 +192,17 @@
frappe.db.get_value("Support Settings", "Support Settings", "close_issue_after_days") or 7
)
- issues = frappe.db.sql(
- """ select name from tabIssue where status='Replied' and
- modified<DATE_SUB(CURDATE(), INTERVAL %s DAY) """,
- (auto_close_after_days),
- as_dict=True,
- )
+ table = frappe.qb.DocType("Issue")
+ issues = (
+ frappe.qb.from_(table)
+ .select(table.name)
+ .where(
+ (table.modified < (Now() - Interval(days=auto_close_after_days))) & (table.status == "Replied")
+ )
+ ).run(pluck=True)
for issue in issues:
- doc = frappe.get_doc("Issue", issue.get("name"))
+ doc = frappe.get_doc("Issue", issue)
doc.status = "Closed"
doc.flags.ignore_permissions = True
doc.flags.ignore_mandatory = True
diff --git a/erpnext/templates/pages/product_search.py b/erpnext/templates/pages/product_search.py
index 3ed056f..0768cc3 100644
--- a/erpnext/templates/pages/product_search.py
+++ b/erpnext/templates/pages/product_search.py
@@ -56,7 +56,10 @@
search = "%" + cstr(search) + "%"
# order by
- query += """ ORDER BY ranking desc, modified desc limit %s, %s""" % (cint(start), cint(limit))
+ query += """ ORDER BY ranking desc, modified desc limit %s offset %s""" % (
+ cint(limit),
+ cint(start),
+ )
return frappe.db.sql(query, {"search": search}, as_dict=1) # nosemgrep
diff --git a/erpnext/templates/utils.py b/erpnext/templates/utils.py
index 4295188..48b4480 100644
--- a/erpnext/templates/utils.py
+++ b/erpnext/templates/utils.py
@@ -34,7 +34,6 @@
status="Open",
title=subject,
contact_email=sender,
- to_discuss=message,
)
)
diff --git a/erpnext/tests/test_exotel.py b/erpnext/tests/test_exotel.py
index 1b4299c..f5cca72 100644
--- a/erpnext/tests/test_exotel.py
+++ b/erpnext/tests/test_exotel.py
@@ -59,7 +59,6 @@
f"/api/method/erpnext.erpnext_integrations.exotel_integration.{api_method}",
data=frappe.as_json(data),
content_type="application/json",
- as_tuple=True,
)
# restart db connection to get latest data
frappe.connect()
diff --git a/erpnext/tests/test_init.py b/erpnext/tests/test_init.py
index 4d5fced..18ce93a 100644
--- a/erpnext/tests/test_init.py
+++ b/erpnext/tests/test_init.py
@@ -45,3 +45,8 @@
from frappe.tests.test_translate import verify_translation_files
verify_translation_files("erpnext")
+
+ def test_patches(self):
+ from frappe.tests.test_patches import check_patch_files
+
+ check_patch_files("erpnext")
diff --git a/erpnext/tests/test_subcontracting.py b/erpnext/tests/test_subcontracting.py
index bf12181..93d1c8e 100644
--- a/erpnext/tests/test_subcontracting.py
+++ b/erpnext/tests/test_subcontracting.py
@@ -879,6 +879,55 @@
for key, value in get_supplied_items(pr1).items():
self.assertEqual(value.qty, 2)
+ def test_po_supplied_qty(self):
+ """
+ Check if 'Supplied Qty' in PO's Supplied Items table is reset on submit/cancel.
+ """
+ set_backflush_based_on("Material Transferred for Subcontract")
+ items = [
+ {
+ "warehouse": "_Test Warehouse - _TC",
+ "item_code": "Subcontracted Item SA1",
+ "qty": 5,
+ "rate": 100,
+ },
+ {
+ "warehouse": "_Test Warehouse - _TC",
+ "item_code": "Subcontracted Item SA5",
+ "qty": 6,
+ "rate": 100,
+ },
+ ]
+
+ rm_items = [
+ {"item_code": "Subcontracted SRM Item 1", "qty": 5, "main_item_code": "Subcontracted Item SA1"},
+ {"item_code": "Subcontracted SRM Item 2", "qty": 5, "main_item_code": "Subcontracted Item SA1"},
+ {"item_code": "Subcontracted SRM Item 3", "qty": 5, "main_item_code": "Subcontracted Item SA1"},
+ {"item_code": "Subcontracted SRM Item 5", "qty": 6, "main_item_code": "Subcontracted Item SA5"},
+ {"item_code": "Subcontracted SRM Item 4", "qty": 6, "main_item_code": "Subcontracted Item SA5"},
+ ]
+
+ itemwise_details = make_stock_in_entry(rm_items=rm_items)
+ po = create_purchase_order(
+ rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC"
+ )
+
+ for d in rm_items:
+ d["po_detail"] = po.items[0].name if d.get("qty") == 5 else po.items[1].name
+
+ se = make_stock_transfer_entry(
+ po_no=po.name, rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details)
+ )
+
+ po.reload()
+ for row in po.get("supplied_items"):
+ self.assertIn(row.supplied_qty, [5.0, 6.0])
+
+ se.cancel()
+ po.reload()
+ for row in po.get("supplied_items"):
+ self.assertEqual(row.supplied_qty, 0.0)
+
def add_second_row_in_pr(pr):
item_dict = {}
diff --git a/erpnext/translations/ar.csv b/erpnext/translations/ar.csv
index 91a9da9..e62f61a 100644
--- a/erpnext/translations/ar.csv
+++ b/erpnext/translations/ar.csv
@@ -4297,7 +4297,7 @@
"To allow different rates, disable the {0} checkbox in {1}.",للسماح بمعدلات مختلفة ، قم بتعطيل مربع الاختيار {0} في {1}.,
Current Odometer Value should be greater than Last Odometer Value {0},يجب أن تكون قيمة عداد المسافات الحالية أكبر من قيمة آخر عداد المسافات {0},
No additional expenses has been added,لم يتم إضافة مصاريف إضافية,
-Asset{} {assets_link} created for {},الأصل {} {asset_link} الذي تم إنشاؤه لـ {},
+Asset{} {assets_link} created for {},الأصل {} {assets_link} الذي تم إنشاؤه لـ {},
Row {}: Asset Naming Series is mandatory for the auto creation for item {},الصف {}: سلسلة تسمية الأصول إلزامية للإنشاء التلقائي للعنصر {},
Assets not created for {0}. You will have to create asset manually.,لم يتم إنشاء الأصول لـ {0}. سيكون عليك إنشاء الأصل يدويًا.,
{0} {1} has accounting entries in currency {2} for company {3}. Please select a receivable or payable account with currency {2}.,{0} يحتوي {1} على إدخالات محاسبية بالعملة {2} للشركة {3}. الرجاء تحديد حساب مستحق أو دائن بالعملة {2}.,
diff --git a/erpnext/translations/fr.csv b/erpnext/translations/fr.csv
index 22e3c35..ffc46d2 100644
--- a/erpnext/translations/fr.csv
+++ b/erpnext/translations/fr.csv
@@ -1352,11 +1352,11 @@
Item Group,Groupe d'Article,
Item Group Tree,Arborescence de Groupe d'Article,
Item Group not mentioned in item master for item {0},Le Groupe d'Articles n'est pas mentionné dans la fiche de l'article pour l'article {0},
-Item Name,Nom de l'article,
+Item Name,Nom de l'article,
Item Price added for {0} in Price List {1},Prix de l'Article ajouté pour {0} dans la Liste de Prix {1},
-"Item Price appears multiple times based on Price List, Supplier/Customer, Currency, Item, UOM, Qty and Dates.","Le prix de l'article apparaît plusieurs fois en fonction de la liste de prix, du fournisseur / client, de la devise, de l'article, de l'unité de mesure, de la quantité et des dates.",
+"Item Price appears multiple times based on Price List, Supplier/Customer, Currency, Item, UOM, Qty and Dates.","Le prix de l'article apparaît plusieurs fois en fonction de la liste de prix, du fournisseur / client, de la devise, de l'article, de l'unité de mesure, de la quantité et des dates.",
Item Price updated for {0} in Price List {1},Prix de l'Article mis à jour pour {0} dans la Liste des Prix {1},
-Item Row {0}: {1} {2} does not exist in above '{1}' table,Ligne d'objet {0}: {1} {2} n'existe pas dans la table '{1}' ci-dessus,
+Item Row {0}: {1} {2} does not exist in above '{1}' table,Ligne d'objet {0}: {1} {2} n'existe pas dans la table '{1}' ci-dessus,
Item Tax Row {0} must have account of type Tax or Income or Expense or Chargeable,La Ligne de Taxe d'Article {0} doit indiquer un compte de type Taxes ou Produit ou Charge ou Facturable,
Item Template,Modèle d'article,
Item Variant Settings,Paramètres de Variante d'Article,
@@ -3661,7 +3661,7 @@
Choose a corresponding payment,Choisissez un paiement correspondant,
Click on the link below to verify your email and confirm the appointment,Cliquez sur le lien ci-dessous pour vérifier votre email et confirmer le rendez-vous,
Close,Fermer,
-Communication,la communication,
+Communication,Communication,
Compact Item Print,Impression de l'Article Compacté,
Company,Société,
Company of asset {0} and purchase document {1} doesn't matches.,La société de l'actif {0} et le document d'achat {1} ne correspondent pas.,
@@ -3969,7 +3969,7 @@
Quarterly,Trimestriel,
Queued,File d'Attente,
Quick Entry,Écriture Rapide,
-Quiz {0} does not exist,Le questionnaire {0} n'existe pas,
+Quiz {0} does not exist,Le questionnaire {0} n'existe pas,
Quotation Amount,Montant du devis,
Rate or Discount is required for the price discount.,Le taux ou la remise est requis pour la remise de prix.,
Reason,Raison,
@@ -4071,7 +4071,7 @@
Stores - {0},Magasins - {0},
Student with email {0} does not exist,Étudiant avec le courrier électronique {0} n'existe pas,
Submit Review,Poster un commentaire,
-Submitted,Soumis,
+Submitted,Valider,
Supplier Addresses And Contacts,Adresses et contacts des fournisseurs,
Synchronize this account,Synchroniser ce compte,
Tag,Étiquette,
@@ -9871,8 +9871,42 @@
Convert Item Description to Clean HTML in Transactions,Convertir les descriptions d'articles en HTML valide lors des transactions
Have Default Naming Series for Batch ID?,Nom de série par défaut pour les Lots ou Séries
"The percentage you are allowed to transfer more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed transfer 110 units","Le pourcentage de quantité que vous pourrez réceptionner en plus de la quantité commandée. Par exemple, vous avez commandé 100 unités, votre pourcentage de dépassement est de 10%, vous pourrez réceptionner 110 unités"
-Unit Of Measure (UOM),Unité de mesure (UDM),
Allowed Items,Articles autorisés
Party Specific Item,Restriction d'article disponible
Restrict Items Based On,Type de critére de restriction
Based On Value,critére de restriction
+Unit of Measure (UOM),Unité de mesure (UDM),
+Unit Of Measure (UOM),Unité de mesure (UDM),
+CRM Settings,Paramètres CRM
+Do Not Explode,Ne pas décomposer
+Quick Access, Accés rapides
+{} Available,{} Disponible.s
+{} Pending,{} En attente.s
+{} To Bill,{} à facturer
+{} To Receive,{} A recevoir
+{} Active,{} Actif.ve(s)
+{} Open,{} Ouvert.e(s)
+Incorrect Data Report,Rapport de données incohérentes
+Incorrect Serial No Valuation,Valorisation inccorecte par Num. Série / Lots
+Incorrect Balance Qty After Transaction,Equilibre des quantités aprés une transaction
+Interview Type,Type d'entretien
+Interview Round,Cycle d'entretien
+Interview,Entretien
+Interview Feedback,Retour d'entretien
+Journal Energy Point,Historique des points d'énergies
+Billing Address Details,Adresse de facturation (détails)
+Supplier Address Details,Adresse Fournisseur (détails)
+Retail,Commerce
+Users,Utilisateurs
+Permission Manager,Gestion des permissions
+Fetch Timesheet,Récuprer les temps saisis
+Get Supplier Group Details,Appliquer les informations depuis le Groupe de fournisseur
+Quality Inspection(s),Inspection(s) Qualité
+Set Advances and Allocate (FIFO),Affecter les encours au réglement
+Apply Putaway Rule,Appliquer la régle de routage d'entrepot
+Delete Transactions,Supprimer les transactions
+Default Payment Discount Account,Compte par défaut des paiements de remise
+Unrealized Profit / Loss Account,Compte de perte
+Enable Provisional Accounting For Non Stock Items,Activer la provision pour les articles non stockés
+Publish in Website,Publier sur le Site Web
+List View,Vue en liste
diff --git a/erpnext/translations/ru.csv b/erpnext/translations/ru.csv
index 6b766e7..a4bfb86 100644
--- a/erpnext/translations/ru.csv
+++ b/erpnext/translations/ru.csv
@@ -44,7 +44,7 @@
Account,Аккаунт,
Account Number,Номер аккаунта,
Account Number {0} already used in account {1},"Номер счета {0}, уже использованный в учетной записи {1}",
-Account Pay Only,Счет Оплатить только,
+Account Pay Only,Только оплатить счет,
Account Type,Тип учетной записи,
Account Type for {0} must be {1},Тип счета для {0} должен быть {1},
"Account balance already in Credit, you are not allowed to set 'Balance Must Be' as 'Debit'","Баланс счета в Кредите, запрещена установка 'Баланс должен быть' как 'Дебет'",
@@ -117,7 +117,7 @@
Add Items,Добавить продукты,
Add Leads,Добавить лид,
Add Multiple Tasks,Добавить несколько задач,
-Add Row,Добавить ряд,
+Add Row,Добавить строку,
Add Sales Partners,Добавить партнеров по продажам,
Add Serial No,Добавить серийный номер,
Add Students,Добавить студентов,
@@ -288,7 +288,7 @@
Assets,Активы,
Assign,Назначить,
Assign Salary Structure,Назначить структуру заработной платы,
-Assign To,Назначить в,
+Assign To,Назначить для,
Assign to Employees,Назначить сотрудникам,
Assigning Structures...,Назначение структур...,
Associate,Помощник,
@@ -421,7 +421,7 @@
Bundle items at time of sale.,Собирать продукты в момент продажи.,
Business Development Manager,Менеджер по развитию бизнеса,
Buy,Купить,
-Buying,Покупки,
+Buying,Закупки,
Buying Amount,Сумма покупки,
Buying Price List,Ценовой список покупок,
Buying Rate,Частота покупки,
@@ -490,7 +490,7 @@
Capital Stock,Капитал,
Capital Work in Progress,Капитальная работа в процессе,
Cart,Корзина,
-Cart is Empty,Корзина Пусто,
+Cart is Empty,Корзина пуста,
Case No(s) already in use. Try from Case No {0},Случай Нет (ы) уже используется. Попробуйте из дела № {0},
Cash,Наличные,
Cash Flow Statement,О движении денежных средств,
@@ -578,7 +578,7 @@
Compensatory leave request days not in valid holidays,Дни запроса на получение компенсационных отчислений не действительны,
Complaint,Жалоба,
Completion Date,Дата завершения,
-Computer,компьютер,
+Computer,Компьютер,
Condition,Условия,
Configure,Конфигурировать,
Configure {0},Настроить {0},
@@ -643,7 +643,6 @@
Course Enrollment {0} does not exists,Зачисление на курс {0} не существует,
Course Schedule,Расписание курса,
Course: ,Курс: ,
-Cr,Cr,
Create,Создать,
Create BOM,Создать спецификацию,
Create Delivery Trip,Создать маршрут доставки,
@@ -693,7 +692,7 @@
Creating Company and Importing Chart of Accounts,Создание компании и импорт плана счетов,
Creating Fees,Создание сборов,
Creating Payment Entries......,Создание платежных записей......,
-Creating Salary Slips...,Создание зарплатных листков...,
+Creating Salary Slips...,Создание зарплатных ведомостей...,
Creating student groups,Создание групп студентов,
Creating {0} Invoice,Создание {0} счета,
Credit,Кредит,
@@ -795,7 +794,6 @@
Define Project type.,Установите тип проекта.,
Define budget for a financial year.,Определить бюджет на финансовый год.,
Define various loan types,Определение различных видов кредита,
-Del,Del,
Delay in payment (Days),Задержка в оплате (дни),
Delete all the Transactions for this Company,Удалить все транзакции этой компании,
Deletion is not permitted for country {0},Для страны не разрешено удаление {0},
@@ -997,7 +995,7 @@
Expenses Included In Asset Valuation,"Расходы, включенные в оценку активов",
Expenses Included In Valuation,"Затрат, включаемых в оценке",
Expired Batches,Просроченные партии,
-Expires On,Годен до,
+Expires On,Актуален до,
Expiring On,Срок действия,
Expiry (In Days),Срок действия (в днях),
Explore,Обзор,
@@ -1287,12 +1285,12 @@
Institute Abbreviation,институт Аббревиатура,
Institute Name,Название института,
Instructor,Инструктор,
-Insufficient Stock,Недостаточный Stock,
-Insurance Start date should be less than Insurance End date,"Дата страхование начала должна быть меньше, чем дата страхование End",
+Insufficient Stock,Недостаточный запас,
+Insurance Start date should be less than Insurance End date,"Дата начала страхования должна быть раньше, чем дата окончания",
Integrated Tax,Интегрированный налог,
Inter-State Supplies,Межгосударственные поставки,
-Interest Amount,Проценты Сумма,
-Interests,интересы,
+Interest Amount,Сумма процентов,
+Interests,Интересы,
Intern,Стажер,
Internet Publishing,Интернет издания,
Intra-State Supplies,Внутригосударственные поставки,
@@ -1397,7 +1395,7 @@
Job Description,Описание работы,
Job Offer,Предложение работы,
Job card {0} created,Карта работы {0} создана,
-Jobs,Работы,
+Jobs,Вакансии,
Join,Присоединиться,
Journal Entries {0} are un-linked,Записи в журнале {0} не-связаны,
Journal Entry,Запись в журнале,
@@ -1413,7 +1411,7 @@
Lab Tests and Vital Signs,Лабораторные тесты и жизненные знаки,
Lab result datetime cannot be before testing datetime,Лабораторный результат datetime не может быть до тестирования даты и времени,
Lab testing datetime cannot be before collection datetime,Лабораторное тестирование datetime не может быть до даты сбора данных,
-Label,Ярлык,
+Label,Метка,
Laboratory,Лаборатория,
Language Name,Название языка,
Large,Большой,
@@ -1925,7 +1923,7 @@
Pending Leaves,Ожидающие листья,
Pending Qty,В ожидании кол-во,
Pending Quantity,Количество в ожидании,
-Pending Review,В ожидании отзыв,
+Pending Review,В ожидании отзыва,
Pending activities for today,В ожидании деятельность на сегодняшний день,
Pension Funds,Пенсионные фонды,
Percentage Allocation should be equal to 100%,Процент Распределение должно быть равно 100%,
@@ -1949,7 +1947,7 @@
Planning,Планирование,
Plants and Machineries,Растения и Механизмов,
Please Set Supplier Group in Buying Settings.,Установите группу поставщиков в разделе «Настройки покупок».,
-Please add a Temporary Opening account in Chart of Accounts,"Пожалуйста, добавьте временный вступительный счет в План счетов",
+Please add a Temporary Opening account in Chart of Accounts,"Пожалуйста, добавьте временный вступительный счет в план счетов",
Please add the account to root level Company - ,"Пожалуйста, добавьте счет на корневой уровень компании -",
Please add the remaining benefits {0} to any of the existing component,Добавьте оставшиеся преимущества {0} к любому из существующих компонентов,
Please check Multi Currency option to allow accounts with other currency,"Пожалуйста, проверьте мультивалютный вариант, позволяющий счета другой валюте",
@@ -2146,7 +2144,7 @@
Previous Financial Year is not closed,Предыдущий финансовый год не закрыт,
Price,Цена,
Price List,Прайс-лист,
-Price List Currency not selected,Прайс-лист Обмен не выбран,
+Price List Currency not selected,Валюта прайс-листа не выбрана,
Price List Rate,Прайс-лист Оценить,
Price List master.,Мастер Прайс-лист.,
Price List must be applicable for Buying or Selling,Прайс-лист должен быть применим для покупки или продажи,
@@ -2347,7 +2345,7 @@
Remaining Balance,Остаток средств,
Remarks,Примечания,
Reminder to update GSTIN Sent,Напоминание об обновлении отправленного GSTIN,
-Remove item if charges is not applicable to that item,"Удалить продукт, если сборы не применимы к этому продукту",
+Remove item if charges is not applicable to that item,"Удалить объект, если к нему не применяются сборы",
Removed items with no change in quantity or value.,Удалены пункты без изменения в количестве или стоимости.,
Reopen,Возобновить,
Reorder Level,Уровень переупорядочения,
@@ -2509,7 +2507,7 @@
Salary Slip of employee {0} already created for time sheet {1},Зарплата Скольжение работника {0} уже создан для табеля {1},
Salary Slip submitted for period from {0} to {1},"Зарплатная ведомость отправлена за период с {0} по {1}",
Salary Structure Assignment for Employee already exists,Присвоение структуры зарплаты сотруднику уже существует,
-Salary Structure Missing,Структура заработной платы Отсутствующий,
+Salary Structure Missing,Структура заработной платы отсутствует,
Salary Structure must be submitted before submission of Tax Ememption Declaration,Структура заработной платы должна быть представлена до подачи декларации об освобождении от налогов,
Salary Structure not found for employee {0} and date {1},Структура зарплаты не найдена для сотрудника {0} и даты {1},
Salary Structure should have flexible benefit component(s) to dispense benefit amount,Структура заработной платы должна иметь гибкий компонент (ы) выгоды для распределения суммы пособия,
@@ -2701,10 +2699,10 @@
Setup mode of POS (Online / Offline),Режим настройки POS (Online / Offline),
Setup your Institute in ERPNext,Установите свой институт в ERPNext,
Share Balance,Баланс акций,
-Share Ledger,Поделиться записями,
+Share Ledger,Записи по акциям,
Share Management,Управление долями,
Share Transfer,Передача акций,
-Share Type,Share Тип,
+Share Type,Тип акций,
Shareholder,Акционер,
Ship To State,Корабль в штат,
Shipments,Поставки,
@@ -2796,8 +2794,8 @@
Stock Expenses,Расходы по Запасам,
Stock In Hand,Запасы на руках,
Stock Items,Позиции на складе,
-Stock Ledger,Книга учета Запасов,
-Stock Ledger Entries and GL Entries are reposted for the selected Purchase Receipts,Записи складской книги и записи GL запасов отправляются для выбранных покупок,
+Stock Ledger,Книга учета запасов,
+Stock Ledger Entries and GL Entries are reposted for the selected Purchase Receipts,Записи книги учета запасов и записи GL повторно публикуются для выбранных квитанций о покупках,
Stock Levels,Уровень запасов,
Stock Liabilities,Обязательства по запасам,
Stock Options,Опционы,
@@ -2829,9 +2827,9 @@
Student Group,Учебная группа,
Student Group Strength,Сила студенческой группы,
Student Group is already updated.,Студенческая группа уже обновлена.,
-Student Group: ,Студенческая группа:,
+Student Group: ,Студенческая группа: ,
Student ID,Студенческий билет,
-Student ID: ,Студенческий билет:,
+Student ID: ,Студенческий билет: ,
Student LMS Activity,Студенческая LMS Активность,
Student Mobile No.,Мобильный номер студента,
Student Name,Имя ученика,
@@ -2864,9 +2862,9 @@
Successfully deleted all transactions related to this company!,"Успешно удален все сделки, связанные с этой компанией!",
Sum of Scores of Assessment Criteria needs to be {0}.,Сумма десятков критериев оценки должно быть {0}.,
Sum of points for all goals should be 100. It is {0},Сумма баллов за все цели должны быть 100. Это {0},
-Summary,Резюме,
-Summary for this month and pending activities,Резюме для этого месяца и в ожидании деятельности,
-Summary for this week and pending activities,Резюме на этой неделе и в ожидании деятельности,
+Summary,Сводка,
+Summary for this month and pending activities,Сводка за этот месяц и предстоящие мероприятия,
+Summary for this week and pending activities,Сводка за эту неделю и предстоящие мероприятия,
Sunday,Воскресенье,
Suplier,Поставщик,
Supplier,Поставщик,
@@ -2876,11 +2874,11 @@
Supplier Invoice Date cannot be greater than Posting Date,"Дата Поставщик Счет не может быть больше, чем Дата публикации",
Supplier Invoice No,Поставщик Счет №,
Supplier Invoice No exists in Purchase Invoice {0},Номер счета поставщика отсутствует в счете на покупку {0},
-Supplier Name,наименование поставщика,
+Supplier Name,Наименование поставщика,
Supplier Part No,Деталь поставщика №,
Supplier Quotation,Предложение поставщика,
Supplier Scorecard,Оценочная карта поставщика,
-Supplier Warehouse mandatory for sub-contracted Purchase Receipt,Поставщик Склад обязательным для субподрядчиком ТОВАРНЫЙ ЧЕК,
+Supplier Warehouse mandatory for sub-contracted Purchase Receipt,Наличие склада поставщика обязательно для субподрядной квитанции о покупке,
Supplier database.,База данных поставщиков.,
Supplier {0} not found in {1},Поставщик {0} не найден в {1},
Supplier(s),Поставщик(и),
@@ -3093,7 +3091,7 @@
Total Payments,Всего платежей,
Total Present,Итого Текущая,
Total Qty,Общее количество,
-Total Quantity,Общая численность,
+Total Quantity,Общее количество,
Total Revenue,Общий доход,
Total Student,Всего учеников,
Total Target,Всего целей,
@@ -3199,7 +3197,7 @@
User,Пользователь,
User ID,ID пользователя,
User ID not set for Employee {0},ID пользователя не установлен для сотрудника {0},
-User Remark,Примечание Пользователь,
+User Remark,Примечание пользователя,
User has not applied rule on the invoice {0},Пользователь не применил правило к счету {0},
User {0} already exists,Пользователь {0} уже существует,
User {0} created,Пользователь {0} создан,
@@ -3243,7 +3241,7 @@
View Form,Посмотреть форму,
View Lab Tests,Просмотр лабораторных тестов,
View Leads,Посмотреть лиды,
-View Ledger,Посмотреть Леджер,
+View Ledger,Посмотреть записи,
View Now,Просмотр сейчас,
View a list of all the help videos,Просмотреть список всех справочных видео,
View in Cart,Смотрите в корзину,
@@ -3314,7 +3312,7 @@
Work Summary for {0},Резюме работы для {0},
Work-in-Progress Warehouse is required before Submit,Работа-в-Прогресс Склад требуется перед Отправить,
Workflow,Рабочий процесс,
-Working,Работающий,
+Working,В работе,
Working Hours,Часы работы,
Workstation,Рабочее место,
Workstation is closed on the following dates as per Holiday List: {0},Рабочая место закрыто в следующие даты согласно списка праздников: {0},
@@ -3500,7 +3498,7 @@
Postal Code,Почтовый индекс,
Previous,Предыдущая,
Provider,Поставщик,
-Read Only,Только чтения,
+Read Only,Только чтение,
Recipient,Сторона-реципиент,
Reviews,Отзывы,
Sender,Отправитель,
@@ -3869,15 +3867,19 @@
Not Allowed,Не разрешено,
Not allowed to create accounting dimension for {0},Не разрешено создавать учетное измерение для {0},
Not permitted. Please disable the Lab Test Template,"Не разрешено Пожалуйста, отключите шаблон лабораторного теста",
-Note,Заметки,
+Note,Заметка,
Notes: ,Заметки: ,
-On Converting Opportunity,О возможности конвертации,
-On Purchase Order Submission,При подаче заказа на поставку,
-On Sales Order Submission,На подаче заказа клиента,
-On Task Completion,По завершении задачи,
+On Converting Opportunity,Конвертацию возможности,
+On Purchase Order Submission,Офомление заказа на закупку,
+On Sales Order Submission,Оформление заказа на продажу,
+On Task Completion,Завершении задачи,
On {0} Creation,На {0} создании,
+On Item Creation,Создание продукта,
+On Lead Creation,Создание лида,
+On Supplier Creation,Создание поставщика,
+On Customer Creation,Создание клиента,
Only .csv and .xlsx files are supported currently,В настоящее время поддерживаются только файлы .csv и .xlsx,
-Only expired allocation can be cancelled,Только истекшее распределение может быть отменено,
+Only expired allocation can be cancelled,Отменить можно только просроченное распределение,
Only users with the {0} role can create backdated leave applications,Только пользователи с ролью {0} могут создавать оставленные приложения с задним сроком действия,
Open,Открыт,
Open Contact,Открытый контакт,
@@ -4044,7 +4046,7 @@
Service Level Agreement has been changed to {0}.,Соглашение об уровне обслуживания изменено на {0}.,
Service Level Agreement was reset.,Соглашение об уровне обслуживания было сброшено.,
Service Level Agreement with Entity Type {0} and Entity {1} already exists.,Соглашение об уровне обслуживания с типом объекта {0} и объектом {1} уже существует.,
-Set,Задать,
+Set,Комплект,
Set Meta Tags,Установить метатеги,
Set {0} in company {1},Установить {0} в компании {1},
Setup,Настройки,
@@ -4057,7 +4059,7 @@
Show Warehouse-wise Stock,Показать складской запас,
Size,Размер,
Something went wrong while evaluating the quiz.,Что-то пошло не так при оценке теста.,
-Sr,Sr,
+Sr,№,
Start,Начать,
Start Date cannot be before the current date,Дата начала не может быть раньше текущей даты,
Start Time,Время начала,
@@ -4217,7 +4219,7 @@
No students Found,Студенты не найдены,
Not in Stock,Нет в наличии,
Please select a Customer,Выберите клиента,
-Printed On,Отпечатано на,
+Printed On,Напечатано на,
Received From,Получено от,
Sales Person,Продавец,
To date cannot be before From date,На сегодняшний день не может быть раньше От даты,
@@ -4511,7 +4513,7 @@
Accounting Period,Период учета,
Period Name,Название периода,
Closed Documents,Закрытые документы,
-Accounts Settings,Настройки аккаунта,
+Accounts Settings,Настройка счетов,
Settings for Accounts,Настройки для счетов,
Make Accounting Entry For Every Stock Movement,Создавать бухгалтерские проводки при каждом перемещении запасов,
Users with this role are allowed to set frozen accounts and create / modify accounting entries against frozen accounts,"Пользователи с этой ролью могут замороживать счета, а также создавать / изменять бухгалтерские проводки замороженных счетов",
@@ -4945,14 +4947,14 @@
Min Amt,Мин Amt,
Max Amt,Макс Амт,
Period Settings,Настройки периода,
-Margin,Разница,
+Margin,Маржа,
Margin Type,Тип маржа,
Margin Rate or Amount,Маржинальная ставка или сумма,
Price Discount Scheme,Схема скидок,
Rate or Discount,Стоимость или скидка,
Discount Percentage,Скидка в процентах,
Discount Amount,Сумма скидки,
-For Price List,Для Прейскурантом,
+For Price List,Для прайс-листа,
Product Discount Scheme,Схема скидок на товары,
Same Item,Тот же пункт,
Free Item,Бесплатный товар,
@@ -5082,8 +5084,8 @@
Item Tax Rate,Ставка налогов на продукт,
Tax detail table fetched from item master as a string and stored in this field.\nUsed for Taxes and Charges,Налоговый Подробная таблица выбирается из мастера элемента в виде строки и хранится в этой области.\n Используется по налогам и сборам,
Purchase Order Item,Заказ товара,
-Purchase Receipt Detail,Деталь квитанции о покупке,
-Item Weight Details,Деталь Вес Подробности,
+Purchase Receipt Detail,Сведения о квитанции о покупке,
+Item Weight Details,Сведения о весе товара,
Weight Per Unit,Вес на единицу,
Total Weight,Общий вес,
Weight UOM,Вес Единица измерения,
@@ -5196,7 +5198,7 @@
Contact List,Список контактов,
Hidden list maintaining the list of contacts linked to Shareholder,"Скрытый список, поддерживающий список контактов, связанных с Акционером",
Specify conditions to calculate shipping amount,Укажите условия для расчета суммы доставки,
-Shipping Rule Label,Название правила доставки,
+Shipping Rule Label,Метка правила доставки,
example: Next Day Shipping,Пример: доставка на следующий день,
Shipping Rule Type,Тип правила доставки,
Shipping Account,Счет доставки,
@@ -5234,7 +5236,7 @@
Billing Interval Count,Счет интервала фактурирования,
"Number of intervals for the interval field e.g if Interval is 'Days' and Billing Interval Count is 3, invoices will be generated every 3 days","Количество интервалов для поля интервалов, например, если Interval является «Days», а количество интервалов фактурирования - 3, счета-фактуры будут генерироваться каждые 3 дня",
Payment Plan,Платежный план,
-Subscription Plan Detail,Деталь плана подписки,
+Subscription Plan Detail,Сведения о плана подписки,
Plan,План,
Subscription Settings,Настройки подписки,
Grace Period,Льготный период,
@@ -5385,18 +5387,18 @@
Insurance End Date,Дата окончания страхования,
Comprehensive Insurance,Комплексное страхование,
Maintenance Required,Требуется техническое обслуживание,
-Check if Asset requires Preventive Maintenance or Calibration,"Проверьте, требуется ли Asset профилактическое обслуживание или калибровка",
+Check if Asset requires Preventive Maintenance or Calibration,"Проверьте, требует ли актив профилактического обслуживания или калибровки",
Booked Fixed Asset,Забронированные основные средства,
Purchase Receipt Amount,Сумма покупки,
Default Finance Book,Финансовая книга по умолчанию,
Quality Manager,Менеджер по качеству,
-Asset Category Name,Asset Категория Название,
+Asset Category Name,Название категории активов,
Depreciation Options,Варианты амортизации,
Enable Capital Work in Progress Accounting,Включить капитальную работу в процессе учета,
Finance Book Detail,Финансовая книга,
Asset Category Account,Счет категории активов,
Fixed Asset Account,Счет учета основных средств,
-Accumulated Depreciation Account,Начисленной амортизации Счет,
+Accumulated Depreciation Account,Счет накопленной амортизации,
Depreciation Expense Account,Износ счет расходов,
Capital Work In Progress Account,Счет капитальной работы,
Asset Finance Book,Финансовая книга по активам,
@@ -5441,7 +5443,7 @@
Assign To Name,Назначить имя,
Repair Status,Статус ремонта,
Error Description,Описание ошибки,
-Downtime,время простоя,
+Downtime,Время простоя,
Repair Cost,Стоимость ремонта,
Manufacturing Manager,Менеджер производства,
Current Asset Value,Текущая стоимость актива,
@@ -5800,7 +5802,7 @@
Skip User creation for new Student,Пропустить создание пользователя для нового студента,
"By default, a new User is created for every new Student. If enabled, no new User will be created when a new Student is created.","По умолчанию для каждого нового Студента создается новый Пользователь. Если этот параметр включен, при создании нового Студента новый Пользователь не создается.",
Instructor Records to be created by,Записи инструкторов должны быть созданы,
-Employee Number,Общее число сотрудников,
+Employee Number,Номер сотрудника,
Fee Category,Категория платы,
Fee Component,Компонент платы,
Fees Category,Категория плат,
@@ -6073,7 +6075,7 @@
ERPNext Account,Учетная запись ERPNext,
Shopify Webhook Detail,Узнайте подробности веб-камеры,
Webhook ID,Идентификатор Webhook,
-Tally Migration,Tally Migration,
+Tally Migration,Tally миграция,
Master Data,Основные данные,
"Data exported from Tally that consists of the Chart of Accounts, Customers, Suppliers, Addresses, Items and UOMs","Данные, экспортированные из Tally, которые состоят из плана счетов, клиентов, поставщиков, адресов, позиций и единиц измерения",
Is Master Data Processed,Обработка основных данных,
@@ -6082,7 +6084,7 @@
Creditors Account set in Tally,Счет кредиторов установлен в Tally,
Tally Debtors Account,Счет Tally должников,
Debtors Account set in Tally,Счет дебитора установлен в Tally,
-Tally Company,Талли Компания,
+Tally Company,Tally Компания,
Company Name as per Imported Tally Data,Название компании согласно импортированным данным подсчета,
Default UOM,Единица измерения по умолчанию,
UOM in case unspecified in imported data,"Единицы измерения, если они не указаны в импортированных данных",
@@ -6108,7 +6110,7 @@
Creation User,Создание пользователя,
"The user that will be used to create Customers, Items and Sales Orders. This user should have the relevant permissions.","Пользователь, который будет использоваться для создания клиентов, товаров и заказов на продажу. Этот пользователь должен иметь соответствующие разрешения.",
"This warehouse will be used to create Sales Orders. The fallback warehouse is ""Stores"".",Этот склад будет использоваться для создания заказов на продажу. Резервный склад "Магазины".,
-"The fallback series is ""SO-WOO-"".",Аварийная серия "SO-WOO-".,
+"The fallback series is ""SO-WOO-"".","Аварийная серия ""SO-WOO-"".",
This company will be used to create Sales Orders.,Эта компания будет использоваться для создания заказов на продажу.,
Delivery After (Days),Доставка после (дней),
This is the default offset (days) for the Delivery Date in Sales Orders. The fallback offset is 7 days from the order placement date.,Это смещение по умолчанию (дни) для даты поставки в заказах на продажу. Смещение отступления составляет 7 дней с даты размещения заказа.,
@@ -6194,7 +6196,7 @@
Occupancy Status,Статус занятости,
Vacant,Вакантно,
Occupied,Занято,
-Item Details,Детальная информация о товаре,
+Item Details,Детальная информация о продукте,
UOM Conversion in Hours,Преобразование UOM в часы,
Rate / UOM,Скорость / UOM,
Change in Item,Изменение продукта,
@@ -6441,7 +6443,7 @@
Applicant Name,Имя заявителя,
Appointment Date,Назначенная дата,
Appointment Letter Template,Шаблон письма о назначении,
-Body,Тело,
+Body,Содержимое,
Closing Notes,Заметки,
Appointment Letter content,Письмо о назначении,
Appraisal,Оценка,
@@ -6455,7 +6457,7 @@
Key Responsibility Area,Основная зона ответственности,
Weightage (%),Весовая нагрузка (%),
Score (0-5),Оценка (0-5),
-Score Earned,Оценка Заработано,
+Score Earned,Оценка получена,
Appraisal Template Title,Название шаблона оценки,
Appraisal Template Goal,Цель шаблона оценки,
KRA,КРА,
@@ -6747,7 +6749,7 @@
Awaiting Response,В ожидании ответа,
Job Offer Terms,Условия работы,
Select Terms and Conditions,Выберите Сроки и условия,
-Printing Details,Печатать Подробности,
+Printing Details,Подробности печати,
Job Offer Term,Срок действия предложения,
Offer Term,Условие предложения,
Value / Description,Значение / Описание,
@@ -6866,8 +6868,8 @@
Create Separate Payment Entry Against Benefit Claim,Создать отдельную заявку на подачу заявки на получение пособия,
Condition and Formula,Состояние и формула,
Amount based on formula,Сумма на основе формулы,
-Formula,формула,
-Salary Detail,Заработная плата: Подробности,
+Formula,Формула,
+Salary Detail,Подробно об заработной плате,
Component,Компонент,
Do not include in total,Не включать в общей сложности,
Default Amount,По умолчанию количество,
@@ -6889,7 +6891,7 @@
Total Interest Amount,Общая сумма процентов,
Total Loan Repayment,Общая сумма погашения кредита,
net pay info,Чистая информация платить,
-Gross Pay - Total Deduction - Loan Repayment,Gross Pay - Итого Вычет - Погашение кредита,
+Gross Pay - Total Deduction - Loan Repayment,Валовая заработная плата - Общий вычет - Погашение кредита,
Total in words,Всего в словах,
Net Pay (in words) will be visible once you save the Salary Slip.,"Чистая плата (прописью) будет видна, как только вы сохраните зарплатную ведомость.",
Salary Component for timesheet based payroll.,Компонент заработной платы для расчета зарплаты на основе расписания.,
@@ -6959,7 +6961,7 @@
Attendees,Присутствующие,
Employee Emails,Электронные почты сотрудников,
Training Event Employee,Обучение сотрудников Событие,
-Invited,приглашенный,
+Invited,Приглашенный,
Feedback Submitted,Отзыв отправлен,
Optional,Необязательный,
Training Result Employee,Результат обучения сотрудника,
@@ -7183,7 +7185,7 @@
Item to be manufactured or repacked,Продукт должен быть произведен или переупакован,
Quantity of item obtained after manufacturing / repacking from given quantities of raw materials,Количество пункта получены после изготовления / переупаковка от заданных величин сырья,
Set rate of sub-assembly item based on BOM,Установить скорость сборки на основе спецификации,
-Allow Alternative Item,Разрешить альтернативный элемент,
+Allow Alternative Item,Разрешить альтернативный продукт,
Item UOM,Единиц продукта,
Conversion Rate,Коэффициент конверсии,
Rate Of Materials Based On,Оценить материалов на основе,
@@ -7520,7 +7522,7 @@
Is Milestone,Является этапом,
Task Description,Описание задания,
Dependencies,Зависимости,
-Dependent Tasks,Зависимые задачи,
+Dependent Tasks,Зависит от задач,
Depends on Tasks,Зависит от задач,
Actual Start Date (via Time Sheet),Фактическая дата начала (по табелю учета рабочего времени),
Actual Time (in hours),Фактическое время (в часах),
@@ -7598,7 +7600,7 @@
Import Supplier Invoice,Импортная накладная поставщика,
Invoice Series,Серия счетов,
Upload XML Invoices,Загрузить XML-счета,
-Zip File,Zip-файл,
+Zip File,Zip файл,
Import Invoices,Импорт счетов,
Click on Import Invoices button once the zip file has been attached to the document. Any errors related to processing will be shown in the Error Log.,"Нажмите кнопку «Импортировать счета-фактуры», когда файл zip прикреплен к документу. Любые ошибки, связанные с обработкой, будут отображаться в журнале ошибок.",
Lower Deduction Certificate,Свидетельство о нижнем удержании,
@@ -7633,7 +7635,7 @@
Served,Подается,
Restaurant Reservation,Бронирование ресторанов,
Waitlisted,Лист ожидания,
-No Show,Нет шоу,
+No Show,Не показывать,
No of People,Нет людей,
Reservation Time,Время резервирования,
Reservation End Time,Время окончания бронирования,
@@ -7645,7 +7647,7 @@
Buyer of Goods and Services.,Покупатель товаров и услуг.,
CUST-.YYYY.-,CUST-.YYYY.-,
Default Company Bank Account,Стандартный банковский счет компании,
-From Lead,Из Лида,
+From Lead,Из лида,
Account Manager,Менеджер по работе с клиентами,
Allow Sales Invoice Creation Without Sales Order,Разрешить создание счета без заказа на продажу,
Allow Sales Invoice Creation Without Delivery Note,Разрешить создание счета без накладной,
@@ -7818,14 +7820,14 @@
Company Description,Описание компании,
Registration Details,Регистрационные данные,
Company registration numbers for your reference. Tax numbers etc.,Регистрационные номера компании для вашей справки. Налоговые числа и т.д.,
-Delete Company Transactions,Удалить Сделки Компания,
+Delete Company Transactions,Удалить транзакции компании,
Currency Exchange,Курс обмена валюты,
Specify Exchange Rate to convert one currency into another,Укажите Курс конвертировать одну валюту в другую,
From Currency,Из валюты,
To Currency,В валюту,
For Buying,Для покупки,
For Selling,Для продажи,
-Customer Group Name,Группа Имя клиента,
+Customer Group Name,Название группы клиентов,
Parent Customer Group,Родительская группа клиента,
Only leaf nodes are allowed in transaction,Только листовые узлы допускаются в сделке,
Mention if non-standard receivable account applicable,Упоминание если нестандартная задолженность счет применимо,
@@ -7871,8 +7873,8 @@
"If disable, 'In Words' field will not be visible in any transaction","Если отключить, "В словах" поле не будет видно в любой сделке",
Item Classification,Продуктовая классификация,
General Settings,Основные настройки,
-Item Group Name,Пункт Название группы,
-Parent Item Group,Родитель Пункт Группа,
+Item Group Name,Название группы продуктов,
+Parent Item Group,Родительская группа продукта,
Item Group Defaults,Элемент группы по умолчанию,
Item Tax,Налог на продукт,
Check this if you want to show in website,"Проверьте это, если вы хотите показать в веб-сайт",
@@ -7893,7 +7895,7 @@
Update Series Number,Обновить Идентификаторы по Номеру,
Quotation Lost Reason,Причина Отказа от Предложения,
A third party distributor / dealer / commission agent / affiliate / reseller who sells the companies products for a commission.,"Сторонний дистрибьютер, дилер, агент, филиал или реселлер, который продаёт продукты компании за комиссионное вознаграждение.",
-Sales Partner Name,Имя Партнера по продажам,
+Sales Partner Name,Имя партнера по продажам,
Partner Type,Тип партнера,
Address & Contacts,Адрес и контакты,
Address Desc,Адрес по убыванию,
@@ -7914,7 +7916,7 @@
Set targets Item Group-wise for this Sales Person.,Задайте цели Продуктовых Групп для Продавца,
Supplier Group Name,Название группы поставщиков,
Parent Supplier Group,Родительская группа поставщиков,
-Target Detail,Цель Подробности,
+Target Detail,Подробности цели,
Target Qty,Целевое количество,
Target Amount,Целевая сумма,
Target Distribution,Распределение цели,
@@ -7969,17 +7971,17 @@
Tariff Number,Тарифный номер,
Delivery To,Доставка,
MAT-DN-.YYYY.-,MAT-DN-.YYYY.-,
-Is Return,Является Вернуться,
+Is Return,Возврат,
Issue Credit Note,Кредитная кредитная карта,
-Return Against Delivery Note,Вернуться На накладной,
-Customer's Purchase Order No,Клиентам Заказ Нет,
-Billing Address Name,Адрес для выставления счета Имя,
+Return Against Delivery Note,Возврат по накладной,
+Customer's Purchase Order No,Заказ клиента №,
+Billing Address Name,Название адреса для выставления счета,
Required only for sample item.,Требуется только для образца пункта.,
-"If you have created a standard template in Sales Taxes and Charges Template, select one and click on the button below.","Если вы создали стандартный шаблон в шаблонах Налоги с налогами и сбором платежей, выберите его и нажмите кнопку ниже.",
+"If you have created a standard template in Sales Taxes and Charges Template, select one and click on the button below.","Если вы создали стандартный шаблон в Шаблоне налогов и сборов с продаж, выберите его и нажмите кнопку ниже.",
In Words will be visible once you save the Delivery Note.,По словам будет виден только вы сохраните накладной.,
In Words (Export) will be visible once you save the Delivery Note.,В Слов (Экспорт) будут видны только вы сохраните накладной.,
Transporter Info,Информация для транспортировки,
-Driver Name,Имя драйвера,
+Driver Name,Имя водителя,
Track this Delivery Note against any Project,Подписка на Delivery Note против любого проекта,
Inter Company Reference,Справочник Интер,
Print Without Amount,Распечатать без суммы,
@@ -7989,8 +7991,8 @@
Excise Page Number,Количество Акцизный Страница,
Instructions,Инструкции,
From Warehouse,Со склада,
-Against Sales Order,По Сделке,
-Against Sales Order Item,По Продукту Сделки,
+Against Sales Order,По сделке,
+Against Sales Order Item,По позиции сделки,
Against Sales Invoice,Повторная накладная,
Against Sales Invoice Item,Счет на продажу продукта,
Available Batch Qty at From Warehouse,Доступные Пакетная Кол-во на со склада,
@@ -8006,7 +8008,7 @@
Lock,Заблокировано,
Visited,Посещен,
Order Information,запросить информацию,
-Contact Information,Контакты,
+Contact Information,Контактная информация,
Email sent to,Письмо отправлено,
Dispatch Information,Информация о доставке,
Estimated Arrival,Ожидаемое прибытие,
@@ -8079,7 +8081,7 @@
Supplier Items,Продукты поставщика,
Foreign Trade Details,Сведения о внешней торговле,
Country of Origin,Страна происхождения,
-Sales Details,Продажи Подробности,
+Sales Details,Детали продажи,
Default Sales Unit of Measure,Единица измерения продаж по умолчанию,
Is Sales Item,Продаваемый продукт,
Max Discount (%),Макс. скидка (%),
@@ -8117,9 +8119,9 @@
Alternative Item Code,Альтернативный код продукта,
Two-way,Двусторонний,
Alternative Item Name,Альтернативное название продукта,
-Attribute Name,Имя атрибута,
+Attribute Name,Название атрибута,
Numeric Values,Числовые значения,
-From Range,От хребта,
+From Range,Из диапазона,
Increment,Приращение,
To Range,В диапазоне,
Item Attribute Values,Пункт значений атрибутов,
@@ -8141,7 +8143,7 @@
Default Expense Account,Счет учета затрат по умолчанию,
Sales Defaults,По умолчанию,
Default Selling Cost Center,По умолчанию Продажа Стоимость центр,
-Item Manufacturer,Пункт Производитель,
+Item Manufacturer,Производитель товара,
Item Price,Цена продукта,
Packing Unit,Упаковочный блок,
Quantity that must be bought or sold per UOM,"Количество, которое необходимо купить или продать за UOM",
@@ -8175,7 +8177,7 @@
Purchase Receipt Items,Покупка продуктов,
Get Items From Purchase Receipts,Получить продукты из покупки.,
Distribute Charges Based On,Распределите платежи на основе,
-Landed Cost Help,Земельные Стоимость Помощь,
+Landed Cost Help,Справка по стоимости доставки,
Manufacturers used in Items,Производители использовали в пунктах,
Limited to 12 characters,Ограничено до 12 символов,
MAT-MR-.YYYY.-,МАТ-MR-.YYYY.-,
@@ -8184,13 +8186,13 @@
% Ordered,% заказано,
Terms and Conditions Content,Условия Содержимое,
Quantity and Warehouse,Количество и Склад,
-Lead Time Date,Время и Дата Лида,
-Min Order Qty,Минимальный заказ Кол-во,
+Lead Time Date,Дата выполнения заказа,
+Min Order Qty,Минимальное количество для заказа,
Packed Item,Упаковано,
To Warehouse (Optional),На склад (Необязательно),
Actual Batch Quantity,Фактическое количество партий,
Prevdoc DocType,Prevdoc DocType,
-Parent Detail docname,Родитель Деталь DOCNAME,
+Parent Detail docname,Сведения о родителе docname,
"Generate packing slips for packages to be delivered. Used to notify package number, package contents and its weight.","Создаёт упаковочные листы к упаковкам для доставки. Содержит номер упаковки, перечень содержимого и вес.",
Indicates that the package is a part of this delivery (Only Draft),"Указывает, что пакет является частью этой поставки (только проект)",
MAT-PAC-.YYYY.-,MAT-PAC-.YYYY.-,
@@ -8236,8 +8238,8 @@
Vehicle Number,Номер транспортного средства,
Vehicle Date,Дата транспортного средства,
Received and Accepted,Получил и принял,
-Accepted Quantity,Принято Количество,
-Rejected Quantity,Отклонен Количество,
+Accepted Quantity,Количество принятых,
+Rejected Quantity,Количество отклоненных,
Accepted Qty as per Stock UOM,Принятое количество в соответствии с единицами измерения запаса,
Sample Quantity,Количество образцов,
Rate and Amount,Ставку и сумму,
@@ -8285,7 +8287,7 @@
Warranty Period (Days),Гарантийный срок (дней),
Serial No Details,Серийный номер подробнее,
MAT-STE-.YYYY.-,MAT-STE-.YYYY.-,
-Stock Entry Type,Тип входа,
+Stock Entry Type,Тип складской записи,
Stock Entry (Outward GIT),Вход в акции (внешний GIT),
Material Consumption for Manufacture,Потребление материала для производства,
Repack,Перепаковать,
@@ -8351,7 +8353,7 @@
Auto Material Request,Автоматический запрос материалов,
Inter Warehouse Transfer Settings,Настройки передачи между складами,
Freeze Stock Entries,Замораживание поступления запасов,
-Stock Frozen Upto,остатки заморожены до,
+Stock Frozen Upto,Остатки заморожены до,
Batch Identification,Идентификация партии,
Use Naming Series,Использовать серийный номер,
Naming Series Prefix,Префикс Идентификации по Имени,
@@ -8370,7 +8372,7 @@
Service Level,Уровень обслуживания,
Response By,Ответ от,
Response By Variance,Ответ по отклонениям,
-Ongoing,постоянный,
+Ongoing,Постоянный,
Resolution By,Разрешение по,
Resolution By Variance,Разрешение по отклонениям,
Service Level Agreement Creation,Создание соглашения об уровне обслуживания,
@@ -8447,7 +8449,7 @@
Sent To,Отправить,
Absent Student Report,Отчет о пропуске занятия,
Assessment Plan Status,Статус плана оценки,
-Asset Depreciation Ledger,Износ Леджер активов,
+Asset Depreciation Ledger,Книга амортизации основных средств,
Asset Depreciations and Balances,Активов Амортизация и противовесов,
Available Stock for Packing Items,Доступные Запасы для Комплектации Продуктов,
Bank Clearance Summary,Банк уплата по счетам итого,
@@ -8559,7 +8561,7 @@
Sales Partner Commission Summary,Сводка комиссий партнеров по продажам,
Sales Partner Target Variance based on Item Group,Целевое отклонение партнера по продажам на основе группы товаров,
Sales Partner Transaction Summary,Сводка по сделкам с партнерами по продажам,
-Sales Partners Commission,Комиссионные Партнеров по продажам,
+Sales Partners Commission,Комиссия партнеров по продажам,
Invoiced Amount (Exclusive Tax),Сумма счета (без учета налога),
Average Commission Rate,Средний уровень комиссии,
Sales Payment Summary,Сводка по продажам,
@@ -8579,7 +8581,7 @@
Student Monthly Attendance Sheet,Ежемесячная посещаемость студентов,
Subcontracted Item To Be Received,"Субподрядный предмет, подлежащий получению",
Subcontracted Raw Materials To Be Transferred,Субподрядное сырье для передачи,
-Supplier Ledger Summary,Список поставщиков,
+Supplier Ledger Summary,Сводка книги поставщиков,
Supplier-Wise Sales Analytics,Аналитика продаж в разрезе поставщиков,
Support Hour Distribution,Распределение поддержки,
TDS Computation Summary,Сводка расчетов TDS,
@@ -9242,7 +9244,7 @@
Tasks Overdue,Просроченные задачи,
Completion,Завершение,
Provident Fund Deductions,Отчисления в резервный фонд,
-Purchase Order Analysis,Анализ заказа на закупку,
+Purchase Order Analysis,Анализ заказов на закупку,
From and To Dates are required.,Укажите даты от и до.,
To Date cannot be before From Date.,Дата не может быть раньше даты начала.,
Qty to Bill,Кол-во к счету,
@@ -9267,7 +9269,7 @@
Amount Delivered,Сумма доставки,
Delay (in Days),Задержка (в днях),
Group by Sales Order,Группировать по заказу на продажу,
- Sales Value,Объем продаж,
+ Sales Value, Объем продаж,
Stock Qty vs Serial No Count,Кол-во на складе по сравнению с серийным номером,
Serial No Count,Серийный номер,
Work Order Summary,Сводка заказа на работу,
@@ -9320,8 +9322,8 @@
A customer is already linked to this Member,Клиент уже привязан к этому участнику,
End Date must not be lesser than Start Date,Дата окончания не должна быть меньше даты начала.,
Employee {0} already has Active Shift {1}: {2},Сотрудник {0} уже имеет активную смену {1}: {2},
- from {0},от {0},
- to {0},в {0},
+ from {0}, от {0},
+ to {0}, в {0},
Please select Employee first.,"Пожалуйста, сначала выберите сотрудника.",
Please set {0} for the Employee or for Department: {1},Установите {0} для сотрудника или отдела: {1},
To Date should be greater than From Date,"Дата до должна быть больше, чем Дата",
@@ -9838,3 +9840,8 @@
Creating Purchase Order ...,Создание заказа на поставку ...,
"Select a Supplier from the Default Suppliers of the items below. On selection, a Purchase Order will be made against items belonging to the selected Supplier only.","Выберите поставщика из списка поставщиков по умолчанию для позиций ниже. При выборе Заказ на поставку будет сделан в отношении товаров, принадлежащих только выбранному Поставщику.",
Row #{}: You must select {} serial numbers for item {}.,Строка № {}: необходимо выбрать {} серийных номеров для позиции {}.,
+Items & Pricing,Продукты и цены,
+Overdue,Просрочено,
+Completed,Завершенно,
+Total Tasks,Всего задач,
+Build,Конструктор,
diff --git a/erpnext/utilities/bot.py b/erpnext/utilities/bot.py
deleted file mode 100644
index 5c2e576..0000000
--- a/erpnext/utilities/bot.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-
-
-import frappe
-from frappe import _
-from frappe.utils.bot import BotParser
-
-
-class FindItemBot(BotParser):
- def get_reply(self):
- if self.startswith("where is", "find item", "locate"):
- if not frappe.has_permission("Warehouse"):
- raise frappe.PermissionError
-
- item = "%{0}%".format(self.strip_words(self.query, "where is", "find item", "locate"))
- items = frappe.db.sql(
- """select name from `tabItem` where item_code like %(txt)s
- or item_name like %(txt)s or description like %(txt)s""",
- dict(txt=item),
- )
-
- if items:
- out = []
- warehouses = frappe.get_all("Warehouse")
- for item in items:
- found = False
- for warehouse in warehouses:
- qty = frappe.db.get_value(
- "Bin", {"item_code": item[0], "warehouse": warehouse.name}, "actual_qty"
- )
- if qty:
- out.append(
- _("{0} units of [{1}](/app/Form/Item/{1}) found in [{2}](/app/Form/Warehouse/{2})").format(
- qty, item[0], warehouse.name
- )
- )
- found = True
-
- if not found:
- out.append(_("[{0}](/app/Form/Item/{0}) is out of stock").format(item[0]))
-
- return "\n\n".join(out)
-
- else:
- return _("Did not find any item called {0}").format(item)
diff --git a/erpnext/utilities/doctype/video/video.py b/erpnext/utilities/doctype/video/video.py
index a39d0a9..15dbccd 100644
--- a/erpnext/utilities/doctype/video/video.py
+++ b/erpnext/utilities/doctype/video/video.py
@@ -9,6 +9,7 @@
import pytz
from frappe import _
from frappe.model.document import Document
+from frappe.utils import cint
from pyyoutube import Api
@@ -46,7 +47,7 @@
def get_frequency(value):
# Return numeric value from frequency field, return 1 as fallback default value: 1 hour
if value != "Daily":
- return frappe.utils.cint(value[:2].strip())
+ return cint(value[:2].strip())
elif value:
return 24
return 1
@@ -120,24 +121,12 @@
video_stats = entry.to_dict().get("statistics")
video_id = entry.to_dict().get("id")
stats = {
- "like_count": video_stats.get("likeCount"),
- "view_count": video_stats.get("viewCount"),
- "dislike_count": video_stats.get("dislikeCount"),
- "comment_count": video_stats.get("commentCount"),
- "video_id": video_id,
+ "like_count": cint(video_stats.get("likeCount")),
+ "view_count": cint(video_stats.get("viewCount")),
+ "dislike_count": cint(video_stats.get("dislikeCount")),
+ "comment_count": cint(video_stats.get("commentCount")),
}
-
- frappe.db.sql(
- """
- UPDATE `tabVideo`
- SET
- like_count = %(like_count)s,
- view_count = %(view_count)s,
- dislike_count = %(dislike_count)s,
- comment_count = %(comment_count)s
- WHERE youtube_video_id = %(video_id)s""",
- stats,
- )
+ frappe.db.set_value("Video", video_id, stats)
video_list = frappe.get_all("Video", fields=["youtube_video_id"])
if len(video_list) > 50:
diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py
index 73cbcd4..cd1bf9f 100644
--- a/erpnext/utilities/transaction_base.py
+++ b/erpnext/utilities/transaction_base.py
@@ -5,7 +5,7 @@
import frappe
import frappe.share
from frappe import _
-from frappe.utils import cint, cstr, flt, get_time, now_datetime
+from frappe.utils import cint, flt, get_time, now_datetime
from erpnext.controllers.status_updater import StatusUpdater
@@ -30,64 +30,6 @@
except ValueError:
frappe.throw(_("Invalid Posting Time"))
- def add_calendar_event(self, opts, force=False):
- if (
- cstr(self.contact_by) != cstr(self._prev.contact_by)
- or cstr(self.contact_date) != cstr(self._prev.contact_date)
- or force
- or (hasattr(self, "ends_on") and cstr(self.ends_on) != cstr(self._prev.ends_on))
- ):
-
- self.delete_events()
- self._add_calendar_event(opts)
-
- def delete_events(self):
- participations = frappe.get_all(
- "Event Participants",
- filters={
- "reference_doctype": self.doctype,
- "reference_docname": self.name,
- "parenttype": "Event",
- },
- fields=["name", "parent"],
- )
-
- if participations:
- for participation in participations:
- total_participants = frappe.get_all(
- "Event Participants", filters={"parenttype": "Event", "parent": participation.parent}
- )
-
- if len(total_participants) <= 1:
- frappe.db.sql("delete from `tabEvent` where name='%s'" % participation.parent)
-
- frappe.db.sql("delete from `tabEvent Participants` where name='%s'" % participation.name)
-
- def _add_calendar_event(self, opts):
- opts = frappe._dict(opts)
-
- if self.contact_date:
- event = frappe.get_doc(
- {
- "doctype": "Event",
- "owner": opts.owner or self.owner,
- "subject": opts.subject,
- "description": opts.description,
- "starts_on": self.contact_date,
- "ends_on": opts.ends_on,
- "event_type": "Private",
- }
- )
-
- event.append(
- "event_participants", {"reference_doctype": self.doctype, "reference_docname": self.name}
- )
-
- event.insert(ignore_permissions=True)
-
- if frappe.db.exists("User", self.contact_by):
- frappe.share.add("Event", event.name, self.contact_by, flags={"ignore_share_permission": True})
-
def validate_uom_is_integer(self, uom_field, qty_fields):
validate_uom_is_integer(self, uom_field, qty_fields)
diff --git a/requirements.txt b/requirements.txt
index 85ff515..83e5375 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,7 +3,6 @@
googlemaps
plaid-python~=7.2.1
pycountry~=20.7.3
-PyGithub~=1.55
python-stdnum~=1.16
python-youtube~=0.8.0
taxjar~=1.9.2
diff --git a/sponsors.md b/sponsors.md
index 125b358..57adc8d 100644
--- a/sponsors.md
+++ b/sponsors.md
@@ -61,5 +61,13 @@
Bulk edit via export-import in Bank Reconciliation <a href="https://github.com/frappe/erpnext/issues/1938">#4356</a>
</td>
</tr>
+ <tr>
+ <td style="width: 30%">
+ <a href="https://www.sapconinstruments.com/">Sapcon Instruments Pvt Ltd</a>
+ </td>
+ <td>
+ Level wise BOM Cost Updation and Performance Enhancement <a href="https://github.com/frappe/erpnext/pull/31072">#31072</a>
+ </td>
+ </tr>
</tbody>
</table>