Merge branch 'version-13-hotfix' into fix/delivery-note/billed-amount

This commit is contained in:
Sagar Sharma
2022-01-28 15:38:26 +05:30
committed by GitHub
94 changed files with 1761 additions and 585 deletions

View File

@@ -28,6 +28,7 @@ ignore =
B007, B007,
B950, B950,
W191, W191,
E124, # closing bracket, irritating while writing QB code
max-line-length = 200 max-line-length = 200
exclude=.github/helper/semgrep_rules exclude=.github/helper/semgrep_rules

View File

@@ -5,9 +5,14 @@ on:
paths-ignore: paths-ignore:
- '**.js' - '**.js'
- '**.md' - '**.md'
types: [opened, unlabeled, synchronize, reopened]
workflow_dispatch: workflow_dispatch:
concurrency:
group: patch-mariadb-v13-${{ github.event.number }}
cancel-in-progress: true
jobs: jobs:
test: test:
runs-on: ubuntu-18.04 runs-on: ubuntu-18.04
@@ -25,6 +30,11 @@ jobs:
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
steps: steps:
- name: Check for merge conficts label
if: ${{ contains(github.event.pull_request.labels.*.name, 'conflicts') }}
run: |
echo "Remove merge conflicts and remove conflict label to run CI"
exit 1
- name: Clone - name: Clone
uses: actions/checkout@v2 uses: actions/checkout@v2

View File

@@ -5,6 +5,7 @@ on:
paths-ignore: paths-ignore:
- '**.js' - '**.js'
- '**.md' - '**.md'
types: [opened, unlabeled, synchronize, reopened]
workflow_dispatch: workflow_dispatch:
push: push:
branches: [ develop ] branches: [ develop ]
@@ -12,6 +13,10 @@ on:
- '**.js' - '**.js'
- '**.md' - '**.md'
concurrency:
group: server-mariadb-v13-${{ github.event.number }}
cancel-in-progress: true
jobs: jobs:
test: test:
runs-on: ubuntu-18.04 runs-on: ubuntu-18.04
@@ -35,6 +40,12 @@ jobs:
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
steps: steps:
- name: Check for merge conficts label
if: ${{ contains(github.event.pull_request.labels.*.name, 'conflicts') }}
run: |
echo "Remove merge conflicts and remove conflict label to run CI"
exit 1
- name: Clone - name: Clone
uses: actions/checkout@v2 uses: actions/checkout@v2
@@ -89,39 +100,8 @@ jobs:
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
- name: Run Tests - name: Run Tests
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator --with-coverage run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator
env: env:
TYPE: server TYPE: server
CI_BUILD_ID: ${{ github.run_id }} CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
- name: Upload Coverage Data
run: |
cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE}
cd ${GITHUB_WORKSPACE}
pip3 install coverage==5.5
pip3 install coveralls==3.0.1
coveralls
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COVERALLS_FLAG_NAME: run-${{ matrix.container }}
COVERALLS_SERVICE_NAME: ${{ github.event_name == 'pull_request' && 'github' || 'github-actions' }}
COVERALLS_PARALLEL: true
coveralls:
name: Coverage Wrap Up
needs: test
container: python:3-slim
runs-on: ubuntu-18.04
steps:
- name: Clone
uses: actions/checkout@v2
- name: Coveralls Finished
run: |
cd ${GITHUB_WORKSPACE}
pip3 install coverage==5.5
pip3 install coveralls==3.0.1
coveralls --finish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -6,6 +6,10 @@ on:
- '**.md' - '**.md'
workflow_dispatch: workflow_dispatch:
concurrency:
group: ui-v13-${{ github.event.number }}
cancel-in-progress: true
jobs: jobs:
test: test:
runs-on: ubuntu-18.04 runs-on: ubuntu-18.04

View File

@@ -255,11 +255,13 @@ def book_deferred_income_or_expense(doc, deferred_process, posting_date=None):
enable_check = "enable_deferred_revenue" \ enable_check = "enable_deferred_revenue" \
if doc.doctype=="Sales Invoice" else "enable_deferred_expense" if doc.doctype=="Sales Invoice" else "enable_deferred_expense"
accounts_frozen_upto = frappe.get_cached_value('Accounts Settings', 'None', 'acc_frozen_upto')
def _book_deferred_revenue_or_expense(item, via_journal_entry, submit_journal_entry, book_deferred_entries_based_on): def _book_deferred_revenue_or_expense(item, via_journal_entry, submit_journal_entry, book_deferred_entries_based_on):
start_date, end_date, last_gl_entry = get_booking_dates(doc, item, posting_date=posting_date) start_date, end_date, last_gl_entry = get_booking_dates(doc, item, posting_date=posting_date)
if not (start_date and end_date): return if not (start_date and end_date): return
account_currency = get_account_currency(item.expense_account) account_currency = get_account_currency(item.expense_account or item.income_account)
if doc.doctype == "Sales Invoice": if doc.doctype == "Sales Invoice":
against, project = doc.customer, doc.project against, project = doc.customer, doc.project
credit_account, debit_account = item.income_account, item.deferred_revenue_account credit_account, debit_account = item.income_account, item.deferred_revenue_account
@@ -280,6 +282,10 @@ def book_deferred_income_or_expense(doc, deferred_process, posting_date=None):
if not amount: if not amount:
return return
# check if books nor frozen till endate:
if getdate(end_date) >= getdate(accounts_frozen_upto):
end_date = get_last_day(add_days(accounts_frozen_upto, 1))
if via_journal_entry: if via_journal_entry:
book_revenue_via_journal_entry(doc, credit_account, debit_account, against, amount, book_revenue_via_journal_entry(doc, credit_account, debit_account, against, amount,
base_amount, end_date, project, account_currency, item.cost_center, item, deferred_process, submit_journal_entry) base_amount, end_date, project, account_currency, item.cost_center, item, deferred_process, submit_journal_entry)
@@ -407,8 +413,6 @@ def book_revenue_via_journal_entry(doc, credit_account, debit_account, against,
'account': credit_account, 'account': credit_account,
'credit': base_amount, 'credit': base_amount,
'credit_in_account_currency': amount, 'credit_in_account_currency': amount,
'party_type': 'Customer' if doc.doctype == 'Sales Invoice' else 'Supplier',
'party': against,
'account_currency': account_currency, 'account_currency': account_currency,
'reference_name': doc.name, 'reference_name': doc.name,
'reference_type': doc.doctype, 'reference_type': doc.doctype,
@@ -421,8 +425,6 @@ def book_revenue_via_journal_entry(doc, credit_account, debit_account, against,
'account': debit_account, 'account': debit_account,
'debit': base_amount, 'debit': base_amount,
'debit_in_account_currency': amount, 'debit_in_account_currency': amount,
'party_type': 'Customer' if doc.doctype == 'Sales Invoice' else 'Supplier',
'party': against,
'account_currency': account_currency, 'account_currency': account_currency,
'reference_name': doc.name, 'reference_name': doc.name,
'reference_type': doc.doctype, 'reference_type': doc.doctype,

View File

@@ -17,6 +17,7 @@ from openpyxl.styles import Font
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
from six import string_types from six import string_types
INVALID_VALUES = ("", None)
class BankStatementImport(DataImport): class BankStatementImport(DataImport):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -96,6 +97,18 @@ def download_errored_template(data_import_name):
data_import = frappe.get_doc("Bank Statement Import", data_import_name) data_import = frappe.get_doc("Bank Statement Import", data_import_name)
data_import.export_errored_rows() data_import.export_errored_rows()
def parse_data_from_template(raw_data):
data = []
for i, row in enumerate(raw_data):
if all(v in INVALID_VALUES for v in row):
# empty row
continue
data.append(row)
return data
def start_import(data_import, bank_account, import_file_path, google_sheets_url, bank, template_options): def start_import(data_import, bank_account, import_file_path, google_sheets_url, bank, template_options):
"""This method runs in background job""" """This method runs in background job"""
@@ -105,7 +118,8 @@ def start_import(data_import, bank_account, import_file_path, google_sheets_url,
file = import_file_path if import_file_path else google_sheets_url file = import_file_path if import_file_path else google_sheets_url
import_file = ImportFile("Bank Transaction", file = file, import_type="Insert New Records") import_file = ImportFile("Bank Transaction", file = file, import_type="Insert New Records")
data = import_file.raw_data
data = parse_data_from_template(import_file.raw_data)
if import_file_path: if import_file_path:
add_bank_account(data, bank_account) add_bank_account(data, bank_account)

View File

@@ -397,13 +397,14 @@ class JournalEntry(AccountsController):
debit_or_credit = 'Debit' if d.debit else 'Credit' debit_or_credit = 'Debit' if d.debit else 'Credit'
party_account = get_deferred_booking_accounts(d.reference_type, d.reference_detail_no, party_account = get_deferred_booking_accounts(d.reference_type, d.reference_detail_no,
debit_or_credit) debit_or_credit)
against_voucher = ['', against_voucher[1]]
else: else:
if d.reference_type == "Sales Invoice": if d.reference_type == "Sales Invoice":
party_account = get_party_account_based_on_invoice_discounting(d.reference_name) or against_voucher[1] party_account = get_party_account_based_on_invoice_discounting(d.reference_name) or against_voucher[1]
else: else:
party_account = against_voucher[1] party_account = against_voucher[1]
if (against_voucher[0] != d.party or party_account != d.account): if (against_voucher[0] != cstr(d.party) or party_account != d.account):
frappe.throw(_("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}") frappe.throw(_("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}")
.format(d.idx, field_dict.get(d.reference_type)[0], field_dict.get(d.reference_type)[1], .format(d.idx, field_dict.get(d.reference_type)[0], field_dict.get(d.reference_type)[1],
d.reference_type, d.reference_name)) d.reference_type, d.reference_name))
@@ -468,13 +469,22 @@ class JournalEntry(AccountsController):
def set_against_account(self): def set_against_account(self):
accounts_debited, accounts_credited = [], [] accounts_debited, accounts_credited = [], []
for d in self.get("accounts"): if self.voucher_type in ('Deferred Revenue', 'Deferred Expense'):
if flt(d.debit > 0): accounts_debited.append(d.party or d.account) for d in self.get('accounts'):
if flt(d.credit) > 0: accounts_credited.append(d.party or d.account) if d.reference_type == 'Sales Invoice':
field = 'customer'
else:
field = 'supplier'
for d in self.get("accounts"): d.against_account = frappe.db.get_value(d.reference_type, d.reference_name, field)
if flt(d.debit > 0): d.against_account = ", ".join(list(set(accounts_credited))) else:
if flt(d.credit > 0): d.against_account = ", ".join(list(set(accounts_debited))) for d in self.get("accounts"):
if flt(d.debit > 0): accounts_debited.append(d.party or d.account)
if flt(d.credit) > 0: accounts_credited.append(d.party or d.account)
for d in self.get("accounts"):
if flt(d.debit > 0): d.against_account = ", ".join(list(set(accounts_credited)))
if flt(d.credit > 0): d.against_account = ", ".join(list(set(accounts_debited)))
def validate_debit_credit_amount(self): def validate_debit_credit_amount(self):
for d in self.get('accounts'): for d in self.get('accounts'):

View File

@@ -135,7 +135,7 @@ class OpeningInvoiceCreationTool(Document):
default_uom = frappe.db.get_single_value("Stock Settings", "stock_uom") or _("Nos") default_uom = frappe.db.get_single_value("Stock Settings", "stock_uom") or _("Nos")
rate = flt(row.outstanding_amount) / flt(row.qty) rate = flt(row.outstanding_amount) / flt(row.qty)
return frappe._dict({ item_dict = frappe._dict({
"uom": default_uom, "uom": default_uom,
"rate": rate or 0.0, "rate": rate or 0.0,
"qty": row.qty, "qty": row.qty,
@@ -146,6 +146,13 @@ class OpeningInvoiceCreationTool(Document):
"cost_center": cost_center "cost_center": cost_center
}) })
for dimension in get_accounting_dimensions():
item_dict.update({
dimension: row.get(dimension)
})
return item_dict
item = get_item_dict() item = get_item_dict()
invoice = frappe._dict({ invoice = frappe._dict({
@@ -166,7 +173,7 @@ class OpeningInvoiceCreationTool(Document):
accounting_dimension = get_accounting_dimensions() accounting_dimension = get_accounting_dimensions()
for dimension in accounting_dimension: for dimension in accounting_dimension:
invoice.update({ invoice.update({
dimension: item.get(dimension) dimension: self.get(dimension) or item.get(dimension)
}) })
return invoice return invoice

View File

@@ -7,21 +7,26 @@ import frappe
from frappe.cache_manager import clear_doctype_cache from frappe.cache_manager import clear_doctype_cache
from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
create_dimension,
disable_dimension,
)
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import ( from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
get_temporary_opening_account, get_temporary_opening_account,
) )
test_dependencies = ["Customer", "Supplier"] test_dependencies = ["Customer", "Supplier", "Accounting Dimension"]
class TestOpeningInvoiceCreationTool(unittest.TestCase): class TestOpeningInvoiceCreationTool(unittest.TestCase):
def setUp(self): def setUp(self):
if not frappe.db.exists("Company", "_Test Opening Invoice Company"): if not frappe.db.exists("Company", "_Test Opening Invoice Company"):
make_company() make_company()
create_dimension()
def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None, invoice_number=None): def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None, invoice_number=None, department=None):
doc = frappe.get_single("Opening Invoice Creation Tool") doc = frappe.get_single("Opening Invoice Creation Tool")
args = get_opening_invoice_creation_dict(invoice_type=invoice_type, company=company, args = get_opening_invoice_creation_dict(invoice_type=invoice_type, company=company,
party_1=party_1, party_2=party_2, invoice_number=invoice_number) party_1=party_1, party_2=party_2, invoice_number=invoice_number, department=department)
doc.update(args) doc.update(args)
return doc.make_invoices() return doc.make_invoices()
@@ -106,6 +111,19 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase):
doc = frappe.get_doc('Sales Invoice', inv) doc = frappe.get_doc('Sales Invoice', inv)
doc.cancel() doc.cancel()
def test_opening_invoice_with_accounting_dimension(self):
invoices = self.make_invoices(invoice_type="Sales", company="_Test Opening Invoice Company", department='Sales - _TOIC')
expected_value = {
"keys": ["customer", "outstanding_amount", "status", "department"],
0: ["_Test Customer", 300, "Overdue", "Sales - _TOIC"],
1: ["_Test Customer 1", 250, "Overdue", "Sales - _TOIC"],
}
self.check_expected_values(invoices, expected_value, invoice_type="Sales")
def tearDown(self):
disable_dimension()
def get_opening_invoice_creation_dict(**args): def get_opening_invoice_creation_dict(**args):
party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier" party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier"
company = args.get("company", "_Test Company") company = args.get("company", "_Test Company")

View File

@@ -3,6 +3,7 @@
import json import json
from functools import reduce
import frappe import frappe
from frappe import ValidationError, _, scrub, throw from frappe import ValidationError, _, scrub, throw
@@ -1524,6 +1525,10 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=
pe.received_amount = received_amount pe.received_amount = received_amount
pe.letter_head = doc.get("letter_head") pe.letter_head = doc.get("letter_head")
if dt in ['Purchase Order', 'Sales Order', 'Sales Invoice', 'Purchase Invoice']:
pe.project = (doc.get('project') or
reduce(lambda prev,cur: prev or cur, [x.get('project') for x in doc.get('items')], None)) # get first non-empty project from items
if pe.party_type in ["Customer", "Supplier"]: if pe.party_type in ["Customer", "Supplier"]:
bank_account = get_party_bank_account(pe.party_type, pe.party) bank_account = get_party_bank_account(pe.party_type, pe.party)
pe.set("bank_account", bank_account) pe.set("bank_account", bank_account)

View File

@@ -45,6 +45,7 @@ from erpnext.setup.doctype.company.company import update_company_current_month_s
from erpnext.stock.doctype.batch.batch import set_batch_nos from erpnext.stock.doctype.batch.batch import set_batch_nos
from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so
from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no, get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no, get_serial_nos
from erpnext.stock.utils import calculate_mapped_packed_items_return
form_grid_templates = { form_grid_templates = {
"items": "templates/form_grid/item_grid.html" "items": "templates/form_grid/item_grid.html"
@@ -745,8 +746,11 @@ class SalesInvoice(SellingController):
def update_packing_list(self): def update_packing_list(self):
if cint(self.update_stock) == 1: if cint(self.update_stock) == 1:
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list if cint(self.is_return) and self.return_against:
make_packing_list(self) calculate_mapped_packed_items_return(self)
else:
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
make_packing_list(self)
else: else:
self.set('packed_items', []) self.set('packed_items', [])

View File

@@ -1789,47 +1789,6 @@ class TestSalesInvoice(unittest.TestCase):
check_gl_entries(self, si.name, expected_gle, "2019-01-30") check_gl_entries(self, si.name, expected_gle, "2019-01-30")
def test_deferred_revenue_post_account_freeze_upto_by_admin(self):
frappe.set_user("Administrator")
frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', None)
frappe.db.set_value('Accounts Settings', None, 'frozen_accounts_modifier', None)
deferred_account = create_account(account_name="Deferred Revenue",
parent_account="Current Liabilities - _TC", company="_Test Company")
item = create_item("_Test Item for Deferred Accounting")
item.enable_deferred_revenue = 1
item.deferred_revenue_account = deferred_account
item.no_of_months = 12
item.save()
si = create_sales_invoice(item=item.name, posting_date="2019-01-10", do_not_save=True)
si.items[0].enable_deferred_revenue = 1
si.items[0].service_start_date = "2019-01-10"
si.items[0].service_end_date = "2019-03-15"
si.items[0].deferred_revenue_account = deferred_account
si.save()
si.submit()
frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', getdate('2019-01-31'))
frappe.db.set_value('Accounts Settings', None, 'frozen_accounts_modifier', 'System Manager')
pda1 = frappe.get_doc(dict(
doctype='Process Deferred Accounting',
posting_date=nowdate(),
start_date="2019-01-01",
end_date="2019-03-31",
type="Income",
company="_Test Company"
))
pda1.insert()
self.assertRaises(frappe.ValidationError, pda1.submit)
frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', None)
frappe.db.set_value('Accounts Settings', None, 'frozen_accounts_modifier', None)
def test_fixed_deferred_revenue(self): def test_fixed_deferred_revenue(self):
deferred_account = create_account(account_name="Deferred Revenue", deferred_account = create_account(account_name="Deferred Revenue",
parent_account="Current Liabilities - _TC", company="_Test Company") parent_account="Current Liabilities - _TC", company="_Test Company")
@@ -2206,9 +2165,9 @@ class TestSalesInvoice(unittest.TestCase):
asset.load_from_db() asset.load_from_db()
expected_values = [ expected_values = [
["2020-06-30", 1311.48, 1311.48], ["2020-06-30", 1366.12, 1366.12],
["2021-06-30", 20000.0, 21311.48], ["2021-06-30", 20000.0, 21366.12],
["2021-09-30", 5041.1, 26352.58] ["2021-09-30", 5041.1, 26407.22]
] ]
for i, schedule in enumerate(asset.schedules): for i, schedule in enumerate(asset.schedules):
@@ -2256,12 +2215,12 @@ class TestSalesInvoice(unittest.TestCase):
asset.load_from_db() asset.load_from_db()
expected_values = [ expected_values = [
["2020-06-30", 1311.48, 1311.48, True], ["2020-06-30", 1366.12, 1366.12, True],
["2021-06-30", 20000.0, 21311.48, True], ["2021-06-30", 20000.0, 21366.12, True],
["2022-06-30", 20000.0, 41311.48, False], ["2022-06-30", 20000.0, 41366.12, False],
["2023-06-30", 20000.0, 61311.48, False], ["2023-06-30", 20000.0, 61366.12, False],
["2024-06-30", 20000.0, 81311.48, False], ["2024-06-30", 20000.0, 81366.12, False],
["2025-06-06", 18688.52, 100000.0, False] ["2025-06-06", 18633.88, 100000.0, False]
] ]
for i, schedule in enumerate(asset.schedules): for i, schedule in enumerate(asset.schedules):
@@ -2455,6 +2414,74 @@ class TestSalesInvoice(unittest.TestCase):
frappe.db.set_value('Accounts Settings', None, 'over_billing_allowance', over_billing_allowance) frappe.db.set_value('Accounts Settings', None, 'over_billing_allowance', over_billing_allowance)
def test_multi_currency_deferred_revenue_via_journal_entry(self):
deferred_account = create_account(account_name="Deferred Revenue",
parent_account="Current Liabilities - _TC", company="_Test Company")
acc_settings = frappe.get_single('Accounts Settings')
acc_settings.book_deferred_entries_via_journal_entry = 1
acc_settings.submit_journal_entries = 1
acc_settings.save()
item = create_item("_Test Item for Deferred Accounting")
item.enable_deferred_expense = 1
item.deferred_revenue_account = deferred_account
item.save()
si = create_sales_invoice(customer='_Test Customer USD', currency='USD',
item=item.name, qty=1, rate=100, conversion_rate=60, do_not_save=True)
si.set_posting_time = 1
si.posting_date = '2019-01-01'
si.debit_to = '_Test Receivable USD - _TC'
si.items[0].enable_deferred_revenue = 1
si.items[0].service_start_date = "2019-01-01"
si.items[0].service_end_date = "2019-03-30"
si.items[0].deferred_expense_account = deferred_account
si.save()
si.submit()
frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', getdate('2019-01-31'))
pda1 = frappe.get_doc(dict(
doctype='Process Deferred Accounting',
posting_date=nowdate(),
start_date="2019-01-01",
end_date="2019-03-31",
type="Income",
company="_Test Company"
))
pda1.insert()
pda1.submit()
expected_gle = [
["Sales - _TC", 0.0, 2089.89, "2019-01-28"],
[deferred_account, 2089.89, 0.0, "2019-01-28"],
["Sales - _TC", 0.0, 1887.64, "2019-02-28"],
[deferred_account, 1887.64, 0.0, "2019-02-28"],
["Sales - _TC", 0.0, 2022.47, "2019-03-15"],
[deferred_account, 2022.47, 0.0, "2019-03-15"]
]
gl_entries = gl_entries = frappe.db.sql("""select account, debit, credit, posting_date
from `tabGL Entry`
where voucher_type='Journal Entry' and voucher_detail_no=%s and posting_date <= %s
order by posting_date asc, account asc""", (si.items[0].name, si.posting_date), as_dict=1)
for i, gle in enumerate(gl_entries):
self.assertEqual(expected_gle[i][0], gle.account)
self.assertEqual(expected_gle[i][1], gle.credit)
self.assertEqual(expected_gle[i][2], gle.debit)
self.assertEqual(getdate(expected_gle[i][3]), gle.posting_date)
acc_settings = frappe.get_single('Accounts Settings')
acc_settings.book_deferred_entries_via_journal_entry = 0
acc_settings.submit_journal_entriessubmit_journal_entries = 0
acc_settings.save()
frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', None)
def get_sales_invoice_for_e_invoice(): def get_sales_invoice_for_e_invoice():
si = make_sales_invoice_for_ewaybill() si = make_sales_invoice_for_ewaybill()
si.naming_series = 'INV-2020-.#####' si.naming_series = 'INV-2020-.#####'

View File

@@ -59,7 +59,7 @@ def _get_party_details(party=None, account=None, party_type="Customer", company=
frappe.throw(_("Not permitted for {0}").format(party), frappe.PermissionError) frappe.throw(_("Not permitted for {0}").format(party), frappe.PermissionError)
party = frappe.get_doc(party_type, party) party = frappe.get_doc(party_type, party)
currency = party.default_currency if party.get("default_currency") else get_company_currency(company) currency = party.get("default_currency") or currency or get_company_currency(company)
party_address, shipping_address = set_address_details(party_details, party, party_type, doctype, company, party_address, company_address, shipping_address) party_address, shipping_address = set_address_details(party_details, party, party_type, doctype, company, party_address, company_address, shipping_address)
set_contact_details(party_details, party, party_type) set_contact_details(party_details, party, party_type)

View File

@@ -17,10 +17,42 @@ from erpnext.stock.doctype.item.test_item import create_item
class TestDeferredRevenueAndExpense(unittest.TestCase): class TestDeferredRevenueAndExpense(unittest.TestCase):
@classmethod @classmethod
def setUpClass(self): def setUpClass(self):
clear_old_entries() clear_accounts_and_items()
create_company() create_company()
self.maxDiff = None
def clear_old_entries(self):
sinv = qb.DocType("Sales Invoice")
sinv_item = qb.DocType("Sales Invoice Item")
pinv = qb.DocType("Purchase Invoice")
pinv_item = qb.DocType("Purchase Invoice Item")
# delete existing invoices with deferred items
deferred_invoices = (
qb.from_(sinv)
.join(sinv_item)
.on(sinv.name == sinv_item.parent)
.select(sinv.name)
.where(sinv_item.enable_deferred_revenue == 1)
.run()
)
if deferred_invoices:
qb.from_(sinv).delete().where(sinv.name.isin(deferred_invoices)).run()
deferred_invoices = (
qb.from_(pinv)
.join(pinv_item)
.on(pinv.name == pinv_item.parent)
.select(pinv.name)
.where(pinv_item.enable_deferred_expense == 1)
.run()
)
if deferred_invoices:
qb.from_(pinv).delete().where(pinv.name.isin(deferred_invoices)).run()
def test_deferred_revenue(self): def test_deferred_revenue(self):
self.clear_old_entries()
# created deferred expense accounts, if not found # created deferred expense accounts, if not found
deferred_revenue_account = create_account( deferred_revenue_account = create_account(
account_name="Deferred Revenue", account_name="Deferred Revenue",
@@ -108,6 +140,8 @@ class TestDeferredRevenueAndExpense(unittest.TestCase):
self.assertEqual(report.period_total, expected) self.assertEqual(report.period_total, expected)
def test_deferred_expense(self): def test_deferred_expense(self):
self.clear_old_entries()
# created deferred expense accounts, if not found # created deferred expense accounts, if not found
deferred_expense_account = create_account( deferred_expense_account = create_account(
account_name="Deferred Expense", account_name="Deferred Expense",
@@ -198,6 +232,91 @@ class TestDeferredRevenueAndExpense(unittest.TestCase):
] ]
self.assertEqual(report.period_total, expected) self.assertEqual(report.period_total, expected)
def test_zero_months(self):
self.clear_old_entries()
# created deferred expense accounts, if not found
deferred_revenue_account = create_account(
account_name="Deferred Revenue",
parent_account="Current Liabilities - _CD",
company="_Test Company DR",
)
acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings")
acc_settings.book_deferred_entries_based_on = "Months"
acc_settings.save()
customer = frappe.new_doc("Customer")
customer.customer_name = "_Test Customer DR"
customer.type = "Individual"
customer.insert()
item = create_item(
"_Test Internet Subscription",
is_stock_item=0,
warehouse="All Warehouses - _CD",
company="_Test Company DR",
)
item.enable_deferred_revenue = 1
item.deferred_revenue_account = deferred_revenue_account
item.no_of_months = 0
item.save()
si = create_sales_invoice(
item=item.name,
company="_Test Company DR",
customer="_Test Customer DR",
debit_to="Debtors - _CD",
posting_date="2021-05-01",
parent_cost_center="Main - _CD",
cost_center="Main - _CD",
do_not_submit=True,
rate=300,
price_list_rate=300,
)
si.items[0].enable_deferred_revenue = 1
si.items[0].deferred_revenue_account = deferred_revenue_account
si.items[0].income_account = "Sales - _CD"
si.save()
si.submit()
pda = frappe.get_doc(
dict(
doctype="Process Deferred Accounting",
posting_date=nowdate(),
start_date="2021-05-01",
end_date="2021-08-01",
type="Income",
company="_Test Company DR",
)
)
pda.insert()
pda.submit()
# execute report
fiscal_year = frappe.get_doc("Fiscal Year", frappe.defaults.get_user_default("fiscal_year"))
self.filters = frappe._dict(
{
"company": frappe.defaults.get_user_default("Company"),
"filter_based_on": "Date Range",
"period_start_date": "2021-05-01",
"period_end_date": "2021-08-01",
"from_fiscal_year": fiscal_year.year,
"to_fiscal_year": fiscal_year.year,
"periodicity": "Monthly",
"type": "Revenue",
"with_upcoming_postings": False,
}
)
report = Deferred_Revenue_and_Expense_Report(filters=self.filters)
report.run()
expected = [
{"key": "may_2021", "total": 300.0, "actual": 300.0},
{"key": "jun_2021", "total": 0, "actual": 0},
{"key": "jul_2021", "total": 0, "actual": 0},
{"key": "aug_2021", "total": 0, "actual": 0},
]
self.assertEqual(report.period_total, expected)
def create_company(): def create_company():
company = frappe.db.exists("Company", "_Test Company DR") company = frappe.db.exists("Company", "_Test Company DR")
@@ -209,15 +328,11 @@ def create_company():
company.insert() company.insert()
def clear_old_entries(): def clear_accounts_and_items():
item = qb.DocType("Item") item = qb.DocType("Item")
account = qb.DocType("Account") account = qb.DocType("Account")
customer = qb.DocType("Customer") customer = qb.DocType("Customer")
supplier = qb.DocType("Supplier") supplier = qb.DocType("Supplier")
sinv = qb.DocType("Sales Invoice")
sinv_item = qb.DocType("Sales Invoice Item")
pinv = qb.DocType("Purchase Invoice")
pinv_item = qb.DocType("Purchase Invoice Item")
qb.from_(account).delete().where( qb.from_(account).delete().where(
(account.account_name == "Deferred Revenue") (account.account_name == "Deferred Revenue")
@@ -228,26 +343,3 @@ def clear_old_entries():
).run() ).run()
qb.from_(customer).delete().where(customer.customer_name == "_Test Customer DR").run() qb.from_(customer).delete().where(customer.customer_name == "_Test Customer DR").run()
qb.from_(supplier).delete().where(supplier.supplier_name == "_Test Furniture Supplier").run() qb.from_(supplier).delete().where(supplier.supplier_name == "_Test Furniture Supplier").run()
# delete existing invoices with deferred items
deferred_invoices = (
qb.from_(sinv)
.join(sinv_item)
.on(sinv.name == sinv_item.parent)
.select(sinv.name)
.where(sinv_item.enable_deferred_revenue == 1)
.run()
)
if deferred_invoices:
qb.from_(sinv).delete().where(sinv.name.isin(deferred_invoices)).run()
deferred_invoices = (
qb.from_(pinv)
.join(pinv_item)
.on(pinv.name == pinv_item.parent)
.select(pinv.name)
.where(pinv_item.enable_deferred_expense == 1)
.run()
)
if deferred_invoices:
qb.from_(pinv).delete().where(pinv.name.isin(deferred_invoices)).run()

View File

@@ -186,83 +186,85 @@ class Asset(AccountsController):
if not self.available_for_use_date: if not self.available_for_use_date:
return return
for d in self.get('finance_books'): start = self.clear_depreciation_schedule()
self.validate_asset_finance_books(d)
start = self.clear_depreciation_schedule() for finance_book in self.get('finance_books'):
self.validate_asset_finance_books(finance_book)
# value_after_depreciation - current Asset value # value_after_depreciation - current Asset value
if self.docstatus == 1 and d.value_after_depreciation: if self.docstatus == 1 and finance_book.value_after_depreciation:
value_after_depreciation = flt(d.value_after_depreciation) value_after_depreciation = flt(finance_book.value_after_depreciation)
else: else:
value_after_depreciation = (flt(self.gross_purchase_amount) - value_after_depreciation = (flt(self.gross_purchase_amount) -
flt(self.opening_accumulated_depreciation)) flt(self.opening_accumulated_depreciation))
d.value_after_depreciation = value_after_depreciation finance_book.value_after_depreciation = value_after_depreciation
number_of_pending_depreciations = cint(d.total_number_of_depreciations) - \ number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - \
cint(self.number_of_depreciations_booked) cint(self.number_of_depreciations_booked)
has_pro_rata = self.check_is_pro_rata(d) has_pro_rata = self.check_is_pro_rata(finance_book)
if has_pro_rata: if has_pro_rata:
number_of_pending_depreciations += 1 number_of_pending_depreciations += 1
skip_row = False skip_row = False
for n in range(start, 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)
if skip_row: continue if skip_row: continue
depreciation_amount = get_depreciation_amount(self, value_after_depreciation, d) depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book)
if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1: if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1:
schedule_date = add_months(d.depreciation_start_date, schedule_date = add_months(finance_book.depreciation_start_date,
n * cint(d.frequency_of_depreciation)) n * cint(finance_book.frequency_of_depreciation))
# 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, - d.frequency_of_depreciation + 1) monthly_schedule_date = add_months(schedule_date, - finance_book.frequency_of_depreciation + 1)
# if asset is being sold # if asset is being sold
if date_of_sale: if date_of_sale:
from_date = self.get_from_date(d.finance_book) from_date = self.get_from_date(finance_book.finance_book)
depreciation_amount, days, months = self.get_pro_rata_amt(d, depreciation_amount, depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
from_date, date_of_sale) from_date, date_of_sale)
if depreciation_amount > 0: if depreciation_amount > 0:
self.append("schedules", { self.append("schedules", {
"schedule_date": date_of_sale, "schedule_date": date_of_sale,
"depreciation_amount": depreciation_amount, "depreciation_amount": depreciation_amount,
"depreciation_method": d.depreciation_method, "depreciation_method": finance_book.depreciation_method,
"finance_book": d.finance_book, "finance_book": finance_book.finance_book,
"finance_book_id": d.idx "finance_book_id": finance_book.idx
}) })
break break
# For first row # For first row
if has_pro_rata and not self.opening_accumulated_depreciation and n==0: if has_pro_rata and not self.opening_accumulated_depreciation and n==0:
depreciation_amount, days, months = self.get_pro_rata_amt(d, depreciation_amount, from_date = add_days(self.available_for_use_date, -1) # needed to calc depr amount for available_for_use_date too
self.available_for_use_date, d.depreciation_start_date) depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
from_date, finance_book.depreciation_start_date)
# For first depr schedule date will be the start date # For first depr schedule date will be the start date
# so monthly schedule date is calculated by removing month difference between use date and start date # so monthly schedule date is calculated by removing month difference between use date and start date
monthly_schedule_date = add_months(d.depreciation_start_date, - months + 1) monthly_schedule_date = add_months(finance_book.depreciation_start_date, - months + 1)
# For last row # For last row
elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1: elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1:
if not self.flags.increase_in_asset_life: if not self.flags.increase_in_asset_life:
# In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission # In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission
self.to_date = add_months(self.available_for_use_date, self.to_date = add_months(self.available_for_use_date,
(n + self.number_of_depreciations_booked) * cint(d.frequency_of_depreciation)) (n + self.number_of_depreciations_booked) * cint(finance_book.frequency_of_depreciation))
depreciation_amount_without_pro_rata = depreciation_amount depreciation_amount_without_pro_rata = depreciation_amount
depreciation_amount, days, months = self.get_pro_rata_amt(d, depreciation_amount, days, months = self.get_pro_rata_amt(finance_book,
depreciation_amount, schedule_date, self.to_date) depreciation_amount, schedule_date, self.to_date)
depreciation_amount = self.get_adjusted_depreciation_amount(depreciation_amount_without_pro_rata, depreciation_amount = self.get_adjusted_depreciation_amount(depreciation_amount_without_pro_rata,
depreciation_amount, d.finance_book) depreciation_amount, finance_book.finance_book)
monthly_schedule_date = add_months(schedule_date, 1) monthly_schedule_date = add_months(schedule_date, 1)
schedule_date = add_days(schedule_date, days) schedule_date = add_days(schedule_date, days)
@@ -273,10 +275,10 @@ class Asset(AccountsController):
self.precision("gross_purchase_amount")) self.precision("gross_purchase_amount"))
# Adjust depreciation amount in the last period based on the expected value after useful life # Adjust depreciation amount in the last period based on the expected value after useful life
if d.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1 if finance_book.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1
and value_after_depreciation != d.expected_value_after_useful_life) and value_after_depreciation != finance_book.expected_value_after_useful_life)
or value_after_depreciation < d.expected_value_after_useful_life): or value_after_depreciation < finance_book.expected_value_after_useful_life):
depreciation_amount += (value_after_depreciation - d.expected_value_after_useful_life) depreciation_amount += (value_after_depreciation - finance_book.expected_value_after_useful_life)
skip_row = True skip_row = True
if depreciation_amount > 0: if depreciation_amount > 0:
@@ -286,7 +288,7 @@ class Asset(AccountsController):
# In pro rata case, for first and last depreciation, month range would be different # In pro rata case, for first and last depreciation, month range would be different
month_range = months \ month_range = months \
if (has_pro_rata and n==0) or (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) \ if (has_pro_rata and n==0) or (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) \
else d.frequency_of_depreciation else finance_book.frequency_of_depreciation
for r in range(month_range): for r in range(month_range):
if (has_pro_rata and n == 0): if (has_pro_rata and n == 0):
@@ -312,27 +314,52 @@ class Asset(AccountsController):
self.append("schedules", { self.append("schedules", {
"schedule_date": date, "schedule_date": date,
"depreciation_amount": amount, "depreciation_amount": amount,
"depreciation_method": d.depreciation_method, "depreciation_method": finance_book.depreciation_method,
"finance_book": d.finance_book, "finance_book": finance_book.finance_book,
"finance_book_id": d.idx "finance_book_id": finance_book.idx
}) })
else: else:
self.append("schedules", { self.append("schedules", {
"schedule_date": schedule_date, "schedule_date": schedule_date,
"depreciation_amount": depreciation_amount, "depreciation_amount": depreciation_amount,
"depreciation_method": d.depreciation_method, "depreciation_method": finance_book.depreciation_method,
"finance_book": d.finance_book, "finance_book": finance_book.finance_book,
"finance_book_id": d.idx "finance_book_id": finance_book.idx
}) })
# used when depreciation schedule needs to be modified due to increase in asset life # depreciation schedules need to be cleared before modification due to increase in asset life/asset sales
# JE: Journal Entry, FB: Finance Book
def clear_depreciation_schedule(self): def clear_depreciation_schedule(self):
start = 0 start = []
for n in range(len(self.schedules)): num_of_depreciations_completed = 0
if not self.schedules[n].journal_entry: depr_schedule = []
del self.schedules[n:]
start = n for schedule in self.get('schedules'):
break
# to update start when there are JEs linked with all the schedule rows corresponding to an FB
if len(start) == (int(schedule.finance_book_id) - 2):
start.append(num_of_depreciations_completed)
num_of_depreciations_completed = 0
# to ensure that start will only be updated once for each FB
if len(start) == (int(schedule.finance_book_id) - 1):
if schedule.journal_entry:
num_of_depreciations_completed += 1
depr_schedule.append(schedule)
else:
start.append(num_of_depreciations_completed)
num_of_depreciations_completed = 0
# to update start when all the schedule rows corresponding to the last FB are linked with JEs
if len(start) == (len(self.finance_books) - 1):
start.append(num_of_depreciations_completed)
# when the Depreciation Schedule is being created for the first time
if start == []:
start = [0] * len(self.finance_books)
else:
self.schedules = depr_schedule
return start return start
def get_from_date(self, finance_book): def get_from_date(self, finance_book):
@@ -349,7 +376,9 @@ class Asset(AccountsController):
if from_date: if from_date:
return from_date return from_date
return self.available_for_use_date
# since depr for available_for_use_date is not yet booked
return add_days(self.available_for_use_date, -1)
# if it returns True, depreciation_amount will not be equal for the first and last rows # if it returns True, depreciation_amount will not be equal for the first and last rows
def check_is_pro_rata(self, row): def check_is_pro_rata(self, row):

