Merge branch 'develop' of https://github.com/frappe/erpnext into e_invoice_discounts

This commit is contained in:
Deepesh Garg 2022-07-02 18:42:31 +05:30
commit b4f6429e32
100 changed files with 4139 additions and 1479 deletions

View File

@ -12,7 +12,7 @@ jobs:
- name: 'Setup Environment' - name: 'Setup Environment'
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: 3.8 python-version: '3.10'
- name: 'Clone repo' - name: 'Clone repo'
uses: actions/checkout@v2 uses: actions/checkout@v2

View File

@ -11,10 +11,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python 3.8 - name: Set up Python 3.10
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: 3.8 python-version: '3.10'
- name: Install and Run Pre-commit - name: Install and Run Pre-commit
uses: pre-commit/action@v2.0.3 uses: pre-commit/action@v2.0.3
@ -22,10 +22,8 @@ jobs:
- name: Download Semgrep rules - name: Download Semgrep rules
run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules
- uses: returntocorp/semgrep-action@v1 - name: Download semgrep
env: run: pip install semgrep==0.97.0
SEMGREP_TIMEOUT: 120
with: - name: Run Semgrep rules
config: >- run: semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness
r/python.lang.correctness
./frappe-semgrep-rules/rules

View File

@ -35,9 +35,9 @@ jobs:
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v2 uses: "gabrielfalcao/pyenv-action@v9"
with: with:
python-version: 3.8 versions: 3.10:latest, 3.7:latest
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v2 uses: actions/setup-node@v2
@ -52,7 +52,7 @@ jobs:
uses: actions/cache@v2 uses: actions/cache@v2
with: with:
path: ~/.cache/pip path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
restore-keys: | restore-keys: |
${{ runner.os }}-pip- ${{ runner.os }}-pip-
${{ runner.os }}- ${{ runner.os }}-
@ -82,7 +82,10 @@ jobs:
${{ runner.os }}-yarn- ${{ runner.os }}-yarn-
- name: Install - name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh run: |
pip install frappe-bench
pyenv global $(pyenv versions | grep '3.10')
bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env: env:
DB: mariadb DB: mariadb
TYPE: server TYPE: server
@ -96,18 +99,23 @@ jobs:
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git
pyenv global $(pyenv versions | grep '3.7')
for version in $(seq 12 13) for version in $(seq 12 13)
do do
echo "Updating to v$version" echo "Updating to v$version"
branch_name="version-$version-hotfix" branch_name="version-$version-hotfix"
git -C "apps/frappe" fetch --depth 1 upstream $branch_name:$branch_name git -C "apps/frappe" fetch --depth 1 upstream $branch_name:$branch_name
git -C "apps/erpnext" fetch --depth 1 upstream $branch_name:$branch_name git -C "apps/erpnext" fetch --depth 1 upstream $branch_name:$branch_name
git -C "apps/frappe" checkout -q -f $branch_name git -C "apps/frappe" checkout -q -f $branch_name
git -C "apps/erpnext" checkout -q -f $branch_name git -C "apps/erpnext" checkout -q -f $branch_name
bench setup requirements --python rm -rf ~/frappe-bench/env
bench setup env
bench pip install -e ./apps/erpnext
bench --site test_site migrate bench --site test_site migrate
done done
@ -115,5 +123,10 @@ jobs:
echo "Updating to latest version" echo "Updating to latest version"
git -C "apps/frappe" checkout -q -f "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}" git -C "apps/frappe" checkout -q -f "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"
git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA" git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA"
bench setup requirements --python
pyenv global $(pyenv versions | grep '3.10')
rm -rf ~/frappe-bench/env
bench -v setup env
bench pip install -e ./apps/erpnext
bench --site test_site migrate bench --site test_site migrate

View File

@ -39,7 +39,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
container: [1, 2, 3] container: [1, 2, 3, 4]
name: Python Unit Tests name: Python Unit Tests
@ -59,7 +59,7 @@ jobs:
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: 3.8 python-version: '3.10'
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v2 uses: actions/setup-node@v2
@ -74,7 +74,7 @@ jobs:
uses: actions/cache@v2 uses: actions/cache@v2
with: with:
path: ~/.cache/pip path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
restore-keys: | restore-keys: |
${{ runner.os }}-pip- ${{ runner.os }}-pip-
${{ runner.os }}- ${{ runner.os }}-

View File

@ -21,7 +21,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
container: [1, 2, 3] container: [1]
name: Python Unit Tests name: Python Unit Tests
@ -46,7 +46,7 @@ jobs:
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: 3.8 python-version: '3.10'
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v2 uses: actions/setup-node@v2
@ -61,7 +61,7 @@ jobs:
uses: actions/cache@v2 uses: actions/cache@v2
with: with:
path: ~/.cache/pip path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
restore-keys: | restore-keys: |
${{ runner.os }}-pip- ${{ runner.os }}-pip-
${{ runner.os }}- ${{ runner.os }}-

View File

@ -3,33 +3,30 @@
# These owners will be the default owners for everything in # These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence, # the repo. Unless a later match takes precedence,
erpnext/accounts/ @nextchamp-saqib @deepeshgarg007 erpnext/accounts/ @nextchamp-saqib @deepeshgarg007 @ruthra-kumar
erpnext/assets/ @nextchamp-saqib @deepeshgarg007 erpnext/assets/ @nextchamp-saqib @deepeshgarg007 @ruthra-kumar
erpnext/erpnext_integrations/ @nextchamp-saqib
erpnext/loan_management/ @nextchamp-saqib @deepeshgarg007 erpnext/loan_management/ @nextchamp-saqib @deepeshgarg007
erpnext/regional @nextchamp-saqib @deepeshgarg007 erpnext/regional @nextchamp-saqib @deepeshgarg007 @ruthra-kumar
erpnext/selling @nextchamp-saqib @deepeshgarg007 erpnext/selling @nextchamp-saqib @deepeshgarg007 @ruthra-kumar
erpnext/support/ @nextchamp-saqib @deepeshgarg007 erpnext/support/ @nextchamp-saqib @deepeshgarg007
pos* @nextchamp-saqib pos* @nextchamp-saqib
erpnext/buying/ @marination @rohitwaghchaure @ankush erpnext/buying/ @marination @rohitwaghchaure @s-aga-r
erpnext/e_commerce/ @marination erpnext/e_commerce/ @marination
erpnext/maintenance/ @marination @rohitwaghchaure erpnext/maintenance/ @marination @rohitwaghchaure @s-aga-r
erpnext/manufacturing/ @marination @rohitwaghchaure @ankush erpnext/manufacturing/ @marination @rohitwaghchaure @s-aga-r
erpnext/portal/ @marination erpnext/portal/ @marination
erpnext/quality_management/ @marination @rohitwaghchaure erpnext/quality_management/ @marination @rohitwaghchaure @s-aga-r
erpnext/shopping_cart/ @marination erpnext/shopping_cart/ @marination
erpnext/stock/ @marination @rohitwaghchaure @ankush erpnext/stock/ @marination @rohitwaghchaure @s-aga-r
erpnext/crm/ @ruchamahabal @pateljannat erpnext/crm/ @NagariaHussain
erpnext/education/ @ruchamahabal @pateljannat erpnext/education/ @rutwikhdev
erpnext/hr/ @ruchamahabal @pateljannat erpnext/projects/ @ruchamahabal
erpnext/payroll @ruchamahabal @pateljannat
erpnext/projects/ @ruchamahabal @pateljannat
erpnext/controllers/ @deepeshgarg007 @nextchamp-saqib @rohitwaghchaure @marination @ankush erpnext/controllers/ @deepeshgarg007 @nextchamp-saqib @rohitwaghchaure @marination
erpnext/patches/ @deepeshgarg007 @nextchamp-saqib @marination @ankush erpnext/patches/ @deepeshgarg007 @nextchamp-saqib @marination
erpnext/public/ @nextchamp-saqib @marination erpnext/public/ @nextchamp-saqib @marination
.github/ @ankush .github/ @ankush
requirements.txt @gavindsouza pyproject.toml @gavindsouza @ankush

View File

@ -1 +0,0 @@
hypothesis~=6.31.0

View File

@ -11,3 +11,41 @@ Entries are:
- Purchase Invoice (Itemised) - Purchase Invoice (Itemised)
All accounting entries are stored in the `General Ledger` 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 |

View File

@ -58,6 +58,10 @@ class GLEntry(Document):
validate_balance_type(self.account, adv_adj) validate_balance_type(self.account, adv_adj)
validate_frozen_account(self.account, adv_adj) validate_frozen_account(self.account, adv_adj)
if frappe.db.get_value("Account", self.account, "account_type") not in [
"Receivable",
"Payable",
]:
# Update outstanding amt on against voucher # Update outstanding amt on against voucher
if ( if (
self.against_voucher_type in ["Journal Entry", "Sales Invoice", "Purchase Invoice", "Fees"] self.against_voucher_type in ["Journal Entry", "Sales Invoice", "Purchase Invoice", "Fees"]

View File

@ -149,22 +149,6 @@ frappe.ui.form.on("Journal Entry", {
} }
}); });
} }
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");
}
});
}
} }
}, },

View File