View File

@@ -207,9 +207,9 @@ class TestAsset(AssetSetup):
self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold") self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold")
expected_gle = ( expected_gle = (
("_Test Accumulated Depreciations - _TC", 20392.16, 0.0), ("_Test Accumulated Depreciations - _TC", 20490.2, 0.0),
("_Test Fixed Asset - _TC", 0.0, 100000.0), ("_Test Fixed Asset - _TC", 0.0, 100000.0),
("_Test Gain/Loss on Asset Disposal - _TC", 54607.84, 0.0), ("_Test Gain/Loss on Asset Disposal - _TC", 54509.8, 0.0),
("Debtors - _TC", 25000.0, 0.0) ("Debtors - _TC", 25000.0, 0.0)
) )
@@ -491,10 +491,10 @@ class TestDepreciationMethods(AssetSetup):
) )
expected_schedules = [ expected_schedules = [
["2030-12-31", 27534.25, 27534.25], ['2030-12-31', 27616.44, 27616.44],
["2031-12-31", 30000.0, 57534.25], ['2031-12-31', 30000.0, 57616.44],
["2032-12-31", 30000.0, 87534.25], ['2032-12-31', 30000.0, 87616.44],
["2033-01-30", 2465.75, 90000.0] ['2033-01-30', 2383.56, 90000.0]
] ]
schedules = [[cstr(d.schedule_date), flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2)] schedules = [[cstr(d.schedule_date), flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2)]
@@ -544,10 +544,10 @@ class TestDepreciationMethods(AssetSetup):
self.assertEqual(asset.finance_books[0].rate_of_depreciation, 50.0) self.assertEqual(asset.finance_books[0].rate_of_depreciation, 50.0)
expected_schedules = [ expected_schedules = [
["2030-12-31", 28493.15, 28493.15], ['2030-12-31', 28630.14, 28630.14],
["2031-12-31", 35753.43, 64246.58], ['2031-12-31', 35684.93, 64315.07],
["2032-12-31", 17876.71, 82123.29], ['2032-12-31', 17842.47, 82157.54],
["2033-06-06", 5376.71, 87500.0] ['2033-06-06', 5342.46, 87500.0]
] ]
schedules = [[cstr(d.schedule_date), flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2)] schedules = [[cstr(d.schedule_date), flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2)]
@@ -580,10 +580,10 @@ class TestDepreciationMethods(AssetSetup):
self.assertEqual(asset.finance_books[0].rate_of_depreciation, 50.0) self.assertEqual(asset.finance_books[0].rate_of_depreciation, 50.0)
expected_schedules = [ expected_schedules = [
["2030-12-31", 11780.82, 11780.82], ["2030-12-31", 11849.32, 11849.32],
["2031-12-31", 44109.59, 55890.41], ["2031-12-31", 44075.34, 55924.66],
["2032-12-31", 22054.8, 77945.21], ["2032-12-31", 22037.67, 77962.33],
["2033-07-12", 9554.79, 87500.0] ["2033-07-12", 9537.67, 87500.0]
] ]
schedules = [[cstr(d.schedule_date), flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2)] schedules = [[cstr(d.schedule_date), flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2)]
@@ -642,7 +642,7 @@ class TestDepreciationBasics(AssetSetup):
asset = create_asset( asset = create_asset(
item_code = "Macbook Pro", item_code = "Macbook Pro",
calculate_depreciation = 1, calculate_depreciation = 1,
available_for_use_date = getdate("2019-12-31"), available_for_use_date = getdate("2020-01-01"),
total_number_of_depreciations = 3, total_number_of_depreciations = 3,
expected_value_after_useful_life = 10000, expected_value_after_useful_life = 10000,
depreciation_start_date = getdate("2020-07-01"), depreciation_start_date = getdate("2020-07-01"),
@@ -653,7 +653,7 @@ class TestDepreciationBasics(AssetSetup):
["2020-07-01", 15000, 15000], ["2020-07-01", 15000, 15000],
["2021-07-01", 30000, 45000], ["2021-07-01", 30000, 45000],
["2022-07-01", 30000, 75000], ["2022-07-01", 30000, 75000],
["2022-12-31", 15000, 90000] ["2023-01-01", 15000, 90000]
] ]
for i, schedule in enumerate(asset.schedules): for i, schedule in enumerate(asset.schedules):
@@ -976,6 +976,82 @@ class TestDepreciationBasics(AssetSetup):
self.assertEqual(len(asset.schedules), 1) self.assertEqual(len(asset.schedules), 1)
def test_clear_depreciation_schedule_for_multiple_finance_books(self):
asset = create_asset(
item_code = "Macbook Pro",
available_for_use_date = "2019-12-31",
do_not_save = 1
)
asset.calculate_depreciation = 1
asset.append("finance_books", {
"depreciation_method": "Straight Line",
"frequency_of_depreciation": 1,
"total_number_of_depreciations": 3,
"expected_value_after_useful_life": 10000,
"depreciation_start_date": "2020-01-31"
})
asset.append("finance_books", {
"depreciation_method": "Straight Line",
"frequency_of_depreciation": 1,
"total_number_of_depreciations": 6,
"expected_value_after_useful_life": 10000,
"depreciation_start_date": "2020-01-31"
})
asset.append("finance_books", {
"depreciation_method": "Straight Line",
"frequency_of_depreciation": 12,
"total_number_of_depreciations": 3,
"expected_value_after_useful_life": 10000,
"depreciation_start_date": "2020-12-31"
})
asset.submit()
post_depreciation_entries(date="2020-04-01")
asset.load_from_db()
asset.clear_depreciation_schedule()
self.assertEqual(len(asset.schedules), 6)
for schedule in asset.schedules:
if schedule.idx <= 3:
self.assertEqual(schedule.finance_book_id, "1")
else:
self.assertEqual(schedule.finance_book_id, "2")
def test_depreciation_schedules_are_set_up_for_multiple_finance_books(self):
asset = create_asset(
item_code = "Macbook Pro",
available_for_use_date = "2019-12-31",
do_not_save = 1
)
asset.calculate_depreciation = 1
asset.append("finance_books", {
"depreciation_method": "Straight Line",
"frequency_of_depreciation": 12,
"total_number_of_depreciations": 3,
"expected_value_after_useful_life": 10000,
"depreciation_start_date": "2020-12-31"
})
asset.append("finance_books", {
"depreciation_method": "Straight Line",
"frequency_of_depreciation": 12,
"total_number_of_depreciations": 6,
"expected_value_after_useful_life": 10000,
"depreciation_start_date": "2020-12-31"
})
asset.save()
self.assertEqual(len(asset.schedules), 9)
for schedule in asset.schedules:
if schedule.idx <= 3:
self.assertEqual(schedule.finance_book_id, 1)
else:
self.assertEqual(schedule.finance_book_id, 2)
def test_depreciation_entry_cancellation(self): def test_depreciation_entry_cancellation(self):
asset = create_asset( asset = create_asset(
item_code = "Macbook Pro", item_code = "Macbook Pro",

View File

@@ -191,8 +191,6 @@ class AccountsController(TransactionBase):
frappe.throw(_("Row #{0}: Service Start Date cannot be greater than Service End Date").format(d.idx)) frappe.throw(_("Row #{0}: Service Start Date cannot be greater than Service End Date").format(d.idx))
elif getdate(self.posting_date) > getdate(d.service_end_date): elif getdate(self.posting_date) > getdate(d.service_end_date):
frappe.throw(_("Row #{0}: Service End Date cannot be before Invoice Posting Date").format(d.idx)) frappe.throw(_("Row #{0}: Service End Date cannot be before Invoice Posting Date").format(d.idx))
elif getdate(self.posting_date) > getdate(d.service_start_date):
frappe.throw(_("Row #{0}: Service Start Date cannot be before Invoice Posting Date").format(d.idx))
def validate_invoice_documents_schedule(self): def validate_invoice_documents_schedule(self):
self.validate_payment_schedule_dates() self.validate_payment_schedule_dates()

View File

@@ -385,7 +385,7 @@ class SellingController(StockController):
# Get incoming rate based on original item cost based on valuation method # Get incoming rate based on original item cost based on valuation method
qty = flt(d.get('stock_qty') or d.get('actual_qty')) qty = flt(d.get('stock_qty') or d.get('actual_qty'))
if not d.incoming_rate: if not (self.get("is_return") and d.incoming_rate):
d.incoming_rate = get_incoming_rate({ d.incoming_rate = get_incoming_rate({
"item_code": d.item_code, "item_code": d.item_code,
"warehouse": d.warehouse, "warehouse": d.warehouse,

View File

@@ -17,7 +17,7 @@ from erpnext.accounts.general_ledger import (
from erpnext.accounts.utils import get_fiscal_year from erpnext.accounts.utils import get_fiscal_year
from erpnext.controllers.accounts_controller import AccountsController from erpnext.controllers.accounts_controller import AccountsController
from erpnext.stock import get_warehouse_account_map from erpnext.stock import get_warehouse_account_map
from erpnext.stock.stock_ledger import get_items_to_be_repost, get_valuation_rate from erpnext.stock.stock_ledger import get_items_to_be_repost
class QualityInspectionRequiredError(frappe.ValidationError): pass class QualityInspectionRequiredError(frappe.ValidationError): pass
@@ -111,17 +111,6 @@ class StockController(AccountsController):
self.check_expense_account(item_row) self.check_expense_account(item_row)
# If the item does not have the allow zero valuation rate flag set
# and ( valuation rate not mentioned in an incoming entry
# or incoming entry not found while delivering the item),
# try to pick valuation rate from previous sle or Item master and update in SLE
# Otherwise, throw an exception
if not sle.stock_value_difference and self.doctype != "Stock Reconciliation" \
and not item_row.get("allow_zero_valuation_rate"):
sle = self.update_stock_ledger_entries(sle)
# expense account/ target_warehouse / source_warehouse # expense account/ target_warehouse / source_warehouse
if item_row.get('target_warehouse'): if item_row.get('target_warehouse'):
warehouse = item_row.get('target_warehouse') warehouse = item_row.get('target_warehouse')
@@ -164,26 +153,6 @@ class StockController(AccountsController):
return frappe.flags.debit_field_precision return frappe.flags.debit_field_precision
def update_stock_ledger_entries(self, sle):
sle.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse,
self.doctype, self.name, currency=self.company_currency, company=self.company)
sle.stock_value = flt(sle.qty_after_transaction) * flt(sle.valuation_rate)
sle.stock_value_difference = flt(sle.actual_qty) * flt(sle.valuation_rate)
if sle.name:
frappe.db.sql("""
update
`tabStock Ledger Entry`
set
stock_value = %(stock_value)s,
valuation_rate = %(valuation_rate)s,
stock_value_difference = %(stock_value_difference)s
where
name = %(name)s""", (sle))
return sle
def get_voucher_details(self, default_expense_account, default_cost_center, sle_map): def get_voucher_details(self, default_expense_account, default_cost_center, sle_map):
if self.doctype == "Stock Reconciliation": if self.doctype == "Stock Reconciliation":
reconciliation_purpose = frappe.db.get_value(self.doctype, self.name, "purpose") reconciliation_purpose = frappe.db.get_value(self.doctype, self.name, "purpose")
@@ -287,11 +256,7 @@ class StockController(AccountsController):
for d in self.items: for d in self.items:
if not d.batch_no: continue if not d.batch_no: continue
serial_nos = [sr.name for sr in frappe.get_all("Serial No", frappe.db.set_value("Serial No", {"batch_no": d.batch_no, "status": "Inactive"}, "batch_no", None)
{'batch_no': d.batch_no, 'status': 'Inactive'})]
if serial_nos:
frappe.db.set_value("Serial No", { 'name': ['in', serial_nos] }, "batch_no", None)
d.batch_no = None d.batch_no = None
d.db_set("batch_no", None) d.db_set("batch_no", None)

View File

@@ -276,10 +276,29 @@ def guess_territory():
def decorate_quotation_doc(doc): def decorate_quotation_doc(doc):
for d in doc.get("items", []): for d in doc.get("items", []):
item_code = d.item_code
fields = ["web_item_name", "thumbnail", "website_image", "description", "route"]
# Variant Item
if not frappe.db.exists("Website Item", {"item_code": item_code}):
variant_data = frappe.db.get_values(
"Item",
filters={"item_code": item_code},
fieldname=["variant_of", "item_name", "image"],
as_dict=True
)[0]
item_code = variant_data.variant_of
fields = fields[1:]
d.web_item_name = variant_data.item_name
if variant_data.image: # get image from variant or template web item
d.thumbnail = variant_data.image
fields = fields[2:]
d.update(frappe.db.get_value( d.update(frappe.db.get_value(
"Website Item", "Website Item",
{"item_code": d.item_code}, {"item_code": item_code},
["web_item_name", "thumbnail", "website_image", "description", "route"], fields,
as_dict=True) as_dict=True)
) )

View File

@@ -9,8 +9,13 @@ from frappe.utils import add_months, nowdate
from erpnext.accounts.doctype.tax_rule.tax_rule import ConflictingTaxRule from erpnext.accounts.doctype.tax_rule.tax_rule import ConflictingTaxRule
from erpnext.e_commerce.doctype.website_item.website_item import make_website_item from erpnext.e_commerce.doctype.website_item.website_item import make_website_item
from erpnext.e_commerce.shopping_cart.cart import _get_cart_quotation, get_party, update_cart from erpnext.e_commerce.shopping_cart.cart import (
from erpnext.tests.utils import create_test_contact_and_address _get_cart_quotation,
get_cart_quotation,
get_party,
update_cart,
)
from erpnext.tests.utils import change_settings, create_test_contact_and_address
class TestShoppingCart(unittest.TestCase): class TestShoppingCart(unittest.TestCase):
@@ -34,6 +39,7 @@ class TestShoppingCart(unittest.TestCase):
make_website_item(frappe.get_cached_doc("Item", "_Test Item 2")) make_website_item(frappe.get_cached_doc("Item", "_Test Item 2"))
def tearDown(self): def tearDown(self):
frappe.db.rollback()
frappe.set_user("Administrator") frappe.set_user("Administrator")
self.disable_shopping_cart() self.disable_shopping_cart()
@@ -128,6 +134,43 @@ class TestShoppingCart(unittest.TestCase):
self.remove_test_quotation(quotation) self.remove_test_quotation(quotation)
@change_settings("E Commerce Settings",{
"company": "_Test Company",
"enabled": 1,
"default_customer_group": "_Test Customer Group",
"price_list": "_Test Price List India",
"show_price": 1
})
def test_add_item_variant_without_web_item_to_cart(self):
"Test adding Variants having no Website Items in cart via Template Web Item."
from erpnext.controllers.item_variant import create_variant
from erpnext.e_commerce.doctype.website_item.website_item import make_website_item
from erpnext.stock.doctype.item.test_item import make_item
template_item = make_item("Test-Tshirt-Temp", {
"has_variant": 1,
"variant_based_on": "Item Attribute",
"attributes": [
{"attribute": "Test Size"},
{"attribute": "Test Colour"}
]
})
variant = create_variant("Test-Tshirt-Temp", {
"Test Size": "Small", "Test Colour": "Red"
})
variant.save()
make_website_item(template_item) # publish template not variant
update_cart("Test-Tshirt-Temp-S-R", 1)
cart = get_cart_quotation() # test if cart page gets data without errors
doc = cart.get("doc")
self.assertEqual(doc.get("items")[0].item_name, "Test-Tshirt-Temp-S-R")
# test if items are rendered without error
frappe.render_template("templates/includes/cart/cart_items.html", cart)
def create_tax_rule(self): def create_tax_rule(self):
tax_rule = frappe.get_test_records("Tax Rule")[0] tax_rule = frappe.get_test_records("Tax Rule")[0]
try: try:

View File

@@ -3,24 +3,26 @@ import unittest
import frappe import frappe
from erpnext.controllers.item_variant import create_variant from erpnext.controllers.item_variant import create_variant
from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import (
setup_e_commerce_settings,
)
from erpnext.e_commerce.doctype.website_item.website_item import make_website_item from erpnext.e_commerce.doctype.website_item.website_item import make_website_item
from erpnext.e_commerce.variant_selector.utils import get_next_attribute_and_values
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestCase
test_dependencies = ["Item"] test_dependencies = ["Item"]
class TestVariantSelector(unittest.TestCase): class TestVariantSelector(ERPNextTestCase):
def setUp(self) -> None: @classmethod
self.template_item = make_item("Test-Tshirt-Temp", { def setUpClass(cls):
template_item = make_item("Test-Tshirt-Temp", {
"has_variant": 1, "has_variant": 1,
"variant_based_on": "Item Attribute", "variant_based_on": "Item Attribute",
"attributes": [ "attributes": [
{ {"attribute": "Test Size"},
"attribute": "Test Size" {"attribute": "Test Colour"}
},
{
"attribute": "Test Colour"
}
] ]
}) })
@@ -28,19 +30,16 @@ class TestVariantSelector(unittest.TestCase):
for size in ("Large", "Medium",): for size in ("Large", "Medium",):
for colour in ("Red", "Green",): for colour in ("Red", "Green",):
variant = create_variant("Test-Tshirt-Temp", { variant = create_variant("Test-Tshirt-Temp", {
"Test Size": size, "Test Size": size, "Test Colour": colour
"Test Colour": colour
}) })
variant.save() variant.save()
variant = create_variant("Test-Tshirt-Temp", { variant = create_variant("Test-Tshirt-Temp", {
"Test Size": "Small", "Test Size": "Small", "Test Colour": "Red"
"Test Colour": "Red"
}) })
variant.save() variant.save()
def tearDown(self): make_website_item(template_item) # publish template not variants
frappe.db.rollback()
def test_item_attributes(self): def test_item_attributes(self):
""" """
@@ -51,8 +50,6 @@ class TestVariantSelector(unittest.TestCase):
""" """
from erpnext.e_commerce.variant_selector.utils import get_attributes_and_values from erpnext.e_commerce.variant_selector.utils import get_attributes_and_values
make_website_item(self.template_item) # publish template not variants
attr_data = get_attributes_and_values("Test-Tshirt-Temp") attr_data = get_attributes_and_values("Test-Tshirt-Temp")
self.assertEqual(attr_data[0]["attribute"], "Test Size") self.assertEqual(attr_data[0]["attribute"], "Test Size")
@@ -72,7 +69,7 @@ class TestVariantSelector(unittest.TestCase):
self.assertEqual(len(attr_data[0]["values"]), 2) # ['Medium', 'Large'] self.assertEqual(len(attr_data[0]["values"]), 2) # ['Medium', 'Large']
# teardown # teardown
small_variant.disabled = 1 small_variant.disabled = 0
small_variant.save() small_variant.save()
def test_next_item_variant_values(self): def test_next_item_variant_values(self):
@@ -84,8 +81,6 @@ class TestVariantSelector(unittest.TestCase):
There is a ** Small-Red ** Tshirt. No other colour in this size. There is a ** Small-Red ** Tshirt. No other colour in this size.
On selecting ** Small **, only ** Red ** should be selectable next. On selecting ** Small **, only ** Red ** should be selectable next.
""" """
from erpnext.e_commerce.variant_selector.utils import get_next_attribute_and_values
next_values = get_next_attribute_and_values("Test-Tshirt-Temp", selected_attributes={"Test Size": "Small"}) next_values = get_next_attribute_and_values("Test-Tshirt-Temp", selected_attributes={"Test Size": "Small"})
next_colours = next_values["valid_options_for_attributes"]["Test Colour"] next_colours = next_values["valid_options_for_attributes"]["Test Colour"]
filtered_items = next_values["filtered_items"] filtered_items = next_values["filtered_items"]
@@ -94,3 +89,31 @@ class TestVariantSelector(unittest.TestCase):
self.assertEqual(next_colours.pop(), "Red") self.assertEqual(next_colours.pop(), "Red")
self.assertEqual(len(filtered_items), 1) self.assertEqual(len(filtered_items), 1)
self.assertEqual(filtered_items.pop(), "Test-Tshirt-Temp-S-R") self.assertEqual(filtered_items.pop(), "Test-Tshirt-Temp-S-R")
def test_exact_match_with_price(self):
"""
Test price fetching and matching of variant without Website Item
"""
from erpnext.e_commerce.doctype.website_item.test_website_item import make_web_item_price
frappe.set_user("Administrator")
setup_e_commerce_settings({
"company": "_Test Company",
"enabled": 1,
"default_customer_group": "_Test Customer Group",
"price_list": "_Test Price List India",
"show_price": 1
})
make_web_item_price(item_code="Test-Tshirt-Temp-S-R", price_list_rate=100)
next_values = get_next_attribute_and_values(
"Test-Tshirt-Temp",
selected_attributes={"Test Size": "Small", "Test Colour": "Red"}
)
print(">>>>", next_values)
price_info = next_values["product_info"]["price"]
self.assertEqual(next_values["exact_match"][0],"Test-Tshirt-Temp-S-R")
self.assertEqual(next_values["exact_match"][0],"Test-Tshirt-Temp-S-R")
self.assertEqual(price_info["price_list_rate"], 100.0)
self.assertEqual(price_info["formatted_price_sales_uom"], "₹ 100.00")

View File

@@ -1,7 +1,12 @@
import frappe import frappe
from frappe.utils import cint from frappe.utils import cint
from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import (
get_shopping_cart_settings,
)
from erpnext.e_commerce.shopping_cart.cart import _set_price_list
from erpnext.e_commerce.variant_selector.item_variants_cache import ItemVariantsCacheManager from erpnext.e_commerce.variant_selector.item_variants_cache import ItemVariantsCacheManager
from erpnext.utilities.product import get_price
def get_item_codes_by_attributes(attribute_filters, template_item_code=None): def get_item_codes_by_attributes(attribute_filters, template_item_code=None):
@@ -143,14 +148,13 @@ def get_next_attribute_and_values(item_code, selected_attributes):
filtered_items_count = len(filtered_items) filtered_items_count = len(filtered_items)
# get product info if exact match # get product info if exact match
from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website # from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website
if exact_match: if exact_match:
data = get_product_info_for_website(exact_match[0]) cart_settings = get_shopping_cart_settings()
product_info = data.product_info product_info = get_item_variant_price_dict(exact_match[0], cart_settings)
if product_info: if product_info:
product_info["allow_items_not_in_stock"] = cint(data.cart_settings.allow_items_not_in_stock) product_info["allow_items_not_in_stock"] = cint(cart_settings.allow_items_not_in_stock)
if not data.cart_settings.show_price:
product_info = None
else: else:
product_info = None product_info = None
@@ -195,3 +199,20 @@ def get_item_attributes(item_code):
return attributes return attributes
def get_item_variant_price_dict(item_code, cart_settings):
if cart_settings.enabled and cart_settings.show_price:
is_guest = frappe.session.user == "Guest"
# Show Price if logged in.
# If not logged in, check if price is hidden for guest.
if not is_guest or not cart_settings.hide_price_for_guest:
price_list = _set_price_list(cart_settings, None)
price = get_price(
item_code,
price_list,
cart_settings.default_customer_group,
cart_settings.company
)
return {"price": price}
return None

View File

@@ -201,8 +201,8 @@ def get_course_schedule_events(start, end, filters=None):
conditions = get_event_conditions("Course Schedule", filters) conditions = get_event_conditions("Course Schedule", filters)
data = frappe.db.sql("""select name, course, color, data = frappe.db.sql("""select name, course, color,
timestamp(schedule_date, from_time) as from_datetime, timestamp(schedule_date, from_time) as from_time,
timestamp(schedule_date, to_time) as to_datetime, timestamp(schedule_date, to_time) as to_time,
room, student_group, 0 as 'allDay' room, student_group, 0 as 'allDay'
from `tabCourse Schedule` from `tabCourse Schedule`
where ( schedule_date between %(start)s and %(end)s ) where ( schedule_date between %(start)s and %(end)s )

View File

@@ -3,6 +3,8 @@
# For license information, please see license.txt # For license information, please see license.txt
from datetime import datetime
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
@@ -30,6 +32,14 @@ class CourseSchedule(Document):
if self.from_time > self.to_time: if self.from_time > self.to_time:
frappe.throw(_("From Time cannot be greater than To Time.")) frappe.throw(_("From Time cannot be greater than To Time."))
"""Handles specicfic case to update schedule date in calendar """
if isinstance(self.from_time, str):
try:
datetime_obj = datetime.strptime(self.from_time, '%Y-%m-%d %H:%M:%S')
self.schedule_date = datetime_obj
except ValueError:
pass
def validate_overlap(self): def validate_overlap(self):
"""Validates overlap for Student Group, Instructor, Room""" """Validates overlap for Student Group, Instructor, Room"""
@@ -47,4 +57,4 @@ class CourseSchedule(Document):
validate_overlap_for(self, "Assessment Plan", "student_group") validate_overlap_for(self, "Assessment Plan", "student_group")
validate_overlap_for(self, "Assessment Plan", "room") validate_overlap_for(self, "Assessment Plan", "room")
validate_overlap_for(self, "Assessment Plan", "supervisor", self.instructor) validate_overlap_for(self, "Assessment Plan", "supervisor", self.instructor)

View File

@@ -1,11 +1,10 @@
frappe.views.calendar["Course Schedule"] = { frappe.views.calendar["Course Schedule"] = {
field_map: { field_map: {
// from_datetime and to_datetime don't exist as docfields but are used in onload "start": "from_time",
"start": "from_datetime", "end": "to_time",
"end": "to_datetime",
"id": "name", "id": "name",
"title": "course", "title": "course",
"allDay": "allDay" "allDay": "allDay",
}, },
gantt: false, gantt: false,
order_by: "schedule_date", order_by: "schedule_date",

View File

@@ -6,6 +6,7 @@ import unittest
import frappe import frappe
from frappe.utils import to_timedelta, today from frappe.utils import to_timedelta, today
from frappe.utils.data import add_to_date
from erpnext.education.utils import OverlapError from erpnext.education.utils import OverlapError
@@ -39,6 +40,11 @@ class TestCourseSchedule(unittest.TestCase):
make_course_schedule_test_record(from_time= cs1.from_time, to_time= cs1.to_time, make_course_schedule_test_record(from_time= cs1.from_time, to_time= cs1.to_time,
student_group="Course-TC102-2014-2015 (_Test Academic Term)", instructor="_Test Instructor 2", room=frappe.get_all("Room")[1].name) student_group="Course-TC102-2014-2015 (_Test Academic Term)", instructor="_Test Instructor 2", room=frappe.get_all("Room")[1].name)
def test_update_schedule_date(self):
doc = make_course_schedule_test_record(schedule_date= add_to_date(today(), days=1))
doc.schedule_date = add_to_date(doc.schedule_date, days=1)
doc.save()
def make_course_schedule_test_record(**args): def make_course_schedule_test_record(**args):
args = frappe._dict(args) args = frappe._dict(args)

View File

@@ -99,7 +99,6 @@
"search_index": 1 "search_index": 1
}, },
{ {
"fetch_from": "inpatient_record.patient",
"fieldname": "patient", "fieldname": "patient",
"fieldtype": "Link", "fieldtype": "Link",
"ignore_user_permissions": 1, "ignore_user_permissions": 1,
@@ -559,7 +558,7 @@
], ],
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2020-11-30 11:04:17.195848", "modified": "2022-01-20 12:37:07.943153",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Healthcare", "module": "Healthcare",
"name": "Lab Test", "name": "Lab Test",

View File

@@ -337,9 +337,13 @@ let check_and_set_availability = function(frm) {
}); });
d.fields_dict['department'].df.onchange = () => { d.fields_dict['department'].df.onchange = () => {
d.set_values({ if (d.get_value('department') == frm.doc.department) {
'practitioner': '' d.set_value('practitioner', frm.doc.practitioner);
}); } else {
d.set_value('practitioner', '');
d.fields_dict.available_slots.html('');
d.get_primary_btn().attr('disabled', true);
}
let department = d.get_value('department'); let department = d.get_value('department');
if (department) { if (department) {
d.fields_dict.practitioner.get_query = function() { d.fields_dict.practitioner.get_query = function() {
@@ -426,7 +430,8 @@ let check_and_set_availability = function(frm) {
slot_details.forEach((slot_info) => { slot_details.forEach((slot_info) => {
slot_html += `<div class="slot-info"> slot_html += `<div class="slot-info">
<span> <b> ${__('Practitioner Schedule:')} </b> ${slot_info.slot_name} </span><br> <span> <b> ${slot_info.practitioner_name} </b> </span><br>
<span> <b> ${__('Schedule:')} </b> ${slot_info.slot_name} </span><br>
<span> <b> ${__('Service Unit:')} </b> ${slot_info.service_unit} </span>`; <span> <b> ${__('Service Unit:')} </b> ${slot_info.service_unit} </span>`;
if (slot_info.service_unit_capacity) { if (slot_info.service_unit_capacity) {

View File

@@ -388,7 +388,8 @@ def get_available_slots(practitioner_doc, date):
fields=['name', 'appointment_time', 'duration', 'status']) fields=['name', 'appointment_time', 'duration', 'status'])
slot_details.append({'slot_name': slot_name, 'service_unit': schedule_entry.service_unit, 'avail_slot': available_slots, slot_details.append({'slot_name': slot_name, 'service_unit': schedule_entry.service_unit, 'avail_slot': available_slots,
'appointments': appointments, 'allow_overlap': allow_overlap, 'service_unit_capacity': service_unit_capacity}) 'appointments': appointments, 'allow_overlap': allow_overlap, 'service_unit_capacity': service_unit_capacity,
'practitioner_name': practitioner_doc.practitioner_name})
return slot_details return slot_details

View File

@@ -66,7 +66,6 @@
"search_index": 1 "search_index": 1
}, },
{ {
"fetch_from": "inpatient_record.patient",
"fieldname": "patient", "fieldname": "patient",
"fieldtype": "Link", "fieldtype": "Link",
"hide_days": 1, "hide_days": 1,
@@ -224,7 +223,7 @@
], ],
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2020-07-30 16:53:13.076104", "modified": "2022-01-20 12:38:55.382621",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Healthcare", "module": "Healthcare",
"name": "Sample Collection", "name": "Sample Collection",

View File

@@ -51,7 +51,6 @@
"read_only": 1 "read_only": 1
}, },
{ {
"fetch_from": "inpatient_record.patient",
"fieldname": "patient", "fieldname": "patient",
"fieldtype": "Link", "fieldtype": "Link",
"ignore_user_permissions": 1, "ignore_user_permissions": 1,
@@ -259,7 +258,7 @@
], ],
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2020-05-17 22:23:24.632286", "modified": "2022-01-20 12:30:07.515185",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Healthcare", "module": "Healthcare",
"name": "Vital Signs", "name": "Vital Signs",

View File

@@ -5,9 +5,9 @@
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import cstr, formatdate, get_datetime, getdate, nowdate from frappe.utils import cint, cstr, formatdate, get_datetime, getdate, nowdate
from erpnext.hr.utils import validate_active_employee from erpnext.hr.utils import get_holiday_dates_for_employee, validate_active_employee
class Attendance(Document): class Attendance(Document):
@@ -171,7 +171,7 @@ def get_month_map():
}) })
@frappe.whitelist() @frappe.whitelist()
def get_unmarked_days(employee, month): def get_unmarked_days(employee, month, exclude_holidays=0):
import calendar import calendar
month_map = get_month_map() month_map = get_month_map()
@@ -191,6 +191,11 @@ def get_unmarked_days(employee, month):
]) ])
marked_days = [get_datetime(record.attendance_date) for record in records] marked_days = [get_datetime(record.attendance_date) for record in records]
if cint(exclude_holidays):
holiday_dates = get_holiday_dates_for_employee(employee, month_start, month_end)
holidays = [get_datetime(record) for record in holiday_dates]
marked_days.extend(holidays)
unmarked_days = [] unmarked_days = []
for date in dates_of_month: for date in dates_of_month:

View File

@@ -28,6 +28,7 @@ frappe.listview_settings['Attendance'] = {
onchange: function() { onchange: function() {
dialog.set_df_property("unmarked_days", "hidden", 1); dialog.set_df_property("unmarked_days", "hidden", 1);
dialog.set_df_property("status", "hidden", 1); dialog.set_df_property("status", "hidden", 1);
dialog.set_df_property("exclude_holidays", "hidden", 1);
dialog.set_df_property("month", "value", ''); dialog.set_df_property("month", "value", '');
dialog.set_df_property("unmarked_days", "options", []); dialog.set_df_property("unmarked_days", "options", []);
dialog.no_unmarked_days_left = false; dialog.no_unmarked_days_left = false;
@@ -42,9 +43,14 @@ frappe.listview_settings['Attendance'] = {
onchange: function() { onchange: function() {
if (dialog.fields_dict.employee.value && dialog.fields_dict.month.value) { if (dialog.fields_dict.employee.value && dialog.fields_dict.month.value) {
dialog.set_df_property("status", "hidden", 0); dialog.set_df_property("status", "hidden", 0);
dialog.set_df_property("exclude_holidays", "hidden", 0);
dialog.set_df_property("unmarked_days", "options", []); dialog.set_df_property("unmarked_days", "options", []);
dialog.no_unmarked_days_left = false; dialog.no_unmarked_days_left = false;
me.get_multi_select_options(dialog.fields_dict.employee.value, dialog.fields_dict.month.value).then(options => { me.get_multi_select_options(
dialog.fields_dict.employee.value,
dialog.fields_dict.month.value,
dialog.fields_dict.exclude_holidays.get_value()
).then(options => {
if (options.length > 0) { if (options.length > 0) {
dialog.set_df_property("unmarked_days", "hidden", 0); dialog.set_df_property("unmarked_days", "hidden", 0);
dialog.set_df_property("unmarked_days", "options", options); dialog.set_df_property("unmarked_days", "options", options);
@@ -64,6 +70,31 @@ frappe.listview_settings['Attendance'] = {
reqd: 1, reqd: 1,
}, },
{
label: __("Exclude Holidays"),
fieldtype: "Check",
fieldname: "exclude_holidays",
hidden: 1,
onchange: function() {
if (dialog.fields_dict.employee.value && dialog.fields_dict.month.value) {
dialog.set_df_property("status", "hidden", 0);
dialog.set_df_property("unmarked_days", "options", []);
dialog.no_unmarked_days_left = false;
me.get_multi_select_options(
dialog.fields_dict.employee.value,
dialog.fields_dict.month.value,
dialog.fields_dict.exclude_holidays.get_value()
).then(options => {
if (options.length > 0) {
dialog.set_df_property("unmarked_days", "hidden", 0);
dialog.set_df_property("unmarked_days", "options", options);
} else {
dialog.no_unmarked_days_left = true;
}
});
}
}
},
{ {
label: __("Unmarked Attendance for days"), label: __("Unmarked Attendance for days"),
fieldname: "unmarked_days", fieldname: "unmarked_days",
@@ -105,7 +136,7 @@ frappe.listview_settings['Attendance'] = {
}); });
}, },
get_multi_select_options: function(employee, month) { get_multi_select_options: function(employee, month, exclude_holidays) {
return new Promise(resolve => { return new Promise(resolve => {
frappe.call({ frappe.call({
method: 'erpnext.hr.doctype.attendance.attendance.get_unmarked_days', method: 'erpnext.hr.doctype.attendance.attendance.get_unmarked_days',
@@ -113,6 +144,7 @@ frappe.listview_settings['Attendance'] = {
args: { args: {
employee: employee, employee: employee,
month: month, month: month,
exclude_holidays: exclude_holidays
} }
}).then(r => { }).then(r => {
var options = []; var options = [];

View File

@@ -68,12 +68,18 @@ class Employee(NestedSet):
self.employee_name = ' '.join(filter(lambda x: x, [self.first_name, self.middle_name, self.last_name])) self.employee_name = ' '.join(filter(lambda x: x, [self.first_name, self.middle_name, self.last_name]))
def validate_user_details(self): def validate_user_details(self):
data = frappe.db.get_value('User', if self.user_id:
self.user_id, ['enabled', 'user_image'], as_dict=1) data = frappe.db.get_value("User",
if data.get("user_image") and self.image == '': self.user_id, ["enabled", "user_image"], as_dict=1)
self.image = data.get("user_image")
self.validate_for_enabled_user_id(data.get("enabled", 0)) if not data:
self.validate_duplicate_user_id() self.user_id = None
return
if data.get("user_image") and self.image == "":
self.image = data.get("user_image")
self.validate_for_enabled_user_id(data.get("enabled", 0))
self.validate_duplicate_user_id()
def update_nsm_model(self): def update_nsm_model(self):
frappe.utils.nestedset.update_nsm(self) frappe.utils.nestedset.update_nsm(self)

View File

@@ -22,6 +22,7 @@ from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee
from erpnext.hr.doctype.leave_block_list.leave_block_list import get_applicable_block_dates from erpnext.hr.doctype.leave_block_list.leave_block_list import get_applicable_block_dates
from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import create_leave_ledger_entry from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import create_leave_ledger_entry
from erpnext.hr.utils import ( from erpnext.hr.utils import (
get_holiday_dates_for_employee,
get_leave_period, get_leave_period,
set_employee_name, set_employee_name,
share_doc_with_approver, share_doc_with_approver,
@@ -159,33 +160,57 @@ class LeaveApplication(Document):
.format(formatdate(future_allocation[0].from_date), future_allocation[0].name)) .format(formatdate(future_allocation[0].from_date), future_allocation[0].name))
def update_attendance(self): def update_attendance(self):
if self.status == "Approved": if self.status != "Approved":
for dt in daterange(getdate(self.from_date), getdate(self.to_date)): return
date = dt.strftime("%Y-%m-%d")
status = "Half Day" if self.half_day_date and getdate(date) == getdate(self.half_day_date) else "On Leave"
attendance_name = frappe.db.exists('Attendance', dict(employee = self.employee,
attendance_date = date, docstatus = ('!=', 2)))
holiday_dates = []
if not frappe.db.get_value("Leave Type", self.leave_type, "include_holiday"):
holiday_dates = get_holiday_dates_for_employee(self.employee, self.from_date, self.to_date)
for dt in daterange(getdate(self.from_date), getdate(self.to_date)):
date = dt.strftime("%Y-%m-%d")
attendance_name = frappe.db.exists("Attendance", dict(employee = self.employee,
attendance_date = date, docstatus = ('!=', 2)))
# don't mark attendance for holidays
# if leave type does not include holidays within leaves as leaves
if date in holiday_dates:
if attendance_name: if attendance_name:
# update existing attendance, change absent to on leave # cancel and delete existing attendance for holidays
doc = frappe.get_doc('Attendance', attendance_name) attendance = frappe.get_doc("Attendance", attendance_name)
if doc.status != status: attendance.flags.ignore_permissions = True
doc.db_set('status', status) if attendance.docstatus == 1:
doc.db_set('leave_type', self.leave_type) attendance.cancel()
doc.db_set('leave_application', self.name) frappe.delete_doc("Attendance", attendance_name, force=1)
else: continue
# make new attendance and submit it
doc = frappe.new_doc("Attendance") self.create_or_update_attendance(attendance_name, date)
doc.employee = self.employee
doc.employee_name = self.employee_name def create_or_update_attendance(self, attendance_name, date):
doc.attendance_date = date status = "Half Day" if self.half_day_date and getdate(date) == getdate(self.half_day_date) else "On Leave"
doc.company = self.company
doc.leave_type = self.leave_type if attendance_name:
doc.leave_application = self.name # update existing attendance, change absent to on leave
doc.status = status doc = frappe.get_doc('Attendance', attendance_name)
doc.flags.ignore_validate = True if doc.status != status:
doc.insert(ignore_permissions=True) doc.db_set({
doc.submit() 'status': status,
'leave_type': self.leave_type,
'leave_application': self.name
})
else:
# make new attendance and submit it
doc = frappe.new_doc("Attendance")
doc.employee = self.employee
doc.employee_name = self.employee_name
doc.attendance_date = date
doc.company = self.company
doc.leave_type = self.leave_type
doc.leave_application = self.name
doc.status = status
doc.flags.ignore_validate = True
doc.insert(ignore_permissions=True)
doc.submit()
def cancel_attendance(self): def cancel_attendance(self):
if self.docstatus == 2: if self.docstatus == 2:

View File

@@ -5,7 +5,16 @@ import unittest
import frappe import frappe
from frappe.permissions import clear_user_permissions_for_doctype from frappe.permissions import clear_user_permissions_for_doctype
from frappe.utils import add_days, add_months, getdate, nowdate from frappe.utils import (
add_days,
add_months,
get_first_day,
get_last_day,
get_year_ending,
get_year_start,
getdate,
nowdate,
)
from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation
@@ -19,6 +28,10 @@ from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import (
create_assignment_for_multiple_employees, create_assignment_for_multiple_employees,
) )
from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type
from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
make_holiday_list,
make_leave_application,
)
test_dependencies = ["Leave Allocation", "Leave Block List", "Employee"] test_dependencies = ["Leave Allocation", "Leave Block List", "Employee"]
@@ -61,13 +74,15 @@ class TestLeaveApplication(unittest.TestCase):
for dt in ["Leave Application", "Leave Allocation", "Salary Slip", "Leave Ledger Entry"]: for dt in ["Leave Application", "Leave Allocation", "Salary Slip", "Leave Ledger Entry"]:
frappe.db.sql("DELETE FROM `tab%s`" % dt) #nosec frappe.db.sql("DELETE FROM `tab%s`" % dt) #nosec
frappe.set_user("Administrator")
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
set_leave_approver() set_leave_approver()
frappe.db.sql("delete from tabAttendance where employee='_T-Employee-00001'") frappe.db.sql("delete from tabAttendance where employee='_T-Employee-00001'")
def tearDown(self): def tearDown(self):
frappe.set_user("Administrator") frappe.db.rollback()
def _clear_roles(self): def _clear_roles(self):
frappe.db.sql("""delete from `tabHas Role` where parent in frappe.db.sql("""delete from `tabHas Role` where parent in
@@ -106,6 +121,72 @@ class TestLeaveApplication(unittest.TestCase):
for d in ('2018-01-01', '2018-01-02', '2018-01-03'): for d in ('2018-01-01', '2018-01-02', '2018-01-03'):
self.assertTrue(getdate(d) in dates) self.assertTrue(getdate(d) in dates)
def test_attendance_for_include_holidays(self):
# Case 1: leave type with 'Include holidays within leaves as leaves' enabled
frappe.delete_doc_if_exists("Leave Type", "Test Include Holidays", force=1)
leave_type = frappe.get_doc(dict(
leave_type_name="Test Include Holidays",
doctype="Leave Type",
include_holiday=True
)).insert()
date = getdate()
make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
holiday_list = make_holiday_list()
frappe.db.set_value("Company", "_Test Company", "default_holiday_list", holiday_list)
first_sunday = get_first_sunday(holiday_list)
leave_application = make_leave_application("_T-Employee-00001", first_sunday, add_days(first_sunday, 3), leave_type.name)
leave_application.reload()
self.assertEqual(leave_application.total_leave_days, 4)
self.assertEqual(frappe.db.count('Attendance', {'leave_application': leave_application.name}), 4)
leave_application.cancel()
def test_attendance_update_for_exclude_holidays(self):
# Case 2: leave type with 'Include holidays within leaves as leaves' disabled
frappe.delete_doc_if_exists("Leave Type", "Test Do Not Include Holidays", force=1)
leave_type = frappe.get_doc(dict(
leave_type_name="Test Do Not Include Holidays",
doctype="Leave Type",
include_holiday=False
)).insert()
date = getdate()
make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
holiday_list = make_holiday_list()
frappe.db.set_value("Company", "_Test Company", "default_holiday_list", holiday_list)
first_sunday = get_first_sunday(holiday_list)
# already marked attendance on a holiday should be deleted in this case
config = {
"doctype": "Attendance",
"employee": "_T-Employee-00001",
"status": "Present"
}
attendance_on_holiday = frappe.get_doc(config)
attendance_on_holiday.attendance_date = first_sunday
attendance_on_holiday.save()
# already marked attendance on a non-holiday should be updated
attendance = frappe.get_doc(config)
attendance.attendance_date = add_days(first_sunday, 3)
attendance.save()
leave_application = make_leave_application("_T-Employee-00001", first_sunday, add_days(first_sunday, 3), leave_type.name)
leave_application.reload()
# holiday should be excluded while marking attendance
self.assertEqual(leave_application.total_leave_days, 3)
self.assertEqual(frappe.db.count("Attendance", {"leave_application": leave_application.name}), 3)
# attendance on holiday deleted
self.assertFalse(frappe.db.exists("Attendance", attendance_on_holiday.name))
# attendance on non-holiday updated
self.assertEqual(frappe.db.get_value("Attendance", attendance.name, "status"), "On Leave")
def test_block_list(self): def test_block_list(self):
self._clear_roles() self._clear_roles()
@@ -241,7 +322,13 @@ class TestLeaveApplication(unittest.TestCase):
leave_period = get_leave_period() leave_period = get_leave_period()
today = nowdate() today = nowdate()
holiday_list = 'Test Holiday List for Optional Holiday' holiday_list = 'Test Holiday List for Optional Holiday'
optional_leave_date = add_days(today, 7) employee = get_employee()
default_holiday_list = make_holiday_list()
frappe.db.set_value("Company", "_Test Company", "default_holiday_list", default_holiday_list)
first_sunday = get_first_sunday(default_holiday_list)
optional_leave_date = add_days(first_sunday, 1)
if not frappe.db.exists('Holiday List', holiday_list): if not frappe.db.exists('Holiday List', holiday_list):
frappe.get_doc(dict( frappe.get_doc(dict(
@@ -253,7 +340,6 @@ class TestLeaveApplication(unittest.TestCase):
dict(holiday_date = optional_leave_date, description = 'Test') dict(holiday_date = optional_leave_date, description = 'Test')
] ]
)).insert() )).insert()
employee = get_employee()
frappe.db.set_value('Leave Period', leave_period.name, 'optional_holiday_list', holiday_list) frappe.db.set_value('Leave Period', leave_period.name, 'optional_holiday_list', holiday_list)
leave_type = 'Test Optional Type' leave_type = 'Test Optional Type'
@@ -266,7 +352,7 @@ class TestLeaveApplication(unittest.TestCase):
allocate_leaves(employee, leave_period, leave_type, 10) allocate_leaves(employee, leave_period, leave_type, 10)
date = add_days(today, 6) date = add_days(first_sunday, 2)
leave_application = frappe.get_doc(dict( leave_application = frappe.get_doc(dict(
doctype = 'Leave Application', doctype = 'Leave Application',
@@ -636,13 +722,13 @@ def create_carry_forwarded_allocation(employee, leave_type):
carry_forward=1) carry_forward=1)
leave_allocation.submit() leave_allocation.submit()
def make_allocation_record(employee=None, leave_type=None): def make_allocation_record(employee=None, leave_type=None, from_date=None, to_date=None):
allocation = frappe.get_doc({ allocation = frappe.get_doc({
"doctype": "Leave Allocation", "doctype": "Leave Allocation",
"employee": employee or "_T-Employee-00001", "employee": employee or "_T-Employee-00001",
"leave_type": leave_type or "_Test Leave Type", "leave_type": leave_type or "_Test Leave Type",
"from_date": "2013-01-01", "from_date": from_date or "2013-01-01",
"to_date": "2019-12-31", "to_date": to_date or "2019-12-31",
"new_leaves_allocated": 30 "new_leaves_allocated": 30
}) })
@@ -691,3 +777,16 @@ def allocate_leaves(employee, leave_period, leave_type, new_leaves_allocated, el
}).insert() }).insert()
allocate_leave.submit() allocate_leave.submit()
def get_first_sunday(holiday_list):
month_start_date = get_first_day(nowdate())
month_end_date = get_last_day(nowdate())
first_sunday = frappe.db.sql("""
select holiday_date from `tabHoliday`
where parent = %s
and holiday_date between %s and %s
order by holiday_date
""", (holiday_list, month_start_date, month_end_date))[0][0]
return first_sunday

View File

@@ -1,7 +1,6 @@
# 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 _, throw from frappe import _, throw
from frappe.utils import add_days, cint, cstr, date_diff, formatdate, getdate from frappe.utils import add_days, cint, cstr, date_diff, formatdate, getdate
@@ -306,13 +305,18 @@ class MaintenanceSchedule(TransactionBase):
return schedule.name return schedule.name
@frappe.whitelist() @frappe.whitelist()
def update_serial_nos(s_id): def get_serial_nos_from_schedule(item_code, schedule=None):
serial_nos = frappe.db.get_value('Maintenance Schedule Detail', s_id, 'serial_no') serial_nos = []
if schedule:
serial_nos = frappe.db.get_value('Maintenance Schedule Item', {
'parent': schedule,
'item_code': item_code
}, 'serial_no')
if serial_nos: if serial_nos:
serial_nos = get_serial_nos(serial_nos) serial_nos = get_serial_nos(serial_nos)
return serial_nos
else: return serial_nos
return False
@frappe.whitelist() @frappe.whitelist()
def make_maintenance_visit(source_name, target_doc=None, item_name=None, s_id=None): def make_maintenance_visit(source_name, target_doc=None, item_name=None, s_id=None):
@@ -320,12 +324,9 @@ def make_maintenance_visit(source_name, target_doc=None, item_name=None, s_id=No
def update_status_and_detail(source, target, parent): def update_status_and_detail(source, target, parent):
target.maintenance_type = "Scheduled" target.maintenance_type = "Scheduled"
target.maintenance_schedule = source.name
target.maintenance_schedule_detail = s_id target.maintenance_schedule_detail = s_id
def update_sales_and_serial(source, target, parent): def update_serial(source, target, parent):
sales_person = frappe.db.get_value('Maintenance Schedule Detail', s_id, 'sales_person')
target.service_person = sales_person
serial_nos = get_serial_nos(target.serial_no) serial_nos = get_serial_nos(target.serial_no)
if len(serial_nos) == 1: if len(serial_nos) == 1:
target.serial_no = serial_nos[0] target.serial_no = serial_nos[0]
@@ -346,7 +347,10 @@ def make_maintenance_visit(source_name, target_doc=None, item_name=None, s_id=No
"Maintenance Schedule Item": { "Maintenance Schedule Item": {
"doctype": "Maintenance Visit Purpose", "doctype": "Maintenance Visit Purpose",
"condition": lambda doc: doc.item_name == item_name, "condition": lambda doc: doc.item_name == item_name,
"postprocess": update_sales_and_serial "field_map": {
"sales_person": "service_person"
},
"postprocess": update_serial
} }
}, target_doc) }, target_doc)

View File

@@ -4,11 +4,15 @@
import unittest import unittest
import frappe import frappe
from frappe.utils import format_date
from frappe.utils.data import add_days, formatdate, today from frappe.utils.data import add_days, formatdate, today
from erpnext.maintenance.doctype.maintenance_schedule.maintenance_schedule import ( from erpnext.maintenance.doctype.maintenance_schedule.maintenance_schedule import (
get_serial_nos_from_schedule,
make_maintenance_visit, make_maintenance_visit,
) )
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
# test_records = frappe.get_test_records('Maintenance Schedule') # test_records = frappe.get_test_records('Maintenance Schedule')
@@ -79,6 +83,49 @@ class TestMaintenanceSchedule(unittest.TestCase):
#checks if visit status is back updated in schedule #checks if visit status is back updated in schedule
self.assertTrue(ms.schedules[1].completion_status, "Partially Completed") self.assertTrue(ms.schedules[1].completion_status, "Partially Completed")
self.assertEqual(format_date(visit.mntc_date), format_date(ms.schedules[1].actual_date))
#checks if visit status is updated on cancel
visit.cancel()
ms.reload()
self.assertTrue(ms.schedules[1].completion_status, "Pending")
self.assertEqual(ms.schedules[1].actual_date, None)
def test_serial_no_filters(self):
# Without serial no. set in schedule -> returns None
item_code = "_Test Serial Item"
make_serial_item_with_serial(item_code)
ms = make_maintenance_schedule(item_code=item_code)
ms.submit()
s_item = ms.schedules[0]
mv = make_maintenance_visit(source_name=ms.name, item_name=item_code, s_id=s_item.name)
mvi = mv.purposes[0]
serial_nos = get_serial_nos_from_schedule(mvi.item_name, ms.name)
self.assertEqual(serial_nos, None)
# With serial no. set in schedule -> returns serial nos.
make_serial_item_with_serial(item_code)
ms = make_maintenance_schedule(item_code=item_code, serial_no="TEST001, TEST002")
ms.submit()
s_item = ms.schedules[0]
mv = make_maintenance_visit(source_name=ms.name, item_name=item_code, s_id=s_item.name)
mvi = mv.purposes[0]
serial_nos = get_serial_nos_from_schedule(mvi.item_name, ms.name)
self.assertEqual(serial_nos, ["TEST001", "TEST002"])
frappe.db.rollback()
def make_serial_item_with_serial(item_code):
serial_item_doc = create_item(item_code, is_stock_item=1)
if not serial_item_doc.has_serial_no or not serial_item_doc.serial_no_series:
serial_item_doc.has_serial_no = 1
serial_item_doc.serial_no_series = "TEST.###"
serial_item_doc.save(ignore_permissions=True)
active_serials = frappe.db.get_all('Serial No', {"status": "Active", "item_code": item_code})
if len(active_serials) < 2:
make_serialized_item(item_code=item_code)
def get_events(ms): def get_events(ms):
return frappe.get_all("Event Participants", filters={ return frappe.get_all("Event Participants", filters={
@@ -87,17 +134,18 @@ def get_events(ms):
"parenttype": "Event" "parenttype": "Event"
}) })
def make_maintenance_schedule(): def make_maintenance_schedule(**args):
ms = frappe.new_doc("Maintenance Schedule") ms = frappe.new_doc("Maintenance Schedule")
ms.company = "_Test Company" ms.company = "_Test Company"
ms.customer = "_Test Customer" ms.customer = "_Test Customer"
ms.transaction_date = today() ms.transaction_date = today()
ms.append("items", { ms.append("items", {
"item_code": "_Test Item", "item_code": args.get("item_code") or "_Test Item",
"start_date": today(), "start_date": today(),
"periodicity": "Weekly", "periodicity": "Weekly",
"no_of_visits": 4, "no_of_visits": 4,
"serial_no": args.get("serial_no"),
"sales_person": "Sales Team", "sales_person": "Sales Team",
}) })
ms.insert(ignore_permissions=True) ms.insert(ignore_permissions=True)

View File

@@ -2,52 +2,54 @@
// License: GNU General Public License v3. See license.txt // License: GNU General Public License v3. See license.txt
frappe.provide("erpnext.maintenance"); frappe.provide("erpnext.maintenance");
var serial_nos = [];
frappe.ui.form.on('Maintenance Visit', { frappe.ui.form.on('Maintenance Visit', {
refresh: function (frm) {
//filters for serial_no based on item_code
frm.set_query('serial_no', 'purposes', function (frm, cdt, cdn) {
let item = locals[cdt][cdn];
if (serial_nos) {
return {
filters: {
'item_code': item.item_code,
'name': ["in", serial_nos]
}
};
} else {
return {
filters: {
'item_code': item.item_code
}
};
}
});
},
setup: function (frm) { setup: function (frm) {
frm.set_query('contact_person', erpnext.queries.contact_query); frm.set_query('contact_person', erpnext.queries.contact_query);
frm.set_query('customer_address', erpnext.queries.address_query); frm.set_query('customer_address', erpnext.queries.address_query);
frm.set_query('customer', erpnext.queries.customer); frm.set_query('customer', erpnext.queries.customer);
}, },
onload: function (frm, cdt, cdn) { onload: function (frm) {
let item = locals[cdt][cdn]; // filters for serial no based on item code
if (frm.doc.maintenance_type === "Scheduled") { if (frm.doc.maintenance_type === "Scheduled") {
const schedule_id = item.purposes[0].prevdoc_detail_docname || frm.doc.maintenance_schedule_detail; let item_code = frm.doc.purposes[0].item_code;
frappe.call({ frappe.call({
method: "erpnext.maintenance.doctype.maintenance_schedule.maintenance_schedule.update_serial_nos", method: "erpnext.maintenance.doctype.maintenance_schedule.maintenance_schedule.get_serial_nos_from_schedule",
args: { args: {
s_id: schedule_id schedule: frm.doc.maintenance_schedule,
}, item_code: item_code
callback: function (r) {
serial_nos = r.message;
} }
}).then((r) => {
let serial_nos = r.message;
frm.set_query('serial_no', 'purposes', () => {
if (serial_nos.length > 0) {
return {
filters: {
'item_code': item_code,
'name': ["in", serial_nos]
}
};
}
return {
filters: {
'item_code': item_code
}
};
});
});
} else {
frm.set_query('serial_no', 'purposes', (frm, cdt, cdn) => {
let row = locals[cdt][cdn];
return {
filters: {
'item_code': row.item_code
}
};
}); });
} }
if (!frm.doc.status) { if (!frm.doc.status) {
frm.set_value({ status: 'Draft' }); frm.set_value({ status: 'Draft' });
} }
if (frm.doc.__islocal) { if (frm.doc.__islocal) {
frm.doc.maintenance_type == 'Unscheduled' && frm.clear_table("purposes");
frm.set_value({ mntc_date: frappe.datetime.get_today() }); frm.set_value({ mntc_date: frappe.datetime.get_today() });
} }
}, },
@@ -60,7 +62,6 @@ frappe.ui.form.on('Maintenance Visit', {
contact_person: function (frm) { contact_person: function (frm) {
erpnext.utils.get_contact_details(frm); erpnext.utils.get_contact_details(frm);
} }
}) })
// TODO commonify this code // TODO commonify this code

View File

@@ -179,8 +179,7 @@
"label": "Purposes", "label": "Purposes",
"oldfieldname": "maintenance_visit_details", "oldfieldname": "maintenance_visit_details",
"oldfieldtype": "Table", "oldfieldtype": "Table",
"options": "Maintenance Visit Purpose", "options": "Maintenance Visit Purpose"
"reqd": 1
}, },
{ {
"fieldname": "more_info", "fieldname": "more_info",
@@ -294,10 +293,11 @@
"idx": 1, "idx": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2021-05-27 16:06:17.352572", "modified": "2021-12-17 03:10:27.608112",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Maintenance", "module": "Maintenance",
"name": "Maintenance Visit", "name": "Maintenance Visit",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {

View File

@@ -4,7 +4,7 @@
import frappe import frappe
from frappe import _ from frappe import _
from frappe.utils import get_datetime from frappe.utils import format_date, get_datetime
from erpnext.utilities.transaction_base import TransactionBase from erpnext.utilities.transaction_base import TransactionBase
@@ -18,25 +18,34 @@ class MaintenanceVisit(TransactionBase):
if d.serial_no and not frappe.db.exists("Serial No", d.serial_no): if d.serial_no and not frappe.db.exists("Serial No", d.serial_no):
frappe.throw(_("Serial No {0} does not exist").format(d.serial_no)) frappe.throw(_("Serial No {0} does not exist").format(d.serial_no))
def validate_purpose_table(self):
if not self.purposes:
frappe.throw(_("Add Items in the Purpose Table"), title="Purposes Required")
def validate_maintenance_date(self): def validate_maintenance_date(self):
if self.maintenance_type == "Scheduled" and self.maintenance_schedule_detail: if self.maintenance_type == "Scheduled" and self.maintenance_schedule_detail:
item_ref = frappe.db.get_value('Maintenance Schedule Detail', self.maintenance_schedule_detail, 'item_reference') item_ref = frappe.db.get_value('Maintenance Schedule Detail', self.maintenance_schedule_detail, 'item_reference')
if item_ref: if item_ref:
start_date, end_date = frappe.db.get_value('Maintenance Schedule Item', item_ref, ['start_date', 'end_date']) start_date, end_date = frappe.db.get_value('Maintenance Schedule Item', item_ref, ['start_date', 'end_date'])
if get_datetime(self.mntc_date) < get_datetime(start_date) or get_datetime(self.mntc_date) > get_datetime(end_date): if get_datetime(self.mntc_date) < get_datetime(start_date) or get_datetime(self.mntc_date) > get_datetime(end_date):
frappe.throw(_("Date must be between {0} and {1}").format(start_date, end_date)) frappe.throw(_("Date must be between {0} and {1}")
.format(format_date(start_date), format_date(end_date)))
def validate(self): def validate(self):
self.validate_serial_no() self.validate_serial_no()
self.validate_maintenance_date() self.validate_maintenance_date()
self.validate_purpose_table()
def update_completion_status(self): def update_status_and_actual_date(self, cancel=False):
status = "Pending"
actual_date = None
if not cancel:
status = self.completion_status
actual_date = self.mntc_date
if self.maintenance_schedule_detail: if self.maintenance_schedule_detail:
frappe.db.set_value('Maintenance Schedule Detail', self.maintenance_schedule_detail, 'completion_status', self.completion_status) frappe.db.set_value('Maintenance Schedule Detail', self.maintenance_schedule_detail, 'completion_status', status)
frappe.db.set_value('Maintenance Schedule Detail', self.maintenance_schedule_detail, 'actual_date', actual_date)
def update_actual_date(self):
if self.maintenance_schedule_detail:
frappe.db.set_value('Maintenance Schedule Detail', self.maintenance_schedule_detail, 'actual_date', self.mntc_date)
def update_customer_issue(self, flag): def update_customer_issue(self, flag):
if not self.maintenance_schedule: if not self.maintenance_schedule:
@@ -97,12 +106,12 @@ class MaintenanceVisit(TransactionBase):
def on_submit(self): def on_submit(self):
self.update_customer_issue(1) self.update_customer_issue(1)
frappe.db.set(self, 'status', 'Submitted') frappe.db.set(self, 'status', 'Submitted')
self.update_completion_status() self.update_status_and_actual_date()
self.update_actual_date()
def on_cancel(self): def on_cancel(self):
self.check_if_last_visit() self.check_if_last_visit()
frappe.db.set(self, 'status', 'Cancelled') frappe.db.set(self, 'status', 'Cancelled')
self.update_status_and_actual_date(cancel=True)
def on_update(self): def on_update(self):
pass pass

View File

@@ -149,6 +149,7 @@ class BOM(WebsiteGenerator):
self.set_bom_material_details() self.set_bom_material_details()
self.set_bom_scrap_items_detail() self.set_bom_scrap_items_detail()
self.validate_materials() self.validate_materials()
self.validate_transfer_against()
self.set_routing_operations() self.set_routing_operations()
self.validate_operations() self.validate_operations()
self.calculate_cost() self.calculate_cost()
@@ -682,6 +683,12 @@ class BOM(WebsiteGenerator):
if act_pbom and act_pbom[0][0]: if act_pbom and act_pbom[0][0]:
frappe.throw(_("Cannot deactivate or cancel BOM as it is linked with other BOMs")) frappe.throw(_("Cannot deactivate or cancel BOM as it is linked with other BOMs"))
def validate_transfer_against(self):
if not self.with_operations:
self.transfer_material_against = "Work Order"
if not self.transfer_material_against and not self.is_new():
frappe.throw(_("Setting {} is required").format(self.meta.get_label("transfer_material_against")), title=_("Missing value"))
def set_routing_operations(self): def set_routing_operations(self):
if self.routing and self.with_operations and not self.operations: if self.routing and self.with_operations and not self.operations:
self.get_routing() self.get_routing()

View File

@@ -356,6 +356,36 @@ class TestBOM(ERPNextTestCase):
self.assertTrue(0 < len(filtered) <= 3, msg="Item filtering showing excessive results") self.assertTrue(0 < len(filtered) <= 3, msg="Item filtering showing excessive results")
def test_valid_transfer_defaults(self):
bom_with_op = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "with_operations": 1, "is_active": 1})
bom = frappe.copy_doc(frappe.get_doc("BOM", bom_with_op), ignore_no_copy=False)
# test defaults
bom.docstatus = 0
bom.transfer_material_against = None
bom.insert()
self.assertEqual(bom.transfer_material_against, "Work Order")
bom.reload()
bom.transfer_material_against = None
with self.assertRaises(frappe.ValidationError):
bom.save()
bom.reload()
# test saner default
bom.transfer_material_against = "Job Card"
bom.with_operations = 0
bom.save()
self.assertEqual(bom.transfer_material_against, "Work Order")
# test no value on existing doc
bom.transfer_material_against = None
bom.with_operations = 0
bom.save()
self.assertEqual(bom.transfer_material_against, "Work Order")
bom.delete()
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

@@ -948,11 +948,8 @@ def get_materials_from_other_locations(item, warehouses, new_mr_items, company):
locations = get_available_item_locations(item.get("item_code"), locations = get_available_item_locations(item.get("item_code"),
warehouses, item.get("quantity"), company, ignore_validation=True) warehouses, item.get("quantity"), company, ignore_validation=True)
if not locations:
new_mr_items.append(item)
return
required_qty = item.get("quantity") required_qty = item.get("quantity")
# get available material by transferring to production warehouse
for d in locations: for d in locations:
if required_qty <=0: return if required_qty <=0: return
@@ -963,14 +960,34 @@ def get_materials_from_other_locations(item, warehouses, new_mr_items, company):
new_dict.update({ new_dict.update({
"quantity": quantity, "quantity": quantity,
"material_request_type": "Material Transfer", "material_request_type": "Material Transfer",
"uom": new_dict.get("stock_uom"), # internal transfer should be in stock UOM
"from_warehouse": d.get("warehouse") "from_warehouse": d.get("warehouse")
}) })
required_qty -= quantity required_qty -= quantity
new_mr_items.append(new_dict) new_mr_items.append(new_dict)
# raise purchase request for remaining qty
if required_qty: if required_qty:
stock_uom, purchase_uom = frappe.db.get_value(
'Item',
item['item_code'],
['stock_uom', 'purchase_uom']
)
if purchase_uom != stock_uom and purchase_uom == item['uom']:
conversion_factor = get_uom_conversion_factor(item['item_code'], item['uom'])
if not (conversion_factor or frappe.flags.show_qty_in_stock_uom):
frappe.throw(_("UOM Conversion factor ({0} -> {1}) not found for item: {2}")
.format(purchase_uom, stock_uom, item['item_code']))
required_qty = required_qty / conversion_factor
if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"):
required_qty = ceil(required_qty)
item["quantity"] = required_qty item["quantity"] = required_qty
new_mr_items.append(item) new_mr_items.append(item)
@frappe.whitelist() @frappe.whitelist()

View File

@@ -870,6 +870,57 @@ class TestWorkOrder(ERPNextTestCase):
close_work_order(wo_order, "Closed") close_work_order(wo_order, "Closed")
self.assertEqual(wo_order.get('status'), "Closed") self.assertEqual(wo_order.get('status'), "Closed")
def test_partial_manufacture_entries(self):
cancel_stock_entry = []
frappe.db.set_value("Manufacturing Settings", None,
"backflush_raw_materials_based_on", "Material Transferred for Manufacture")
wo_order = make_wo_order_test_record(planned_start_date=now(), qty=100)
ste1 = test_stock_entry.make_stock_entry(item_code="_Test Item",
target="_Test Warehouse - _TC", qty=120, basic_rate=5000.0)
ste2 = test_stock_entry.make_stock_entry(item_code="_Test Item Home Desktop 100",
target="_Test Warehouse - _TC", qty=240, basic_rate=1000.0)
cancel_stock_entry.extend([ste1.name, ste2.name])
sm = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 100))
for row in sm.get('items'):
if row.get('item_code') == '_Test Item':
row.qty = 110
sm.submit()
cancel_stock_entry.append(sm.name)
s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 90))
for row in s.get('items'):
if row.get('item_code') == '_Test Item':
self.assertEqual(row.get('qty'), 100)
s.submit()
cancel_stock_entry.append(s.name)
s1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5))
for row in s1.get('items'):
if row.get('item_code') == '_Test Item':
self.assertEqual(row.get('qty'), 5)
s1.submit()
cancel_stock_entry.append(s1.name)
s2 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5))
for row in s2.get('items'):
if row.get('item_code') == '_Test Item':
self.assertEqual(row.get('qty'), 5)
cancel_stock_entry.reverse()
for ste in cancel_stock_entry:
doc = frappe.get_doc("Stock Entry", ste)
doc.cancel()
frappe.db.set_value("Manufacturing Settings", None,
"backflush_raw_materials_based_on", "BOM")
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

@@ -131,16 +131,14 @@ frappe.ui.form.on("Work Order", {
erpnext.work_order.set_custom_buttons(frm); erpnext.work_order.set_custom_buttons(frm);
frm.set_intro(""); frm.set_intro("");
if (frm.doc.docstatus === 0 && !frm.doc.__islocal) { if (frm.doc.docstatus === 0 && !frm.is_new()) {
frm.set_intro(__("Submit this Work Order for further processing.")); frm.set_intro(__("Submit this Work Order for further processing."));
} else {
frm.trigger("show_progress_for_items");
frm.trigger("show_progress_for_operations");
} }
if (frm.doc.status != "Closed") { if (frm.doc.status != "Closed") {
if (frm.doc.docstatus===1) {
frm.trigger('show_progress_for_items');
frm.trigger('show_progress_for_operations');
}
if (frm.doc.docstatus === 1 if (frm.doc.docstatus === 1
&& frm.doc.operations && frm.doc.operations.length) { && frm.doc.operations && frm.doc.operations.length) {

View File

@@ -333,12 +333,13 @@
"options": "fa fa-wrench" "options": "fa fa-wrench"
}, },
{ {
"default": "Work Order",
"depends_on": "operations", "depends_on": "operations",
"fetch_from": "bom_no.transfer_material_against",
"fetch_if_empty": 1,
"fieldname": "transfer_material_against", "fieldname": "transfer_material_against",
"fieldtype": "Select", "fieldtype": "Select",
"label": "Transfer Material Against", "label": "Transfer Material Against",
"options": "Work Order\nJob Card" "options": "\nWork Order\nJob Card"
}, },
{ {
"fieldname": "operations", "fieldname": "operations",
@@ -574,7 +575,7 @@
"image_field": "image", "image_field": "image",
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2021-11-08 17:36:07.016300", "modified": "2022-01-24 21:18:12.160114",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Work Order", "name": "Work Order",
@@ -607,6 +608,7 @@
], ],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "ASC", "sort_order": "ASC",
"states": [],
"title_field": "production_item", "title_field": "production_item",
"track_changes": 1, "track_changes": 1,
"track_seen": 1 "track_seen": 1

View File

@@ -65,6 +65,7 @@ class WorkOrder(Document):
self.validate_warehouse_belongs_to_company() self.validate_warehouse_belongs_to_company()
self.calculate_operating_cost() self.calculate_operating_cost()
self.validate_qty() self.validate_qty()
self.validate_transfer_against()
self.validate_operation_time() self.validate_operation_time()
self.status = self.get_status() self.status = self.get_status()
@@ -72,6 +73,7 @@ class WorkOrder(Document):
self.set_required_items(reset_only_qty = len(self.get("required_items"))) self.set_required_items(reset_only_qty = len(self.get("required_items")))
def validate_sales_order(self): def validate_sales_order(self):
if self.sales_order: if self.sales_order:
self.check_sales_order_on_hold_or_close() self.check_sales_order_on_hold_or_close()
@@ -621,6 +623,16 @@ class WorkOrder(Document):
if not self.qty > 0: if not self.qty > 0:
frappe.throw(_("Quantity to Manufacture must be greater than 0.")) frappe.throw(_("Quantity to Manufacture must be greater than 0."))
def validate_transfer_against(self):
if not self.docstatus == 1:
# let user configure operations until they're ready to submit
return
if not self.operations:
self.transfer_material_against = "Work Order"
if not self.transfer_material_against:
frappe.throw(_("Setting {} is required").format(self.meta.get_label("transfer_material_against")), title=_("Missing value"))
def validate_operation_time(self): def validate_operation_time(self):
for d in self.operations: for d in self.operations:
if not d.time_in_mins > 0: if not d.time_in_mins > 0:

View File

@@ -4,6 +4,39 @@
frappe.query_reports["BOM Operations Time"] = { frappe.query_reports["BOM Operations Time"] = {
"filters": [ "filters": [
{
"fieldname": "item_code",
"label": __("Item Code"),
"fieldtype": "Link",
"width": "100",
"options": "Item",
"get_query": () =>{
return {
filters: { "disabled": 0, "is_stock_item": 1 }
}
}
},
{
"fieldname": "bom_id",
"label": __("BOM ID"),
"fieldtype": "MultiSelectList",
"width": "100",
"options": "BOM",
"get_data": function(txt) {
return frappe.db.get_link_options("BOM", txt);
},
"get_query": () =>{
return {
filters: { "docstatus": 1, "is_active": 1, "with_operations": 1 }
}
}
},
{
"fieldname": "workstation",
"label": __("Workstation"),
"fieldtype": "Link",
"width": "100",
"options": "Workstation"
},
] ]
}; };

View File

@@ -1,14 +1,16 @@
{ {
"add_total_row": 0, "add_total_row": 1,
"columns": [],
"creation": "2020-03-03 01:41:20.862521", "creation": "2020-03-03 01:41:20.862521",
"disable_prepared_report": 0, "disable_prepared_report": 0,
"disabled": 0, "disabled": 0,
"docstatus": 0, "docstatus": 0,
"doctype": "Report", "doctype": "Report",
"filters": [],
"idx": 0, "idx": 0,
"is_standard": "Yes", "is_standard": "Yes",
"letter_head": "", "letter_head": "",
"modified": "2020-03-03 01:41:20.862521", "modified": "2022-01-20 14:21:47.771591",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "BOM Operations Time", "name": "BOM Operations Time",

View File

@@ -12,19 +12,15 @@ def execute(filters=None):
return columns, data return columns, data
def get_data(filters): def get_data(filters):
data = [] bom_wise_data = {}
bom_data, report_data = [], []
bom_data = [] bom_operation_data = get_filtered_data(filters)
for d in frappe.db.sql("""
SELECT for d in bom_operation_data:
bom.name, bom.item, bom.item_name, bom.uom,
bomps.operation, bomps.workstation, bomps.time_in_mins
FROM `tabBOM` bom, `tabBOM Operation` bomps
WHERE
bom.docstatus = 1 and bom.is_active = 1 and bom.name = bomps.parent
""", as_dict=1):
row = get_args() row = get_args()
if d.name not in bom_data: if d.name not in bom_data:
bom_wise_data[d.name] = []
bom_data.append(d.name) bom_data.append(d.name)
row.update(d) row.update(d)
else: else:
@@ -34,14 +30,49 @@ def get_data(filters):
"time_in_mins": d.time_in_mins "time_in_mins": d.time_in_mins
}) })
data.append(row) # maintain BOM wise data for grouping such as:
# {"BOM A": [{Row1}, {Row2}], "BOM B": ...}
bom_wise_data[d.name].append(row)
used_as_subassembly_items = get_bom_count(bom_data) used_as_subassembly_items = get_bom_count(bom_data)
for d in data: for d in bom_wise_data:
d.used_as_subassembly_items = used_as_subassembly_items.get(d.name, 0) for row in bom_wise_data[d]:
row.used_as_subassembly_items = used_as_subassembly_items.get(row.name, 0)
report_data.append(row)
return data return report_data
def get_filtered_data(filters):
bom = frappe.qb.DocType("BOM")
bom_ops = frappe.qb.DocType("BOM Operation")
bom_ops_query = (
frappe.qb.from_(bom)
.join(bom_ops).on(bom.name == bom_ops.parent)
.select(
bom.name, bom.item, bom.item_name, bom.uom,
bom_ops.operation, bom_ops.workstation, bom_ops.time_in_mins
).where(
(bom.docstatus == 1)
& (bom.is_active == 1)
)
)
if filters.get("item_code"):
bom_ops_query = bom_ops_query.where(bom.item == filters.get("item_code"))
if filters.get("bom_id"):
bom_ops_query = bom_ops_query.where(bom.name.isin(filters.get("bom_id")))
if filters.get("workstation"):
bom_ops_query = bom_ops_query.where(
bom_ops.workstation == filters.get("workstation")
)
bom_operation_data = bom_ops_query.run(as_dict=True)
return bom_operation_data
def get_bom_count(bom_data): def get_bom_count(bom_data):
data = frappe.get_all("BOM Item", data = frappe.get_all("BOM Item",
@@ -68,13 +99,13 @@ def get_columns(filters):
"options": "BOM", "options": "BOM",
"fieldname": "name", "fieldname": "name",
"fieldtype": "Link", "fieldtype": "Link",
"width": 140 "width": 220
}, { }, {
"label": _("BOM Item Code"), "label": _("Item Code"),
"options": "Item", "options": "Item",
"fieldname": "item", "fieldname": "item",
"fieldtype": "Link", "fieldtype": "Link",
"width": 140 "width": 150
}, { }, {
"label": _("Item Name"), "label": _("Item Name"),
"fieldname": "item_name", "fieldname": "item_name",
@@ -85,13 +116,13 @@ def get_columns(filters):
"options": "UOM", "options": "UOM",
"fieldname": "uom", "fieldname": "uom",
"fieldtype": "Link", "fieldtype": "Link",
"width": 140 "width": 100
}, { }, {
"label": _("Operation"), "label": _("Operation"),
"options": "Operation", "options": "Operation",
"fieldname": "operation", "fieldname": "operation",
"fieldtype": "Link", "fieldtype": "Link",
"width": 120 "width": 140
}, { }, {
"label": _("Workstation"), "label": _("Workstation"),
"options": "Workstation", "options": "Workstation",
@@ -101,11 +132,11 @@ def get_columns(filters):
}, { }, {
"label": _("Time (In Mins)"), "label": _("Time (In Mins)"),
"fieldname": "time_in_mins", "fieldname": "time_in_mins",
"fieldtype": "Int", "fieldtype": "Float",
"width": 140 "width": 120
}, { }, {
"label": _("Sub-assembly BOM Count"), "label": _("Sub-assembly BOM Count"),
"fieldname": "used_as_subassembly_items", "fieldname": "used_as_subassembly_items",
"fieldtype": "Int", "fieldtype": "Int",
"width": 180 "width": 200
}] }]

View File

@@ -305,7 +305,7 @@ erpnext.patches.v13_0.shopify_deprecation_warning
erpnext.patches.v13_0.add_custom_field_for_south_africa #2 erpnext.patches.v13_0.add_custom_field_for_south_africa #2
erpnext.patches.v13_0.rename_discharge_ordered_date_in_ip_record erpnext.patches.v13_0.rename_discharge_ordered_date_in_ip_record
erpnext.patches.v13_0.remove_bad_selling_defaults erpnext.patches.v13_0.remove_bad_selling_defaults
erpnext.patches.v13_0.trim_whitespace_from_serial_nos erpnext.patches.v13_0.trim_whitespace_from_serial_nos # 16-01-2022
erpnext.patches.v13_0.migrate_stripe_api erpnext.patches.v13_0.migrate_stripe_api
erpnext.patches.v13_0.reset_clearance_date_for_intracompany_payment_entries erpnext.patches.v13_0.reset_clearance_date_for_intracompany_payment_entries
execute:frappe.reload_doc("erpnext_integrations", "doctype", "TaxJar Settings") execute:frappe.reload_doc("erpnext_integrations", "doctype", "TaxJar Settings")
@@ -337,8 +337,13 @@ erpnext.patches.v13_0.item_naming_series_not_mandatory
erpnext.patches.v13_0.update_category_in_ltds_certificate erpnext.patches.v13_0.update_category_in_ltds_certificate
erpnext.patches.v13_0.create_ksa_vat_custom_fields erpnext.patches.v13_0.create_ksa_vat_custom_fields
erpnext.patches.v13_0.rename_ksa_qr_field erpnext.patches.v13_0.rename_ksa_qr_field
erpnext.patches.v13_0.wipe_serial_no_field_for_0_qty
erpnext.patches.v13_0.disable_ksa_print_format_for_others # 16-12-2021 erpnext.patches.v13_0.disable_ksa_print_format_for_others # 16-12-2021
erpnext.patches.v13_0.update_tax_category_for_rcm erpnext.patches.v13_0.update_tax_category_for_rcm
erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template
erpnext.patches.v13_0.agriculture_deprecation_warning erpnext.patches.v13_0.agriculture_deprecation_warning
erpnext.patches.v13_0.set_billed_amount_in_returned_dn erpnext.patches.v13_0.set_billed_amount_in_returned_dn
erpnext.patches.v13_0.update_maintenance_schedule_field_in_visit
erpnext.patches.v13_0.hospitality_deprecation_warning
erpnext.patches.v13_0.delete_bank_reconciliation_detail
erpnext.patches.v13_0.update_sane_transfer_against

View File

@@ -3,14 +3,28 @@ import frappe
def execute(): def execute():
try: #handle type casting for is_cancelled field
frappe.db.sql("UPDATE `tabStock Ledger Entry` SET is_cancelled = 0 where is_cancelled in ('', NULL, 'No')") module_doctypes = (
frappe.db.sql("UPDATE `tabSerial No` SET is_cancelled = 0 where is_cancelled in ('', NULL, 'No')") ('stock', 'Stock Ledger Entry'),
('stock', 'Serial No'),
('accounts', 'GL Entry')
)
frappe.db.sql("UPDATE `tabStock Ledger Entry` SET is_cancelled = 1 where is_cancelled = 'Yes'") for module, doctype in module_doctypes:
frappe.db.sql("UPDATE `tabSerial No` SET is_cancelled = 1 where is_cancelled = 'Yes'") if (not frappe.db.has_column(doctype, "is_cancelled")
or frappe.db.get_column_type(doctype, "is_cancelled").lower() == "int(1)"
):
continue
frappe.reload_doc("stock", "doctype", "stock_ledger_entry") frappe.db.sql("""
frappe.reload_doc("stock", "doctype", "serial_no") UPDATE `tab{doctype}`
except Exception: SET is_cancelled = 0
pass where is_cancelled in ('', NULL, 'No')"""
.format(doctype=doctype))
frappe.db.sql("""
UPDATE `tab{doctype}`
SET is_cancelled = 1
where is_cancelled = 'Yes'"""
.format(doctype=doctype))
frappe.reload_doc(module, "doctype", frappe.scrub(doctype))

View File

@@ -0,0 +1,13 @@
# Copyright (c) 2019, Frappe and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
def execute():
if frappe.db.exists('DocType', 'Bank Reconciliation Detail') and \
frappe.db.exists('DocType', 'Bank Clearance Detail'):
frappe.delete_doc("DocType", 'Bank Reconciliation Detail', force=1)

View File

@@ -12,6 +12,7 @@ def execute():
for report in reports_to_delete: for report in reports_to_delete:
if frappe.db.exists("Report", report): if frappe.db.exists("Report", report):
delete_links_from_desktop_icons(report)
delete_auto_email_reports(report) delete_auto_email_reports(report)
check_and_delete_linked_reports(report) check_and_delete_linked_reports(report)
@@ -22,3 +23,9 @@ def delete_auto_email_reports(report):
auto_email_reports = frappe.db.get_values("Auto Email Report", {"report": report}, ["name"]) auto_email_reports = frappe.db.get_values("Auto Email Report", {"report": report}, ["name"])
for auto_email_report in auto_email_reports: for auto_email_report in auto_email_reports:
frappe.delete_doc("Auto Email Report", auto_email_report[0]) frappe.delete_doc("Auto Email Report", auto_email_report[0])
def delete_links_from_desktop_icons(report):
""" Check for one or multiple Desktop Icons and delete """
desktop_icons = frappe.db.get_values("Desktop Icon", {"_report": report}, ["name"])
for desktop_icon in desktop_icons:
frappe.delete_doc("Desktop Icon", desktop_icon[0])

View File

@@ -0,0 +1,10 @@
import click
def execute():
click.secho(
"Hospitality Domain is moved to a separate app and will be removed from ERPNext in version-14.\n"
"When upgrading to ERPNext version-14, please install the app to continue using the Agriculture domain: https://github.com/frappe/hospitality",
fg="yellow",
)

View File

@@ -13,4 +13,6 @@ def execute():
row.item_code = web_item row.item_code = web_item
homepage.flags.ignore_mandatory = True homepage.flags.ignore_mandatory = True
homepage.flags.ignore_links = True
homepage.save() homepage.save()

View File

@@ -5,6 +5,9 @@ from erpnext.regional.india.setup import make_custom_fields
def execute(): def execute():
if frappe.get_all('Company', filters = {'country': 'India'}): if frappe.get_all('Company', filters = {'country': 'India'}):
frappe.reload_doc('accounts', 'doctype', 'POS Invoice')
frappe.reload_doc('accounts', 'doctype', 'POS Invoice Item')
make_custom_fields() make_custom_fields()
if not frappe.db.exists('Party Type', 'Donor'): if not frappe.db.exists('Party Type', 'Donor'):

View File

@@ -9,13 +9,15 @@ def execute():
from `tabStock Ledger Entry` from `tabStock Ledger Entry`
where where
is_cancelled = 0 is_cancelled = 0
and (serial_no like %s or serial_no like %s or serial_no like %s or serial_no like %s) and ( serial_no like %s or serial_no like %s or serial_no like %s or serial_no like %s
or serial_no = %s )
""", """,
( (
" %", # leading whitespace " %", # leading whitespace
"% ", # trailing whitespace "% ", # trailing whitespace
"%\n %", # leading whitespace on newline "%\n %", # leading whitespace on newline
"% \n%", # trailing whitespace on newline "% \n%", # trailing whitespace on newline
"\n", # just new line
), ),
as_dict=True, as_dict=True,
) )

View File

@@ -38,4 +38,4 @@ def execute():
jc.production_item = wo.production_item, jc.item_name = wo.item_name jc.production_item = wo.production_item, jc.item_name = wo.item_name
WHERE WHERE
jc.work_order = wo.name and IFNULL(jc.production_item, "") = "" jc.work_order = wo.name and IFNULL(jc.production_item, "") = ""
""") """)

View File

@@ -0,0 +1,24 @@
import frappe
def execute():
frappe.reload_doc("maintenance", "doctype", "maintenance_visit")
# Updates the Maintenance Schedule link to fetch serial nos
from frappe.query_builder.functions import Coalesce
mvp = frappe.qb.DocType('Maintenance Visit Purpose')
mv = frappe.qb.DocType('Maintenance Visit')
frappe.qb.update(
mv
).join(
mvp
).on(mvp.parent == mv.name).set(
mv.maintenance_schedule,
Coalesce(mvp.prevdoc_docname, '')
).where(
(mv.maintenance_type == "Scheduled")
& (mvp.prevdoc_docname.notnull())
& (mv.docstatus < 2)
).run(as_dict=1)

View File

@@ -0,0 +1,11 @@
import frappe
def execute():
bom = frappe.qb.DocType("BOM")
(frappe.qb
.update(bom)
.set(bom.transfer_material_against, "Work Order")
.where(bom.with_operations == 0)
).run()

View File

@@ -0,0 +1,18 @@
import frappe
def execute():
doctype = "Stock Reconciliation Item"
if not frappe.db.has_column(doctype, "current_serial_no"):
# nothing to fix if column doesn't exist
return
sr_item = frappe.qb.DocType(doctype)
(frappe.qb
.update(sr_item)
.set(sr_item.current_serial_no, None)
.where(sr_item.current_qty == 0)
).run()

View File

@@ -999,6 +999,8 @@ def make_leave_application(employee, from_date, to_date, leave_type, company=Non
)) ))
leave_application.submit() leave_application.submit()
return leave_application
def setup_test(): def setup_test():
make_earning_salary_component(setup=True, company_list=["_Test Company"]) make_earning_salary_component(setup=True, company_list=["_Test Company"])
make_deduction_salary_component(setup=True, company_list=["_Test Company"]) make_deduction_salary_component(setup=True, company_list=["_Test Company"])

View File

@@ -5,7 +5,7 @@ import datetime
import unittest import unittest
import frappe import frappe
from frappe.utils import add_months, now_datetime, nowdate from frappe.utils import add_months, add_to_date, now_datetime, nowdate
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.hr.doctype.employee.test_employee import make_employee from erpnext.hr.doctype.employee.test_employee import make_employee
@@ -151,6 +151,27 @@ class TestTimesheet(unittest.TestCase):
settings.ignore_employee_time_overlap = initial_setting settings.ignore_employee_time_overlap = initial_setting
settings.save() settings.save()
def test_to_time(self):
emp = make_employee("test_employee_6@salary.com")
from_time = now_datetime()
timesheet = frappe.new_doc("Timesheet")
timesheet.employee = emp
timesheet.append(
'time_logs',
{
"billable": 1,
"activity_type": "_Test Activity Type",
"from_time": from_time,
"hours": 2,
"company": "_Test Company"
}
)
timesheet.save()
to_time = timesheet.time_logs[0].to_time
self.assertEqual(to_time, add_to_date(from_time, hours=2, as_datetime=True))
def make_salary_structure_for_timesheet(employee, company=None): def make_salary_structure_for_timesheet(employee, company=None):
salary_structure_name = "Timesheet Salary Structure Test" salary_structure_name = "Timesheet Salary Structure Test"

View File

@@ -7,7 +7,7 @@ import json
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import flt, getdate, time_diff_in_hours from frappe.utils import add_to_date, flt, getdate, time_diff_in_hours
from erpnext.controllers.queries import get_match_cond from erpnext.controllers.queries import get_match_cond
from erpnext.hr.utils import validate_active_employee from erpnext.hr.utils import validate_active_employee
@@ -136,10 +136,19 @@ class Timesheet(Document):
def validate_time_logs(self): def validate_time_logs(self):
for data in self.get('time_logs'): for data in self.get('time_logs'):
self.set_to_time(data)
self.validate_overlap(data) self.validate_overlap(data)
self.set_project(data) self.set_project(data)
self.validate_project(data) self.validate_project(data)
def set_to_time(self, data):
if not (data.from_time and data.hours):
return
_to_time = add_to_date(data.from_time, hours=data.hours, as_datetime=True)
if data.to_time != _to_time:
data.to_time = _to_time
def validate_overlap(self, data): def validate_overlap(self, data):
settings = frappe.get_single('Projects Settings') settings = frappe.get_single('Projects Settings')
self.validate_overlap_for("user", data, self.user, settings.ignore_user_time_overlap) self.validate_overlap_for("user", data, self.user, settings.ignore_user_time_overlap)

View File

@@ -590,7 +590,6 @@ body.product-page {
top: -10px; top: -10px;
left: -12px; left: -12px;
background: var(--red-600); background: var(--red-600);
width: 16px;
align-items: center; align-items: center;
height: 16px; height: 16px;
font-size: 10px; font-size: 10px;

View File

@@ -53,7 +53,8 @@ frappe.query_reports["GSTR-1"] = {
{ "value": "CDNR-REG", "label": __("Credit/Debit Notes (Registered) - 9B") }, { "value": "CDNR-REG", "label": __("Credit/Debit Notes (Registered) - 9B") },
{ "value": "CDNR-UNREG", "label": __("Credit/Debit Notes (Unregistered) - 9B") }, { "value": "CDNR-UNREG", "label": __("Credit/Debit Notes (Unregistered) - 9B") },
{ "value": "EXPORT", "label": __("Export Invoice - 6A") }, { "value": "EXPORT", "label": __("Export Invoice - 6A") },
{ "value": "Advances", "label": __("Tax Liability (Advances Received) - 11A(1), 11A(2)") } { "value": "Advances", "label": __("Tax Liability (Advances Received) - 11A(1), 11A(2)") },
{ "value": "NIL Rated", "label": __("NIL RATED/EXEMPTED Invoices") }
], ],
"default": "B2B" "default": "B2B"
} }

View File

@@ -41,7 +41,8 @@ class Gstr1Report(object):
port_code, port_code,
shipping_bill_number, shipping_bill_number,
shipping_bill_date, shipping_bill_date,
reason_for_issuing_document reason_for_issuing_document,
company_gstin
""" """
def run(self): def run(self):
@@ -63,6 +64,8 @@ class Gstr1Report(object):
self.get_b2c_data() self.get_b2c_data()
elif self.filters.get("type_of_business") == "Advances": elif self.filters.get("type_of_business") == "Advances":
self.get_advance_data() self.get_advance_data()
elif self.filters.get("type_of_business") == "NIL Rated":
self.get_nil_rated_invoices()
elif self.invoices: elif self.invoices:
for inv, items_based_on_rate in self.items_based_on_tax_rate.items(): for inv, items_based_on_rate in self.items_based_on_tax_rate.items():
invoice_details = self.invoices.get(inv) invoice_details = self.invoices.get(inv)
@@ -92,6 +95,57 @@ class Gstr1Report(object):
row= [key[0], key[1], value[0], value[1]] row= [key[0], key[1], value[0], value[1]]
self.data.append(row) self.data.append(row)
def get_nil_rated_invoices(self):
nil_exempt_output = [
{
"description": "Inter-State supplies to registered persons",
"nil_rated": 0.0,
"exempted": 0.0,
"non_gst": 0.0
},
{
"description": "Intra-State supplies to registered persons",
"nil_rated": 0.0,
"exempted": 0.0,
"non_gst": 0.0
},
{
"description": "Inter-State supplies to unregistered persons",
"nil_rated": 0.0,
"exempted": 0.0,
"non_gst": 0.0
},
{
"description": "Intra-State supplies to unregistered persons",
"nil_rated": 0.0,
"exempted": 0.0,
"non_gst": 0.0
}
]
for invoice, details in self.nil_exempt_non_gst.items():
invoice_detail = self.invoices.get(invoice)
if invoice_detail.get('gst_category') in ("Registered Regular", "Deemed Export", "SEZ"):
if is_inter_state(invoice_detail):
nil_exempt_output[0]["nil_rated"] += details[0]
nil_exempt_output[0]["exempted"] += details[1]
nil_exempt_output[0]["non_gst"] += details[2]
else:
nil_exempt_output[1]["nil_rated"] += details[0]
nil_exempt_output[1]["exempted"] += details[1]
nil_exempt_output[1]["non_gst"] += details[2]
else:
if is_inter_state(invoice_detail):
nil_exempt_output[2]["nil_rated"] += details[0]
nil_exempt_output[2]["exempted"] += details[1]
nil_exempt_output[2]["non_gst"] += details[2]
else:
nil_exempt_output[3]["nil_rated"] += details[0]
nil_exempt_output[3]["exempted"] += details[1]
nil_exempt_output[3]["non_gst"] += details[2]
self.data = nil_exempt_output
def get_b2c_data(self): def get_b2c_data(self):
b2cs_output = {} b2cs_output = {}
@@ -241,10 +295,11 @@ class Gstr1Report(object):
def get_invoice_items(self): def get_invoice_items(self):
self.invoice_items = frappe._dict() self.invoice_items = frappe._dict()
self.item_tax_rate = frappe._dict() self.item_tax_rate = frappe._dict()
self.nil_exempt_non_gst = {}
items = frappe.db.sql(""" items = frappe.db.sql("""
select item_code, parent, taxable_value, base_net_amount, item_tax_rate select item_code, parent, taxable_value, base_net_amount, item_tax_rate, is_nil_exempt,
from `tab%s Item` is_non_gst from `tab%s Item`
where parent in (%s) where parent in (%s)
""" % (self.doctype, ', '.join(['%s']*len(self.invoices))), tuple(self.invoices), as_dict=1) """ % (self.doctype, ', '.join(['%s']*len(self.invoices))), tuple(self.invoices), as_dict=1)
@@ -261,6 +316,16 @@ class Gstr1Report(object):
tax_rate_dict = self.item_tax_rate.setdefault(d.parent, {}).setdefault(d.item_code, []) tax_rate_dict = self.item_tax_rate.setdefault(d.parent, {}).setdefault(d.item_code, [])
tax_rate_dict.append(rate) tax_rate_dict.append(rate)
if d.is_nil_exempt:
self.nil_exempt_non_gst.setdefault(d.parent, [0.0, 0.0, 0.0])
if item_tax_rate:
self.nil_exempt_non_gst[d.parent][0] += d.get('taxable_value', 0)
else:
self.nil_exempt_non_gst[d.parent][1] += d.get('taxable_value', 0)
elif d.is_non_gst:
self.nil_exempt_non_gst.setdefault(d.parent, [0.0, 0.0, 0.0])
self.nil_exempt_non_gst[d.parent][2] += d.get('taxable_value', 0)
def get_items_based_on_tax_rate(self): def get_items_based_on_tax_rate(self):
self.tax_details = frappe.db.sql(""" self.tax_details = frappe.db.sql("""
select select
@@ -323,21 +388,24 @@ class Gstr1Report(object):
self.items_based_on_tax_rate.setdefault(invoice, {}).setdefault(0, items.keys()) self.items_based_on_tax_rate.setdefault(invoice, {}).setdefault(0, items.keys())
def get_columns(self): def get_columns(self):
self.tax_columns = [
{
"fieldname": "rate",
"label": "Rate",
"fieldtype": "Int",
"width": 60
},
{
"fieldname": "taxable_value",
"label": "Taxable Value",
"fieldtype": "Currency",
"width": 100
}
]
self.other_columns = [] self.other_columns = []
self.tax_columns = []
if self.filters.get("type_of_business") != "NIL Rated":
self.tax_columns = [
{
"fieldname": "rate",
"label": "Rate",
"fieldtype": "Int",
"width": 60
},
{
"fieldname": "taxable_value",
"label": "Taxable Value",
"fieldtype": "Currency",
"width": 100
}
]
if self.filters.get("type_of_business") == "B2B": if self.filters.get("type_of_business") == "B2B":
self.invoice_columns = [ self.invoice_columns = [
@@ -706,6 +774,33 @@ class Gstr1Report(object):
"width": 100 "width": 100
} }
] ]
elif self.filters.get("type_of_business") == "NIL Rated":
self.invoice_columns = [
{
"fieldname": "description",
"label": "Description",
"fieldtype": "Data",
"width": 420
},
{
"fieldname": "nil_rated",
"label": "Nil Rated",
"fieldtype": "Currency",
"width": 200
},
{
"fieldname": "exempted",
"label": "Exempted",
"fieldtype": "Currency",
"width": 200
},
{
"fieldname": "non_gst",
"label": "Non GST",
"fieldtype": "Currency",
"width": 200
}
]
self.columns = self.invoice_columns + self.tax_columns + self.other_columns self.columns = self.invoice_columns + self.tax_columns + self.other_columns
@@ -769,6 +864,11 @@ def get_json(filters, report_name, data):
out = get_advances_json(res, gstin) out = get_advances_json(res, gstin)
gst_json["at"] = out gst_json["at"] = out
elif filters["type_of_business"] == "NIL Rated":
res = report_data[:-1]
out = get_exempted_json(res)
gst_json["nil"] = out
return { return {
'report_name': report_name, 'report_name': report_name,
'report_type': filters['type_of_business'], 'report_type': filters['type_of_business'],
@@ -981,6 +1081,36 @@ def get_cdnr_unreg_json(res, gstin):
return out return out
def get_exempted_json(data):
out = {
"inv": [
{
"sply_ty": "INTRB2B"
},
{
"sply_ty": "INTRAB2B"
},
{
"sply_ty": "INTRB2C"
},
{
"sply_ty": "INTRAB2C"
}
]
}
for i, v in enumerate(data):
if data[i].get('nil_rated'):
out['inv'][i]['nil_amt'] = data[i]['nil_rated']
if data[i].get('exempted'):
out['inv'][i]['expt_amt'] = data[i]['exempted']
if data[i].get('non_gst'):
out['inv'][i]['ngsup_amt'] = data[i]['non_gst']
return out
def get_invoice_type_for_cdnr(row): def get_invoice_type_for_cdnr(row):
if row.get('gst_category') == 'SEZ': if row.get('gst_category') == 'SEZ':
if row.get('export_type') == 'WPAY': if row.get('export_type') == 'WPAY':
@@ -1065,3 +1195,9 @@ def download_json_file():
frappe.response['filecontent'] = data['data'] frappe.response['filecontent'] = data['data']
frappe.response['content_type'] = 'application/json' frappe.response['content_type'] = 'application/json'
frappe.response['type'] = 'download' frappe.response['type'] = 'download'
def is_inter_state(invoice_detail):
if invoice_detail.place_of_supply.split("-")[0] != invoice_detail.company_gstin[:2]:
return True
else:
return False

View File

@@ -142,7 +142,7 @@ class Customer(TransactionBase):
self.update_lead_status() self.update_lead_status()
if self.flags.is_new_doc: if self.flags.is_new_doc:
self.create_lead_address_contact() self.link_lead_address_and_contact()
self.update_customer_groups() self.update_customer_groups()
@@ -176,63 +176,25 @@ class Customer(TransactionBase):
if self.lead_name: if self.lead_name:
frappe.db.set_value("Lead", self.lead_name, "status", "Converted") frappe.db.set_value("Lead", self.lead_name, "status", "Converted")
def create_lead_address_contact(self): def link_lead_address_and_contact(self):
if self.lead_name: if self.lead_name:
# assign lead address to customer (if already not set) # assign lead address and contact to customer (if already not set)
address_names = frappe.get_all('Dynamic Link', filters={ linked_contacts_and_addresses = frappe.get_all(
"parenttype":"Address", "Dynamic Link",
"link_doctype":"Lead", filters=[
"link_name":self.lead_name ["parenttype", "in", ["Contact", "Address"]],
}, fields=["parent as name"]) ["link_doctype", "=", "Lead"],
["link_name", "=", self.lead_name],
],
fields=["parent as name", "parenttype as doctype"],
)
for address_name in address_names: for row in linked_contacts_and_addresses:
address = frappe.get_doc('Address', address_name.get('name')) linked_doc = frappe.get_doc(row.doctype, row.name)
if not address.has_link('Customer', self.name): if not linked_doc.has_link('Customer', self.name):
address.append('links', dict(link_doctype='Customer', link_name=self.name)) linked_doc.append('links', dict(link_doctype='Customer', link_name=self.name))
address.save(ignore_permissions=self.flags.ignore_permissions) linked_doc.save(ignore_permissions=self.flags.ignore_permissions)
lead = frappe.db.get_value("Lead", self.lead_name, ["organization_lead", "lead_name", "email_id", "phone", "mobile_no", "gender", "salutation"], as_dict=True)
if not lead.lead_name:
frappe.throw(_("Please mention the Lead Name in Lead {0}").format(self.lead_name))
if lead.organization_lead:
contact_names = frappe.get_all('Dynamic Link', filters={
"parenttype":"Contact",
"link_doctype":"Lead",
"link_name":self.lead_name
}, fields=["parent as name"])
for contact_name in contact_names:
contact = frappe.get_doc('Contact', contact_name.get('name'))
if not contact.has_link('Customer', self.name):
contact.append('links', dict(link_doctype='Customer', link_name=self.name))
contact.save(ignore_permissions=self.flags.ignore_permissions)
else:
lead.lead_name = lead.lead_name.lstrip().split(" ")
lead.first_name = lead.lead_name[0]
lead.last_name = " ".join(lead.lead_name[1:])
# create contact from lead
contact = frappe.new_doc('Contact')
contact.first_name = lead.first_name
contact.last_name = lead.last_name
contact.gender = lead.gender
contact.salutation = lead.salutation
contact.email_id = lead.email_id
contact.phone = lead.phone
contact.mobile_no = lead.mobile_no
contact.is_primary_contact = 1
contact.append('links', dict(link_doctype='Customer', link_name=self.name))
if lead.email_id:
contact.append('email_ids', dict(email_id=lead.email_id, is_primary=1))
if lead.mobile_no:
contact.append('phone_nos', dict(phone=lead.mobile_no, is_primary_mobile_no=1))
contact.flags.ignore_permissions = self.flags.ignore_permissions
contact.autoname()
if not frappe.db.exists("Contact", contact.name):
contact.insert()
def validate_name_with_customer_group(self): def validate_name_with_customer_group(self):
if frappe.db.exists("Customer Group", self.name): if frappe.db.exists("Customer Group", self.name):

View File

@@ -23,19 +23,24 @@ def execute(filters=None):
row = [] row = []
outstanding_amt = get_customer_outstanding(d.name, filters.get("company"), outstanding_amt = get_customer_outstanding(d.name, filters.get("company"),
ignore_outstanding_sales_order=d.bypass_credit_limit_check_at_sales_order) ignore_outstanding_sales_order=d.bypass_credit_limit_check)
credit_limit = get_credit_limit(d.name, filters.get("company")) credit_limit = get_credit_limit(d.name, filters.get("company"))
bal = flt(credit_limit) - flt(outstanding_amt) bal = flt(credit_limit) - flt(outstanding_amt)
if customer_naming_type == "Naming Series": if customer_naming_type == "Naming Series":
row = [d.name, d.customer_name, credit_limit, outstanding_amt, bal, row = [
d.bypass_credit_limit_check, d.is_frozen, d.name, d.customer_name, credit_limit,
d.disabled] outstanding_amt, bal, d.bypass_credit_limit_check,
d.is_frozen, d.disabled
]
else: else:
row = [d.name, credit_limit, outstanding_amt, bal, row = [
d.bypass_credit_limit_check_at_sales_order, d.is_frozen, d.disabled] d.name, credit_limit, outstanding_amt, bal,
d.bypass_credit_limit_check, d.is_frozen,
d.disabled
]
if credit_limit: if credit_limit:
data.append(row) data.append(row)

View File

@@ -80,7 +80,7 @@ def get_data(conditions, filters):
and so.docstatus = 1 and so.docstatus = 1
{conditions} {conditions}
GROUP BY soi.name GROUP BY soi.name
ORDER BY so.transaction_date ASC ORDER BY so.transaction_date ASC, soi.item_code ASC
""".format(conditions=conditions), filters, as_dict=1) """.format(conditions=conditions), filters, as_dict=1)
return data return data

View File

@@ -293,6 +293,7 @@ def get_batches(item_code, warehouse, qty=1, throw=False, serial_no=None):
join `tabStock Ledger Entry` ignore index (item_code, warehouse) join `tabStock Ledger Entry` ignore index (item_code, warehouse)
on (`tabBatch`.batch_id = `tabStock Ledger Entry`.batch_no ) on (`tabBatch`.batch_id = `tabStock Ledger Entry`.batch_no )
where `tabStock Ledger Entry`.item_code = %s and `tabStock Ledger Entry`.warehouse = %s where `tabStock Ledger Entry`.item_code = %s and `tabStock Ledger Entry`.warehouse = %s
and `tabStock Ledger Entry`.is_cancelled = 0
and (`tabBatch`.expiry_date >= CURDATE() or `tabBatch`.expiry_date IS NULL) {0} and (`tabBatch`.expiry_date >= CURDATE() or `tabBatch`.expiry_date IS NULL) {0}
group by batch_id group by batch_id
order by `tabBatch`.expiry_date ASC, `tabBatch`.creation ASC order by `tabBatch`.expiry_date ASC, `tabBatch`.creation ASC

View File

@@ -14,6 +14,7 @@ from erpnext.controllers.accounts_controller import get_taxes_and_charges
from erpnext.controllers.selling_controller import SellingController from erpnext.controllers.selling_controller import SellingController
from erpnext.stock.doctype.batch.batch import set_batch_nos from erpnext.stock.doctype.batch.batch import set_batch_nos
from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no
from erpnext.stock.utils import calculate_mapped_packed_items_return
form_grid_templates = { form_grid_templates = {
"items": "templates/form_grid/item_grid.html" "items": "templates/form_grid/item_grid.html"
@@ -128,8 +129,12 @@ class DeliveryNote(SellingController):
self.validate_uom_is_integer("uom", "qty") self.validate_uom_is_integer("uom", "qty")
self.validate_with_previous_doc() self.validate_with_previous_doc()
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list # Keeps mapped packed_items in case product bundle is updated.
make_packing_list(self) if self.is_return and self.return_against:
calculate_mapped_packed_items_return(self)
else:
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
make_packing_list(self)
if self._action != 'submit' and not self.is_return: if self._action != 'submit' and not self.is_return:
set_batch_nos(self, 'warehouse', throw=True) set_batch_nos(self, 'warehouse', throw=True)

View File

@@ -386,8 +386,7 @@ class TestDeliveryNote(ERPNextTestCase):
self.assertEqual(actual_qty, 25) self.assertEqual(actual_qty, 25)
# return bundled item # return bundled item
dn1 = create_delivery_note(item_code='_Test Product Bundle Item', is_return=1, dn1 = create_return_delivery_note(source_name=dn.name, rate=500, qty=-2)
return_against=dn.name, qty=-2, rate=500, company=company, warehouse="Stores - TCP1", expense_account="Cost of Goods Sold - TCP1", cost_center="Main - TCP1")
# qty after return # qty after return
actual_qty = get_qty_after_transaction(warehouse="Stores - TCP1") actual_qty = get_qty_after_transaction(warehouse="Stores - TCP1")
@@ -823,6 +822,15 @@ class TestDeliveryNote(ERPNextTestCase):
automatically_fetch_payment_terms(enable=0) automatically_fetch_payment_terms(enable=0)
def create_return_delivery_note(**args):
args = frappe._dict(args)
from erpnext.controllers.sales_and_purchase_return import make_return_doc
doc = make_return_doc("Delivery Note", args.source_name, None)
doc.items[0].rate = args.rate
doc.items[0].qty = args.qty
doc.submit()
return doc
def create_delivery_note(**args): def create_delivery_note(**args):
dn = frappe.new_doc("Delivery Note") dn = frappe.new_doc("Delivery Note")
args = frappe._dict(args) args = frappe._dict(args)

View File

@@ -219,18 +219,20 @@ class Item(Document):
self.item_code)) self.item_code))
def add_default_uom_in_conversion_factor_table(self): def add_default_uom_in_conversion_factor_table(self):
uom_conv_list = [d.uom for d in self.get("uoms")] if not self.is_new() and self.has_value_changed("stock_uom"):
if self.stock_uom not in uom_conv_list: self.uoms = []
ch = self.append('uoms', {}) frappe.msgprint(
ch.uom = self.stock_uom _("Successfully changed Stock UOM, please redefine conversion factors for new UOM."),
ch.conversion_factor = 1 alert=True,
)
to_remove = [] uoms_list = [d.uom for d in self.get("uoms")]
for d in self.get("uoms"):
if d.conversion_factor == 1 and d.uom != self.stock_uom:
to_remove.append(d)
[self.remove(d) for d in to_remove] if self.stock_uom not in uoms_list:
self.append("uoms", {
"uom": self.stock_uom,
"conversion_factor": 1
})
def update_website_item(self): def update_website_item(self):
"""Update Website Item if change in Item impacts it.""" """Update Website Item if change in Item impacts it."""
@@ -347,14 +349,6 @@ class Item(Document):
frappe.throw(_("Barcode {0} is not a valid {1} code").format( frappe.throw(_("Barcode {0} is not a valid {1} code").format(
item_barcode.barcode, item_barcode.barcode_type), InvalidBarcode) item_barcode.barcode, item_barcode.barcode_type), InvalidBarcode)
if item_barcode.barcode != item_barcode.name:
# if barcode is getting updated , the row name has to reset.
# Delete previous old row doc and re-enter row as if new to reset name in db.
item_barcode.set("__islocal", True)
item_barcode_entry_name = item_barcode.name
item_barcode.name = None
frappe.delete_doc("Item Barcode", item_barcode_entry_name)
def validate_warehouse_for_reorder(self): def validate_warehouse_for_reorder(self):
'''Validate Reorder level table for duplicate and conditional mandatory''' '''Validate Reorder level table for duplicate and conditional mandatory'''
warehouse = [] warehouse = []

View File

@@ -573,6 +573,16 @@ class TestItem(ERPNextTestCase):
except frappe.ValidationError as e: except frappe.ValidationError as e:
self.fail(f"UoM change not allowed even though no SLE / BIN with positive qty exists: {e}") self.fail(f"UoM change not allowed even though no SLE / BIN with positive qty exists: {e}")
def test_erasure_of_old_conversions(self):
item = create_item("_item change uom")
item.stock_uom = "Gram"
item.append("uoms", frappe._dict(uom="Box", conversion_factor=2))
item.save()
item.reload()
item.stock_uom = "Nos"
item.save()
self.assertEqual(len(item.uoms), 1)
def test_validate_stock_item(self): def test_validate_stock_item(self):
self.assertRaises(frappe.ValidationError, validate_is_stock_item, "_Test Non Stock Item") self.assertRaises(frappe.ValidationError, validate_is_stock_item, "_Test Non Stock Item")

View File

@@ -1,7 +1,7 @@
{ {
"actions": [], "actions": [],
"autoname": "REPOST-ITEM-VAL-.######", "autoname": "REPOST-ITEM-VAL-.######",
"creation": "2020-10-22 22:27:07.742161", "creation": "2022-01-11 15:03:38.273179",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
@@ -129,7 +129,7 @@
"reqd": 1 "reqd": 1
}, },
{ {
"default": "0", "default": "1",
"fieldname": "allow_negative_stock", "fieldname": "allow_negative_stock",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Allow Negative Stock" "label": "Allow Negative Stock"
@@ -177,7 +177,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2021-11-24 02:18:10.524560", "modified": "2022-01-18 10:57:33.450907",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Repost Item Valuation", "name": "Repost Item Valuation",
@@ -227,5 +227,6 @@
} }
], ],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC" "sort_order": "DESC",
} "states": []
}