@ -137,7 +137,8 @@
"fieldname": "finance_book", "fieldname": "finance_book",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Finance Book", "label": "Finance Book",
"options": "Finance Book" "options": "Finance Book",
"read_only": 1
}, },
{ {
"fieldname": "2_add_edit_gl_entries", "fieldname": "2_add_edit_gl_entries",
@ -538,7 +539,7 @@
"idx": 176, "idx": 176,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2022-04-06 17:18:46.865259", "modified": "2022-06-23 22:01:32.348337",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Journal Entry", "name": "Journal Entry",

View File

@ -800,9 +800,7 @@ class JournalEntry(AccountsController):
self.total_amount_in_words = money_in_words(amt, currency) self.total_amount_in_words = money_in_words(amt, currency)
def make_gl_entries(self, cancel=0, adv_adj=0): def build_gl_map(self):
from erpnext.accounts.general_ledger import make_gl_entries
gl_map = [] gl_map = []
for d in self.get("accounts"): for d in self.get("accounts"):
if d.debit or d.credit: if d.debit or d.credit:
@ -838,7 +836,12 @@ class JournalEntry(AccountsController):
item=d, 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"): if self.voucher_type in ("Deferred Revenue", "Deferred Expense"):
update_outstanding = "No" update_outstanding = "No"
else: else:
@ -1201,24 +1204,6 @@ def get_payment_entry(ref_doc, args):
return je if args.get("journal_entry") else je.as_dict() return je if args.get("journal_entry") else je.as_dict()
@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.whitelist()
@frappe.validate_and_sanitize_search_inputs @frappe.validate_and_sanitize_search_inputs
def get_against_jv(doctype, txt, searchfield, start, page_len, filters): def get_against_jv(doctype, txt, searchfield, start, page_len, filters):

View File

@ -6,7 +6,7 @@ import json
from functools import reduce from functools import reduce
import frappe 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 from frappe.utils import cint, comma_or, flt, getdate, nowdate
import erpnext import erpnext
@ -785,7 +785,7 @@ class PaymentEntry(AccountsController):
self.set("remarks", "\n".join(remarks)) 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"): if self.payment_type in ("Receive", "Pay") and not self.get("party_account_field"):
self.setup_party_account_field() self.setup_party_account_field()
@ -794,7 +794,10 @@ class PaymentEntry(AccountsController):
self.add_bank_gl_entries(gl_entries) self.add_bank_gl_entries(gl_entries)
self.add_deductions_gl_entries(gl_entries) self.add_deductions_gl_entries(gl_entries)
self.add_tax_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) gl_entries = process_gl_map(gl_entries)
make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj) make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj)
@ -1195,6 +1198,9 @@ def get_outstanding_reference_documents(args):
if args.get("party_type") == "Member": if args.get("party_type") == "Member":
return return
ple = qb.DocType("Payment Ledger Entry")
common_filter = []
# confirm that Supplier is not blocked # confirm that Supplier is not blocked
if args.get("party_type") == "Supplier": if args.get("party_type") == "Supplier":
supplier_status = get_supplier_block_status(args["party"]) supplier_status = get_supplier_block_status(args["party"])
@ -1216,10 +1222,13 @@ def get_outstanding_reference_documents(args):
condition = " and voucher_type={0} and voucher_no={1}".format( condition = " and voucher_type={0} and voucher_no={1}".format(
frappe.db.escape(args["voucher_type"]), frappe.db.escape(args["voucher_no"]) 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 # Add cost center condition
if args.get("cost_center"): if args.get("cost_center"):
condition += " and cost_center='%s'" % 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 = { date_fields_dict = {
"posting_date": ["from_posting_date", "to_posting_date"], "posting_date": ["from_posting_date", "to_posting_date"],
@ -1231,16 +1240,19 @@ def get_outstanding_reference_documents(args):
condition += " and {0} between '{1}' and '{2}'".format( condition += " and {0} between '{1}' and '{2}'".format(
fieldname, args.get(date_fields[0]), args.get(date_fields[1]) 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"): if args.get("company"):
condition += " and company = {0}".format(frappe.db.escape(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( outstanding_invoices = get_outstanding_invoices(
args.get("party_type"), args.get("party_type"),
args.get("party"), args.get("party"),
args.get("party_account"), args.get("party_account"),
filters=args, common_filter=common_filter,
condition=condition, 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) outstanding_invoices = split_invoices_based_on_payment_terms(outstanding_invoices)

View File

@ -6,6 +6,19 @@ import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document 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): class PaymentLedgerEntry(Document):
def validate_account(self): def validate_account(self):
@ -18,5 +31,119 @@ class PaymentLedgerEntry(Document):
if not valid_account: if not valid_account:
frappe.throw(_("{0} account is not of type {1}").format(self.account, self.account_type)) 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): def validate(self):
self.validate_account() 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
)

View File

@ -3,16 +3,26 @@
import frappe import frappe
from frappe import _, msgprint from frappe import _, msgprint, qb
from frappe.model.document import Document 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 from frappe.utils import flt, getdate, nowdate, today
import erpnext 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 from erpnext.controllers.accounts_controller import get_advance_payment_entries
class PaymentReconciliation(Document): class PaymentReconciliation(Document):
def __init__(self, *args, **kwargs):
super(PaymentReconciliation, self).__init__(*args, **kwargs)
self.common_filter_conditions = []
@frappe.whitelist() @frappe.whitelist()
def get_unreconciled_entries(self): def get_unreconciled_entries(self):
self.get_nonreconciled_payment_entries() self.get_nonreconciled_payment_entries()
@ -108,53 +118,57 @@ class PaymentReconciliation(Document):
return list(journal_entries) return list(journal_entries)
def get_dr_or_cr_notes(self): 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 = ( self.build_qb_filter_conditions(get_return_invoices=True)
"debit_in_account_currency"
if dr_or_cr == "credit_in_account_currency"
else "credit_in_account_currency"
)
ple = qb.DocType("Payment Ledger Entry")
voucher_type = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice" voucher_type = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
return frappe.db.sql( if erpnext.get_party_account_type(self.party_type) == "Receivable":
""" SELECT doc.name as reference_name, %(voucher_type)s as reference_type, self.common_filter_conditions.append(ple.account_type == "Receivable")
(sum(gl.{dr_or_cr}) - sum(gl.{reconciled_dr_or_cr})) as amount, doc.posting_date, else:
account_currency as currency self.common_filter_conditions.append(ple.account_type == "Payable")
FROM `tab{doc}` doc, `tabGL Entry` gl self.common_filter_conditions.append(ple.account == self.receivable_payable_account)
WHERE
(doc.name = gl.against_voucher or doc.name = gl.voucher_no) # get return invoices
and doc.{party_type_field} = %(party)s doc = qb.DocType(voucher_type)
and doc.is_return = 1 and ifnull(doc.return_against, "") = "" return_invoices = (
and gl.against_voucher_type = %(voucher_type)s qb.from_(doc)
and doc.docstatus = 1 and gl.party = %(party)s .select(ConstantColumn(voucher_type).as_("voucher_type"), doc.name.as_("voucher_no"))
and gl.party_type = %(party_type)s and gl.account = %(account)s .where(
and gl.is_cancelled = 0 {condition} (doc.docstatus == 1)
GROUP BY doc.name & (doc[frappe.scrub(self.party_type)] == self.party)
Having & (doc.is_return == 1)
amount > 0 & (IfNull(doc.return_against, "") == "")
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,
) )
.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): def add_payment_entries(self, non_reconciled_payments):
self.set("payments", []) self.set("payments", [])
@ -166,10 +180,15 @@ class PaymentReconciliation(Document):
def get_invoice_entries(self): def get_invoice_entries(self):
# Fetch JVs, Sales and Purchase Invoices for 'invoices' to reconcile against # 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( 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: if self.invoice_limit:
@ -329,66 +348,33 @@ class PaymentReconciliation(Document):
if not invoices_to_reconcile: if not invoices_to_reconcile:
frappe.throw(_("No records found in Allocation table")) frappe.throw(_("No records found in Allocation table"))
def get_conditions(self, get_invoices=False, get_payments=False, get_return_invoices=False): def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False):
condition = " and company = '{0}' ".format(self.company) 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): self.common_filter_conditions.append(ple.company == self.company)
condition = " and cost_center = '{0}' ".format(self.cost_center)
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: if get_invoices:
condition += ( if self.from_invoice_date:
" and posting_date >= {0}".format(frappe.db.escape(self.from_invoice_date)) self.common_filter_conditions.append(ple.posting_date.gte(self.from_invoice_date))
if self.from_invoice_date if self.to_invoice_date:
else "" self.common_filter_conditions.append(ple.posting_date.lte(self.to_invoice_date))
)
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)
)
elif get_return_invoices: elif get_return_invoices:
condition = " and doc.company = '{0}' ".format(self.company) if self.from_payment_date:
condition += ( self.common_filter_conditions.append(ple.posting_date.gte(self.from_payment_date))
" and doc.posting_date >= {0}".format(frappe.db.escape(self.from_payment_date)) if self.to_payment_date:
if self.from_payment_date self.common_filter_conditions.append(ple.posting_date.lte(self.to_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.minimum_invoice_amount: def get_conditions(self, get_payments=False):
condition += " and gl.{dr_or_cr} >= {amount}".format( condition = " and company = '{0}' ".format(self.company)
dr_or_cr=dr_or_cr, amount=flt(self.minimum_payment_amount)
) if self.get("cost_center") and get_payments:
if self.maximum_invoice_amount: condition = " and cost_center = '{0}' ".format(self.cost_center)
condition += " and gl.{dr_or_cr} <= {amount}".format(
dr_or_cr=dr_or_cr, amount=flt(self.maximum_payment_amount)
)
else:
condition += ( condition += (
" and posting_date >= {0}".format(frappe.db.escape(self.from_payment_date)) " and posting_date >= {0}".format(frappe.db.escape(self.from_payment_date))
if self.from_payment_date if self.from_payment_date

View File

@ -4,93 +4,453 @@
import unittest import unittest
import frappe 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.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): class TestPaymentReconciliation(FrappeTestCase):
@classmethod def setUp(self):
def setUpClass(cls): self.create_company()
make_customer() self.create_item()
make_invoice_and_payment() self.create_customer()
self.clear_old_entries()
def test_payment_reconciliation(self): def tearDown(self):
payment_reco = frappe.get_doc("Payment Reconciliation") frappe.db.rollback()
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()
self.assertEqual(len(payment_reco.get("invoices")), 1) def create_company(self):
self.assertEqual(len(payment_reco.get("payments")), 1) company = None
if frappe.db.exists("Company", "_Test Payment Reconciliation"):
payment_entry = payment_reco.get("payments")[0].reference_name company = frappe.get_doc("Company", "_Test Payment Reconciliation")
invoice = payment_reco.get("invoices")[0].invoice_number else:
company = frappe.get_doc(
payment_reco.allocate_entries(
{ {
"payments": [payment_reco.get("payments")[0].as_dict()], "doctype": "Company",
"invoices": [payment_reco.get("invoices")[0].as_dict()], "company_name": "_Test Payment Reconciliation",
"country": "India",
"default_currency": "INR",
"create_chart_of_accounts_based_on": "Standard Template",
"chart_of_accounts": "Standard",
} }
) )
payment_reco.reconcile() company = company.save()
payment_entry_doc = frappe.get_doc("Payment Entry", payment_entry) self.company = company.name
self.assertEqual(payment_entry_doc.get("references")[0].reference_name, invoice) 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"
# create bank account
def make_customer(): if frappe.db.exists("Account", "HDFC - _PR"):
if not frappe.db.get_value("Customer", "_Test Payment Reco Customer"): self.bank = "HDFC - _PR"
frappe.get_doc( else:
bank_acc = frappe.get_doc(
{ {
"doctype": "Customer", "doctype": "Account",
"customer_name": "_Test Payment Reco Customer", "account_name": "HDFC",
"customer_type": "Individual", "parent_account": "Bank Accounts - _PR",
"customer_group": "_Test Customer Group", "company": self.company,
"territory": "_Test Territory",
}
).insert()
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()
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() bank_acc.save()
pe.submit() 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
)
self.item = item if isinstance(item, str) else item.item_code
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 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 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()
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)

View File

@ -165,17 +165,6 @@ class PurchaseInvoice(BuyingController):
super(PurchaseInvoice, self).set_missing_values(for_validate) 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): def validate_credit_to_acc(self):
if not self.credit_to: if not self.credit_to:
self.credit_to = get_party_account("Supplier", self.supplier, self.company) self.credit_to = get_party_account("Supplier", self.supplier, self.company)

View File

@ -114,6 +114,7 @@ class SalesInvoice(SellingController):
self.set_income_account_for_fixed_assets() self.set_income_account_for_fixed_assets()
self.validate_item_cost_centers() self.validate_item_cost_centers()
self.validate_income_account() self.validate_income_account()
self.check_conversion_rate()
validate_inter_company_party( validate_inter_company_party(
self.doctype, self.customer, self.company, self.inter_company_invoice_reference self.doctype, self.customer, self.company, self.inter_company_invoice_reference

View File

@ -792,6 +792,54 @@ class TestSalesInvoice(unittest.TestCase):
jv.cancel() jv.cancel()
self.assertEqual(frappe.db.get_value("Sales Invoice", w.name, "outstanding_amount"), 562.0) 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): def test_sales_invoice_gl_entry_without_perpetual_inventory(self):
si = frappe.copy_doc(test_records[1]) si = frappe.copy_doc(test_records[1])
si.insert() si.insert()
@ -1583,6 +1631,17 @@ class TestSalesInvoice(unittest.TestCase):
self.assertTrue(gle) 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): def test_invalid_currency(self):
# Customer currency = USD # Customer currency = USD

View File

@ -35,7 +35,13 @@ def make_gl_entries(
validate_disabled_accounts(gl_map) validate_disabled_accounts(gl_map)
gl_map = process_gl_map(gl_map, merge_entries) gl_map = process_gl_map(gl_map, merge_entries)
if gl_map and len(gl_map) > 1: 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) save_entries(gl_map, adv_adj, update_outstanding, from_repost)
# Post GL Map proccess there may no be any GL Entries # Post GL Map proccess there may no be any GL Entries
elif gl_map: elif gl_map:
@ -126,7 +132,7 @@ def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None):
for sub_cost_center, percentage in cost_center_allocation.get(cost_center, {}).items(): for sub_cost_center, percentage in cost_center_allocation.get(cost_center, {}).items():
gle = copy.deepcopy(d) gle = copy.deepcopy(d)
gle.cost_center = sub_cost_center 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) gle[field] = flt(flt(d.get(field)) * percentage / 100, precision)
new_gl_map.append(gle) new_gl_map.append(gle)
else: else:
@ -482,6 +488,9 @@ def make_reverse_gl_entries(
if gl_entries: if gl_entries:
create_payment_ledger_entry(gl_entries, cancel=1) 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) validate_accounting_period(gl_entries)
check_freezing_date(gl_entries[0]["posting_date"], adv_adj) check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"]) set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"])

View File

@ -128,6 +128,7 @@ class ReceivablePayableReport(object):
credit_note_in_account_currency=0.0, credit_note_in_account_currency=0.0,
outstanding_in_account_currency=0.0, outstanding_in_account_currency=0.0,
) )
self.get_invoices(ple)
if self.filters.get("group_by_party"): if self.filters.get("group_by_party"):
self.init_subtotal_row(ple.party) self.init_subtotal_row(ple.party)

View File

@ -43,7 +43,7 @@ def get_columns():
"options": "Account", "options": "Account",
"width": 170, "width": 170,
}, },
{"label": _("Amount"), "fieldname": "amount", "width": 120}, {"label": _("Amount"), "fieldname": "amount", "fieldtype": "Currency", "width": 120},
] ]
return columns return columns

View File

@ -425,7 +425,7 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map):
update_value_in_dict(totals, "opening", gle) update_value_in_dict(totals, "opening", gle)
update_value_in_dict(totals, "closing", 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: 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, "total", gle)
update_value_in_dict(gle_map[group_by_value].totals, "closing", gle) update_value_in_dict(gle_map[group_by_value].totals, "closing", gle)

View File

@ -9,6 +9,8 @@ import frappe
import frappe.defaults import frappe.defaults
from frappe import _, qb, throw from frappe import _, qb, throw
from frappe.model.meta import get_field_precision from frappe.model.meta import get_field_precision
from frappe.query_builder import AliasedQuery, Criterion, Table
from frappe.query_builder.functions import Sum
from frappe.query_builder.utils import DocType from frappe.query_builder.utils import DocType
from frappe.utils import ( from frappe.utils import (
cint, cint,
@ -437,7 +439,8 @@ def reconcile_against_document(args):
# cancel advance entry # cancel advance entry
doc = frappe.get_doc(voucher_type, voucher_no) doc = frappe.get_doc(voucher_type, voucher_no)
frappe.flags.ignore_party_validation = True 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: for entry in entries:
check_if_advance_entry_modified(entry) check_if_advance_entry_modified(entry)
@ -452,7 +455,9 @@ def reconcile_against_document(args):
doc.save(ignore_permissions=True) doc.save(ignore_permissions=True)
# re-submit advance entry # re-submit advance entry
doc = frappe.get_doc(entry.voucher_type, entry.voucher_no) 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 frappe.flags.ignore_party_validation = False
if entry.voucher_type in ("Payment Entry", "Journal Entry"): if entry.voucher_type in ("Payment Entry", "Journal Entry"):
@ -816,7 +821,11 @@ def get_held_invoices(party_type, party):
return held_invoices 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 = [] outstanding_invoices = []
precision = frappe.get_precision("Sales Invoice", "outstanding_amount") or 2 precision = frappe.get_precision("Sales Invoice", "outstanding_amount") or 2
@ -829,76 +838,30 @@ def get_outstanding_invoices(party_type, party, account, condition=None, filters
else: else:
party_account_type = erpnext.get_party_account_type(party_type) 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) held_invoices = get_held_invoices(party_type, party)
invoice_list = frappe.db.sql( common_filter = common_filter or []
""" common_filter.append(ple.account_type == party_account_type)
select common_filter.append(ple.account == account)
voucher_no, voucher_type, posting_date, due_date, common_filter.append(ple.party_type == party_type)
ifnull(sum({dr_or_cr}), 0) as invoice_amount, common_filter.append(ple.party == party)
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,
)
payment_entries = frappe.db.sql( ple_query = QueryPaymentLedger()
""" invoice_list = ple_query.get_voucher_outstandings(
select against_voucher_type, against_voucher, common_filter=common_filter,
ifnull(sum({payment_dr_or_cr}), 0) as payment_amount min_outstanding=min_outstanding,
from `tabGL Entry` max_outstanding=max_outstanding,
where party_type = %(party_type)s and party = %(party)s get_invoices=True,
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,
) )
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: for d in invoice_list:
payment_amount = pe_map.get((d.voucher_type, d.voucher_no), 0) payment_amount = d.invoice_amount - d.outstanding
outstanding_amount = flt(d.invoice_amount - payment_amount, precision) outstanding_amount = d.outstanding
if outstanding_amount > 0.5 / (10**precision): if outstanding_amount > 0.5 / (10**precision):
if ( if (
filters min_outstanding
and filters.get("outstanding_amt_greater_than") and max_outstanding
and not ( and not (outstanding_amount >= min_outstanding and outstanding_amount <= max_outstanding)
outstanding_amount >= filters.get("outstanding_amt_greater_than")
and outstanding_amount <= filters.get("outstanding_amt_less_than")
)
): ):
continue continue
@ -1389,7 +1352,9 @@ def check_and_delete_linked_reports(report):
frappe.delete_doc("Desktop Icon", icon) 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: if gl_entries:
ple = None ple = None
@ -1462,9 +1427,42 @@ def create_payment_ledger_entry(gl_entries, cancel=0):
if cancel: if cancel:
delink_original_entry(ple) delink_original_entry(ple)
ple.flags.ignore_permissions = 1 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() 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): def delink_original_entry(pl_entry):
if pl_entry: if pl_entry:
ple = qb.DocType("Payment Ledger Entry") ple = qb.DocType("Payment Ledger Entry")
@ -1486,3 +1484,196 @@ def delink_original_entry(pl_entry):
) )
) )
query.run() 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

View File

@ -252,6 +252,7 @@ class Asset(AccountsController):
number_of_pending_depreciations += 1 number_of_pending_depreciations += 1
skip_row = False 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): for n in range(start[finance_book.idx - 1], number_of_pending_depreciations):
# If depreciation is already completed (for double declining balance) # If depreciation is already completed (for double declining balance)
@ -265,6 +266,9 @@ class Asset(AccountsController):
finance_book.depreciation_start_date, n * cint(finance_book.frequency_of_depreciation) 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 # schedule date will be a year later from start date
# so monthly schedule date is calculated by removing 11 months from it # 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) monthly_schedule_date = add_months(schedule_date, -finance_book.frequency_of_depreciation + 1)
@ -849,14 +853,9 @@ class Asset(AccountsController):
if args.get("rate_of_depreciation") and on_validate: if args.get("rate_of_depreciation") and on_validate:
return args.get("rate_of_depreciation") 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) 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(args.get("total_number_of_depreciations"), 2))
depreciation_rate = math.pow(value, 1.0 / flt(no_of_years, 2))
return 100 * (1 - flt(depreciation_rate, float_precision)) return 100 * (1 - flt(depreciation_rate, float_precision))
@ -1105,9 +1104,18 @@ def is_cwip_accounting_enabled(asset_category):
def get_total_days(date, frequency): def get_total_days(date, frequency):
period_start_date = add_months(date, cint(frequency) * -1) 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) 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 @erpnext.allow_regional
def get_depreciation_amount(asset, depreciable_value, row): def get_depreciation_amount(asset, depreciable_value, row):
if row.depreciation_method in ("Straight Line", "Manual"): if row.depreciation_method in ("Straight Line", "Manual"):

View File

@ -707,6 +707,39 @@ class TestDepreciationMethods(AssetSetup):
self.assertEqual(schedules, expected_schedules) 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): def test_discounted_wdv_depreciation_rate_for_indian_region(self):
# set indian company # set indian company
company_flag = frappe.flags.company company_flag = frappe.flags.company
@ -838,7 +871,7 @@ class TestDepreciationBasics(AssetSetup):
expected_values = [["2020-12-31", 30000.0], ["2021-12-31", 30000.0], ["2022-12-31", 30000.0]] expected_values = [["2020-12-31", 30000.0], ["2021-12-31", 30000.0], ["2022-12-31", 30000.0]]
for i, schedule in enumerate(asset.schedules): 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) self.assertEqual(expected_values[i][1], schedule.depreciation_amount)
def test_set_accumulated_depreciation(self): def test_set_accumulated_depreciation(self):
@ -1333,6 +1366,32 @@ class TestDepreciationBasics(AssetSetup):
asset.cost_center = "Main - _TC" asset.cost_center = "Main - _TC"
asset.submit() 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(): def create_asset_data():
if not frappe.db.exists("Asset Category", "Computers"): if not frappe.db.exists("Asset Category", "Computers"):

View File

@ -425,7 +425,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends e
company: me.frm.doc.company company: me.frm.doc.company
}, },
allow_child_item_selection: true, allow_child_item_selection: true,
child_fielname: "items", child_fieldname: "items",
child_columns: ["item_code", "qty"] child_columns: ["item_code", "qty"]
}) })
}, __("Get Items From")); }, __("Get Items From"));

View File

@ -140,6 +140,43 @@ class TestPurchaseOrder(FrappeTestCase):
# ordered qty decreases as ordered qty is 0 (deleted row) # ordered qty decreases as ordered qty is 0 (deleted row)
self.assertEqual(get_ordered_qty(), existing_ordered_qty - 10) # 0 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): def test_update_child(self):
mr = make_material_request(qty=10) mr = make_material_request(qty=10)
po = make_purchase_order(mr.name) po = make_purchase_order(mr.name)

View File

@ -59,6 +59,7 @@ frappe.query_reports["Purchase Order Analysis"] = {
for (let option of status){ for (let option of status){
options.push({ options.push({
"value": option, "value": option,
"label": __(option),
"description": "" "description": ""
}) })
} }

View File

@ -1848,6 +1848,17 @@ class AccountsController(TransactionBase):
jv.save() jv.save()
jv.submit() 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() @frappe.whitelist()
def get_tax_rate(account_head): def get_tax_rate(account_head):
@ -2429,7 +2440,7 @@ def update_bin_on_delete(row, doctype):
update_bin_qty(row.item_code, row.warehouse, qty_dict) 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 = [] deleted_children = []
updated_item_names = [d.get("docname") for d in data] updated_item_names = [d.get("docname") for d in data]
for item in parent.items: for item in parent.items:
@ -2448,6 +2459,8 @@ def validate_and_delete_children(parent, data):
for d in deleted_children: for d in deleted_children:
update_bin_on_delete(d, parent.doctype) update_bin_on_delete(d, parent.doctype)
return bool(deleted_children)
@frappe.whitelist() @frappe.whitelist()
def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"): def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"):
@ -2511,13 +2524,38 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
): ):
frappe.throw(_("Cannot set quantity less than received quantity")) 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) 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"] sales_doctypes = ["Sales Order", "Sales Invoice", "Delivery Note", "Quotation"]
parent = frappe.get_doc(parent_doctype, parent_doctype_name) parent = frappe.get_doc(parent_doctype, parent_doctype_name)
check_doc_permissions(parent, "write") 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: for d in data:
new_child_flag = False new_child_flag = False
@ -2528,6 +2566,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
if not d.get("docname"): if not d.get("docname"):
new_child_flag = True new_child_flag = True
items_added_or_removed = True
check_doc_permissions(parent, "create") check_doc_permissions(parent, "create")
child_item = get_new_child_item(d) child_item = get_new_child_item(d)
else: else:
@ -2550,6 +2589,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
qty_unchanged = prev_qty == new_qty qty_unchanged = prev_qty == new_qty
uom_unchanged = prev_uom == new_uom uom_unchanged = prev_uom == new_uom
conversion_factor_unchanged = prev_con_fac == new_con_fac conversion_factor_unchanged = prev_con_fac == new_con_fac
any_conversion_factor_changed |= not conversion_factor_unchanged
date_unchanged = ( date_unchanged = (
prev_date == getdate(new_date) if prev_date and new_date else False prev_date == getdate(new_date) if prev_date and new_date else False
) # in case of delivery note etc ) # in case of delivery note etc
@ -2563,6 +2603,8 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
continue continue
validate_quantity(child_item, d) 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")) child_item.qty = flt(d.get("qty"))
rate_precision = child_item.precision("rate") or 2 rate_precision = child_item.precision("rate") or 2
@ -2668,6 +2710,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
parent.update_ordered_and_reserved_qty() parent.update_ordered_and_reserved_qty()
parent.update_receiving_percentage() parent.update_receiving_percentage()
if parent.is_subcontracted: if parent.is_subcontracted:
if should_update_supplied_items(parent):
parent.update_reserved_qty_for_subcontract() parent.update_reserved_qty_for_subcontract()
parent.create_raw_materials_supplied("supplied_items") parent.create_raw_materials_supplied("supplied_items")
parent.save() parent.save()

View File

@ -29,8 +29,8 @@ def employee_query(doctype, txt, searchfield, start, page_len, filters):
or employee_name like %(txt)s) or employee_name like %(txt)s)
{fcond} {mcond} {fcond} {mcond}
order by 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),
if(locate(%(_txt)s, employee_name), locate(%(_txt)s, employee_name), 99999), (case when locate(%(_txt)s, employee_name) > 0 then locate(%(_txt)s, employee_name) else 99999 end),
idx desc, idx desc,
name, employee_name name, employee_name
limit %(page_len)s offset %(start)s""".format( limit %(page_len)s offset %(start)s""".format(
@ -60,9 +60,9 @@ def lead_query(doctype, txt, searchfield, start, page_len, filters):
or company_name like %(txt)s) or company_name like %(txt)s)
{mcond} {mcond}
order by 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),
if(locate(%(_txt)s, lead_name), locate(%(_txt)s, lead_name), 99999), (case when locate(%(_txt)s, lead_name) > 0 then locate(%(_txt)s, lead_name) else 99999 end),
if(locate(%(_txt)s, company_name), locate(%(_txt)s, company_name), 99999), (case when locate(%(_txt)s, company_name) > 0 then locate(%(_txt)s, company_name) else 99999 end),
idx desc, idx desc,
name, lead_name name, lead_name
limit %(page_len)s offset %(start)s""".format( limit %(page_len)s offset %(start)s""".format(
@ -96,8 +96,8 @@ def customer_query(doctype, txt, searchfield, start, page_len, filters):
and ({scond}) and disabled=0 and ({scond}) and disabled=0
{fcond} {mcond} {fcond} {mcond}
order by 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),
if(locate(%(_txt)s, customer_name), locate(%(_txt)s, customer_name), 99999), (case when locate(%(_txt)s, customer_name) > 0 then locate(%(_txt)s, customer_name) else 99999 end),
idx desc, idx desc,
name, customer_name name, customer_name
limit %(page_len)s offset %(start)s""".format( limit %(page_len)s offset %(start)s""".format(
@ -130,11 +130,11 @@ def supplier_query(doctype, txt, searchfield, start, page_len, filters):
where docstatus < 2 where docstatus < 2
and ({key} like %(txt)s and ({key} like %(txt)s
or supplier_name like %(txt)s) and disabled=0 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} {mcond}
order by 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),
if(locate(%(_txt)s, supplier_name), locate(%(_txt)s, supplier_name), 99999), (case when locate(%(_txt)s, supplier_name) > 0 then locate(%(_txt)s, supplier_name) else 99999 end),
idx desc, idx desc,
name, supplier_name name, supplier_name
limit %(page_len)s offset %(start)s""".format( limit %(page_len)s offset %(start)s""".format(
@ -305,15 +305,15 @@ def bom(doctype, txt, searchfield, start, page_len, filters):
return frappe.db.sql( return frappe.db.sql(
"""select {fields} """select {fields}
from tabBOM from `tabBOM`
where tabBOM.docstatus=1 where `tabBOM`.docstatus=1
and tabBOM.is_active=1 and `tabBOM`.is_active=1
and tabBOM.`{key}` like %(txt)s and `tabBOM`.`{key}` like %(txt)s
{fcond} {mcond} {fcond} {mcond}
order by 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 idx desc, name
limit %(start)s, %(page_len)s """.format( limit %(page_len)s offset %(start)s""".format(
fields=", ".join(fields), fields=", ".join(fields),
fcond=get_filters_cond(doctype, filters, conditions).replace("%", "%%"), fcond=get_filters_cond(doctype, filters, conditions).replace("%", "%%"),
mcond=get_match_cond(doctype).replace("%", "%%"), mcond=get_match_cond(doctype).replace("%", "%%"),
@ -348,8 +348,8 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters):
`tabProject`.status not in ('Completed', 'Cancelled') `tabProject`.status not in ('Completed', 'Cancelled')
and {cond} {scond} {match_cond} and {cond} {scond} {match_cond}
order by order by
if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), (case when locate(%(_txt)s, `tabProject`.name) > 0 then locate(%(_txt)s, `tabProject`.name) else 99999 end),
idx desc, `tabProject`.idx desc,
`tabProject`.name asc `tabProject`.name asc
limit {page_len} offset {start}""".format( limit {page_len} offset {start}""".format(
fields=", ".join(["`tabProject`.{0}".format(f) for f in fields]), fields=", ".join(["`tabProject`.{0}".format(f) for f in fields]),

View File

@ -443,7 +443,7 @@ class StatusUpdater(Document):
"""update `tab%(target_parent_dt)s` """update `tab%(target_parent_dt)s`
set %(target_parent_field)s = round( set %(target_parent_field)s = round(
ifnull((select 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 / 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 %(update_modified)s
@ -455,9 +455,9 @@ class StatusUpdater(Document):
if args.get("status_field"): if args.get("status_field"):
frappe.db.sql( frappe.db.sql(
"""update `tab%(target_parent_dt)s` """update `tab%(target_parent_dt)s`
set %(status_field)s = if(%(target_parent_field)s<0.001, set %(status_field)s = (case when %(target_parent_field)s<0.001 then 'Not %(keyword)s'
'Not %(keyword)s', if(%(target_parent_field)s>=99.999999, else case when %(target_parent_field)s>=99.999999 then 'Fully %(keyword)s'
'Fully %(keyword)s', 'Partly %(keyword)s')) else 'Partly %(keyword)s' end end)
where name='%(name)s'""" where name='%(name)s'"""
% args % args
) )

View File

@ -166,7 +166,7 @@ class StockController(AccountsController):
"against": warehouse_account[sle.warehouse]["account"], "against": warehouse_account[sle.warehouse]["account"],
"cost_center": item_row.cost_center, "cost_center": item_row.cost_center,
"remarks": self.get("remarks") or _("Accounting Entry for Stock"), "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"), "project": item_row.get("project") or self.get("project"),
"is_opening": item_row.get("is_opening") or self.get("is_opening") or "No", "is_opening": item_row.get("is_opening") or self.get("is_opening") or "No",
}, },

View File

View File

@ -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": []
}

View File

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

View File

@ -10,12 +10,10 @@
"campaign_naming_by", "campaign_naming_by",
"allow_lead_duplication_based_on_emails", "allow_lead_duplication_based_on_emails",
"column_break_4", "column_break_4",
"create_event_on_next_contact_date",
"auto_creation_of_contact", "auto_creation_of_contact",
"opportunity_section", "opportunity_section",
"close_opportunity_after_days", "close_opportunity_after_days",
"column_break_9", "column_break_9",
"create_event_on_next_contact_date_opportunity",
"quotation_section", "quotation_section",
"default_valid_till", "default_valid_till",
"section_break_13", "section_break_13",
@ -55,12 +53,6 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Auto Creation of Contact" "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", "fieldname": "opportunity_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
@ -73,12 +65,6 @@
"fieldtype": "Int", "fieldtype": "Int",
"label": "Close Replied Opportunity After Days" "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", "fieldname": "column_break_4",
"fieldtype": "Column Break" "fieldtype": "Column Break"
@ -105,7 +91,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2021-12-20 12:51:38.894252", "modified": "2022-06-06 11:22:08.464253",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "CRM", "module": "CRM",
"name": "CRM Settings", "name": "CRM Settings",
@ -143,5 +129,6 @@
], ],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@ -24,31 +24,39 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
this.frm.set_query("lead_owner", function (doc, cdt, cdn) { this.frm.set_query("lead_owner", function (doc, cdt, cdn) {
return { query: "frappe.core.doctype.user.user.user_query" } 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 () { refresh () {
var me = this;
let doc = this.frm.doc; let doc = this.frm.doc;
erpnext.toggle_naming_series(); 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) { 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(__("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(__("Quotation"), this.make_quotation, __("Create"));
if (!doc.__onload.linked_prospects.length) {
this.frm.add_custom_button(__("Prospect"), this.make_prospect, __("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')); this.frm.add_custom_button(__('Add to Prospect'), this.add_lead_to_prospect, __('Action'));
} }
}
if (!this.frm.is_new()) { if (!this.frm.is_new()) {
frappe.contacts.render_address_and_contact(this.frm); frappe.contacts.render_address_and_contact(this.frm);
cur_frm.trigger('render_contact_day_html');
} else { } else {
frappe.contacts.clear_address_and_contact(this.frm); frappe.contacts.clear_address_and_contact(this.frm);
} }
this.frm.dashboard.links_area.hide();
this.show_notes();
this.show_activities();
} }
add_lead_to_prospect () { add_lead_to_prospect () {
@ -74,7 +82,7 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
} }
}, },
freeze: true, freeze: true,
freeze_message: __('...Adding Lead to Prospect') freeze_message: __('Adding Lead to Prospect...')
}); });
}, __('Add Lead to Prospect'), __('Add')); }, __('Add Lead to Prospect'), __('Add'));
} }
@ -86,13 +94,6 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
}) })
} }
make_opportunity () {
frappe.model.open_mapped_doc({
method: "erpnext.crm.doctype.lead.lead.make_opportunity",
frm: cur_frm
})
}
make_quotation () { make_quotation () {
frappe.model.open_mapped_doc({ frappe.model.open_mapped_doc({
method: "erpnext.crm.doctype.lead.lead.make_quotation", method: "erpnext.crm.doctype.lead.lead.make_quotation",
@ -111,9 +112,10 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
prospect.fax = cur_frm.doc.fax; prospect.fax = cur_frm.doc.fax;
prospect.website = cur_frm.doc.website; prospect.website = cur_frm.doc.website;
prospect.prospect_owner = cur_frm.doc.lead_owner; 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'); let leads_row = frappe.model.add_child(prospect, 'leads');
lead_prospect_row.lead = cur_frm.doc.name; leads_row.lead = cur_frm.doc.name;
frappe.set_route("Form", "Prospect", prospect.name); frappe.set_route("Form", "Prospect", prospect.name);
}); });
@ -125,26 +127,109 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
} }
} }
contact_date () { show_notes() {
if (this.frm.doc.contact_date) { if (this.frm.doc.docstatus == 1) return;
let d = moment(this.frm.doc.contact_date);
d.add(1, "day"); const crm_notes = new erpnext.utils.CRMNotes({
this.frm.set_value("ends_on", d.format(frappe.defaultDatetimeFormat)); frm: this.frm,
} notes_wrapper: $(this.frm.fields_dict.notes_html.wrapper),
});
crm_notes.refresh();
} }
render_contact_day_html() { show_activities() {
if (cur_frm.doc.contact_date) { if (this.frm.doc.docstatus == 1) return;
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()); const crm_activities = new erpnext.utils.CRMActivities({
let color = diff_days > 0 ? "orange" : "green"; frm: this.frm,
let message = diff_days > 0 ? __("Next Contact Date") : __("Last Contact Date"); open_activities_wrapper: $(this.frm.fields_dict.open_activities_html.wrapper),
let html = `<div class="col-xs-12"> all_activities_wrapper: $(this.frm.fields_dict.all_activities_html.wrapper),
<span class="indicator whitespace-nowrap ${color}"><span> ${message} : ${frappe.datetime.global_date_format(contact_date)}</span></span> form_wrapper: $(this.frm.wrapper),
</div>` ; });
cur_frm.dashboard.set_headline_alert(html); crm_activities.refresh();
}
} }
}; };
extend_cscript(cur_frm.cscript, new erpnext.LeadController({ frm: cur_frm })); 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
});
}
}
});

View File

@ -3,78 +3,80 @@
"allow_events_in_timeline": 1, "allow_events_in_timeline": 1,
"allow_import": 1, "allow_import": 1,
"autoname": "naming_series:", "autoname": "naming_series:",
"creation": "2013-04-10 11:45:37", "creation": "2022-02-08 13:14:41.083327",
"doctype": "DocType", "doctype": "DocType",
"document_type": "Document", "document_type": "Document",
"email_append_to": 1, "email_append_to": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"lead_details",
"naming_series", "naming_series",
"salutation", "salutation",
"first_name", "first_name",
"middle_name", "middle_name",
"last_name", "last_name",
"column_break_1",
"lead_name", "lead_name",
"col_break123", "job_title",
"status",
"company_name",
"designation",
"gender", "gender",
"contact_details_section", "source",
"col_break123",
"lead_owner",
"status",
"customer",
"type",
"request_type",
"contact_info_tab",
"email_id", "email_id",
"website",
"column_break_20",
"mobile_no", "mobile_no",
"whatsapp_no", "whatsapp_no",
"column_break_16", "column_break_16",
"phone", "phone",
"phone_ext", "phone_ext",
"additional_information_section", "organization_section",
"company_name",
"no_of_employees", "no_of_employees",
"column_break_28",
"annual_revenue",
"industry", "industry",
"market_segment", "market_segment",
"column_break_22", "column_break_31",
"territory",
"fax", "fax",
"website",
"type",
"request_type",
"address_section", "address_section",
"address_html", "address_html",
"column_break_38",
"city", "city",
"pincode",
"county",
"column_break2",
"contact_html",
"state", "state",
"country", "country",
"section_break_12", "column_break2",
"lead_owner", "contact_html",
"ends_on", "qualification_tab",
"column_break_14", "qualification_status",
"contact_by", "column_break_64",
"contact_date", "qualified_by",
"lead_source_details_section", "qualified_on",
"company", "other_info_tab",
"territory",
"language",
"column_break_50",
"source",
"campaign_name", "campaign_name",
"company",
"column_break_22",
"language",
"image",
"title",
"column_break_50",
"disabled",
"unsubscribed", "unsubscribed",
"blog_subscriber", "blog_subscriber",
"notes_section", "activities_tab",
"notes", "open_activities_html",
"other_information_section", "all_activities_section",
"customer", "all_activities_html",
"image", "notes_tab",
"title" "notes_html",
"notes"
], ],
"fields": [ "fields": [
{
"fieldname": "lead_details",
"fieldtype": "Section Break",
"label": "Lead Details",
"options": "fa fa-user"
},
{ {
"fieldname": "naming_series", "fieldname": "naming_series",
"fieldtype": "Select", "fieldtype": "Select",
@ -86,6 +88,7 @@
"set_only_once": 1 "set_only_once": 1
}, },
{ {
"depends_on": "eval:!doc.__islocal",
"fieldname": "lead_name", "fieldname": "lead_name",
"fieldtype": "Data", "fieldtype": "Data",
"in_global_search": 1, "in_global_search": 1,
@ -108,7 +111,7 @@
{ {
"fieldname": "email_id", "fieldname": "email_id",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Email Address", "label": "Email",
"oldfieldname": "email_id", "oldfieldname": "email_id",
"oldfieldtype": "Data", "oldfieldtype": "Data",
"options": "Email", "options": "Email",
@ -189,50 +192,9 @@
"print_hide": 1 "print_hide": 1
}, },
{ {
"fieldname": "section_break_12", "fieldname": "contact_info_tab",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Follow Up" "label": "Contact Info"
},
{
"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"
}, },
{ {
"fieldname": "address_html", "fieldname": "address_html",
@ -240,34 +202,6 @@
"label": "Address HTML", "label": "Address HTML",
"read_only": 1 "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", "fieldname": "column_break2",
"fieldtype": "Column Break" "fieldtype": "Column Break"
@ -289,7 +223,7 @@
{ {
"fieldname": "mobile_no", "fieldname": "mobile_no",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Mobile No.", "label": "Mobile No",
"oldfieldname": "mobile_no", "oldfieldname": "mobile_no",
"oldfieldtype": "Data", "oldfieldtype": "Data",
"options": "Phone" "options": "Phone"
@ -347,8 +281,7 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Website", "label": "Website",
"oldfieldname": "website", "oldfieldname": "website",
"oldfieldtype": "Data", "oldfieldtype": "Data"
"options": "URL"
}, },
{ {
"fieldname": "territory", "fieldname": "territory",
@ -380,14 +313,6 @@
"label": "Title", "label": "Title",
"print_hide": 1 "print_hide": 1
}, },
{
"fieldname": "designation",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Designation",
"options": "Designation"
},
{ {
"fieldname": "language", "fieldname": "language",
"fieldtype": "Link", "fieldtype": "Link",
@ -410,12 +335,6 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Last Name" "label": "Last Name"
}, },
{
"collapsible": 1,
"fieldname": "additional_information_section",
"fieldtype": "Section Break",
"label": "Additional Information"
},
{ {
"fieldname": "no_of_employees", "fieldname": "no_of_employees",
"fieldtype": "Int", "fieldtype": "Int",
@ -428,35 +347,13 @@
{ {
"fieldname": "whatsapp_no", "fieldname": "whatsapp_no",
"fieldtype": "Data", "fieldtype": "Data",
"label": "WhatsApp No.", "label": "WhatsApp",
"options": "Phone" "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", "fieldname": "column_break_50",
"fieldtype": "Column Break" "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", "fieldname": "column_break_16",
"fieldtype": "Column Break" "fieldtype": "Column Break"
@ -465,17 +362,156 @@
"fieldname": "phone_ext", "fieldname": "phone_ext",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Phone Ext." "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", "icon": "fa fa-user",
"idx": 5, "idx": 5,
"image_field": "image", "image_field": "image",
"links": [], "links": [],
"modified": "2021-08-04 00:24:57.208590", "modified": "2022-06-27 21:56:17.392756",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "CRM", "module": "CRM",
"name": "Lead", "name": "Lead",
"name_case": "Title Case", "name_case": "Title Case",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
@ -535,6 +571,7 @@
"show_name_in_global_search": 1, "show_name_in_global_search": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"subject_field": "title", "subject_field": "title",
"title_field": "title" "title_field": "title"
} }

View File

@ -1,27 +1,19 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import frappe import frappe
from frappe import _ from frappe import _
from frappe.contacts.address_and_contact import load_address_and_contact from frappe.contacts.address_and_contact import load_address_and_contact
from frappe.email.inbox import link_communication_to_document from frappe.email.inbox import link_communication_to_document
from frappe.model.mapper import get_mapped_doc from frappe.model.mapper import get_mapped_doc
from frappe.utils import ( from frappe.utils import comma_and, get_link_to_form, has_gravatar, validate_email_address
comma_and,
cstr,
get_link_to_form,
getdate,
has_gravatar,
nowdate,
validate_email_address,
)
from erpnext.accounts.party import set_taxes from erpnext.accounts.party import set_taxes
from erpnext.controllers.selling_controller import SellingController 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): def get_feed(self):
return "{0}: {1}".format(_(self.status), self.lead_name) return "{0}: {1}".format(_(self.status), self.lead_name)
@ -29,6 +21,7 @@ class Lead(SellingController):
customer = frappe.db.get_value("Customer", {"lead_name": self.name}) customer = frappe.db.get_value("Customer", {"lead_name": self.name})
self.get("__onload").is_customer = customer self.get("__onload").is_customer = customer
load_address_and_contact(self) load_address_and_contact(self)
self.set_onload("linked_prospects", self.get_linked_prospects())
def validate(self): def validate(self):
self.set_full_name() self.set_full_name()
@ -37,79 +30,42 @@ class Lead(SellingController):
self.set_status() self.set_status()
self.check_email_id_is_unique() self.check_email_id_is_unique()
self.validate_email_id() self.validate_email_id()
self.validate_contact_date()
self.set_prev()
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
)
def before_insert(self): 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() self.contact_doc = self.create_contact()
def after_insert(self): def after_insert(self):
self.update_links() self.link_to_contact()
def update_links(self): def on_update(self):
# update contact links self.update_prospect()
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): def on_trash(self):
if frappe.db.get_single_value("CRM Settings", "create_event_on_next_contact_date"): frappe.db.sql("""update `tabIssue` set lead='' where lead=%s""", self.name)
super(Lead, self).add_calendar_event(
{ self.unlink_dynamic_links()
"owner": self.lead_owner, self.remove_link_from_prospect()
"starts_on": self.contact_date,
"ends_on": self.ends_on or "", def set_full_name(self):
"subject": ("Contact " + cstr(self.lead_name)), if self.first_name:
"description": ("Contact " + cstr(self.lead_name)) self.lead_name = " ".join(
+ (self.contact_by and (". By : " + cstr(self.contact_by)) or ""), filter(None, [self.salutation, self.first_name, self.middle_name, self.last_name])
},
force,
) )
def update_prospects(self): def set_lead_name(self):
prospects = frappe.get_all("Prospect Lead", filters={"lead": self.name}, fields=["parent"]) if not self.lead_name:
for row in prospects: # Check for leads being created through data import
prospect = frappe.get_doc("Prospect", row.parent) if not self.company_name and not self.email_id and not self.flags.ignore_mandatory:
prospect.save(ignore_permissions=True) 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 set_title(self):
self.title = self.company_name or self.lead_name
def check_email_id_is_unique(self): def check_email_id_is_unique(self):
if self.email_id: if self.email_id:
@ -124,15 +80,47 @@ class Lead(SellingController):
if duplicate_leads: if duplicate_leads:
frappe.throw( 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, frappe.DuplicateEntryError,
) )
def on_trash(self): def validate_email_id(self):
frappe.db.sql("""update `tabIssue` set lead='' where lead=%s""", self.name) if self.email_id:
if not self.flags.ignore_email_validation:
validate_email_address(self.email_id, throw=True)
self.unlink_dynamic_links() if self.email_id == self.lead_owner:
self.delete_events() 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): def unlink_dynamic_links(self):
links = frappe.get_all( links = frappe.get_all(
@ -155,6 +143,30 @@ class Lead(SellingController):
linked_doc.remove(to_remove) linked_doc.remove(to_remove)
linked_doc.save(ignore_permissions=True) 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): def has_customer(self):
return frappe.db.get_value("Customer", {"lead_name": self.name}) return frappe.db.get_value("Customer", {"lead_name": self.name})
@ -171,21 +183,16 @@ class Lead(SellingController):
"Quotation", {"party_name": self.name, "docstatus": 1, "status": "Lost"} "Quotation", {"party_name": self.name, "docstatus": 1, "status": "Lost"}
) )
def set_lead_name(self): @frappe.whitelist()
if not self.lead_name: def create_prospect_and_contact(self, data):
# Check for leads being created through data import data = frappe._dict(data)
if not self.company_name and not self.email_id and not self.flags.ignore_mandatory: if data.create_contact:
frappe.throw(_("A Lead requires either a person's name or an organization's name")) self.create_contact()
elif self.company_name:
self.lead_name = self.company_name
else:
self.lead_name = self.email_id.split("@")[0]
def set_title(self): if data.create_prospect:
self.title = self.company_name or self.lead_name self.create_prospect(data.prospect_name)
def create_contact(self): def create_contact(self):
if frappe.db.get_single_value("CRM Settings", "auto_creation_of_contact"):
if not self.lead_name: if not self.lead_name:
self.set_full_name() self.set_full_name()
self.set_lead_name() self.set_lead_name()
@ -197,7 +204,7 @@ class Lead(SellingController):
"last_name": self.last_name, "last_name": self.last_name,
"salutation": self.salutation, "salutation": self.salutation,
"gender": self.gender, "gender": self.gender,
"designation": self.designation, "job_title": self.job_title,
"company_name": self.company_name, "company_name": self.company_name,
} }
) )
@ -216,6 +223,39 @@ class Lead(SellingController):
return 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",
{
"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,
},
)
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() @frappe.whitelist()
def make_customer(source_name, target_doc=None): def make_customer(source_name, target_doc=None):
@ -274,6 +314,8 @@ def make_opportunity(source_name, target_doc=None):
"company_name": "customer_name", "company_name": "customer_name",
"email_id": "contact_email", "email_id": "contact_email",
"mobile_no": "contact_mobile", "mobile_no": "contact_mobile",
"lead_owner": "opportunity_owner",
"notes": "notes",
}, },
} }
}, },
@ -422,21 +464,25 @@ def get_lead_with_phone_number(number):
return lead 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() @frappe.whitelist()
def add_lead_to_prospect(lead, prospect): def add_lead_to_prospect(lead, prospect):
prospect = frappe.get_doc("Prospect", prospect) prospect = frappe.get_doc("Prospect", prospect)
prospect.append("prospect_lead", {"lead": lead}) prospect.append("leads", {"lead": lead})
prospect.save(ignore_permissions=True) 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( frappe.msgprint(
_("Lead {0} has been added to prospect {1}.").format( _("Lead {0} has been added to prospect {1}.").format(
frappe.bold(lead), frappe.bold(prospect.name) frappe.bold(lead), frappe.bold(prospect.name)
), ),
title=_("Lead Added"), title=_("Lead -> Prospect"),
indicator="green", indicator="green",
) )

View File

@ -16,7 +16,7 @@ frappe.listview_settings['Lead'] = {
prospect.prospect_owner = r.lead_owner; prospect.prospect_owner = r.lead_owner;
leads.forEach(function(lead) { 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; lead_prospect_row.lead = lead.name;
}); });
frappe.set_route("Form", "Prospect", prospect.name); frappe.set_route("Form", "Prospect", prospect.name);

View File

@ -5,7 +5,10 @@
import unittest import unittest
import frappe 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") test_records = frappe.get_test_records("Lead")
@ -83,6 +86,105 @@ class TestLead(unittest.TestCase):
self.assertEqual(frappe.db.exists("Lead", lead_doc.name), None) self.assertEqual(frappe.db.exists("Lead", lead_doc.name), None)
self.assertEqual(len(address_1.get("links")), 1) 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): def make_lead(**args):
args = frappe._dict(args) args = frappe._dict(args)
@ -93,6 +195,7 @@ def make_lead(**args):
"first_name": args.first_name or "_Test", "first_name": args.first_name or "_Test",
"last_name": args.last_name or "Lead", "last_name": args.last_name or "Lead",
"email_id": args.email_id or "new_lead_{}@example.com".format(random_string(5)), "email_id": args.email_id or "new_lead_{}@example.com".format(random_string(5)),
"company_name": args.company_name or "_Test Company",
} }
).insert() ).insert()

View File

@ -32,13 +32,6 @@ frappe.ui.form.on("Opportunity", {
} }
}, },
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) { onload_post_render: function(frm) {
frm.get_field("items").grid.set_multiple_add("item_code", "qty"); frm.get_field("items").grid.set_multiple_add("item_code", "qty");
}, },
@ -130,6 +123,13 @@ frappe.ui.form.on("Opportunity", {
}); });
} }
} }
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) { set_contact_link: function(frm) {
@ -227,8 +227,7 @@ frappe.ui.form.on("Opportunity", {
'total': flt(total), 'total': flt(total),
'base_total': flt(base_total) 'base_total': flt(base_total)
}); });
} },
}); });
frappe.ui.form.on("Opportunity Item", { frappe.ui.form.on("Opportunity Item", {
calculate: function(frm, cdt, cdn) { calculate: function(frm, cdt, cdn) {
@ -264,13 +263,14 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
this.frm.trigger('currency'); this.frm.trigger('currency');
} }
refresh() {
this.show_notes();
this.show_activities();
}
setup_queries() { setup_queries() {
var me = this; 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); me.frm.set_query('customer_address', erpnext.queries.address_query);
this.frm.set_query("item_code", "items", function() { this.frm.set_query("item_code", "items", function() {
@ -287,6 +287,14 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
} }
else if (me.frm.doc.opportunity_from == "Customer") { else if (me.frm.doc.opportunity_from == "Customer") {
me.frm.set_query('party_name', erpnext.queries['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 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
frm: cur_frm 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})); extend_cscript(cur_frm.cscript, new erpnext.crm.Opportunity({frm: cur_frm}));

View File

@ -1,5 +1,6 @@
{ {
"actions": [], "actions": [],
"allow_events_in_timeline": 1,
"allow_import": 1, "allow_import": 1,
"allow_rename": 1, "allow_rename": 1,
"autoname": "naming_series:", "autoname": "naming_series:",
@ -11,68 +12,87 @@
"email_append_to": 1, "email_append_to": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"from_section",
"naming_series", "naming_series",
"opportunity_from", "opportunity_from",
"party_name", "party_name",
"customer_name", "customer_name",
"source",
"column_break0",
"title",
"opportunity_type",
"status", "status",
"converted_by", "column_break0",
"opportunity_type",
"source",
"opportunity_owner",
"column_break_10",
"sales_stage", "sales_stage",
"first_response_time",
"expected_closing", "expected_closing",
"next_contact", "probability",
"contact_by", "organization_details_section",
"contact_date", "no_of_employees",
"column_break2", "annual_revenue",
"to_discuss", "customer_group",
"column_break_23",
"industry",
"market_segment",
"website",
"column_break_31",
"city",
"state",
"country",
"territory",
"section_break_14", "section_break_14",
"currency", "currency",
"column_break_36",
"conversion_rate", "conversion_rate",
"base_opportunity_amount",
"with_items",
"column_break_17", "column_break_17",
"probability",
"opportunity_amount", "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",
"items", "items",
"section_break_32", "section_break_32",
"base_total", "base_total",
"column_break_33", "column_break_33",
"total", "total",
"contact_info", "activities_tab",
"customer_address", "open_activities_html",
"address_display", "all_activities_section",
"territory", "all_activities_html",
"customer_group", "notes_tab",
"column_break3", "notes_html",
"contact_person", "notes",
"contact_display", "dashboard_tab"
"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"
], ],
"fields": [ "fields": [
{
"fieldname": "from_section",
"fieldtype": "Section Break",
"options": "fa fa-user"
},
{ {
"fieldname": "naming_series", "fieldname": "naming_series",
"fieldtype": "Select", "fieldtype": "Select",
@ -113,8 +133,9 @@
"bold": 1, "bold": 1,
"fieldname": "customer_name", "fieldname": "customer_name",
"fieldtype": "Data", "fieldtype": "Data",
"hidden": 1,
"in_global_search": 1, "in_global_search": 1,
"label": "Customer / Lead Name", "label": "Customer Name",
"read_only": 1 "read_only": 1
}, },
{ {
@ -166,48 +187,10 @@
"fieldtype": "Date", "fieldtype": "Date",
"label": "Expected Closing Date" "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", "fieldname": "section_break_14",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Sales" "label": "Opportunity Value"
}, },
{ {
"fieldname": "currency", "fieldname": "currency",
@ -221,12 +204,6 @@
"label": "Opportunity Amount", "label": "Opportunity Amount",
"options": "currency" "options": "currency"
}, },
{
"default": "0",
"fieldname": "with_items",
"fieldtype": "Check",
"label": "With Items"
},
{ {
"fieldname": "column_break_17", "fieldname": "column_break_17",
"fieldtype": "Column Break" "fieldtype": "Column Break"
@ -245,9 +222,8 @@
"label": "Probability (%)" "label": "Probability (%)"
}, },
{ {
"depends_on": "with_items",
"fieldname": "items_section", "fieldname": "items_section",
"fieldtype": "Section Break", "fieldtype": "Tab Break",
"label": "Items", "label": "Items",
"oldfieldtype": "Section Break", "oldfieldtype": "Section Break",
"options": "fa fa-shopping-cart" "options": "fa fa-shopping-cart"
@ -262,18 +238,16 @@
"options": "Opportunity Item" "options": "Opportunity Item"
}, },
{ {
"collapsible": 1,
"collapsible_depends_on": "next_contact_by",
"depends_on": "eval:doc.party_name",
"fieldname": "contact_info", "fieldname": "contact_info",
"fieldtype": "Section Break", "fieldtype": "Tab Break",
"label": "Contact Info", "label": "Contacts",
"options": "fa fa-bullhorn" "options": "fa fa-bullhorn"
}, },
{ {
"depends_on": "eval:doc.party_name", "depends_on": "eval:doc.party_name",
"fieldname": "customer_address", "fieldname": "customer_address",
"fieldtype": "Link", "fieldtype": "Link",
"hidden": 1,
"label": "Customer / Lead Address", "label": "Customer / Lead Address",
"options": "Address", "options": "Address",
"print_hide": 1 "print_hide": 1
@ -327,19 +301,16 @@
"read_only": 1 "read_only": 1
}, },
{ {
"depends_on": "eval:doc.party_name",
"fieldname": "contact_email", "fieldname": "contact_email",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Contact Email", "label": "Contact Email",
"options": "Email", "options": "Email"
"read_only": 1
}, },
{ {
"depends_on": "eval:doc.party_name",
"fieldname": "contact_mobile", "fieldname": "contact_mobile",
"fieldtype": "Small Text", "fieldtype": "Data",
"label": "Contact Mobile No", "label": "Contact Mobile",
"read_only": 1 "options": "Phone"
}, },
{ {
"collapsible": 1, "collapsible": 1,
@ -416,12 +387,6 @@
"options": "Opportunity Lost Reason Detail", "options": "Opportunity Lost Reason Detail",
"read_only": 1 "read_only": 1
}, },
{
"fieldname": "converted_by",
"fieldtype": "Link",
"label": "Converted By",
"options": "User"
},
{ {
"bold": 1, "bold": 1,
"fieldname": "first_response_time", "fieldname": "first_response_time",
@ -474,6 +439,7 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"depends_on": "eval:doc.status===\"Lost\"",
"fieldname": "lost_detail_section", "fieldname": "lost_detail_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Lost Reasons" "label": "Lost Reasons"
@ -488,12 +454,179 @@
"label": "Competitors", "label": "Competitors",
"options": "Competitor Detail", "options": "Competitor Detail",
"read_only": 1 "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", "icon": "fa fa-info-sign",
"idx": 195, "idx": 195,
"links": [], "links": [],
"modified": "2022-01-29 19:32:26.382896", "modified": "2022-06-27 18:44:32.858696",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "CRM", "module": "CRM",
"name": "Opportunity", "name": "Opportunity",

View File

@ -6,53 +6,54 @@ import json
import frappe import frappe
from frappe import _ 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.email.inbox import link_communication_to_document
from frappe.model.mapper import get_mapped_doc from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import DocType, Interval from frappe.query_builder import DocType, Interval
from frappe.query_builder.functions import Now from frappe.query_builder.functions import Now
from frappe.utils import cint, flt, get_fullname 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.setup.utils import get_exchange_rate
from erpnext.utilities.transaction_base import TransactionBase 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): def after_insert(self):
if self.opportunity_from == "Lead": if self.opportunity_from == "Lead":
frappe.get_doc("Lead", self.party_name).set_status(update=True) 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"): if frappe.db.get_single_value("CRM Settings", "carry_forward_communication_and_comments"):
copy_comments(self.opportunity_from, self.party_name, self) 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): 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.make_new_lead_if_required()
self.validate_item_details() self.validate_item_details()
self.validate_uom_is_integer("uom", "qty") self.validate_uom_is_integer("uom", "qty")
self.validate_cust_name() self.validate_cust_name()
self.map_fields() self.map_fields()
self.set_exchange_rate()
if not self.title: if not self.title:
self.title = self.customer_name 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): def map_fields(self):
for field in self.meta.get_valid_columns(): for field in self.meta.get_valid_columns():
@ -63,18 +64,65 @@ class Opportunity(TransactionBase):
except Exception: except Exception:
continue 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): def calculate_totals(self):
total = base_total = 0 total = base_total = 0
for item in self.get("items"): for item in self.get("items"):
item.amount = flt(item.rate) * flt(item.qty) item.amount = flt(item.rate) * flt(item.qty)
item.base_rate = flt(self.conversion_rate * item.rate) item.base_rate = flt(self.conversion_rate) * flt(item.rate)
item.base_amount = flt(self.conversion_rate * item.amount) item.base_amount = flt(self.conversion_rate) * flt(item.amount)
total += item.amount total += item.amount
base_total += item.base_amount base_total += item.base_amount
self.total = flt(total) self.total = flt(total)
self.base_total = flt(base_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): def make_new_lead_if_required(self):
"""Set lead against new opportunity""" """Set lead against new opportunity"""
if (not self.get("party_name")) and self.contact_email: if (not self.get("party_name")) and self.contact_email:
@ -144,11 +192,8 @@ class Opportunity(TransactionBase):
else: else:
frappe.throw(_("Cannot declare as lost, because Quotation has been made.")) frappe.throw(_("Cannot declare as lost, because Quotation has been made."))
def on_trash(self):
self.delete_events()
def has_active_quotation(self): def has_active_quotation(self):
if not self.with_items: if not self.get("items", []):
return frappe.get_all( return frappe.get_all(
"Quotation", "Quotation",
{"opportunity": self.name, "status": ("not in", ["Lost", "Closed"]), "docstatus": 1}, {"opportunity": self.name, "status": ("not in", ["Lost", "Closed"]), "docstatus": 1},
@ -165,7 +210,7 @@ class Opportunity(TransactionBase):
) )
def has_ordered_quotation(self): def has_ordered_quotation(self):
if not self.with_items: if not self.get("items", []):
return frappe.get_all( return frappe.get_all(
"Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name" "Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name"
) )
@ -195,43 +240,20 @@ class Opportunity(TransactionBase):
return True return True
def validate_cust_name(self): def validate_cust_name(self):
if self.party_name and self.opportunity_from == "Customer": if self.party_name:
if self.opportunity_from == "Customer":
self.customer_name = frappe.db.get_value("Customer", self.party_name, "customer_name") self.customer_name = frappe.db.get_value("Customer", self.party_name, "customer_name")
elif self.party_name and self.opportunity_from == "Lead": 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_name, company_name = frappe.db.get_value(
"Lead", self.party_name, ["lead_name", "company_name"] "Lead", self.party_name, ["lead_name", "company_name"]
) )
self.customer_name = company_name or lead_name customer_name = company_name or lead_name
def on_update(self): self.customer_name = customer_name
self.add_calendar_event() elif self.opportunity_from == "Prospect":
self.customer_name = self.party_name
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)
def validate_item_details(self): def validate_item_details(self):
if not self.get("items"): if not self.get("items"):
@ -295,7 +317,7 @@ def make_quotation(source_name, target_doc=None):
quotation.run_method("set_missing_values") quotation.run_method("set_missing_values")
quotation.run_method("calculate_taxes_and_totals") quotation.run_method("calculate_taxes_and_totals")
if not source.with_items: if not source.get("items", []):
quotation.opportunity = source.name quotation.opportunity = source.name
doclist = get_mapped_doc( doclist = get_mapped_doc(
@ -440,34 +462,3 @@ def make_opportunity_from_communication(communication, company, ignore_communica
link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links) link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links)
return opportunity.name 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

View File

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

View File

@ -77,42 +77,6 @@ class TestOpportunity(unittest.TestCase):
create_communication(opp_doc.doctype, opp_doc.name, opp_doc.contact_email) create_communication(opp_doc.doctype, opp_doc.name, opp_doc.contact_email)
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(): def make_opportunity_from_lead():
new_lead_email_id = "new{}@example.com".format(random_string(5)) new_lead_email_id = "new{}@example.com".format(random_string(5))
@ -139,7 +103,6 @@ def make_opportunity(**args):
"opportunity_from": args.opportunity_from or "Customer", "opportunity_from": args.opportunity_from or "Customer",
"opportunity_type": "Sales", "opportunity_type": "Sales",
"conversion_rate": 1.0, "conversion_rate": 1.0,
"with_items": args.with_items or 0,
"transaction_date": today(), "transaction_date": today(),
} }
) )

View File

@ -8,7 +8,9 @@
"transaction_date": "2013-12-12", "transaction_date": "2013-12-12",
"items": [{ "items": [{
"item_name": "Test Item", "item_name": "Test Item",
"description": "Some description" "description": "Some description",
"qty": 5,
"rate": 100
}] }]
} }
] ]

View File

@ -27,5 +27,26 @@ frappe.ui.form.on('Prospect', {
} else { } else {
frappe.contacts.clear_address_and_contact(frm); 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();
}
}); });

View File

@ -1,33 +1,42 @@
{ {
"actions": [], "actions": [],
"allow_events_in_timeline": 1,
"autoname": "field:company_name", "autoname": "field:company_name",
"creation": "2021-08-19 00:21:06.995448", "creation": "2021-08-19 00:21:06.995448",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"overview_tab",
"company_name", "company_name",
"industry",
"market_segment",
"customer_group", "customer_group",
"no_of_employees",
"annual_revenue",
"column_break_4",
"market_segment",
"industry",
"territory", "territory",
"column_break_6", "column_break_6",
"no_of_employees",
"currency",
"annual_revenue",
"more_details_section",
"fax",
"website",
"column_break_13",
"prospect_owner", "prospect_owner",
"website",
"fax",
"company", "company",
"leads_section",
"prospect_lead",
"address_and_contact_section", "address_and_contact_section",
"column_break_16",
"contacts_tab",
"address_html", "address_html",
"column_break_17", "column_break_18",
"contact_html", "contact_html",
"leads_section",
"leads",
"opportunities_tab",
"opportunities",
"activities_tab",
"open_activities_html",
"all_activities_section",
"all_activities_html",
"notes_section", "notes_section",
"notes_html",
"notes" "notes"
], ],
"fields": [ "fields": [
@ -71,15 +80,9 @@
}, },
{ {
"fieldname": "no_of_employees", "fieldname": "no_of_employees",
"fieldtype": "Int", "fieldtype": "Select",
"label": "No. of Employees" "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": "currency",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Currency",
"options": "Currency"
}, },
{ {
"fieldname": "annual_revenue", "fieldname": "annual_revenue",
@ -97,8 +100,7 @@
{ {
"fieldname": "website", "fieldname": "website",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Website", "label": "Website"
"options": "URL"
}, },
{ {
"fieldname": "prospect_owner", "fieldname": "prospect_owner",
@ -108,23 +110,14 @@
}, },
{ {
"fieldname": "leads_section", "fieldname": "leads_section",
"fieldtype": "Section Break", "fieldtype": "Tab Break",
"label": "Leads" "label": "Leads"
}, },
{
"fieldname": "prospect_lead",
"fieldtype": "Table",
"options": "Prospect Lead"
},
{ {
"fieldname": "address_html", "fieldname": "address_html",
"fieldtype": "HTML", "fieldtype": "HTML",
"label": "Address HTML" "label": "Address HTML"
}, },
{
"fieldname": "column_break_17",
"fieldtype": "Column Break"
},
{ {
"fieldname": "contact_html", "fieldname": "contact_html",
"fieldtype": "HTML", "fieldtype": "HTML",
@ -132,28 +125,16 @@
}, },
{ {
"collapsible": 1, "collapsible": 1,
"depends_on": "eval:!doc.__islocal",
"fieldname": "notes_section", "fieldname": "notes_section",
"fieldtype": "Section Break", "fieldtype": "Tab Break",
"label": "Notes" "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", "depends_on": "eval: !doc.__islocal",
"fieldname": "address_and_contact_section", "fieldname": "address_and_contact_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Address and Contact" "label": "Address"
}, },
{ {
"fieldname": "company", "fieldname": "company",
@ -161,11 +142,83 @@
"label": "Company", "label": "Company",
"options": "Company", "options": "Company",
"reqd": 1 "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, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2021-11-01 13:10:36.759249", "modified": "2022-06-21 15:10:26.887502",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "CRM", "module": "CRM",
"name": "Prospect", "name": "Prospect",
@ -207,6 +260,7 @@
], ],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"title_field": "company_name", "title_field": "company_name",
"track_changes": 1 "track_changes": 1
} }

View File

@ -3,19 +3,15 @@
import frappe import frappe
from frappe.contacts.address_and_contact import load_address_and_contact 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 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): def onload(self):
load_address_and_contact(self) load_address_and_contact(self)
def validate(self):
self.update_lead_details()
def on_update(self): def on_update(self):
self.link_with_lead_contact_and_address() self.link_with_lead_contact_and_address()
@ -23,23 +19,24 @@ class Prospect(Document):
self.unlink_dynamic_links() self.unlink_dynamic_links()
def after_insert(self): def after_insert(self):
if frappe.db.get_single_value("CRM Settings", "carry_forward_communication_and_comments"): carry_forward_communication_and_comments = frappe.db.get_single_value(
for row in self.get("prospect_lead"): "CRM Settings", "carry_forward_communication_and_comments"
copy_comments("Lead", row.lead, self)
add_link_in_communication("Lead", row.lead, self)
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 for row in self.get("leads"):
row.email = lead.email_id if carry_forward_communication_and_comments:
row.mobile_no = lead.mobile_no 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): def link_with_lead_contact_and_address(self):
for row in self.prospect_lead: for row in self.leads:
links = frappe.get_all( links = frappe.get_all(
"Dynamic Link", "Dynamic Link",
filters={"link_doctype": "Lead", "link_name": row.lead}, filters={"link_doctype": "Lead", "link_name": row.lead},
@ -116,9 +113,7 @@ def make_opportunity(source_name, target_doc=None):
{ {
"Prospect": { "Prospect": {
"doctype": "Opportunity", "doctype": "Opportunity",
"field_map": { "field_map": {"name": "party_name", "prospect_owner": "opportunity_owner"},
"name": "party_name",
},
} }
}, },
target_doc, target_doc,
@ -127,3 +122,25 @@ def make_opportunity(source_name, target_doc=None):
) )
return doclist 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",
],
)

View File

@ -20,7 +20,7 @@ class TestProspect(unittest.TestCase):
add_lead_to_prospect(lead_doc.name, prospect_doc.name) add_lead_to_prospect(lead_doc.name, prospect_doc.name)
prospect_doc.reload() prospect_doc.reload()
lead_exists_in_prosoect = False 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: if rec.lead == lead_doc.name:
lead_exists_in_prosoect = True lead_exists_in_prosoect = True
self.assertEqual(lead_exists_in_prosoect, True) self.assertEqual(lead_exists_in_prosoect, True)

View File

@ -7,12 +7,15 @@
"field_order": [ "field_order": [
"lead", "lead",
"lead_name", "lead_name",
"status",
"email", "email",
"mobile_no" "column_break_4",
"mobile_no",
"lead_owner",
"status"
], ],
"fields": [ "fields": [
{ {
"columns": 2,
"fieldname": "lead", "fieldname": "lead",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
@ -21,6 +24,8 @@
"reqd": 1 "reqd": 1
}, },
{ {
"columns": 2,
"fetch_from": "lead.lead_name",
"fieldname": "lead_name", "fieldname": "lead_name",
"fieldtype": "Data", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
@ -28,14 +33,17 @@
"read_only": 1 "read_only": 1
}, },
{ {
"columns": 1,
"fetch_from": "lead.status",
"fieldname": "status", "fieldname": "status",
"fieldtype": "Select", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"label": "Status", "label": "Status",
"options": "Lead\nOpen\nReplied\nOpportunity\nQuotation\nLost Quotation\nInterested\nConverted\nDo Not Contact",
"read_only": 1 "read_only": 1
}, },
{ {
"columns": 2,
"fetch_from": "lead.email_id",
"fieldname": "email", "fieldname": "email",
"fieldtype": "Data", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
@ -44,18 +52,32 @@
"read_only": 1 "read_only": 1
}, },
{ {
"columns": 2,
"fetch_from": "lead.mobile_no",
"fieldname": "mobile_no", "fieldname": "mobile_no",
"fieldtype": "Data", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"label": "Mobile No", "label": "Mobile No",
"options": "Phone", "options": "Phone",
"read_only": 1 "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, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2021-08-25 12:58:24.638054", "modified": "2022-04-28 20:27:58.805970",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "CRM", "module": "CRM",
"name": "Prospect Lead", "name": "Prospect Lead",
@ -63,5 +85,6 @@
"permissions": [], "permissions": [],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@ -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": []
}

View File

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

View File

@ -57,11 +57,5 @@ frappe.query_reports["Lost Opportunity"] = {
"fieldtype": "Dynamic Link", "fieldtype": "Dynamic Link",
"options": "opportunity_from" "options": "opportunity_from"
}, },
{
"fieldname":"contact_by",
"label": __("Next Contact By"),
"fieldtype": "Link",
"options": "User"
},
] ]
}; };

View File

@ -7,8 +7,8 @@
"doctype": "Report", "doctype": "Report",
"idx": 0, "idx": 0,
"is_standard": "Yes", "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}", "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": "2020-07-29 15:49:02.848845", "modified": "2022-06-04 15:49:02.848845",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "CRM", "module": "CRM",
"name": "Lost Opportunity", "name": "Lost Opportunity",

View File

@ -61,13 +61,6 @@ def get_columns():
"options": "Territory", "options": "Territory",
"width": 150, "width": 150,
}, },
{
"label": _("Next Contact By"),
"fieldname": "contact_by",
"fieldtype": "Link",
"options": "User",
"width": 150,
},
] ]
return columns return columns
@ -81,7 +74,6 @@ def get_data(filters):
`tabOpportunity`.party_name, `tabOpportunity`.party_name,
`tabOpportunity`.customer_name, `tabOpportunity`.customer_name,
`tabOpportunity`.opportunity_type, `tabOpportunity`.opportunity_type,
`tabOpportunity`.contact_by,
GROUP_CONCAT(`tabOpportunity Lost Reason Detail`.lost_reason separator ', ') lost_reason, GROUP_CONCAT(`tabOpportunity Lost Reason Detail`.lost_reason separator ', ') lost_reason,
`tabOpportunity`.sales_stage, `tabOpportunity`.sales_stage,
`tabOpportunity`.territory `tabOpportunity`.territory
@ -115,9 +107,6 @@ def get_conditions(filters):
if filters.get("party_name"): if filters.get("party_name"):
conditions.append(" and `tabOpportunity`.party_name=%(party_name)s") 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 "" return " ".join(conditions) if conditions else ""

View File

@ -1,4 +1,7 @@
import frappe 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): def update_lead_phone_numbers(contact, method):
@ -41,7 +44,7 @@ def copy_comments(doctype, docname, doc):
comment.insert() comment.insert()
def add_link_in_communication(doctype, docname, doc): def link_communications(doctype, docname, doc):
communication_list = get_linked_communication_list(doctype, docname) communication_list = get_linked_communication_list(doctype, docname)
for communication in communication_list: for communication in communication_list:
@ -60,3 +63,159 @@ def get_linked_communication_list(doctype, docname):
) )
return communications + communication_links 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()

View File

@ -299,7 +299,11 @@ doc_events = {
"on_update": [ "on_update": [
"erpnext.support.doctype.service_level_agreement.service_level_agreement.on_communication_update", "erpnext.support.doctype.service_level_agreement.service_level_agreement.on_communication_update",
"erpnext.support.doctype.issue.issue.set_first_response_time", "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": { "Sales Taxes and Charges Template": {
"on_update": "erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings.validate_cart_settings" "on_update": "erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings.validate_cart_settings"
@ -398,6 +402,14 @@ scheduler_events = {
"0/30 * * * *": [ "0/30 * * * *": [
"erpnext.utilities.doctype.video.video.update_youtube_data", "erpnext.utilities.doctype.video.video.update_youtube_data",
], ],
# Hourly but offset by 30 minutes
"30 * * * *": [
"erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs",
],
# Daily but offset by 45 minutes
"45 0 * * *": [
"erpnext.stock.reorder_item.reorder_item",
],
}, },
"all": [ "all": [
"erpnext.projects.doctype.project.project.project_status_update_reminder", "erpnext.projects.doctype.project.project.project_status_update_reminder",
@ -407,7 +419,6 @@ scheduler_events = {
"hourly": [ "hourly": [
"erpnext.hr.doctype.daily_work_summary_group.daily_work_summary_group.trigger_emails", "erpnext.hr.doctype.daily_work_summary_group.daily_work_summary_group.trigger_emails",
"erpnext.accounts.doctype.subscription.subscription.process_all", "erpnext.accounts.doctype.subscription.subscription.process_all",
"erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs",
"erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization", "erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization",
"erpnext.projects.doctype.project.project.hourly_reminder", "erpnext.projects.doctype.project.project.hourly_reminder",
"erpnext.projects.doctype.project.project.collect_project_status", "erpnext.projects.doctype.project.project.collect_project_status",
@ -418,7 +429,6 @@ scheduler_events = {
"erpnext.bulk_transaction.doctype.bulk_transaction_log.bulk_transaction_log.retry_failing_transaction", "erpnext.bulk_transaction.doctype.bulk_transaction_log.bulk_transaction_log.retry_failing_transaction",
], ],
"daily": [ "daily": [
"erpnext.stock.reorder_item.reorder_item",
"erpnext.support.doctype.issue.issue.auto_close_tickets", "erpnext.support.doctype.issue.issue.auto_close_tickets",
"erpnext.crm.doctype.opportunity.opportunity.auto_close_opportunity", "erpnext.crm.doctype.opportunity.opportunity.auto_close_opportunity",
"erpnext.controllers.accounts_controller.update_invoice_status", "erpnext.controllers.accounts_controller.update_invoice_status",
@ -453,7 +463,7 @@ scheduler_events = {
"erpnext.hr.utils.allocate_earned_leaves", "erpnext.hr.utils.allocate_earned_leaves",
"erpnext.loan_management.doctype.process_loan_security_shortfall.process_loan_security_shortfall.create_process_loan_security_shortfall", "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.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",
], ],
"weekly": ["erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_weekly"], "weekly": ["erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_weekly"],
"monthly": ["erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_monthly"], "monthly": ["erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_monthly"],

View File

@ -445,6 +445,7 @@ class BOM(WebsiteGenerator):
and self.is_active and self.is_active
): ):
frappe.db.set(self, "is_default", 1) frappe.db.set(self, "is_default", 1)
frappe.db.set_value("Item", self.item, "default_bom", self.name)
else: else:
frappe.db.set(self, "is_default", 0) frappe.db.set(self, "is_default", 0)
item = frappe.get_doc("Item", self.item) item = frappe.get_doc("Item", self.item)

View File

@ -559,6 +559,42 @@ class TestBOM(FrappeTestCase):
bom.submit() bom.submit()
self.assertEqual(bom.items[0].rate, 42) 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"): 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}) return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1})

View File

@ -7,11 +7,11 @@
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"current_bom",
"new_bom",
"column_break_3",
"update_type", "update_type",
"status", "status",
"column_break_3",
"current_bom",
"new_bom",
"error_log", "error_log",
"progress_section", "progress_section",
"current_level", "current_level",
@ -37,6 +37,7 @@
"options": "BOM" "options": "BOM"
}, },
{ {
"depends_on": "eval:doc.update_type === \"Replace BOM\"",
"fieldname": "column_break_3", "fieldname": "column_break_3",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
@ -87,6 +88,7 @@
"options": "BOM Update Batch" "options": "BOM Update Batch"
}, },
{ {
"depends_on": "eval:doc.status !== \"Completed\"",
"fieldname": "current_level", "fieldname": "current_level",
"fieldtype": "Int", "fieldtype": "Int",
"label": "Current Level" "label": "Current Level"
@ -96,7 +98,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2022-06-06 15:15:23.883251", "modified": "2022-06-20 15:43:55.696388",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "BOM Update Log", "name": "BOM Update Log",

View File

@ -6,6 +6,8 @@ from typing import Any, Dict, List, Optional, Tuple, Union
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder import DocType, Interval
from frappe.query_builder.functions import Now
from frappe.utils import cint, cstr from frappe.utils import cint, cstr
from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import ( from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import (
@ -22,6 +24,17 @@ class BOMMissingError(frappe.ValidationError):
class BOMUpdateLog(Document): 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): def validate(self):
if self.update_type == "Replace BOM": if self.update_type == "Replace BOM":
self.validate_boms_are_specified() self.validate_boms_are_specified()
@ -77,7 +90,11 @@ class BOMUpdateLog(Document):
now=frappe.flags.in_test, now=frappe.flags.in_test,
) )
else: else:
process_boms_cost_level_wise(self) frappe.enqueue(
method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.process_boms_cost_level_wise",
update_doc=self,
now=frappe.flags.in_test,
)
def run_replace_bom_job( def run_replace_bom_job(
@ -112,6 +129,7 @@ def process_boms_cost_level_wise(
current_boms = {} current_boms = {}
values = {} values = {}
try:
if update_doc.status == "Queued": if update_doc.status == "Queued":
# First level yet to process. On Submit. # First level yet to process. On Submit.
current_level = 0 current_level = 0
@ -134,6 +152,8 @@ def process_boms_cost_level_wise(
set_values_in_log(update_doc.name, values, commit=True) set_values_in_log(update_doc.name, values, commit=True)
queue_bom_cost_jobs(current_boms, update_doc, current_level) queue_bom_cost_jobs(current_boms, update_doc, current_level)
except Exception:
handle_exception(update_doc)
def queue_bom_cost_jobs( def queue_bom_cost_jobs(
@ -199,16 +219,22 @@ def resume_bom_cost_update_jobs():
current_boms, processed_boms = get_processed_current_boms(log, bom_batches) 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) parent_boms = get_next_higher_level_boms(child_boms=current_boms, processed_boms=processed_boms)
# Unset processed BOMs if log is complete, it is used for next level 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( set_values_in_log(
log.name, log.name,
values={ values={
"processed_boms": json.dumps([] if not parent_boms else processed_boms), "processed_boms": processed_boms,
"status": "Completed" if not parent_boms else "In Progress", "status": status,
}, },
commit=True, 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 if parent_boms: # there is a next level to process
process_boms_cost_level_wise( process_boms_cost_level_wise(
update_doc=frappe.get_doc("BOM Update Log", log.name), parent_boms=parent_boms update_doc=frappe.get_doc("BOM Update Log", log.name), parent_boms=parent_boms

View File

@ -1,6 +1,6 @@
frappe.listview_settings['BOM Update Log'] = { frappe.listview_settings['BOM Update Log'] = {
add_fields: ["status"], add_fields: ["status"],
get_indicator: function(doc) { get_indicator: (doc) => {
let status_map = { let status_map = {
"Queued": "orange", "Queued": "orange",
"In Progress": "blue", "In Progress": "blue",
@ -9,5 +9,22 @@ frappe.listview_settings['BOM Update Log'] = {
}; };
return [__(doc.status), status_map[doc.status], "status,=," + doc.status]; 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);
});
},
}; };

View File

@ -25,6 +25,7 @@ from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_childr
from erpnext.manufacturing.doctype.bom.bom import validate_bom_no from erpnext.manufacturing.doctype.bom.bom import validate_bom_no
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details 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.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.utilities.transaction_base import validate_uom_is_integer
class ProductionPlan(Document): class ProductionPlan(Document):
@ -33,6 +34,7 @@ class ProductionPlan(Document):
self.calculate_total_planned_qty() self.calculate_total_planned_qty()
self.set_status() self.set_status()
self._rename_temporary_references() self._rename_temporary_references()
validate_uom_is_integer(self, "stock_uom", "planned_qty")
def set_pending_qty_in_row_without_reference(self): def set_pending_qty_in_row_without_reference(self):
"Set Pending Qty in independent rows (not from SO or MR)." "Set Pending Qty in independent rows (not from SO or MR)."

View File

@ -679,15 +679,23 @@ class TestProductionPlan(FrappeTestCase):
self.assertFalse(pp.all_items_completed()) self.assertFalse(pp.all_items_completed())
def test_production_plan_planned_qty(self): def test_production_plan_planned_qty(self):
pln = create_production_plan(item_code="_Test FG Item", planned_qty=0.55) # Case 1: When Planned Qty is non-integer and UOM is integer.
pln.make_work_order() from erpnext.utilities.transaction_base import UOMMustBeIntegerError
work_order = frappe.db.get_value("Work Order", {"production_plan": pln.name}, "name")
wo_doc = frappe.get_doc("Work Order", work_order) self.assertRaises(
wo_doc.update( UOMMustBeIntegerError, create_production_plan, item_code="_Test FG Item", planned_qty=0.55
{"wip_warehouse": "Work In Progress - _TC", "fg_warehouse": "Finished Goods - _TC"}
) )
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): def test_temporary_name_relinking(self):
@ -751,6 +759,7 @@ def create_production_plan(**args):
"bom_no": frappe.db.get_value("Item", args.item_code, "default_bom"), "bom_no": frappe.db.get_value("Item", args.item_code, "default_bom"),
"planned_qty": args.planned_qty or 1, "planned_qty": args.planned_qty or 1,
"planned_start_date": args.planned_start_date or now_datetime(), "planned_start_date": args.planned_start_date or now_datetime(),
"stock_uom": args.stock_uom or "Nos",
}, },
) )

View File

@ -1,6 +1,8 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import copy
import frappe import frappe
from frappe.tests.utils import FrappeTestCase, change_settings, timeout from frappe.tests.utils import FrappeTestCase, change_settings, timeout
from frappe.utils import add_days, add_months, cint, flt, now, today from frappe.utils import add_days, add_months, cint, flt, now, today
@ -19,6 +21,7 @@ from erpnext.manufacturing.doctype.work_order.work_order import (
) )
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order 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.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.stock_entry import test_stock_entry
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.utils import get_bin from erpnext.stock.utils import get_bin
@ -28,6 +31,7 @@ class TestWorkOrder(FrappeTestCase):
def setUp(self): def setUp(self):
self.warehouse = "_Test Warehouse 2 - _TC" self.warehouse = "_Test Warehouse 2 - _TC"
self.item = "_Test Item" self.item = "_Test Item"
prepare_data_for_backflush_based_on_materials_transferred()
def tearDown(self): def tearDown(self):
frappe.db.rollback() frappe.db.rollback()
@ -527,6 +531,8 @@ class TestWorkOrder(FrappeTestCase):
work_order.cancel() work_order.cancel()
def test_work_order_with_non_transfer_item(self): 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} items = {"Finished Good Transfer Item": 1, "_Test FG Item": 1, "_Test FG Item 1": 0}
for item, allow_transfer in items.items(): for item, allow_transfer in items.items():
make_item(item, {"include_item_in_manufacturing": allow_transfer}) make_item(item, {"include_item_in_manufacturing": allow_transfer})
@ -1071,7 +1077,7 @@ class TestWorkOrder(FrappeTestCase):
sm = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 100)) sm = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 100))
for row in sm.get("items"): for row in sm.get("items"):
if row.get("item_code") == "_Test Item": if row.get("item_code") == "_Test Item":
row.qty = 110 row.qty = 120
sm.submit() sm.submit()
cancel_stock_entry.append(sm.name) cancel_stock_entry.append(sm.name)
@ -1079,21 +1085,21 @@ class TestWorkOrder(FrappeTestCase):
s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 90)) s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 90))
for row in s.get("items"): for row in s.get("items"):
if row.get("item_code") == "_Test Item": if row.get("item_code") == "_Test Item":
self.assertEqual(row.get("qty"), 100) self.assertEqual(row.get("qty"), 108)
s.submit() s.submit()
cancel_stock_entry.append(s.name) cancel_stock_entry.append(s.name)
s1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5)) s1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5))
for row in s1.get("items"): for row in s1.get("items"):
if row.get("item_code") == "_Test Item": if row.get("item_code") == "_Test Item":
self.assertEqual(row.get("qty"), 5) self.assertEqual(row.get("qty"), 6)
s1.submit() s1.submit()
cancel_stock_entry.append(s1.name) cancel_stock_entry.append(s1.name)
s2 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5)) s2 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5))
for row in s2.get("items"): for row in s2.get("items"):
if row.get("item_code") == "_Test Item": if row.get("item_code") == "_Test Item":
self.assertEqual(row.get("qty"), 5) self.assertEqual(row.get("qty"), 6)
cancel_stock_entry.reverse() cancel_stock_entry.reverse()
for ste in cancel_stock_entry: for ste in cancel_stock_entry:
@ -1203,6 +1209,269 @@ class TestWorkOrder(FrappeTestCase):
self.assertEqual(work_order.required_items[0].transferred_qty, 1) self.assertEqual(work_order.required_items[0].transferred_qty, 1)
self.assertEqual(work_order.required_items[1].transferred_qty, 2) 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): def update_job_card(job_card, jc_qty=None):
employee = frappe.db.get_value("Employee", {"status": "Active"}, "name") employee = frappe.db.get_value("Employee", {"status": "Active"}, "name")

View File

@ -375,3 +375,4 @@ execute:frappe.delete_doc("DocType", "Naming Series")
erpnext.patches.v13_0.set_payroll_entry_status erpnext.patches.v13_0.set_payroll_entry_status
erpnext.patches.v13_0.job_card_status_on_hold erpnext.patches.v13_0.job_card_status_on_hold
erpnext.patches.v14_0.migrate_gl_to_payment_ledger erpnext.patches.v14_0.migrate_gl_to_payment_ledger
erpnext.patches.v14_0.crm_ux_cleanup

View File

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

View File

@ -1,6 +1,6 @@
import frappe import frappe
from frappe import qb from frappe import qb
from frappe.query_builder import Case from frappe.query_builder import Case, CustomFunction
from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import IfNull from frappe.query_builder.functions import IfNull
@ -87,6 +87,7 @@ def execute():
gl = qb.DocType("GL Entry") gl = qb.DocType("GL Entry")
account = qb.DocType("Account") account = qb.DocType("Account")
ifelse = CustomFunction("IF", ["condition", "then", "else"])
gl_entries = ( gl_entries = (
qb.from_(gl) qb.from_(gl)
@ -96,8 +97,12 @@ def execute():
gl.star, gl.star,
ConstantColumn(1).as_("docstatus"), ConstantColumn(1).as_("docstatus"),
account.account_type.as_("account_type"), account.account_type.as_("account_type"),
IfNull(gl.against_voucher_type, gl.voucher_type).as_("against_voucher_type"), IfNull(
IfNull(gl.against_voucher, gl.voucher_no).as_("against_voucher_no"), 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 # convert debit/credit to amount
Case() Case()
.when(account.account_type == "Receivable", gl.debit - gl.credit) .when(account.account_type == "Receivable", gl.debit - gl.credit)

View File

@ -1035,8 +1035,8 @@ def employee_query(doctype, txt, searchfield, start, page_len, filters):
{emp_cond} {emp_cond}
{fcond} {mcond} {fcond} {mcond}
order by 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),
if(locate(%(_txt)s, employee_name), locate(%(_txt)s, employee_name), 99999), (case when locate(%(_txt)s, employee_name) > 0 then locate(%(_txt)s, employee_name) else 99999 end),
idx desc, idx desc,
name, employee_name name, employee_name
limit %(page_len)s offset %(start)s""".format( limit %(page_len)s offset %(start)s""".format(

View File

@ -624,7 +624,7 @@ class SalarySlip(TransactionBase):
data = self.get_data_for_eval() data = self.get_data_for_eval()
for struct_row in self._salary_structure_doc.get(component_type): for struct_row in self._salary_structure_doc.get(component_type):
amount = self.eval_condition_and_formula(struct_row, data) amount = self.eval_condition_and_formula(struct_row, data)
if amount and struct_row.statistical_component == 0: if amount is not None and struct_row.statistical_component == 0:
self.update_component_row(struct_row, amount, component_type) self.update_component_row(struct_row, amount, component_type)
def get_data_for_eval(self): def get_data_for_eval(self):
@ -854,6 +854,10 @@ class SalarySlip(TransactionBase):
component_row, joining_date, relieving_date component_row, joining_date, relieving_date
)[0] )[0]
# remove 0 valued components that have been updated later
if component_row.amount == 0:
self.remove(component_row)
def set_precision_for_component_amounts(self): def set_precision_for_component_amounts(self):
for component_type in ("earnings", "deductions"): for component_type in ("earnings", "deductions"):
for component_row in self.get(component_type): for component_row in self.get(component_type):

View File

@ -387,8 +387,8 @@ def get_users_for_project(doctype, txt, searchfield, start, page_len, filters):
or full_name like %(txt)s) or full_name like %(txt)s)
{fcond} {mcond} {fcond} {mcond}
order by 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),
if(locate(%(_txt)s, full_name), locate(%(_txt)s, full_name), 99999), (case when locate(%(_txt)s, full_name) > 0 then locate(%(_txt)s, full_name) else 99999 end)
idx desc, idx desc,
name, full_name name, full_name
limit %(page_len)s offset %(start)s""".format( limit %(page_len)s offset %(start)s""".format(

View File

@ -84,7 +84,9 @@ class TestTimesheet(unittest.TestCase):
emp = make_employee("test_employee_6@salary.com") emp = make_employee("test_employee_6@salary.com")
timesheet = make_timesheet(emp, simulate=True, is_billable=1) 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.due_date = nowdate()
sales_invoice.submit() sales_invoice.submit()
timesheet = frappe.get_doc("Timesheet", timesheet.name) timesheet = frappe.get_doc("Timesheet", timesheet.name)

View File

@ -1107,10 +1107,26 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
} }
} }
is_a_mapped_document(item) {
const mapped_item_field_map = {
"Delivery Note Item": ["si_detail", "so_detail", "dn_detail"],
"Sales Invoice Item": ["dn_detail", "so_detail", "sales_invoice_item"],
"Purchase Receipt Item": ["purchase_order_item", "purchase_invoice_item", "purchase_receipt_item"],
"Purchase Invoice Item": ["purchase_order_item", "pr_detail", "po_detail"],
};
const mappped_fields = mapped_item_field_map[item.doctype] || [];
return mappped_fields
.map((field) => item[field])
.filter(Boolean).length > 0;
}
batch_no(doc, cdt, cdn) { batch_no(doc, cdt, cdn) {
let item = frappe.get_doc(cdt, cdn); let item = frappe.get_doc(cdt, cdn);
if (!this.is_a_mapped_document(item)) {
this.apply_price_list(item, true); this.apply_price_list(item, true);
} }
}
toggle_conversion_factor(item) { toggle_conversion_factor(item) {
// toggle read only property for conversion factor field if the uom and stock uom are same // toggle read only property for conversion factor field if the uom and stock uom are same

View File

@ -22,5 +22,8 @@ import "./utils/barcode_scanner";
import "./telephony"; import "./telephony";
import "./templates/call_link.html"; import "./templates/call_link.html";
import "./bulk_transaction_processing"; 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' // import { sum } from 'frappe/public/utils/util.js'

View File

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

View File

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

View File

@ -713,7 +713,7 @@ erpnext.utils.map_current_doc = function(opts) {
get_query: opts.get_query, get_query: opts.get_query,
add_filters_group: 1, add_filters_group: 1,
allow_child_item_selection: opts.allow_child_item_selection, allow_child_item_selection: opts.allow_child_item_selection,
child_fieldname: opts.child_fielname, child_fieldname: opts.child_fieldname,
child_columns: opts.child_columns, child_columns: opts.child_columns,
size: opts.size, size: opts.size,
action: function(selections, args) { action: function(selections, args) {

View File

@ -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();
}
}
});
}
};

View File

@ -375,6 +375,12 @@ def create_internal_customer(
if not allowed_to_interact_with: if not allowed_to_interact_with:
allowed_to_interact_with = represents_company 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): if not frappe.db.exists("Customer", customer_name):
customer = frappe.get_doc( customer = frappe.get_doc(
{ {

View File

@ -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 // License: GNU General Public License v3. See license.txt
cur_frm.cscript.refresh = function(doc, cdt, cdn) { frappe.ui.form.on("Product Bundle", {
cur_frm.toggle_enable('new_item_code', doc.__islocal); refresh: function (frm) {
} frm.toggle_enable("new_item_code", frm.is_new());
frm.set_query("new_item_code", () => {
cur_frm.fields_dict.new_item_code.get_query = function() {
return { return {
query: "erpnext.selling.doctype.product_bundle.product_bundle.get_new_item_code" 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');
}

View File

@ -33,6 +33,8 @@
"reqd": 1 "reqd": 1
}, },
{ {
"fetch_from": "item_code.description",
"fetch_if_empty": 1,
"fieldname": "description", "fieldname": "description",
"fieldtype": "Text Editor", "fieldtype": "Text Editor",
"in_list_view": 1, "in_list_view": 1,
@ -51,6 +53,8 @@
"print_hide": 1 "print_hide": 1
}, },
{ {
"fetch_from": "item_code.stock_uom",
"fetch_if_empty": 1,
"fieldname": "uom", "fieldname": "uom",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
@ -64,7 +68,7 @@
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2020-02-28 14:06:05.725655", "modified": "2022-06-27 05:30:18.475150",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Product Bundle Item", "name": "Product Bundle Item",
@ -72,5 +76,6 @@
"permissions": [], "permissions": [],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@ -8,7 +8,6 @@ from frappe.model.mapper import get_mapped_doc
from frappe.utils import flt, getdate, nowdate from frappe.utils import flt, getdate, nowdate
from erpnext.controllers.selling_controller import SellingController 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"} form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@ -36,16 +35,6 @@ class Quotation(SellingController):
make_packing_list(self) 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): def validate_valid_till(self):
if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date): if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date):
frappe.throw(_("Valid till date cannot be before transaction date")) frappe.throw(_("Valid till date cannot be before transaction date"))
@ -127,7 +116,7 @@ class Quotation(SellingController):
@frappe.whitelist() @frappe.whitelist()
def declare_enquiry_lost(self, lost_reasons_list, competitors, detailed_reason=None): 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"]) get_lost_reasons = frappe.get_list("Quotation Lost Reason", fields=["name"])
lost_reasons_lst = [reason.get("name") for reason in get_lost_reasons] lost_reasons_lst = [reason.get("name") for reason in get_lost_reasons]
frappe.db.set(self, "status", "Lost") frappe.db.set(self, "status", "Lost")
@ -218,6 +207,15 @@ def make_sales_order(source_name, target_doc=None):
def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
customer = _make_customer(source_name, ignore_permissions) 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): def set_missing_values(source, target):
if customer: if customer:
@ -233,7 +231,9 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
target.run_method("calculate_taxes_and_totals") target.run_method("calculate_taxes_and_totals")
def update_item(obj, target, source_parent): 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: if obj.against_blanket_order:
target.against_blanket_order = obj.against_blanket_order target.against_blanket_order = obj.against_blanket_order
@ -249,6 +249,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
"doctype": "Sales Order Item", "doctype": "Sales Order Item",
"field_map": {"parent": "prevdoc_docname"}, "field_map": {"parent": "prevdoc_docname"},
"postprocess": update_item, "postprocess": update_item,
"condition": lambda doc: doc.qty > 0,
}, },
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True}, "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, "Sales Team": {"doctype": "Sales Team", "add_if_empty": True},

View File

@ -55,6 +55,7 @@ frappe.query_reports["Sales Order Analysis"] = {
for (let option of status){ for (let option of status){
options.push({ options.push({
"value": option, "value": option,
"label": __(option),
"description": "" "description": ""
}) })
} }

View File

@ -219,8 +219,8 @@ erpnext.company.setup_queries = function(frm) {
["default_discount_account", {}], ["default_discount_account", {}],
["discount_allowed_account", {"root_type": "Expense"}], ["discount_allowed_account", {"root_type": "Expense"}],
["discount_received_account", {"root_type": "Income"}], ["discount_received_account", {"root_type": "Income"}],
["exchange_gain_loss_account", {"root_type": "Expense"}], ["exchange_gain_loss_account", {"root_type": ["in", ["Expense", "Income"]]}],
["unrealized_exchange_gain_loss_account", {"root_type": "Expense"}], ["unrealized_exchange_gain_loss_account", {"root_type": ["in", ["Expense", "Income"]]}],
["accumulated_depreciation_account", ["accumulated_depreciation_account",
{"root_type": "Asset", "account_type": "Accumulated Depreciation"}], {"root_type": "Asset", "account_type": "Accumulated Depreciation"}],
["depreciation_expense_account", {"root_type": "Expense", "account_type": "Depreciation"}], ["depreciation_expense_account", {"root_type": "Expense", "account_type": "Depreciation"}],

View File

@ -260,6 +260,16 @@ erpnext.stock.move_item = function (item, source, target, actual_qty, rate, call
} }
dialog.set_primary_action(__('Create Stock Entry'), function () { dialog.set_primary_action(__('Create Stock Entry'), function () {
if (source && (dialog.get_value("qty") == 0 || dialog.get_value("qty") > actual_qty)) {
frappe.msgprint(__("Quantity must be greater than zero, and less or equal to {0}", [actual_qty]));
return;
}
if (dialog.get_value("source") === dialog.get_value("target")) {
frappe.msgprint(__("Source and target warehouse must be different"));
return;
}
frappe.model.with_doctype('Stock Entry', function () { frappe.model.with_doctype('Stock Entry', function () {
let doc = frappe.model.get_new_doc('Stock Entry'); let doc = frappe.model.get_new_doc('Stock Entry');
doc.from_warehouse = dialog.get_value('source'); doc.from_warehouse = dialog.get_value('source');

View File

@ -1064,6 +1064,33 @@ class TestDeliveryNote(FrappeTestCase):
self.assertEqual(dn.items[0].rate, rate) 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): def create_delivery_note(**args):
dn = frappe.new_doc("Delivery Note") dn = frappe.new_doc("Delivery Note")

View File

@ -699,7 +699,7 @@ def get_pending_work_orders(doctype, txt, searchfield, start, page_length, filte
AND `company` = %(company)s AND `company` = %(company)s
AND `name` like %(txt)s AND `name` like %(txt)s
ORDER BY 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 LIMIT
%(start)s, %(page_length)s""", %(start)s, %(page_length)s""",
{ {

View File

@ -596,21 +596,6 @@ class StockEntry(StockController):
title=_("Insufficient Stock"), 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() @frappe.whitelist()
def get_stock_and_rate(self): def get_stock_and_rate(self):
""" """
@ -1321,7 +1306,7 @@ class StockEntry(StockController):
and not self.pro_doc.skip_transfer and not self.pro_doc.skip_transfer
and self.flags.backflush_based_on == "Material Transferred for Manufacture" and self.flags.backflush_based_on == "Material Transferred for Manufacture"
): ):
self.get_transfered_raw_materials() self.add_transfered_raw_materials_in_items()
elif ( elif (
self.work_order self.work_order
@ -1365,7 +1350,6 @@ class StockEntry(StockController):
# fetch the serial_no of the first stock entry for the second stock entry # fetch the serial_no of the first stock entry for the second stock entry
if self.work_order and self.purpose == "Manufacture": 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) work_order = frappe.get_doc("Work Order", self.work_order)
add_additional_cost(self, work_order) add_additional_cost(self, work_order)
@ -1655,120 +1639,79 @@ class StockEntry(StockController):
} }
) )
def get_transfered_raw_materials(self): def add_transfered_raw_materials_in_items(self) -> None:
transferred_materials = frappe.db.sql( available_materials = get_available_materials(self.work_order)
"""
select wo_data = frappe.db.get_value(
item_name, original_item, item_code, sum(qty) as qty, sed.t_warehouse as warehouse, "Work Order",
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
""",
self.work_order, self.work_order,
["qty", "produced_qty", "material_transferred_for_manufacturing as trans_qty"],
as_dict=1, as_dict=1,
) )
materials_already_backflushed = frappe.db.sql( for key, row in available_materials.items():
""" remaining_qty_to_produce = flt(wo_data.trans_qty) - flt(wo_data.produced_qty)
select if remaining_qty_to_produce <= 0:
item_code, sed.s_warehouse as warehouse, sum(qty) as qty continue
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,
)
backflushed_materials = {} qty = (flt(row.qty) * flt(self.fg_completed_qty)) / remaining_qty_to_produce
for d in materials_already_backflushed:
backflushed_materials.setdefault(d.item_code, []).append({d.warehouse: d.qty})
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")): if cint(frappe.get_cached_value("UOM", item.stock_uom, "must_be_whole_number")):
qty = frappe.utils.ceil(qty) qty = frappe.utils.ceil(qty)
if qty > 0: if row.batch_details:
self.add_to_stock_entry_detail( for batch_no, batch_qty in row.batch_details.items():
{ if qty <= 0 or batch_qty <= 0:
item.item_code: { 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, "from_warehouse": item.warehouse,
"to_warehouse": "", "to_warehouse": "",
"qty": qty, "qty": qty,
"item_name": item.item_name, "item_name": item.item_name,
"batch_no": item.batch_no,
"description": item.description, "description": item.description,
"stock_uom": item.stock_uom, "stock_uom": item.stock_uom,
"expense_account": item.expense_account, "expense_account": item.expense_account,
"cost_center": item.buying_cost_center, "cost_center": item.buying_cost_center,
"original_item": item.original_item, "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): def get_pending_raw_materials(self, backflush_based_on=None):
""" """
issue (item quantity) that is pending to issue or desire to transfer, issue (item quantity) that is pending to issue or desire to transfer,
@ -2528,3 +2471,81 @@ def get_supplied_items(purchase_order):
) )
return supplied_item_details 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)

View File

@ -499,7 +499,7 @@ def add_additional_uom_columns(columns, result, include_uom, conversion_factors)
def get_incoming_outgoing_rate_for_cancel(item_code, voucher_type, voucher_no, voucher_detail_no): def get_incoming_outgoing_rate_for_cancel(item_code, voucher_type, voucher_no, voucher_detail_no):
outgoing_rate = frappe.db.sql( 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` FROM `tabStock Ledger Entry`
WHERE voucher_type = %s and voucher_no = %s WHERE voucher_type = %s and voucher_no = %s
and item_code = %s and voucher_detail_no = %s and item_code = %s and voucher_detail_no = %s

View File

@ -34,7 +34,6 @@ def send_message(subject="Website Query", message="", sender="", status="Open"):
status="Open", status="Open",
title=subject, title=subject,
contact_email=sender, contact_email=sender,
to_discuss=message,
) )
) )

View File

@ -44,7 +44,7 @@ Accessable Value,Доступная стоимость,
Account,Аккаунт, Account,Аккаунт,
Account Number,Номер аккаунта, Account Number,Номер аккаунта,
Account Number {0} already used in account {1},"Номер счета {0}, уже использованный в учетной записи {1}", Account Number {0} already used in account {1},"Номер счета {0}, уже использованный в учетной записи {1}",
Account Pay Only,Счет Оплатить только, Account Pay Only,Только оплатить счет,
Account Type,Тип учетной записи, Account Type,Тип учетной записи,
Account Type for {0} must be {1},Тип счета для {0} должен быть {1}, 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'","Баланс счета в Кредите, запрещена установка 'Баланс должен быть' как 'Дебет'", "Account balance already in Credit, you are not allowed to set 'Balance Must Be' as 'Debit'","Баланс счета в Кредите, запрещена установка 'Баланс должен быть' как 'Дебет'",
@ -117,7 +117,7 @@ Add Item,Добавить продукт,
Add Items,Добавить продукты, Add Items,Добавить продукты,
Add Leads,Добавить лид, Add Leads,Добавить лид,
Add Multiple Tasks,Добавить несколько задач, Add Multiple Tasks,Добавить несколько задач,
Add Row,Добавить ряд, Add Row,Добавить строку,
Add Sales Partners,Добавить партнеров по продажам, Add Sales Partners,Добавить партнеров по продажам,
Add Serial No,Добавить серийный номер, Add Serial No,Добавить серийный номер,
Add Students,Добавить студентов, Add Students,Добавить студентов,
@ -692,7 +692,7 @@ Created {0} scorecards for {1} between: ,Созданы {0} оценочные
Creating Company and Importing Chart of Accounts,Создание компании и импорт плана счетов, Creating Company and Importing Chart of Accounts,Создание компании и импорт плана счетов,
Creating Fees,Создание сборов, Creating Fees,Создание сборов,
Creating Payment Entries......,Создание платежных записей......, Creating Payment Entries......,Создание платежных записей......,
Creating Salary Slips...,Создание зарплатных листков..., Creating Salary Slips...,Создание зарплатных ведомостей...,
Creating student groups,Создание групп студентов, Creating student groups,Создание групп студентов,
Creating {0} Invoice,Создание {0} счета, Creating {0} Invoice,Создание {0} счета,
Credit,Кредит, Credit,Кредит,
@ -995,7 +995,7 @@ Expenses,Расходы,
Expenses Included In Asset Valuation,"Расходы, включенные в оценку активов", Expenses Included In Asset Valuation,"Расходы, включенные в оценку активов",
Expenses Included In Valuation,"Затрат, включаемых в оценке", Expenses Included In Valuation,"Затрат, включаемых в оценке",
Expired Batches,Просроченные партии, Expired Batches,Просроченные партии,
Expires On,Годен до, Expires On,Актуален до,
Expiring On,Срок действия, Expiring On,Срок действия,
Expiry (In Days),Срок действия (в днях), Expiry (In Days),Срок действия (в днях),
Explore,Обзор, Explore,Обзор,
@ -1411,7 +1411,7 @@ Lab Test UOM,Лабораторная проверка UOM,
Lab Tests and Vital Signs,Лабораторные тесты и жизненные знаки, Lab Tests and Vital Signs,Лабораторные тесты и жизненные знаки,
Lab result datetime cannot be before testing datetime,Лабораторный результат datetime не может быть до тестирования даты и времени, Lab result datetime cannot be before testing datetime,Лабораторный результат datetime не может быть до тестирования даты и времени,
Lab testing datetime cannot be before collection datetime,Лабораторное тестирование datetime не может быть до даты сбора данных, Lab testing datetime cannot be before collection datetime,Лабораторное тестирование datetime не может быть до даты сбора данных,
Label,Ярлык, Label,Метка,
Laboratory,Лаборатория, Laboratory,Лаборатория,
Language Name,Название языка, Language Name,Название языка,
Large,Большой, Large,Большой,
@ -2874,7 +2874,7 @@ Supplier Id,Id поставщика,
Supplier Invoice Date cannot be greater than Posting Date,"Дата Поставщик Счет не может быть больше, чем Дата публикации", Supplier Invoice Date cannot be greater than Posting Date,"Дата Поставщик Счет не может быть больше, чем Дата публикации",
Supplier Invoice No,Поставщик Счет №, Supplier Invoice No,Поставщик Счет №,
Supplier Invoice No exists in Purchase Invoice {0},Номер счета поставщика отсутствует в счете на покупку {0}, Supplier Invoice No exists in Purchase Invoice {0},Номер счета поставщика отсутствует в счете на покупку {0},
Supplier Name,наименование поставщика, Supplier Name,Наименование поставщика,
Supplier Part No,Деталь поставщика №, Supplier Part No,Деталь поставщика №,
Supplier Quotation,Предложение поставщика, Supplier Quotation,Предложение поставщика,
Supplier Scorecard,Оценочная карта поставщика, Supplier Scorecard,Оценочная карта поставщика,
@ -3091,7 +3091,7 @@ Total Payment Amount in Payment Schedule must be equal to Grand / Rounded Total,
Total Payments,Всего платежей, Total Payments,Всего платежей,
Total Present,Итого Текущая, Total Present,Итого Текущая,
Total Qty,Общее количество, Total Qty,Общее количество,
Total Quantity,Общая численность, Total Quantity,Общее количество,
Total Revenue,Общий доход, Total Revenue,Общий доход,
Total Student,Всего учеников, Total Student,Всего учеников,
Total Target,Всего целей, Total Target,Всего целей,
@ -3498,7 +3498,7 @@ Postal,Почтовый,
Postal Code,Почтовый индекс, Postal Code,Почтовый индекс,
Previous,Предыдущая, Previous,Предыдущая,
Provider,Поставщик, Provider,Поставщик,
Read Only,Только чтения, Read Only,Только чтение,
Recipient,Сторона-реципиент, Recipient,Сторона-реципиент,
Reviews,Отзывы, Reviews,Отзывы,
Sender,Отправитель, Sender,Отправитель,
@ -3879,7 +3879,7 @@ On Lead Creation,Создание лида,
On Supplier Creation,Создание поставщика, On Supplier Creation,Создание поставщика,
On Customer Creation,Создание клиента, On Customer Creation,Создание клиента,
Only .csv and .xlsx files are supported currently,В настоящее время поддерживаются только файлы .csv и .xlsx, 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} могут создавать оставленные приложения с задним сроком действия, Only users with the {0} role can create backdated leave applications,Только пользователи с ролью {0} могут создавать оставленные приложения с задним сроком действия,
Open,Открыт, Open,Открыт,
Open Contact,Открытый контакт, Open Contact,Открытый контакт,
@ -4046,7 +4046,7 @@ Server Error,Ошибка сервера,
Service Level Agreement has been changed to {0}.,Соглашение об уровне обслуживания изменено на {0}., Service Level Agreement has been changed to {0}.,Соглашение об уровне обслуживания изменено на {0}.,
Service Level Agreement was reset.,Соглашение об уровне обслуживания было сброшено., Service Level Agreement was reset.,Соглашение об уровне обслуживания было сброшено.,
Service Level Agreement with Entity Type {0} and Entity {1} already exists.,Соглашение об уровне обслуживания с типом объекта {0} и объектом {1} уже существует., Service Level Agreement with Entity Type {0} and Entity {1} already exists.,Соглашение об уровне обслуживания с типом объекта {0} и объектом {1} уже существует.,
Set,Задать, Set,Комплект,
Set Meta Tags,Установить метатеги, Set Meta Tags,Установить метатеги,
Set {0} in company {1},Установить {0} в компании {1}, Set {0} in company {1},Установить {0} в компании {1},
Setup,Настройки, Setup,Настройки,
@ -4059,7 +4059,7 @@ Show Stock Ageing Data,Показать данные о старении зап
Show Warehouse-wise Stock,Показать складской запас, Show Warehouse-wise Stock,Показать складской запас,
Size,Размер, Size,Размер,
Something went wrong while evaluating the quiz.,Что-то пошло не так при оценке теста., Something went wrong while evaluating the quiz.,Что-то пошло не так при оценке теста.,
Sr,Sr, Sr,,
Start,Начать, Start,Начать,
Start Date cannot be before the current date,Дата начала не может быть раньше текущей даты, Start Date cannot be before the current date,Дата начала не может быть раньше текущей даты,
Start Time,Время начала, Start Time,Время начала,
@ -4513,7 +4513,7 @@ Mandatory For Profit and Loss Account,Обязательно для счета
Accounting Period,Период учета, Accounting Period,Период учета,
Period Name,Название периода, Period Name,Название периода,
Closed Documents,Закрытые документы, Closed Documents,Закрытые документы,
Accounts Settings,Настройки аккаунта, Accounts Settings,Настройка счетов,
Settings for Accounts,Настройки для счетов, Settings for Accounts,Настройки для счетов,
Make Accounting Entry For Every Stock Movement,Создавать бухгалтерские проводки при каждом перемещении запасов, 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,"Пользователи с этой ролью могут замороживать счета, а также создавать / изменять бухгалтерские проводки замороженных счетов", Users with this role are allowed to set frozen accounts and create / modify accounting entries against frozen accounts,"Пользователи с этой ролью могут замороживать счета, а также создавать / изменять бухгалтерские проводки замороженных счетов",
@ -5084,8 +5084,8 @@ Allow Zero Valuation Rate,Разрешить нулевую оценку,
Item Tax Rate,Ставка налогов на продукт, Item Tax Rate,Ставка налогов на продукт,
Tax detail table fetched from item master as a string and stored in this field.\nUsed for Taxes and Charges,Налоговый Подробная таблица выбирается из мастера элемента в виде строки и хранится в этой области.\n Используется по налогам и сборам, 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 Order Item,Заказ товара,
Purchase Receipt Detail,Деталь квитанции о покупке, Purchase Receipt Detail,Сведения о квитанции о покупке,
Item Weight Details,Деталь Вес Подробности, Item Weight Details,Сведения о весе товара,
Weight Per Unit,Вес на единицу, Weight Per Unit,Вес на единицу,
Total Weight,Общий вес, Total Weight,Общий вес,
Weight UOM,Вес Единица измерения, Weight UOM,Вес Единица измерения,
@ -5198,7 +5198,7 @@ Address and Contacts,Адрес и контакты,
Contact List,Список контактов, Contact List,Список контактов,
Hidden list maintaining the list of contacts linked to Shareholder,"Скрытый список, поддерживающий список контактов, связанных с Акционером", Hidden list maintaining the list of contacts linked to Shareholder,"Скрытый список, поддерживающий список контактов, связанных с Акционером",
Specify conditions to calculate shipping amount,Укажите условия для расчета суммы доставки, Specify conditions to calculate shipping amount,Укажите условия для расчета суммы доставки,
Shipping Rule Label,Название правила доставки, Shipping Rule Label,Метка правила доставки,
example: Next Day Shipping,Пример: доставка на следующий день, example: Next Day Shipping,Пример: доставка на следующий день,
Shipping Rule Type,Тип правила доставки, Shipping Rule Type,Тип правила доставки,
Shipping Account,Счет доставки, Shipping Account,Счет доставки,
@ -5236,7 +5236,7 @@ Billing Interval,Интервал выставления счетов,
Billing Interval Count,Счет интервала фактурирования, 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 дня", "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,Платежный план, Payment Plan,Платежный план,
Subscription Plan Detail,Деталь плана подписки, Subscription Plan Detail,Сведения о плана подписки,
Plan,План, Plan,План,
Subscription Settings,Настройки подписки, Subscription Settings,Настройки подписки,
Grace Period,Льготный период, Grace Period,Льготный период,
@ -5802,7 +5802,7 @@ Make Academic Term Mandatory,Сделать академический срок
Skip User creation for new Student,Пропустить создание пользователя для нового студента, 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.","По умолчанию для каждого нового Студента создается новый Пользователь. Если этот параметр включен, при создании нового Студента новый Пользователь не создается.", "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,Записи инструкторов должны быть созданы, Instructor Records to be created by,Записи инструкторов должны быть созданы,
Employee Number,Общее число сотрудников, Employee Number,Номер сотрудника,
Fee Category,Категория платы, Fee Category,Категория платы,
Fee Component,Компонент платы, Fee Component,Компонент платы,
Fees Category,Категория плат, Fees Category,Категория плат,
@ -6196,7 +6196,7 @@ Inpatient Occupancy,Стационарное размещение,
Occupancy Status,Статус занятости, Occupancy Status,Статус занятости,
Vacant,Вакантно, Vacant,Вакантно,
Occupied,Занято, Occupied,Занято,
Item Details,Детальная информация о товаре, Item Details,Детальная информация о продукте,
UOM Conversion in Hours,Преобразование UOM в часы, UOM Conversion in Hours,Преобразование UOM в часы,
Rate / UOM,Скорость / UOM, Rate / UOM,Скорость / UOM,
Change in Item,Изменение продукта, Change in Item,Изменение продукта,
@ -6868,8 +6868,8 @@ Only Tax Impact (Cannot Claim But Part of Taxable Income),Только нало
Create Separate Payment Entry Against Benefit Claim,Создать отдельную заявку на подачу заявки на получение пособия, Create Separate Payment Entry Against Benefit Claim,Создать отдельную заявку на подачу заявки на получение пособия,
Condition and Formula,Состояние и формула, Condition and Formula,Состояние и формула,
Amount based on formula,Сумма на основе формулы, Amount based on formula,Сумма на основе формулы,
Formula,формула, Formula,Формула,
Salary Detail,Заработная плата: Подробности, Salary Detail,Подробно об заработной плате,
Component,Компонент, Component,Компонент,
Do not include in total,Не включать в общей сложности, Do not include in total,Не включать в общей сложности,
Default Amount,По умолчанию количество, Default Amount,По умолчанию количество,
@ -6891,7 +6891,7 @@ Total Principal Amount,Общая сумма,
Total Interest Amount,Общая сумма процентов, Total Interest Amount,Общая сумма процентов,
Total Loan Repayment,Общая сумма погашения кредита, Total Loan Repayment,Общая сумма погашения кредита,
net pay info,Чистая информация платить, net pay info,Чистая информация платить,
Gross Pay - Total Deduction - Loan Repayment,Gross Pay - Итого Вычет - Погашение кредита, Gross Pay - Total Deduction - Loan Repayment,Валовая заработная плата - Общий вычет - Погашение кредита,
Total in words,Всего в словах, Total in words,Всего в словах,
Net Pay (in words) will be visible once you save the Salary Slip.,"Чистая плата (прописью) будет видна, как только вы сохраните зарплатную ведомость.", Net Pay (in words) will be visible once you save the Salary Slip.,"Чистая плата (прописью) будет видна, как только вы сохраните зарплатную ведомость.",
Salary Component for timesheet based payroll.,Компонент заработной платы для расчета зарплаты на основе расписания., Salary Component for timesheet based payroll.,Компонент заработной платы для расчета зарплаты на основе расписания.,
@ -6961,7 +6961,7 @@ Trainer Email,Электронная почта тренера,
Attendees,Присутствующие, Attendees,Присутствующие,
Employee Emails,Электронные почты сотрудников, Employee Emails,Электронные почты сотрудников,
Training Event Employee,Обучение сотрудников Событие, Training Event Employee,Обучение сотрудников Событие,
Invited,приглашенный, Invited,Приглашенный,
Feedback Submitted,Отзыв отправлен, Feedback Submitted,Отзыв отправлен,
Optional,Необязательный, Optional,Необязательный,
Training Result Employee,Результат обучения сотрудника, Training Result Employee,Результат обучения сотрудника,
@ -7185,7 +7185,7 @@ Ordered Quantity,Заказанное количество,
Item to be manufactured or repacked,Продукт должен быть произведен или переупакован, Item to be manufactured or repacked,Продукт должен быть произведен или переупакован,
Quantity of item obtained after manufacturing / repacking from given quantities of raw materials,Количество пункта получены после изготовления / переупаковка от заданных величин сырья, Quantity of item obtained after manufacturing / repacking from given quantities of raw materials,Количество пункта получены после изготовления / переупаковка от заданных величин сырья,
Set rate of sub-assembly item based on BOM,Установить скорость сборки на основе спецификации, Set rate of sub-assembly item based on BOM,Установить скорость сборки на основе спецификации,
Allow Alternative Item,Разрешить альтернативный элемент, Allow Alternative Item,Разрешить альтернативный продукт,
Item UOM,Единиц продукта, Item UOM,Единиц продукта,
Conversion Rate,Коэффициент конверсии, Conversion Rate,Коэффициент конверсии,
Rate Of Materials Based On,Оценить материалов на основе, Rate Of Materials Based On,Оценить материалов на основе,
@ -7600,7 +7600,7 @@ Invoices with no Place Of Supply,Счета без места поставки,
Import Supplier Invoice,Импортная накладная поставщика, Import Supplier Invoice,Импортная накладная поставщика,
Invoice Series,Серия счетов, Invoice Series,Серия счетов,
Upload XML Invoices,Загрузить XML-счета, Upload XML Invoices,Загрузить XML-счета,
Zip File,Zip-файл, Zip File,Zip файл,
Import Invoices,Импорт счетов, 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 прикреплен к документу. Любые ошибки, связанные с обработкой, будут отображаться в журнале ошибок.", 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,Свидетельство о нижнем удержании, Lower Deduction Certificate,Свидетельство о нижнем удержании,
@ -7635,7 +7635,7 @@ Restaurant Order Entry Item,Номер заказа заказа рестора
Served,Подается, Served,Подается,
Restaurant Reservation,Бронирование ресторанов, Restaurant Reservation,Бронирование ресторанов,
Waitlisted,Лист ожидания, Waitlisted,Лист ожидания,
No Show,Нет шоу, No Show,Не показывать,
No of People,Нет людей, No of People,Нет людей,
Reservation Time,Время резервирования, Reservation Time,Время резервирования,
Reservation End Time,Время окончания бронирования, Reservation End Time,Время окончания бронирования,
@ -7873,8 +7873,8 @@ Disable In Words,Отключить в словах,
"If disable, 'In Words' field will not be visible in any transaction","Если отключить, &quot;В словах&quot; поле не будет видно в любой сделке", "If disable, 'In Words' field will not be visible in any transaction","Если отключить, &quot;В словах&quot; поле не будет видно в любой сделке",
Item Classification,Продуктовая классификация, Item Classification,Продуктовая классификация,
General Settings,Основные настройки, General Settings,Основные настройки,
Item Group Name,Пункт Название группы, Item Group Name,Название группы продуктов,
Parent Item Group,Родитель Пункт Группа, Parent Item Group,Родительская группа продукта,
Item Group Defaults,Элемент группы по умолчанию, Item Group Defaults,Элемент группы по умолчанию,
Item Tax,Налог на продукт, Item Tax,Налог на продукт,
Check this if you want to show in website,"Проверьте это, если вы хотите показать в веб-сайт", Check this if you want to show in website,"Проверьте это, если вы хотите показать в веб-сайт",
@ -7971,13 +7971,13 @@ Customs Tariff Number,Номер таможенного тарифа,
Tariff Number,Тарифный номер, Tariff Number,Тарифный номер,
Delivery To,Доставка, Delivery To,Доставка,
MAT-DN-.YYYY.-,MAT-DN-.YYYY.-, MAT-DN-.YYYY.-,MAT-DN-.YYYY.-,
Is Return,Является Вернуться, Is Return,Возврат,
Issue Credit Note,Кредитная кредитная карта, Issue Credit Note,Кредитная кредитная карта,
Return Against Delivery Note,Вернуться На накладной, Return Against Delivery Note,Возврат по накладной,
Customer's Purchase Order No,Клиентам Заказ Нет, Customer's Purchase Order No,Заказ клиента №,
Billing Address Name,Название адреса для выставления счета, Billing Address Name,Название адреса для выставления счета,
Required only for sample item.,Требуется только для образца пункта., 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 will be visible once you save the Delivery Note.,По словам будет виден только вы сохраните накладной.,
In Words (Export) will be visible once you save the Delivery Note.,В Слов (Экспорт) будут видны только вы сохраните накладной., In Words (Export) will be visible once you save the Delivery Note.,В Слов (Экспорт) будут видны только вы сохраните накладной.,
Transporter Info,Информация для транспортировки, Transporter Info,Информация для транспортировки,
@ -7991,8 +7991,8 @@ Installation Status,Состояние установки,
Excise Page Number,Количество Акцизный Страница, Excise Page Number,Количество Акцизный Страница,
Instructions,Инструкции, Instructions,Инструкции,
From Warehouse,Со склада, From Warehouse,Со склада,
Against Sales Order,По Сделке, Against Sales Order,По сделке,
Against Sales Order Item,По Продукту Сделки, Against Sales Order Item,По позиции сделки,
Against Sales Invoice,Повторная накладная, Against Sales Invoice,Повторная накладная,
Against Sales Invoice Item,Счет на продажу продукта, Against Sales Invoice Item,Счет на продажу продукта,
Available Batch Qty at From Warehouse,Доступные Пакетная Кол-во на со склада, Available Batch Qty at From Warehouse,Доступные Пакетная Кол-во на со склада,
@ -8008,7 +8008,7 @@ Delivery Stop,Остановить доставку,
Lock,Заблокировано, Lock,Заблокировано,
Visited,Посещен, Visited,Посещен,
Order Information,запросить информацию, Order Information,запросить информацию,
Contact Information,Контакты, Contact Information,Контактная информация,
Email sent to,Письмо отправлено, Email sent to,Письмо отправлено,
Dispatch Information,Информация о доставке, Dispatch Information,Информация о доставке,
Estimated Arrival,Ожидаемое прибытие, Estimated Arrival,Ожидаемое прибытие,
@ -8121,7 +8121,7 @@ Two-way,Двусторонний,
Alternative Item Name,Альтернативное название продукта, Alternative Item Name,Альтернативное название продукта,
Attribute Name,Название атрибута, Attribute Name,Название атрибута,
Numeric Values,Числовые значения, Numeric Values,Числовые значения,
From Range,От хребта, From Range,Из диапазона,
Increment,Приращение, Increment,Приращение,
To Range,В диапазоне, To Range,В диапазоне,
Item Attribute Values,Пункт значений атрибутов, Item Attribute Values,Пункт значений атрибутов,
@ -8143,7 +8143,7 @@ Default Supplier,Поставщик по умолчанию,
Default Expense Account,Счет учета затрат по умолчанию, Default Expense Account,Счет учета затрат по умолчанию,
Sales Defaults,По умолчанию, Sales Defaults,По умолчанию,
Default Selling Cost Center,По умолчанию Продажа Стоимость центр, Default Selling Cost Center,По умолчанию Продажа Стоимость центр,
Item Manufacturer,Пункт Производитель, Item Manufacturer,Производитель товара,
Item Price,Цена продукта, Item Price,Цена продукта,
Packing Unit,Упаковочный блок, Packing Unit,Упаковочный блок,
Quantity that must be bought or sold per UOM,"Количество, которое необходимо купить или продать за UOM", Quantity that must be bought or sold per UOM,"Количество, которое необходимо купить или продать за UOM",
@ -8177,7 +8177,7 @@ Purchase Receipts,Покупка Поступления,
Purchase Receipt Items,Покупка продуктов, Purchase Receipt Items,Покупка продуктов,
Get Items From Purchase Receipts,Получить продукты из покупки., Get Items From Purchase Receipts,Получить продукты из покупки.,
Distribute Charges Based On,Распределите платежи на основе, Distribute Charges Based On,Распределите платежи на основе,
Landed Cost Help,Земельные Стоимость Помощь, Landed Cost Help,Справка по стоимости доставки,
Manufacturers used in Items,Производители использовали в пунктах, Manufacturers used in Items,Производители использовали в пунктах,
Limited to 12 characters,Ограничено до 12 символов, Limited to 12 characters,Ограничено до 12 символов,
MAT-MR-.YYYY.-,МАТ-MR-.YYYY.-, MAT-MR-.YYYY.-,МАТ-MR-.YYYY.-,
@ -8186,13 +8186,13 @@ Transferred,Переданы,
% Ordered,% заказано, % Ordered,% заказано,
Terms and Conditions Content,Условия Содержимое, Terms and Conditions Content,Условия Содержимое,
Quantity and Warehouse,Количество и Склад, Quantity and Warehouse,Количество и Склад,
Lead Time Date,Время и Дата Лида, Lead Time Date,Дата выполнения заказа,
Min Order Qty,Минимальный заказ Кол-во, Min Order Qty,Минимальное количество для заказа,
Packed Item,Упаковано, Packed Item,Упаковано,
To Warehouse (Optional),На склад (Необязательно), To Warehouse (Optional),На склад (Необязательно),
Actual Batch Quantity,Фактическое количество партий, Actual Batch Quantity,Фактическое количество партий,
Prevdoc DocType,Prevdoc DocType, 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.","Создаёт упаковочные листы к упаковкам для доставки. Содержит номер упаковки, перечень содержимого и вес.", "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),"Указывает, что пакет является частью этой поставки (только проект)", Indicates that the package is a part of this delivery (Only Draft),"Указывает, что пакет является частью этой поставки (только проект)",
MAT-PAC-.YYYY.-,MAT-PAC-.YYYY.-, MAT-PAC-.YYYY.-,MAT-PAC-.YYYY.-,
@ -8353,7 +8353,7 @@ Automatically Set Serial Nos based on FIFO,Автоматически устан
Auto Material Request,Автоматический запрос материалов, Auto Material Request,Автоматический запрос материалов,
Inter Warehouse Transfer Settings,Настройки передачи между складами, Inter Warehouse Transfer Settings,Настройки передачи между складами,
Freeze Stock Entries,Замораживание поступления запасов, Freeze Stock Entries,Замораживание поступления запасов,
Stock Frozen Upto,остатки заморожены до, Stock Frozen Upto,Остатки заморожены до,
Batch Identification,Идентификация партии, Batch Identification,Идентификация партии,
Use Naming Series,Использовать серийный номер, Use Naming Series,Использовать серийный номер,
Naming Series Prefix,Префикс Идентификации по Имени, Naming Series Prefix,Префикс Идентификации по Имени,
@ -8372,7 +8372,7 @@ Issue Split From,Выпуск Сплит От,
Service Level,Уровень обслуживания, Service Level,Уровень обслуживания,
Response By,Ответ от, Response By,Ответ от,
Response By Variance,Ответ по отклонениям, Response By Variance,Ответ по отклонениям,
Ongoing,постоянный, Ongoing,Постоянный,
Resolution By,Разрешение по, Resolution By,Разрешение по,
Resolution By Variance,Разрешение по отклонениям, Resolution By Variance,Разрешение по отклонениям,
Service Level Agreement Creation,Создание соглашения об уровне обслуживания, Service Level Agreement Creation,Создание соглашения об уровне обслуживания,

Can't render this file because it is too large.

View File

@ -9,6 +9,7 @@ import frappe
import pytz import pytz
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import cint
from pyyoutube import Api from pyyoutube import Api
@ -46,7 +47,7 @@ def is_tracking_enabled():
def get_frequency(value): def get_frequency(value):
# Return numeric value from frequency field, return 1 as fallback default value: 1 hour # Return numeric value from frequency field, return 1 as fallback default value: 1 hour
if value != "Daily": if value != "Daily":
return frappe.utils.cint(value[:2].strip()) return cint(value[:2].strip())
elif value: elif value:
return 24 return 24
return 1 return 1
@ -120,24 +121,12 @@ def batch_update_youtube_data():
video_stats = entry.to_dict().get("statistics") video_stats = entry.to_dict().get("statistics")
video_id = entry.to_dict().get("id") video_id = entry.to_dict().get("id")
stats = { stats = {
"like_count": video_stats.get("likeCount"), "like_count": cint(video_stats.get("likeCount")),
"view_count": video_stats.get("viewCount"), "view_count": cint(video_stats.get("viewCount")),
"dislike_count": video_stats.get("dislikeCount"), "dislike_count": cint(video_stats.get("dislikeCount")),
"comment_count": video_stats.get("commentCount"), "comment_count": cint(video_stats.get("commentCount")),
"video_id": video_id,
} }
frappe.db.set_value("Video", video_id, stats)
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,
)
video_list = frappe.get_all("Video", fields=["youtube_video_id"]) video_list = frappe.get_all("Video", fields=["youtube_video_id"])
if len(video_list) > 50: if len(video_list) > 50:

View File

@ -5,7 +5,7 @@
import frappe import frappe
import frappe.share import frappe.share
from frappe import _ 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 from erpnext.controllers.status_updater import StatusUpdater
@ -30,64 +30,6 @@ class TransactionBase(StatusUpdater):
except ValueError: except ValueError:
frappe.throw(_("Invalid Posting Time")) 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): def validate_uom_is_integer(self, uom_field, qty_fields):
validate_uom_is_integer(self, uom_field, qty_fields) validate_uom_is_integer(self, uom_field, qty_fields)

View File

@ -1,3 +1,35 @@
[project]
name = "erpnext"
authors = [
{ name = "Frappe Technologies Pvt Ltd", email = "developers@frappe.io"}
]
description = "Open Source ERP"
requires-python = ">=3.10"
readme = "README.md"
dynamic = ["version"]
dependencies = [
# Core dependencies
"pycountry~=20.7.3",
"python-stdnum~=1.16",
"Unidecode~=1.2.0",
"redisearch~=2.1.0",
# integration dependencies
"gocardless-pro~=1.22.0",
"googlemaps",
"plaid-python~=7.2.1",
"python-youtube~=0.8.0",
"taxjar~=1.9.2",
"tweepy~=3.10.0",
]
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
[tool.bench.dev-dependencies]
hypothesis = "~=6.31.0"
[tool.black] [tool.black]
line-length = 99 line-length = 99

View File

@ -1,11 +0,0 @@
# frappe # https://github.com/frappe/frappe is installed during bench-init
gocardless-pro~=1.22.0
googlemaps
plaid-python~=7.2.1
pycountry~=20.7.3
python-stdnum~=1.16
python-youtube~=0.8.0
taxjar~=1.9.2
tweepy~=3.10.0
Unidecode~=1.2.0
redisearch~=2.1.0

View File

@ -1,23 +1,6 @@
from setuptools import setup, find_packages # TODO: Remove this file when v15.0.0 is released
import re, ast from setuptools import setup
# get version from __version__ variable in erpnext/__init__.py name = "frappe"
_version_re = re.compile(r"__version__\s+=\s+(.*)")
with open("requirements.txt") as f: setup()
install_requires = f.read().strip().split("\n")
with open("erpnext/__init__.py", "rb") as f:
version = str(ast.literal_eval(_version_re.search(f.read().decode("utf-8")).group(1)))
setup(
name="erpnext",
version=version,
description="Open Source ERP",
author="Frappe Technologies",
author_email="info@erpnext.com",
packages=find_packages(),
zip_safe=False,
include_package_data=True,
install_requires=install_requires,
)