View File

@@ -27,8 +27,7 @@ class RepostItemValuation(Document):
self.item_code = None self.item_code = None
self.warehouse = None self.warehouse = None
self.allow_negative_stock = self.allow_negative_stock or \ self.allow_negative_stock = 1
cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
def set_company(self): def set_company(self):
if self.based_on == "Transaction": if self.based_on == "Transaction":

View File

@@ -421,10 +421,16 @@ def update_serial_nos(sle, item_det):
def get_auto_serial_nos(serial_no_series, qty): def get_auto_serial_nos(serial_no_series, qty):
serial_nos = [] serial_nos = []
for i in range(cint(qty)): for i in range(cint(qty)):
serial_nos.append(make_autoname(serial_no_series, "Serial No")) serial_nos.append(get_new_serial_number(serial_no_series))
return "\n".join(serial_nos) return "\n".join(serial_nos)
def get_new_serial_number(series):
sr_no = make_autoname(series, "Serial No")
if frappe.db.exists("Serial No", sr_no):
sr_no = get_new_serial_number(series)
return sr_no
def auto_make_serial_nos(args): def auto_make_serial_nos(args):
serial_nos = get_serial_nos(args.get('serial_no')) serial_nos = get_serial_nos(args.get('serial_no'))
created_numbers = [] created_numbers = []

View File

@@ -8,8 +8,10 @@
import frappe import frappe
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
@@ -21,6 +23,10 @@ from erpnext.tests.utils import ERPNextTestCase
class TestSerialNo(ERPNextTestCase): class TestSerialNo(ERPNextTestCase):
def tearDown(self):
frappe.db.rollback()
def test_cannot_create_direct(self): def test_cannot_create_direct(self):
frappe.delete_doc_if_exists("Serial No", "_TCSER0001") frappe.delete_doc_if_exists("Serial No", "_TCSER0001")
@@ -176,6 +182,24 @@ class TestSerialNo(ERPNextTestCase):
self.assertEqual(sn_doc.warehouse, "_Test Warehouse - _TC") self.assertEqual(sn_doc.warehouse, "_Test Warehouse - _TC")
self.assertEqual(sn_doc.purchase_document_no, se.name) self.assertEqual(sn_doc.purchase_document_no, se.name)
def test_auto_creation_of_serial_no(self):
"""
Test if auto created Serial No excludes existing serial numbers
"""
item_code = make_item("_Test Auto Serial Item ", {
"has_serial_no": 1,
"serial_no_series": "XYZ.###"
}).item_code
# Reserve XYZ005
pr_1 = make_purchase_receipt(item_code=item_code, qty=1, serial_no="XYZ005")
# XYZ005 is already used and will throw an error if used again
pr_2 = make_purchase_receipt(item_code=item_code, qty=10)
self.assertEqual(get_serial_nos(pr_1.get("items")[0].serial_no)[0], "XYZ005")
for serial_no in get_serial_nos(pr_2.get("items")[0].serial_no):
self.assertNotEqual(serial_no, "XYZ005")
def test_serial_no_sanitation(self): def test_serial_no_sanitation(self):
"Test if Serial No input is sanitised before entering the DB." "Test if Serial No input is sanitised before entering the DB."
item_code = "_Test Serialized Item" item_code = "_Test Serialized Item"
@@ -192,7 +216,28 @@ class TestSerialNo(ERPNextTestCase):
self.assertEqual(se.get("items")[0].serial_no, "_TS1\n_TS2\n_TS3\n_TS4 - 2021") self.assertEqual(se.get("items")[0].serial_no, "_TS1\n_TS2\n_TS3\n_TS4 - 2021")
frappe.db.rollback() def test_correct_serial_no_incoming_rate(self):
""" Check correct consumption rate based on serial no record.
"""
item_code = "_Test Serialized Item"
warehouse = "_Test Warehouse - _TC"
serial_nos = ["LOWVALUATION", "HIGHVALUATION"]
in1 = make_stock_entry(item_code=item_code, to_warehouse=warehouse, qty=1, rate=42,
serial_no=serial_nos[0])
in2 = make_stock_entry(item_code=item_code, to_warehouse=warehouse, qty=1, rate=113,
serial_no=serial_nos[1])
out = create_delivery_note(item_code=item_code, qty=1, serial_no=serial_nos[0], do_not_submit=True)
# change serial no
out.items[0].serial_no = serial_nos[1]
out.save()
out.submit()
value_diff = frappe.db.get_value("Stock Ledger Entry",
{"voucher_no": out.name, "voucher_type": "Delivery Note"},
"stock_value_difference"
)
self.assertEqual(value_diff, -113)
def tearDown(self):
frappe.db.rollback()

View File

@@ -1446,14 +1446,15 @@ class StockEntry(StockController):
qty = req_qty_each * flt(self.fg_completed_qty) qty = req_qty_each * flt(self.fg_completed_qty)
elif backflushed_materials.get(item.item_code): elif backflushed_materials.get(item.item_code):
precision = frappe.get_precision("Stock Entry Detail", "qty")
for d in backflushed_materials.get(item.item_code): for d in backflushed_materials.get(item.item_code):
if d.get(item.warehouse): if d.get(item.warehouse) > 0:
if (qty > req_qty): if (qty > req_qty):
qty = (qty/trans_qty) * flt(self.fg_completed_qty) qty = ((flt(qty, precision) - flt(d.get(item.warehouse), precision))
/ (flt(trans_qty, precision) - flt(produced_qty, precision))
) * flt(self.fg_completed_qty)
if consumed_qty and frappe.db.get_single_value("Manufacturing Settings", d[item.warehouse] -= qty
"material_consumption"):
qty -= consumed_qty
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)

View File

@@ -852,6 +852,34 @@ class TestStockEntry(ERPNextTestCase):
self.assertEqual(se.get("items")[0].allow_zero_valuation_rate, 1) self.assertEqual(se.get("items")[0].allow_zero_valuation_rate, 1)
self.assertEqual(se.get("items")[0].amount, 0) self.assertEqual(se.get("items")[0].amount, 0)
def test_zero_incoming_rate(self):
""" Make sure incoming rate of 0 is allowed while consuming.
qty | rate | valuation rate
1 | 100 | 100
1 | 0 | 50
-1 | 100 | 0
-1 | 0 <--- assert this
"""
item_code = "_TestZeroVal"
warehouse = "_Test Warehouse - _TC"
create_item('_TestZeroVal')
_receipt = make_stock_entry(item_code=item_code, qty=1, to_warehouse=warehouse, rate=100)
receipt2 = make_stock_entry(item_code=item_code, qty=1, to_warehouse=warehouse, rate=0, do_not_save=True)
receipt2.items[0].allow_zero_valuation_rate = 1
receipt2.save()
receipt2.submit()
issue = make_stock_entry(item_code=item_code, qty=1, from_warehouse=warehouse)
value_diff = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": issue.name, "voucher_type": "Stock Entry"}, "stock_value_difference")
self.assertEqual(value_diff, -100)
issue2 = make_stock_entry(item_code=item_code, qty=1, from_warehouse=warehouse)
value_diff = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": issue2.name, "voucher_type": "Stock Entry"}, "stock_value_difference")
self.assertEqual(value_diff, 0)
def test_gle_for_opening_stock_entry(self): def test_gle_for_opening_stock_entry(self):
mr = make_stock_entry(item_code="_Test Item", target="Stores - TCP1", mr = make_stock_entry(item_code="_Test Item", target="Stores - TCP1",
company="_Test Company with perpetual inventory", qty=50, basic_rate=100, company="_Test Company with perpetual inventory", qty=50, basic_rate=100,

View File

@@ -5,7 +5,10 @@ import frappe
from frappe.core.page.permission_manager.permission_manager import reset from frappe.core.page.permission_manager.permission_manager import reset
from frappe.utils import add_days, today from frappe.utils import add_days, today
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.delivery_note.test_delivery_note import (
create_delivery_note,
create_return_delivery_note,
)
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
create_landed_cost_voucher, create_landed_cost_voucher,
@@ -232,8 +235,7 @@ class TestStockLedgerEntry(ERPNextTestCase):
self.assertEqual(outgoing_rate, 100) self.assertEqual(outgoing_rate, 100)
# Return Entry: Qty = -2, Rate = 150 # Return Entry: Qty = -2, Rate = 150
return_dn = create_delivery_note(is_return=1, return_against=dn.name, item_code=bundled_item, qty=-2, rate=150, return_dn = create_return_delivery_note(source_name=dn.name, rate=150, qty=-2)
company=company, warehouse="Stores - _TC", expense_account="Cost of Goods Sold - _TC", cost_center="Main - _TC")
# check incoming rate for Return entry # check incoming rate for Return entry
incoming_rate, stock_value_difference = frappe.db.get_value("Stock Ledger Entry", incoming_rate, stock_value_difference = frappe.db.get_value("Stock Ledger Entry",

View File

@@ -6,7 +6,7 @@
import frappe import frappe
from frappe.utils import add_days, flt, nowdate, nowtime, random_string from frappe.utils import add_days, cstr, flt, nowdate, nowtime, random_string
from erpnext.accounts.utils import get_stock_and_account_balance from erpnext.accounts.utils import get_stock_and_account_balance
from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.item.test_item import create_item
@@ -439,8 +439,8 @@ class TestStockReconciliation(ERPNextTestCase):
self.assertRaises(frappe.ValidationError, sr.submit) self.assertRaises(frappe.ValidationError, sr.submit)
def test_serial_no_cancellation(self): def test_serial_no_cancellation(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item = create_item("Stock-Reco-Serial-Item-9", is_stock_item=1) item = create_item("Stock-Reco-Serial-Item-9", is_stock_item=1)
if not item.has_serial_no: if not item.has_serial_no:
item.has_serial_no = 1 item.has_serial_no = 1
@@ -466,6 +466,31 @@ class TestStockReconciliation(ERPNextTestCase):
self.assertEqual(len(active_sr_no), 10) self.assertEqual(len(active_sr_no), 10)
def test_serial_no_creation_and_inactivation(self):
item = create_item("_TestItemCreatedWithStockReco", is_stock_item=1)
if not item.has_serial_no:
item.has_serial_no = 1
item.save()
item_code = item.name
warehouse = "_Test Warehouse - _TC"
sr = create_stock_reconciliation(item_code=item.name, warehouse=warehouse,
serial_no="SR-CREATED-SR-NO", qty=1, do_not_submit=True, rate=100)
sr.save()
self.assertEqual(cstr(sr.items[0].current_serial_no), "")
sr.submit()
active_sr_no = frappe.get_all("Serial No",
filters={"item_code": item_code, "warehouse": warehouse, "status": "Active"})
self.assertEqual(len(active_sr_no), 1)
sr.cancel()
active_sr_no = frappe.get_all("Serial No",
filters={"item_code": item_code, "warehouse": warehouse, "status": "Active"})
self.assertEqual(len(active_sr_no), 0)
def create_batch_item_with_batch(item_name, batch_id): def create_batch_item_with_batch(item_name, batch_id):
batch_item_doc = create_item(item_name, is_stock_item=1) batch_item_doc = create_item(item_name, is_stock_item=1)
if not batch_item_doc.has_batch_no: if not batch_item_doc.has_batch_no:

View File

@@ -55,7 +55,8 @@ def get_stock_ledger_entries(filters):
return frappe.db.sql("""select item_code, batch_no, warehouse, return frappe.db.sql("""select item_code, batch_no, warehouse,
posting_date, actual_qty posting_date, actual_qty
from `tabStock Ledger Entry` from `tabStock Ledger Entry`
where docstatus < 2 and ifnull(batch_no, '') != '' %s order by item_code, warehouse""" % where is_cancelled = 0
and docstatus < 2 and ifnull(batch_no, '') != '' %s order by item_code, warehouse""" %
conditions, as_dict=1) conditions, as_dict=1)
def get_item_warehouse_batch_map(filters, float_precision): def get_item_warehouse_batch_map(filters, float_precision):

View File

@@ -91,7 +91,7 @@ def get_stock_value_difference_list(filtered_entries: FilteredEntries) -> SVDLis
voucher_nos = [fe.get('voucher_no') for fe in filtered_entries] voucher_nos = [fe.get('voucher_no') for fe in filtered_entries]
svd_list = frappe.get_list( svd_list = frappe.get_list(
'Stock Ledger Entry', fields=['item_code','stock_value_difference'], 'Stock Ledger Entry', fields=['item_code','stock_value_difference'],
filters=[('voucher_no', 'in', voucher_nos)] filters=[('voucher_no', 'in', voucher_nos), ("is_cancelled", "=", 0)]
) )
assign_item_groups_to_svd_list(svd_list) assign_item_groups_to_svd_list(svd_list)
return svd_list return svd_list

View File

@@ -76,6 +76,7 @@ def get_consumed_items(condition):
on sle.voucher_no = se.name on sle.voucher_no = se.name
where where
actual_qty < 0 actual_qty < 0
and is_cancelled = 0
and voucher_type not in ('Delivery Note', 'Sales Invoice') and voucher_type not in ('Delivery Note', 'Sales Invoice')
%s %s
group by item_code""" % condition, as_dict=1) group by item_code""" % condition, as_dict=1)

View File

@@ -86,10 +86,10 @@ frappe.query_reports["Stock Ledger"] = {
], ],
"formatter": function (value, row, column, data, default_formatter) { "formatter": function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data); value = default_formatter(value, row, column, data);
if (column.fieldname == "out_qty" && data.out_qty < 0) { if (column.fieldname == "out_qty" && data && data.out_qty < 0) {
value = "<span style='color:red'>" + value + "</span>"; value = "<span style='color:red'>" + value + "</span>";
} }
else if (column.fieldname == "in_qty" && data.in_qty > 0) { else if (column.fieldname == "in_qty" && data && data.in_qty > 0) {
value = "<span style='color:green'>" + value + "</span>"; value = "<span style='color:green'>" + value + "</span>";
} }

View File

@@ -7,7 +7,7 @@ import json
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.meta import get_field_precision from frappe.model.meta import get_field_precision
from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdate from frappe.utils import cint, cstr, flt, get_datetime, get_link_to_form, getdate, now, nowdate
from six import iteritems from six import iteritems
import erpnext import erpnext
@@ -105,6 +105,7 @@ def get_args_for_future_sle(row):
def validate_serial_no(sle): def validate_serial_no(sle):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
for sn in get_serial_nos(sle.serial_no): for sn in get_serial_nos(sle.serial_no):
args = copy.deepcopy(sle) args = copy.deepcopy(sle)
args.serial_no = sn args.serial_no = sn
@@ -415,6 +416,8 @@ class update_entries_after(object):
return sorted(entries_to_fix, key=lambda k: k['timestamp']) return sorted(entries_to_fix, key=lambda k: k['timestamp'])
def process_sle(self, sle): def process_sle(self, sle):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
# previous sle data for this warehouse # previous sle data for this warehouse
self.wh_data = self.data[sle.warehouse] self.wh_data = self.data[sle.warehouse]
@@ -429,7 +432,7 @@ class update_entries_after(object):
if not self.args.get("sle_id"): if not self.args.get("sle_id"):
self.get_dynamic_incoming_outgoing_rate(sle) self.get_dynamic_incoming_outgoing_rate(sle)
if sle.serial_no: if get_serial_nos(sle.serial_no):
self.get_serialized_values(sle) self.get_serialized_values(sle)
self.wh_data.qty_after_transaction += flt(sle.actual_qty) self.wh_data.qty_after_transaction += flt(sle.actual_qty)
if sle.voucher_type == "Stock Reconciliation": if sle.voucher_type == "Stock Reconciliation":
@@ -441,8 +444,9 @@ class update_entries_after(object):
# assert # assert
self.wh_data.valuation_rate = sle.valuation_rate self.wh_data.valuation_rate = sle.valuation_rate
self.wh_data.qty_after_transaction = sle.qty_after_transaction self.wh_data.qty_after_transaction = sle.qty_after_transaction
self.wh_data.stock_queue = [[self.wh_data.qty_after_transaction, self.wh_data.valuation_rate]]
self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate) self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate)
if self.valuation_method != "Moving Average":
self.wh_data.stock_queue = [[self.wh_data.qty_after_transaction, self.wh_data.valuation_rate]]
else: else:
if self.valuation_method == "Moving Average": if self.valuation_method == "Moving Average":
self.get_moving_average_values(sle) self.get_moving_average_values(sle)
@@ -595,9 +599,9 @@ class update_entries_after(object):
incoming_rate = self.wh_data.valuation_rate incoming_rate = self.wh_data.valuation_rate
stock_value_change = 0 stock_value_change = 0
if incoming_rate: if actual_qty > 0:
stock_value_change = actual_qty * incoming_rate stock_value_change = actual_qty * incoming_rate
elif actual_qty < 0: else:
# In case of delivery/stock issue, get average purchase rate # In case of delivery/stock issue, get average purchase rate
# of serial nos of current entry # of serial nos of current entry
if not sle.is_cancelled: if not sle.is_cancelled:
@@ -639,6 +643,7 @@ class update_entries_after(object):
where where
company = %s company = %s
and actual_qty > 0 and actual_qty > 0
and is_cancelled = 0
and (serial_no = %s and (serial_no = %s
or serial_no like %s or serial_no like %s
or serial_no like %s or serial_no like %s
@@ -942,6 +947,7 @@ def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no,
item_code = %s item_code = %s
AND warehouse = %s AND warehouse = %s
AND valuation_rate >= 0 AND valuation_rate >= 0
AND is_cancelled = 0
AND NOT (voucher_no = %s AND voucher_type = %s) AND NOT (voucher_no = %s AND voucher_type = %s)
order by posting_date desc, posting_time desc, name desc limit 1""", (item_code, warehouse, voucher_no, voucher_type)) order by posting_date desc, posting_time desc, name desc limit 1""", (item_code, warehouse, voucher_no, voucher_type))
@@ -952,6 +958,7 @@ def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no,
where where
item_code = %s item_code = %s
AND valuation_rate > 0 AND valuation_rate > 0
AND is_cancelled = 0
AND NOT(voucher_no = %s AND voucher_type = %s) AND NOT(voucher_no = %s AND voucher_type = %s)
order by posting_date desc, posting_time desc, name desc limit 1""", (item_code, voucher_no, voucher_type)) order by posting_date desc, posting_time desc, name desc limit 1""", (item_code, voucher_no, voucher_type))
@@ -1132,26 +1139,31 @@ def get_future_sle_with_negative_qty(args):
def get_future_sle_with_negative_batch_qty(args): def get_future_sle_with_negative_batch_qty(args):
return frappe.db.sql(""" batch_ledger = frappe.db.sql("""
with batch_ledger as ( select
select posting_date, posting_time, voucher_type, voucher_no, actual_qty
posting_date, posting_time, voucher_type, voucher_no, from `tabStock Ledger Entry`
sum(actual_qty) over (order by posting_date, posting_time, creation) as cumulative_total
from `tabStock Ledger Entry`
where
item_code = %(item_code)s
and warehouse = %(warehouse)s
and batch_no=%(batch_no)s
and is_cancelled = 0
order by posting_date, posting_time, creation
)
select * from batch_ledger
where where
cumulative_total < 0.0 item_code = %(item_code)s
and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s) and warehouse = %(warehouse)s
limit 1 and batch_no=%(batch_no)s
and is_cancelled = 0
order by timestamp(posting_date, posting_time), creation
""", args, as_dict=1) """, args, as_dict=1)
cumulative_total = 0.0
current_posting_datetime = get_datetime(str(args.posting_date) + " " + str(args.posting_time))
for entry in batch_ledger:
cumulative_total += entry.actual_qty
if cumulative_total > -1e-6:
continue
if (get_datetime(str(entry.posting_date) + " " + str(entry.posting_time))
>= current_posting_datetime):
entry.cumulative_total = cumulative_total
return [entry]
def _round_off_if_near_zero(number: float, precision: int = 6) -> float: def _round_off_if_near_zero(number: float, precision: int = 6) -> float:
""" Rounds off the number to zero only if number is close to zero for decimal """ Rounds off the number to zero only if number is close to zero for decimal

View File

@@ -104,7 +104,7 @@ def get_stock_balance(item_code, warehouse, posting_date=None, posting_time=None
serial_nos = get_serial_nos_data_after_transactions(args) serial_nos = get_serial_nos_data_after_transactions(args)
return ((last_entry.qty_after_transaction, last_entry.valuation_rate, serial_nos) return ((last_entry.qty_after_transaction, last_entry.valuation_rate, serial_nos)
if last_entry else (0.0, 0.0, 0.0)) if last_entry else (0.0, 0.0, None))
else: else:
return (last_entry.qty_after_transaction, last_entry.valuation_rate) if last_entry else (0.0, 0.0) return (last_entry.qty_after_transaction, last_entry.valuation_rate) if last_entry else (0.0, 0.0)
else: else:
@@ -420,6 +420,19 @@ def is_reposting_item_valuation_in_progress():
if reposting_in_progress: if reposting_in_progress:
frappe.msgprint(_("Item valuation reposting in progress. Report might show incorrect item valuation."), alert=1) frappe.msgprint(_("Item valuation reposting in progress. Report might show incorrect item valuation."), alert=1)
def calculate_mapped_packed_items_return(return_doc):
parent_items = set([item.parent_item for item in return_doc.packed_items])
against_doc = frappe.get_doc(return_doc.doctype, return_doc.return_against)
for original_bundle, returned_bundle in zip(against_doc.items, return_doc.items):
if original_bundle.item_code in parent_items:
for returned_packed_item, original_packed_item in zip(return_doc.packed_items, against_doc.packed_items):
if returned_packed_item.parent_item == original_bundle.item_code:
returned_packed_item.parent_detail_docname = returned_bundle.name
returned_packed_item.qty = (original_packed_item.qty / original_bundle.qty) * returned_bundle.qty
def check_pending_reposting(posting_date: str, throw_error: bool = True) -> bool: def check_pending_reposting(posting_date: str, throw_error: bool = True) -> bool:
"""Check if there are pending reposting job till the specified posting date.""" """Check if there are pending reposting job till the specified posting date."""

View File

@@ -214,7 +214,7 @@ class ItemConfigure {
? `<div class="alert alert-success d-flex justify-content-between align-items-center" role="alert"> ? `<div class="alert alert-success d-flex justify-content-between align-items-center" role="alert">
<div><div> <div><div>
${one_item} ${one_item}
${product_info && product_info.price && !$.isEmptyObject() ${product_info && product_info.price && !$.isEmptyObject(product_info.price)
? '(' + product_info.price.formatted_price_sales_uom + ')' ? '(' + product_info.price.formatted_price_sales_uom + ')'
: '' : ''
} }

View File

@@ -92,6 +92,8 @@ def change_settings(doctype, settings_dict):
for key, value in settings_dict.items(): for key, value in settings_dict.items():
setattr(settings, key, value) setattr(settings, key, value)
settings.save() settings.save()
# singles are cached by default, clear to avoid flake
frappe.db.value_cache[settings] = {}
yield # yield control to calling function yield # yield control to calling function
finally: finally: