Merge branch 'develop' into promotion-enhancements

This commit is contained in:
Rucha Mahabal 2022-04-22 16:38:01 +05:30 committed by GitHub
commit ed04241cf4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
168 changed files with 6344 additions and 3934 deletions

View File

@ -26,3 +26,6 @@ b147b85e6ac19a9220cd1e2958a6ebd99373283a
# bulk format python code with black
494bd9ef78313436f0424b918f200dab8fc7c20b
# bulk format python code with black
baec607ff5905b1c67531096a9cf50ec7ff00a5d

31
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,31 @@
name: Generate Semantic Release
on:
push:
branches:
- version-13
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout Entire Repository
uses: actions/checkout@v2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js v14
uses: actions/setup-node@v2
with:
node-version: 14
- name: Setup dependencies
run: |
npm install @semantic-release/git @semantic-release/exec --no-save
- name: Create Release
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GIT_AUTHOR_NAME: "Frappe PR Bot"
GIT_AUTHOR_EMAIL: "developers@frappe.io"
GIT_COMMITTER_NAME: "Frappe PR Bot"
GIT_COMMITTER_EMAIL: "developers@frappe.io"
run: npx semantic-release

View File

@ -118,10 +118,26 @@ jobs:
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
- name: Upload coverage data
uses: actions/upload-artifact@v3
with:
name: coverage-${{ matrix.container }}
path: /home/runner/frappe-bench/sites/coverage.xml
coverage:
name: Coverage Wrap Up
needs: test
runs-on: ubuntu-latest
steps:
- name: Clone
uses: actions/checkout@v2
- name: Download artifacts
uses: actions/download-artifact@v3
- name: Upload coverage data
uses: codecov/codecov-action@v2
with:
name: MariaDB
fail_ci_if_error: true
files: /home/runner/frappe-bench/sites/coverage.xml
verbose: true

View File

@ -88,3 +88,37 @@ pull_request_rules:
- version-12-pre-release
assignees:
- "{{ author }}"
- name: Automatic merge on CI success and review
conditions:
- status-success=linters
- status-success=Sider
- status-success=Semantic Pull Request
- status-success=Patch Test
- status-success=Python Unit Tests (1)
- status-success=Python Unit Tests (2)
- status-success=Python Unit Tests (3)
- label!=dont-merge
- label!=squash
- "#approved-reviews-by>=1"
actions:
merge:
method: merge
- name: Automatic squash on CI success and review
conditions:
- status-success=linters
- status-success=Sider
- status-success=Patch Test
- status-success=Python Unit Tests (1)
- status-success=Python Unit Tests (2)
- status-success=Python Unit Tests (3)
- label!=dont-merge
- label=squash
- "#approved-reviews-by>=1"
actions:
merge:
method: squash
commit_message_template: |
{{ title }} (#{{ number }})
{{ body }}

24
.releaserc Normal file
View File

@ -0,0 +1,24 @@
{
"branches": ["version-13"],
"plugins": [
"@semantic-release/commit-analyzer", {
"preset": "angular",
"releaseRules": [
{"breaking": true, "release": false}
]
},
"@semantic-release/release-notes-generator",
[
"@semantic-release/exec", {
"prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" erpnext/__init__.py'
}
],
[
"@semantic-release/git", {
"assets": ["erpnext/__init__.py"],
"message": "chore(release): Bumped to Version ${nextRelease.version}\n\n${nextRelease.notes}"
}
],
"@semantic-release/github"
]
}

View File

@ -21,7 +21,6 @@ coverage:
comment:
layout: "diff, files"
require_changes: true
after_n_builds: 3
ignore:
- "erpnext/demo"

View File

@ -386,7 +386,6 @@ def book_deferred_income_or_expense(doc, deferred_process, posting_date=None):
doc,
credit_account,
debit_account,
against,
amount,
base_amount,
end_date,
@ -570,7 +569,6 @@ def book_revenue_via_journal_entry(
doc,
credit_account,
debit_account,
against,
amount,
base_amount,
posting_date,
@ -591,6 +589,7 @@ def book_revenue_via_journal_entry(
journal_entry.voucher_type = (
"Deferred Revenue" if doc.doctype == "Sales Invoice" else "Deferred Expense"
)
journal_entry.process_deferred_accounting = deferred_process
debit_entry = {
"account": credit_account,

View File

@ -205,10 +205,16 @@ def get_doctypes_with_dimensions():
return frappe.get_hooks("accounting_dimension_doctypes")
def get_accounting_dimensions(as_list=True):
def get_accounting_dimensions(as_list=True, filters=None):
if not filters:
filters = {"disabled": 0}
if frappe.flags.accounting_dimensions is None:
frappe.flags.accounting_dimensions = frappe.get_all(
"Accounting Dimension", fields=["label", "fieldname", "disabled", "document_type"]
"Accounting Dimension",
fields=["label", "fieldname", "disabled", "document_type"],
filters=filters,
)
if as_list:

View File

@ -11,6 +11,8 @@ from frappe.utils import nowdate
class CurrencyExchangeSettings(Document):
def validate(self):
self.set_parameters_and_result()
if frappe.flags.in_test or frappe.flags.in_install or frappe.flags.in_setup_wizard:
return
response, value = self.validate_parameters()
self.validate_result(response, value)
@ -35,9 +37,6 @@ class CurrencyExchangeSettings(Document):
self.append("req_params", {"key": "symbols", "value": "{to_currency}"})
def validate_parameters(self):
if frappe.flags.in_test:
return None, None
params = {}
for row in self.req_params:
params[row.key] = row.value.format(
@ -59,9 +58,6 @@ class CurrencyExchangeSettings(Document):
return response, value
def validate_result(self, response, value):
if frappe.flags.in_test:
return
try:
for key in self.result_key:
value = value[

View File

@ -3,6 +3,6 @@
frappe.ui.form.on('GL Entry', {
refresh: function(frm) {
frm.page.btn_secondary.hide()
}
});

View File

@ -269,6 +269,11 @@ class GLEntry(Document):
if not self.fiscal_year:
self.fiscal_year = get_fiscal_year(self.posting_date, company=self.company)[0]
def on_cancel(self):
msg = _("Individual GL Entry cannot be cancelled.")
msg += "<br>" + _("Please cancel related transaction.")
frappe.throw(msg)
def validate_balance_type(account, adv_adj=False):
if not adv_adj and account:

View File

@ -10,6 +10,7 @@
"sgst_account",
"igst_account",
"cess_account",
"utgst_account",
"is_reverse_charge_account"
],
"fields": [
@ -64,12 +65,18 @@
"fieldtype": "Check",
"in_list_view": 1,
"label": "Is Reverse Charge Account"
},
{
"fieldname": "utgst_account",
"fieldtype": "Link",
"label": "UTGST Account",
"options": "Account"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-04-09 12:30:25.889993",
"modified": "2022-04-07 12:59:14.039768",
"modified_by": "Administrator",
"module": "Accounts",
"name": "GST Account",
@ -78,5 +85,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@ -3,7 +3,7 @@
"allow_auto_repeat": 1,
"allow_import": 1,
"autoname": "naming_series:",
"creation": "2013-03-25 10:53:52",
"creation": "2022-01-25 10:29:58.717206",
"doctype": "DocType",
"document_type": "Document",
"engine": "InnoDB",
@ -13,6 +13,7 @@
"voucher_type",
"naming_series",
"finance_book",
"process_deferred_accounting",
"reversal_of",
"tax_withholding_category",
"column_break1",
@ -524,13 +525,20 @@
"label": "Reversal Of",
"options": "Journal Entry",
"read_only": 1
},
{
"fieldname": "process_deferred_accounting",
"fieldtype": "Link",
"label": "Process Deferred Accounting",
"options": "Process Deferred Accounting",
"read_only": 1
}
],
"icon": "fa fa-file-text",
"idx": 176,
"is_submittable": 1,
"links": [],
"modified": "2022-01-04 13:39:36.485954",
"modified": "2022-04-06 17:18:46.865259",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry",
@ -578,6 +586,7 @@
"search_fields": "voucher_type,posting_date, due_date, cheque_no",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1
}

View File

@ -18,7 +18,6 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category
)
from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import (
check_if_stock_and_account_balance_synced,
get_account_currency,
get_balance_on,
get_stock_accounts,
@ -88,9 +87,6 @@ class JournalEntry(AccountsController):
self.update_inter_company_jv()
self.update_invoice_discounting()
self.update_status_for_full_and_final_statement()
check_if_stock_and_account_balance_synced(
self.posting_date, self.company, self.doctype, self.name
)
def on_cancel(self):
from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries

View File

@ -224,10 +224,7 @@ frappe.ui.form.on('Payment Entry', {
(frm.doc.total_allocated_amount > party_amount)));
frm.toggle_display("set_exchange_gain_loss",
(frm.doc.paid_amount && frm.doc.received_amount && frm.doc.difference_amount &&
((frm.doc.paid_from_account_currency != company_currency ||
frm.doc.paid_to_account_currency != company_currency) &&
frm.doc.paid_from_account_currency != frm.doc.paid_to_account_currency)));
frm.doc.paid_amount && frm.doc.received_amount && frm.doc.difference_amount);
frm.refresh_fields();
},

View File

@ -350,9 +350,13 @@ class PaymentReconciliation(Document):
)
if self.minimum_invoice_amount:
condition += " and `{0}` >= {1}".format(dr_or_cr, flt(self.minimum_invoice_amount))
condition += " and {dr_or_cr} >= {amount}".format(
dr_or_cr=dr_or_cr, amount=flt(self.minimum_invoice_amount)
)
if self.maximum_invoice_amount:
condition += " and `{0}` <= {1}".format(dr_or_cr, flt(self.maximum_invoice_amount))
condition += " and {dr_or_cr} <= {amount}".format(
dr_or_cr=dr_or_cr, amount=flt(self.maximum_invoice_amount)
)
elif get_return_invoices:
condition = " and doc.company = '{0}' ".format(self.company)
@ -367,15 +371,19 @@ class PaymentReconciliation(Document):
else ""
)
dr_or_cr = (
"gl.debit_in_account_currency"
"debit_in_account_currency"
if erpnext.get_party_account_type(self.party_type) == "Receivable"
else "gl.credit_in_account_currency"
else "credit_in_account_currency"
)
if self.minimum_invoice_amount:
condition += " and `{0}` >= {1}".format(dr_or_cr, flt(self.minimum_payment_amount))
condition += " and gl.{dr_or_cr} >= {amount}".format(
dr_or_cr=dr_or_cr, amount=flt(self.minimum_payment_amount)
)
if self.maximum_invoice_amount:
condition += " and `{0}` <= {1}".format(dr_or_cr, flt(self.maximum_payment_amount))
condition += " and gl.{dr_or_cr} <= {amount}".format(
dr_or_cr=dr_or_cr, amount=flt(self.maximum_payment_amount)
)
else:
condition += (

View File

@ -35,10 +35,11 @@ class PricingRule(Document):
self.margin_rate_or_amount = 0.0
def validate_duplicate_apply_on(self):
field = apply_on_dict.get(self.apply_on)
values = [d.get(frappe.scrub(self.apply_on)) for d in self.get(field) if field]
if len(values) != len(set(values)):
frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on))
if self.apply_on != "Transaction":
field = apply_on_dict.get(self.apply_on)
values = [d.get(frappe.scrub(self.apply_on)) for d in self.get(field) if field]
if len(values) != len(set(values)):
frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on))
def validate_mandatory(self):
for apply_on, field in apply_on_dict.items():

View File

@ -11,7 +11,7 @@ from erpnext.accounts.deferred_revenue import (
convert_deferred_expense_to_expense,
convert_deferred_revenue_to_income,
)
from erpnext.accounts.general_ledger import make_reverse_gl_entries
from erpnext.accounts.general_ledger import make_gl_entries
class ProcessDeferredAccounting(Document):
@ -34,4 +34,4 @@ class ProcessDeferredAccounting(Document):
filters={"against_voucher_type": self.doctype, "against_voucher": self.name},
)
make_reverse_gl_entries(gl_entries=gl_entries)
make_gl_entries(gl_entries=gl_entries, cancel=1)

View File

@ -34,8 +34,9 @@ class ProcessStatementOfAccounts(Document):
frappe.throw(_("Customers not selected."))
if self.enable_auto_email:
self.to_date = self.start_date
self.from_date = add_months(self.to_date, -1 * self.filter_duration)
if self.start_date and getdate(self.start_date) >= getdate(today()):
self.to_date = self.start_date
self.from_date = add_months(self.to_date, -1 * self.filter_duration)
def get_report_pdf(doc, consolidated=True):

View File

@ -30,6 +30,9 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
onload() {
super.onload();
// Ignore linked advances
this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry'];
if(!this.frm.doc.__islocal) {
// show credit_to in print format
if(!this.frm.doc.supplier && this.frm.doc.credit_to) {

View File

@ -811,7 +811,9 @@ class PurchaseInvoice(BuyingController):
if provisional_accounting_for_non_stock_items:
if item.purchase_receipt:
provisional_account = self.get_company_default("default_provisional_account")
provisional_account = frappe.db.get_value(
"Purchase Receipt Item", item.pr_detail, "provisional_expense_account"
) or self.get_company_default("default_provisional_account")
purchase_receipt_doc = purchase_receipt_doc_map.get(item.purchase_receipt)
if not purchase_receipt_doc:
@ -834,7 +836,7 @@ class PurchaseInvoice(BuyingController):
if expense_booked_in_pr:
# Intentionally passing purchase invoice item to handle partial billing
purchase_receipt_doc.add_provisional_gl_entry(
item, gl_entries, self.posting_date, reverse=1
item, gl_entries, self.posting_date, provisional_account, reverse=1
)
if not self.is_internal_transfer():

View File

@ -1482,7 +1482,8 @@ class TestPurchaseInvoice(unittest.TestCase):
self.assertEqual(payment_entry.taxes[0].allocated_amount, 0)
def test_provisional_accounting_entry(self):
item = create_item("_Test Non Stock Item", is_stock_item=0)
create_item("_Test Non Stock Item", is_stock_item=0)
provisional_account = create_account(
account_name="Provision Account",
parent_account="Current Liabilities - _TC",
@ -1505,6 +1506,8 @@ class TestPurchaseInvoice(unittest.TestCase):
pi.save()
pi.submit()
self.assertEquals(pr.items[0].provisional_expense_account, "Provision Account - _TC")
# Check GLE for Purchase Invoice
expected_gle = [
["Cost of Goods Sold - _TC", 250, 0, add_days(pr.posting_date, -1)],

View File

@ -33,7 +33,9 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
var me = this;
super.onload();
this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log', 'POS Closing Entry'];
this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log',
'POS Closing Entry', 'Journal Entry', 'Payment Entry'];
if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) {
// show debit_to in print format
this.frm.set_df_property("debit_to", "print_hide", 0);

View File

@ -2240,6 +2240,14 @@ class TestSalesInvoice(unittest.TestCase):
check_gl_entries(self, si.name, expected_gle, "2019-01-30")
def test_deferred_revenue_missing_account(self):
si = create_sales_invoice(posting_date="2019-01-10", do_not_submit=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"
self.assertRaises(frappe.ValidationError, si.save)
def test_fixed_deferred_revenue(self):
deferred_account = create_account(
account_name="Deferred Revenue",
@ -3104,7 +3112,7 @@ class TestSalesInvoice(unittest.TestCase):
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.submit_journal_entries = 0
acc_settings.save()
frappe.db.set_value("Accounts Settings", None, "acc_frozen_upto", None)
@ -3116,6 +3124,62 @@ class TestSalesInvoice(unittest.TestCase):
si.reload()
self.assertTrue(si.items[0].serial_no)
def test_gain_loss_with_advance_entry(self):
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
unlink_enabled = frappe.db.get_value(
"Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice"
)
frappe.db.set_value(
"Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice", 1
)
jv = make_journal_entry("_Test Receivable USD - _TC", "_Test Bank - _TC", -7000, save=False)
jv.accounts[0].exchange_rate = 70
jv.accounts[0].credit_in_account_currency = 100
jv.accounts[0].party_type = "Customer"
jv.accounts[0].party = "_Test Customer USD"
jv.save()
jv.submit()
si = create_sales_invoice(
customer="_Test Customer USD",
debit_to="_Test Receivable USD - _TC",
currency="USD",
conversion_rate=75,
do_not_save=1,
rate=100,
)
si.append(
"advances",
{
"reference_type": "Journal Entry",
"reference_name": jv.name,
"reference_row": jv.accounts[0].name,
"advance_amount": 100,
"allocated_amount": 100,
"ref_exchange_rate": 70,
},
)
si.save()
si.submit()
expected_gle = [
["_Test Receivable USD - _TC", 7500.0, 500],
["Exchange Gain/Loss - _TC", 500.0, 0.0],
["Sales - _TC", 0.0, 7500.0],
]
check_gl_entries(self, si.name, expected_gle, nowdate())
frappe.db.set_value(
"Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice", unlink_enabled
)
def get_sales_invoice_for_e_invoice():
si = make_sales_invoice_for_ewaybill()

View File

@ -124,11 +124,10 @@ def get_columns(invoice_list, additional_table_columns):
_("Purchase Receipt") + ":Link/Purchase Receipt:100",
{"fieldname": "currency", "label": _("Currency"), "fieldtype": "Data", "width": 80},
]
expense_accounts = (
tax_accounts
) = (
expense_columns
) = tax_columns = unrealized_profit_loss_accounts = unrealized_profit_loss_account_columns = []
expense_accounts = []
tax_accounts = []
unrealized_profit_loss_accounts = []
if invoice_list:
expense_accounts = frappe.db.sql_list(
@ -163,10 +162,11 @@ def get_columns(invoice_list, additional_table_columns):
unrealized_profit_loss_account_columns = [
(account + ":Currency/currency:120") for account in unrealized_profit_loss_accounts
]
for account in tax_accounts:
if account not in expense_accounts:
tax_columns.append(account + ":Currency/currency:120")
tax_columns = [
(account + ":Currency/currency:120")
for account in tax_accounts
if account not in expense_accounts
]
columns = (
columns

View File

@ -1,10 +1,17 @@
import unittest
import frappe
from frappe.test_runner import make_test_objects
from erpnext.accounts.party import get_party_shipping_address
from erpnext.accounts.utils import get_future_stock_vouchers, get_voucherwise_gl_entries
from erpnext.accounts.utils import (
get_future_stock_vouchers,
get_voucherwise_gl_entries,
sort_stock_vouchers_by_posting_date,
)
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.stock_entry.stock_entry_utils import make_stock_entry
class TestUtils(unittest.TestCase):
@ -47,6 +54,25 @@ class TestUtils(unittest.TestCase):
msg="get_voucherwise_gl_entries not returning expected GLes",
)
def test_stock_voucher_sorting(self):
vouchers = []
item = make_item().name
stock_entry = {"item": item, "to_warehouse": "_Test Warehouse - _TC", "qty": 1, "rate": 10}
se1 = make_stock_entry(posting_date="2022-01-01", **stock_entry)
se2 = make_stock_entry(posting_date="2022-02-01", **stock_entry)
se3 = make_stock_entry(posting_date="2022-03-01", **stock_entry)
for doc in (se1, se2, se3):
vouchers.append((doc.doctype, doc.name))
vouchers.append(("Stock Entry", "Wat"))
sorted_vouchers = sort_stock_vouchers_by_posting_date(list(reversed(vouchers)))
self.assertEqual(sorted_vouchers, vouchers)
ADDRESS_RECORDS = [
{

View File

@ -3,6 +3,7 @@
from json import loads
from typing import List, Tuple
import frappe
import frappe.defaults
@ -18,10 +19,6 @@ from erpnext.stock import get_warehouse_account_map
from erpnext.stock.utils import get_stock_value_on
class StockValueAndAccountBalanceOutOfSync(frappe.ValidationError):
pass
class FiscalYearError(frappe.ValidationError):
pass
@ -1126,6 +1123,9 @@ def update_gl_entries_after(
def repost_gle_for_stock_vouchers(
stock_vouchers, posting_date, company=None, warehouse_account=None
):
if not stock_vouchers:
return
def _delete_gl_entries(voucher_type, voucher_no):
frappe.db.sql(
"""delete from `tabGL Entry`
@ -1133,6 +1133,8 @@ def repost_gle_for_stock_vouchers(
(voucher_type, voucher_no),
)
stock_vouchers = sort_stock_vouchers_by_posting_date(stock_vouchers)
if not warehouse_account:
warehouse_account = get_warehouse_account_map(company)
@ -1153,6 +1155,27 @@ def repost_gle_for_stock_vouchers(
_delete_gl_entries(voucher_type, voucher_no)
def sort_stock_vouchers_by_posting_date(
stock_vouchers: List[Tuple[str, str]]
) -> List[Tuple[str, str]]:
sle = frappe.qb.DocType("Stock Ledger Entry")
voucher_nos = [v[1] for v in stock_vouchers]
sles = (
frappe.qb.from_(sle)
.select(sle.voucher_type, sle.voucher_no, sle.posting_date, sle.posting_time, sle.creation)
.where((sle.is_cancelled == 0) & (sle.voucher_no.isin(voucher_nos)))
.groupby(sle.voucher_type, sle.voucher_no)
).run(as_dict=True)
sorted_vouchers = [(sle.voucher_type, sle.voucher_no) for sle in sles]
unknown_vouchers = set(stock_vouchers) - set(sorted_vouchers)
if unknown_vouchers:
sorted_vouchers.extend(unknown_vouchers)
return sorted_vouchers
def get_future_stock_vouchers(
posting_date, posting_time, for_warehouses=None, for_items=None, company=None
):
@ -1246,47 +1269,6 @@ def compare_existing_and_expected_gle(existing_gle, expected_gle, precision):
return matched
def check_if_stock_and_account_balance_synced(
posting_date, company, voucher_type=None, voucher_no=None
):
if not cint(erpnext.is_perpetual_inventory_enabled(company)):
return
accounts = get_stock_accounts(company, voucher_type, voucher_no)
stock_adjustment_account = frappe.db.get_value("Company", company, "stock_adjustment_account")
for account in accounts:
account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(
account, posting_date, company
)
if abs(account_bal - stock_bal) > 0.1:
precision = get_field_precision(
frappe.get_meta("GL Entry").get_field("debit"),
currency=frappe.get_cached_value("Company", company, "default_currency"),
)
diff = flt(stock_bal - account_bal, precision)
error_reason = _(
"Stock Value ({0}) and Account Balance ({1}) are out of sync for account {2} and it's linked warehouses as on {3}."
).format(stock_bal, account_bal, frappe.bold(account), posting_date)
error_resolution = _("Please create an adjustment Journal Entry for amount {0} on {1}").format(
frappe.bold(diff), frappe.bold(posting_date)
)
frappe.msgprint(
msg="""{0}<br></br>{1}<br></br>""".format(error_reason, error_resolution),
raise_exception=StockValueAndAccountBalanceOutOfSync,
title=_("Values Out Of Sync"),
primary_action={
"label": _("Make Journal Entry"),
"client_action": "erpnext.route_to_adjustment_jv",
"args": get_journal_entry(account, stock_adjustment_account, diff),
},
)
def get_stock_accounts(company, voucher_type=None, voucher_no=None):
stock_accounts = [
d.name

View File

@ -65,7 +65,6 @@ class TestRequestforQuotation(FrappeTestCase):
)
sq.submit()
frappe.form_dict = frappe.local("form_dict")
frappe.form_dict.name = rfq.name
self.assertEqual(check_supplier_has_docname_access(supplier_wt_appos[0].get("supplier")), True)

View File

@ -18,16 +18,16 @@
"tax_id",
"tax_category",
"tax_withholding_category",
"is_transporter",
"is_internal_supplier",
"represents_company",
"image",
"column_break0",
"supplier_group",
"supplier_type",
"allow_purchase_invoice_creation_without_purchase_order",
"allow_purchase_invoice_creation_without_purchase_receipt",
"is_internal_supplier",
"represents_company",
"disabled",
"is_transporter",
"warn_rfqs",
"warn_pos",
"prevent_rfqs",
@ -38,12 +38,6 @@
"default_currency",
"column_break_10",
"default_price_list",
"section_credit_limit",
"payment_terms",
"cb_21",
"on_hold",
"hold_type",
"release_date",
"address_contacts",
"address_html",
"column_break1",
@ -57,6 +51,12 @@
"primary_address",
"default_payable_accounts",
"accounts",
"section_credit_limit",
"payment_terms",
"cb_21",
"on_hold",
"hold_type",
"release_date",
"default_tax_withholding_config",
"column_break2",
"website",
@ -258,7 +258,7 @@
"collapsible": 1,
"fieldname": "section_credit_limit",
"fieldtype": "Section Break",
"label": "Credit Limit"
"label": "Payment Terms"
},
{
"fieldname": "payment_terms",
@ -432,7 +432,7 @@
"link_fieldname": "party"
}
],
"modified": "2021-10-20 22:03:33.147249",
"modified": "2022-04-16 18:02:27.838623",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier",
@ -497,6 +497,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "ASC",
"states": [],
"title_field": "supplier_name",
"track_changes": 1
}

View File

@ -180,6 +180,7 @@ class AccountsController(TransactionBase):
else:
self.validate_deferred_start_and_end_date()
self.validate_deferred_income_expense_account()
self.set_inter_company_account()
if self.doctype == "Purchase Invoice":
@ -208,6 +209,27 @@ class AccountsController(TransactionBase):
(self.doctype, self.name),
)
def validate_deferred_income_expense_account(self):
field_map = {
"Sales Invoice": "deferred_revenue_account",
"Purchase Invoice": "deferred_expense_account",
}
for item in self.get("items"):
if item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"):
if not item.get(field_map.get(self.doctype)):
default_deferred_account = frappe.db.get_value(
"Company", self.company, "default_" + field_map.get(self.doctype)
)
if not default_deferred_account:
frappe.throw(
_(
"Row #{0}: Please update deferred revenue/expense account in item row or default account in company master"
).format(item.idx)
)
else:
item.set(field_map.get(self.doctype), default_deferred_account)
def validate_deferred_start_and_end_date(self):
for d in self.items:
if d.get("enable_deferred_revenue") or d.get("enable_deferred_expense"):
@ -1975,12 +1997,13 @@ def get_advance_journal_entries(
reference_condition = " and (" + " or ".join(conditions) + ")" if conditions else ""
# nosemgrep
journal_entries = frappe.db.sql(
"""
select
"Journal Entry" as reference_type, t1.name as reference_name,
t1.remark as remarks, t2.{0} as amount, t2.name as reference_row,
t2.reference_name as against_order
t2.reference_name as against_order, t2.exchange_rate
from
`tabJournal Entry` t1, `tabJournal Entry Account` t2
where

View File

@ -22,6 +22,9 @@ class QtyMismatchError(ValidationError):
class BuyingController(StockController, Subcontracting):
def __setup__(self):
self.flags.ignore_permlevel_for_fields = ["buying_price_list", "price_list_currency"]
def get_feed(self):
if self.get("supplier_name"):
return _("From {0} | {1} {2}").format(self.supplier_name, self.currency, self.grand_total)

View File

@ -330,7 +330,6 @@ def make_return_doc(doctype, source_name, target_doc=None):
doc = frappe.get_doc(target)
doc.is_return = 1
doc.return_against = source.name
doc.ignore_pricing_rule = 1
doc.set_warehouse = ""
if doctype == "Sales Invoice" or doctype == "POS Invoice":
doc.is_pos = source.is_pos

View File

@ -16,6 +16,9 @@ from erpnext.stock.utils import get_incoming_rate
class SellingController(StockController):
def __setup__(self):
self.flags.ignore_permlevel_for_fields = ["selling_price_list", "price_list_currency"]
def get_feed(self):
return _("To {0} | {1} {2}").format(self.customer_name, self.currency, self.grand_total)

View File

@ -307,6 +307,11 @@ class calculate_taxes_and_totals(object):
self.doc.round_floats_in(self.doc, ["total", "base_total", "net_total", "base_net_total"])
def calculate_shipping_charges(self):
# Do not apply shipping rule for POS
if self.doc.get("is_pos"):
return
if hasattr(self.doc, "shipping_rule") and self.doc.shipping_rule:
shipping_rule = frappe.get_doc("Shipping Rule", self.doc.shipping_rule)
shipping_rule.apply(self.doc)

View File

@ -126,7 +126,8 @@ class Opportunity(TransactionBase):
def declare_enquiry_lost(self, lost_reasons_list, competitors, detailed_reason=None):
if not self.has_active_quotation():
self.status = "Lost"
self.lost_reasons = self.competitors = []
self.lost_reasons = []
self.competitors = []
if detailed_reason:
self.order_lost_reason = detailed_reason

View File

@ -24,17 +24,16 @@ frappe.ui.form.on("E Commerce Settings", {
);
}
frappe.model.with_doctype("Item", () => {
frappe.model.with_doctype("Website Item", () => {
const web_item_meta = frappe.get_meta('Website Item');
const valid_fields = web_item_meta.fields.filter(
df => ["Link", "Table MultiSelect"].includes(df.fieldtype) && !df.hidden
).map(df => ({ label: df.label, value: df.fieldname }));
frm.fields_dict.filter_fields.grid.update_docfield_property(
'fieldname', 'fieldtype', 'Select'
const valid_fields = web_item_meta.fields.filter(df =>
["Link", "Table MultiSelect"].includes(df.fieldtype) && !df.hidden
).map(df =>
({ label: df.label, value: df.fieldname })
);
frm.fields_dict.filter_fields.grid.update_docfield_property(
frm.get_field("filter_fields").grid.update_docfield_property(
'fieldname', 'options', valid_fields
);
});

View File

@ -27,7 +27,7 @@ class ECommerceSettings(Document):
self.is_redisearch_loaded = is_search_module_loaded()
def validate(self):
self.validate_field_filters()
self.validate_field_filters(self.filter_fields, self.enable_field_filters)
self.validate_attribute_filters()
self.validate_checkout()
self.validate_search_index_fields()
@ -51,21 +51,22 @@ class ECommerceSettings(Document):
define_autocomplete_dictionary()
create_website_items_index()
def validate_field_filters(self):
if not (self.enable_field_filters and self.filter_fields):
@staticmethod
def validate_field_filters(filter_fields, enable_field_filters):
if not (enable_field_filters and filter_fields):
return
item_meta = frappe.get_meta("Item")
web_item_meta = frappe.get_meta("Website Item")
valid_fields = [
df.fieldname for df in item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"]
df.fieldname for df in web_item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"]
]
for f in self.filter_fields:
if f.fieldname not in valid_fields:
for row in filter_fields:
if row.fieldname not in valid_fields:
frappe.throw(
_(
"Filter Fields Row #{0}: Fieldname <b>{1}</b> must be of type 'Link' or 'Table MultiSelect'"
).format(f.idx, f.fieldname)
"Filter Fields Row #{0}: Fieldname {1} must be of type 'Link' or 'Table MultiSelect'"
).format(row.idx, frappe.bold(row.fieldname))
)
def validate_attribute_filters(self):

View File

@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import unittest
@ -11,44 +10,35 @@ from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import (
class TestECommerceSettings(unittest.TestCase):
def setUp(self):
frappe.db.sql("""delete from `tabSingles` where doctype="Shipping Cart Settings" """)
def get_cart_settings(self):
return frappe.get_doc({"doctype": "E Commerce Settings", "company": "_Test Company"})
# NOTE: Exchangrate API has all enabled currencies that ERPNext supports.
# We aren't checking just currency exchange record anymore
# while validating price list currency exchange rate to that of company.
# The API is being used to fetch the rate which again almost always
# gives back a valid value (for valid currencies).
# This makes the test obsolete.
# Commenting because im not sure if there's a better test we can write
# def test_exchange_rate_exists(self):
# frappe.db.sql("""delete from `tabCurrency Exchange`""")
# cart_settings = self.get_cart_settings()
# cart_settings.price_list = "_Test Price List Rest of the World"
# self.assertRaises(ShoppingCartSetupError, cart_settings.validate_exchange_rates_exist)
# from erpnext.setup.doctype.currency_exchange.test_currency_exchange import (
# test_records as currency_exchange_records,
# )
# frappe.get_doc(currency_exchange_records[0]).insert()
# cart_settings.validate_exchange_rates_exist()
def tearDown(self):
frappe.db.rollback()
def test_tax_rule_validation(self):
frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 0")
frappe.db.commit() # nosemgrep
cart_settings = self.get_cart_settings()
cart_settings = frappe.get_doc("E Commerce Settings")
cart_settings.enabled = 1
if not frappe.db.get_value("Tax Rule", {"use_for_shopping_cart": 1}, "name"):
self.assertRaises(ShoppingCartSetupError, cart_settings.validate_tax_rule)
frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 1")
def test_invalid_filter_fields(self):
"Check if Item fields are blocked in E Commerce Settings filter fields."
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
setup_e_commerce_settings({"enable_field_filters": 1})
create_custom_field(
"Item",
dict(owner="Administrator", fieldname="test_data", label="Test", fieldtype="Data"),
)
settings = frappe.get_doc("E Commerce Settings")
settings.append("filter_fields", {"fieldname": "test_data"})
self.assertRaises(frappe.ValidationError, settings.save)
def setup_e_commerce_settings(values_dict):
"Accepts a dict of values that updates E Commerce Settings."

View File

@ -22,12 +22,14 @@ class ProductFiltersBuilder:
fields, filter_data = [], []
filter_fields = [row.fieldname for row in self.doc.filter_fields] # fields in settings
# filter valid field filters i.e. those that exist in Item
item_meta = frappe.get_meta("Item", cached=True)
fields = [item_meta.get_field(field) for field in filter_fields if item_meta.has_field(field)]
# filter valid field filters i.e. those that exist in Website Item
web_item_meta = frappe.get_meta("Website Item", cached=True)
fields = [
web_item_meta.get_field(field) for field in filter_fields if web_item_meta.has_field(field)
]
for df in fields:
item_filters, item_or_filters = {"published_in_website": 1}, []
item_filters, item_or_filters = {"published": 1}, []
link_doctype_values = self.get_filtered_link_doctype_records(df)
if df.fieldtype == "Link":
@ -50,9 +52,13 @@ class ProductFiltersBuilder:
]
)
# exclude variants if mentioned in settings
if frappe.db.get_single_value("E Commerce Settings", "hide_variants"):
item_filters["variant_of"] = ["is", "not set"]
# Get link field values attached to published items
item_values = frappe.get_all(
"Item",
"Website Item",
fields=[df.fieldname],
filters=item_filters,
or_filters=item_or_filters,

View File

@ -277,6 +277,54 @@ class TestProductDataEngine(unittest.TestCase):
# tear down
setup_e_commerce_settings({"enable_attribute_filters": 1, "hide_variants": 0})
def test_custom_field_as_filter(self):
"Test if custom field functions as filter correctly."
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
create_custom_field(
"Website Item",
dict(
owner="Administrator",
fieldname="supplier",
label="Supplier",
fieldtype="Link",
options="Supplier",
insert_after="on_backorder",
),
)
frappe.db.set_value(
"Website Item", {"item_code": "Test 11I Laptop"}, "supplier", "_Test Supplier"
)
frappe.db.set_value(
"Website Item", {"item_code": "Test 12I Laptop"}, "supplier", "_Test Supplier 1"
)
settings = frappe.get_doc("E Commerce Settings")
settings.append("filter_fields", {"fieldname": "supplier"})
settings.save()
filter_engine = ProductFiltersBuilder()
field_filters = filter_engine.get_field_filters()
custom_filter = field_filters[1]
filter_values = custom_filter[1]
self.assertEqual(custom_filter[0].options, "Supplier")
self.assertEqual(len(filter_values), 2)
self.assertIn("_Test Supplier", filter_values)
# test if custom filter works in query
field_filters = {"supplier": "_Test Supplier 1"}
engine = ProductQuery()
result = engine.query(
attributes={}, fields=field_filters, search_term=None, start=0, item_group=None
)
items = result.get("items")
# check if only 'Raw Material' are fetched in the right order
self.assertEqual(len(items), 1)
self.assertEqual(items[0].get("item_code"), "Test 12I Laptop")
def create_variant_web_item():
"Create Variant and Template Website Items."

View File

@ -37,11 +37,26 @@ def handle_end_call(**kwargs):
@frappe.whitelist(allow_guest=True)
def handle_missed_call(**kwargs):
update_call_log(kwargs, "Missed")
status = ""
call_type = kwargs.get("CallType")
dial_call_status = kwargs.get("DialCallStatus")
if call_type == "incomplete" and dial_call_status == "no-answer":
status = "No Answer"
elif call_type == "client-hangup" and dial_call_status == "canceled":
status = "Canceled"
elif call_type == "incomplete" and dial_call_status == "failed":
status = "Failed"
update_call_log(kwargs, status)
def update_call_log(call_payload, status="Ringing", call_log=None):
call_log = call_log or get_call_log(call_payload)
# for a new sid, call_log and get_call_log will be empty so create a new log
if not call_log:
call_log = create_call_log(call_payload)
if call_log:
call_log.status = status
call_log.to = call_payload.get("DialWhomNumber")
@ -53,16 +68,9 @@ def update_call_log(call_payload, status="Ringing", call_log=None):
def get_call_log(call_payload):
call_log = frappe.get_all(
"Call Log",
{
"id": call_payload.get("CallSid"),
},
limit=1,
)
if call_log:
return frappe.get_doc("Call Log", call_log[0].name)
call_log_id = call_payload.get("CallSid")
if frappe.db.exists("Call Log", call_log_id):
return frappe.get_doc("Call Log", call_log_id)
def create_call_log(call_payload):

View File

@ -59,6 +59,7 @@ treeviews = [
"Warehouse",
"Item Group",
"Customer Group",
"Supplier Group",
"Sales Person",
"Territory",
"Assessment Group",

View File

@ -5,11 +5,21 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import cint, cstr, formatdate, get_datetime, getdate, nowdate
from frappe.query_builder import Criterion
from frappe.utils import cint, cstr, formatdate, get_datetime, get_link_to_form, getdate, nowdate
from erpnext.hr.doctype.shift_assignment.shift_assignment import has_overlapping_timings
from erpnext.hr.utils import get_holiday_dates_for_employee, validate_active_employee
class DuplicateAttendanceError(frappe.ValidationError):
pass
class OverlappingShiftAttendanceError(frappe.ValidationError):
pass
class Attendance(Document):
def validate(self):
from erpnext.controllers.status_updater import validate_status
@ -18,6 +28,7 @@ class Attendance(Document):
validate_active_employee(self.employee)
self.validate_attendance_date()
self.validate_duplicate_record()
self.validate_overlapping_shift_attendance()
self.validate_employee_status()
self.check_leave_record()
@ -35,21 +46,35 @@ class Attendance(Document):
frappe.throw(_("Attendance date can not be less than employee's joining date"))
def validate_duplicate_record(self):
res = frappe.db.sql(
"""
select name from `tabAttendance`
where employee = %s
and attendance_date = %s
and name != %s
and docstatus != 2
""",
(self.employee, getdate(self.attendance_date), self.name),
duplicate = get_duplicate_attendance_record(
self.employee, self.attendance_date, self.shift, self.name
)
if res:
if duplicate:
frappe.throw(
_("Attendance for employee {0} is already marked for the date {1}").format(
frappe.bold(self.employee), frappe.bold(self.attendance_date)
)
_("Attendance for employee {0} is already marked for the date {1}: {2}").format(
frappe.bold(self.employee),
frappe.bold(self.attendance_date),
get_link_to_form("Attendance", duplicate[0].name),
),
title=_("Duplicate Attendance"),
exc=DuplicateAttendanceError,
)
def validate_overlapping_shift_attendance(self):
attendance = get_overlapping_shift_attendance(
self.employee, self.attendance_date, self.shift, self.name
)
if attendance:
frappe.throw(
_("Attendance for employee {0} is already marked for an overlapping shift {1}: {2}").format(
frappe.bold(self.employee),
frappe.bold(attendance.shift),
get_link_to_form("Attendance", attendance.name),
),
title=_("Overlapping Shift Attendance"),
exc=OverlappingShiftAttendanceError,
)
def validate_employee_status(self):
@ -103,6 +128,69 @@ class Attendance(Document):
frappe.throw(_("Employee {0} is not active or does not exist").format(self.employee))
def get_duplicate_attendance_record(employee, attendance_date, shift, name=None):
attendance = frappe.qb.DocType("Attendance")
query = (
frappe.qb.from_(attendance)
.select(attendance.name)
.where((attendance.employee == employee) & (attendance.docstatus < 2))
)
if shift:
query = query.where(
Criterion.any(
[
Criterion.all(
[
((attendance.shift.isnull()) | (attendance.shift == "")),
(attendance.attendance_date == attendance_date),
]
),
Criterion.all(
[
((attendance.shift.isnotnull()) | (attendance.shift != "")),
(attendance.attendance_date == attendance_date),
(attendance.shift == shift),
]
),
]
)
)
else:
query = query.where((attendance.attendance_date == attendance_date))
if name:
query = query.where(attendance.name != name)
return query.run(as_dict=True)
def get_overlapping_shift_attendance(employee, attendance_date, shift, name=None):
if not shift:
return {}
attendance = frappe.qb.DocType("Attendance")
query = (
frappe.qb.from_(attendance)
.select(attendance.name, attendance.shift)
.where(
(attendance.employee == employee)
& (attendance.docstatus < 2)
& (attendance.attendance_date == attendance_date)
& (attendance.shift != shift)
)
)
if name:
query = query.where(attendance.name != name)
overlapping_attendance = query.run(as_dict=True)
if overlapping_attendance and has_overlapping_timings(shift, overlapping_attendance[0].shift):
return overlapping_attendance[0]
return {}
@frappe.whitelist()
def get_events(start, end, filters=None):
events = []
@ -141,28 +229,39 @@ def add_attendance(events, start, end, conditions=None):
def mark_attendance(
employee, attendance_date, status, shift=None, leave_type=None, ignore_validate=False
employee,
attendance_date,
status,
shift=None,
leave_type=None,
ignore_validate=False,
late_entry=False,
early_exit=False,
):
if not frappe.db.exists(
"Attendance",
{"employee": employee, "attendance_date": attendance_date, "docstatus": ("!=", "2")},
):
company = frappe.db.get_value("Employee", employee, "company")
attendance = frappe.get_doc(
{
"doctype": "Attendance",
"employee": employee,
"attendance_date": attendance_date,
"status": status,
"company": company,
"shift": shift,
"leave_type": leave_type,
}
)
attendance.flags.ignore_validate = ignore_validate
attendance.insert()
attendance.submit()
return attendance.name
if get_duplicate_attendance_record(employee, attendance_date, shift):
return
if get_overlapping_shift_attendance(employee, attendance_date, shift):
return
company = frappe.db.get_value("Employee", employee, "company")
attendance = frappe.get_doc(
{
"doctype": "Attendance",
"employee": employee,
"attendance_date": attendance_date,
"status": status,
"company": company,
"shift": shift,
"leave_type": leave_type,
"late_entry": late_entry,
"early_exit": early_exit,
}
)
attendance.flags.ignore_validate = ignore_validate
attendance.insert()
attendance.submit()
return attendance.name
@frappe.whitelist()

View File

@ -6,6 +6,8 @@ from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, get_year_ending, get_year_start, getdate, now_datetime, nowdate
from erpnext.hr.doctype.attendance.attendance import (
DuplicateAttendanceError,
OverlappingShiftAttendanceError,
get_month_map,
get_unmarked_days,
mark_attendance,
@ -23,11 +25,112 @@ class TestAttendance(FrappeTestCase):
from_date = get_year_start(getdate())
to_date = get_year_ending(getdate())
self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date)
frappe.db.delete("Attendance")
def test_duplicate_attendance(self):
employee = make_employee("test_duplicate_attendance@example.com", company="_Test Company")
date = nowdate()
mark_attendance(employee, date, "Present")
attendance = frappe.get_doc(
{
"doctype": "Attendance",
"employee": employee,
"attendance_date": date,
"status": "Absent",
"company": "_Test Company",
}
)
self.assertRaises(DuplicateAttendanceError, attendance.insert)
def test_duplicate_attendance_with_shift(self):
from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type
employee = make_employee("test_duplicate_attendance@example.com", company="_Test Company")
date = nowdate()
shift_1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="10:00:00")
mark_attendance(employee, date, "Present", shift=shift_1.name)
# attendance record with shift
attendance = frappe.get_doc(
{
"doctype": "Attendance",
"employee": employee,
"attendance_date": date,
"status": "Absent",
"company": "_Test Company",
"shift": shift_1.name,
}
)
self.assertRaises(DuplicateAttendanceError, attendance.insert)
# attendance record without any shift
attendance = frappe.get_doc(
{
"doctype": "Attendance",
"employee": employee,
"attendance_date": date,
"status": "Absent",
"company": "_Test Company",
}
)
self.assertRaises(DuplicateAttendanceError, attendance.insert)
def test_overlapping_shift_attendance_validation(self):
from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type
employee = make_employee("test_overlap_attendance@example.com", company="_Test Company")
date = nowdate()
shift_1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="10:00:00")
shift_2 = setup_shift_type(shift_type="Shift 2", start_time="09:30:00", end_time="11:00:00")
mark_attendance(employee, date, "Present", shift=shift_1.name)
# attendance record with overlapping shift
attendance = frappe.get_doc(
{
"doctype": "Attendance",
"employee": employee,
"attendance_date": date,
"status": "Absent",
"company": "_Test Company",
"shift": shift_2.name,
}
)
self.assertRaises(OverlappingShiftAttendanceError, attendance.insert)
def test_allow_attendance_with_different_shifts(self):
# allows attendance with 2 different non-overlapping shifts
from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type
employee = make_employee("test_duplicate_attendance@example.com", company="_Test Company")
date = nowdate()
shift_1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="10:00:00")
shift_2 = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="12:00:00")
mark_attendance(employee, date, "Present", shift_1.name)
frappe.get_doc(
{
"doctype": "Attendance",
"employee": employee,
"attendance_date": date,
"status": "Absent",
"company": "_Test Company",
"shift": shift_2.name,
}
).insert()
def test_mark_absent(self):
employee = make_employee("test_mark_absent@example.com")
date = nowdate()
frappe.db.delete("Attendance", {"employee": employee, "attendance_date": date})
attendance = mark_attendance(employee, date, "Absent")
fetch_attendance = frappe.get_value(
"Attendance", {"employee": employee, "attendance_date": date, "status": "Absent"}
@ -42,7 +145,6 @@ class TestAttendance(FrappeTestCase):
employee = make_employee(
"test_unmarked_days@example.com", date_of_joining=add_days(first_day, -1)
)
frappe.db.delete("Attendance", {"employee": employee})
frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list)
first_sunday = get_first_sunday(self.holiday_list, for_date=first_day)
@ -67,8 +169,6 @@ class TestAttendance(FrappeTestCase):
employee = make_employee(
"test_unmarked_days@example.com", date_of_joining=add_days(first_day, -1)
)
frappe.db.delete("Attendance", {"employee": employee})
frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list)
first_sunday = get_first_sunday(self.holiday_list, for_date=first_day)
@ -95,7 +195,6 @@ class TestAttendance(FrappeTestCase):
employee = make_employee(
"test_unmarked_days_as_per_doj@example.com", date_of_joining=doj, relieving_date=relieving_date
)
frappe.db.delete("Attendance", {"employee": employee})
frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list)

View File

@ -4,7 +4,7 @@
"allow_import": 1,
"allow_rename": 1,
"autoname": "naming_series:",
"creation": "2013-03-07 09:04:18",
"creation": "2022-02-21 11:54:09.632218",
"doctype": "DocType",
"document_type": "Setup",
"editable_grid": 1,
@ -872,6 +872,7 @@
],
"search_fields": "employee_name",
"show_name_in_global_search": 1,
"show_title_field_in_link": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],

View File

@ -7,6 +7,10 @@ from frappe import _
from frappe.model.document import Document
from frappe.utils import cint, get_datetime
from erpnext.hr.doctype.attendance.attendance import (
get_duplicate_attendance_record,
get_overlapping_shift_attendance,
)
from erpnext.hr.doctype.shift_assignment.shift_assignment import (
get_actual_start_end_datetime_of_shift,
)
@ -33,24 +37,24 @@ class EmployeeCheckin(Document):
shift_actual_timings = get_actual_start_end_datetime_of_shift(
self.employee, get_datetime(self.time), True
)
if shift_actual_timings[0] and shift_actual_timings[1]:
if shift_actual_timings:
if (
shift_actual_timings[2].shift_type.determine_check_in_and_check_out
shift_actual_timings.shift_type.determine_check_in_and_check_out
== "Strictly based on Log Type in Employee Checkin"
and not self.log_type
and not self.skip_auto_attendance
):
frappe.throw(
_("Log Type is required for check-ins falling in the shift: {0}.").format(
shift_actual_timings[2].shift_type.name
shift_actual_timings.shift_type.name
)
)
if not self.attendance:
self.shift = shift_actual_timings[2].shift_type.name
self.shift_actual_start = shift_actual_timings[0]
self.shift_actual_end = shift_actual_timings[1]
self.shift_start = shift_actual_timings[2].start_datetime
self.shift_end = shift_actual_timings[2].end_datetime
self.shift = shift_actual_timings.shift_type.name
self.shift_actual_start = shift_actual_timings.actual_start
self.shift_actual_end = shift_actual_timings.actual_end
self.shift_start = shift_actual_timings.start_datetime
self.shift_end = shift_actual_timings.end_datetime
else:
self.shift = None
@ -136,10 +140,10 @@ def mark_attendance_and_link_log(
return None
elif attendance_status in ("Present", "Absent", "Half Day"):
employee_doc = frappe.get_doc("Employee", employee)
if not frappe.db.exists(
"Attendance",
{"employee": employee, "attendance_date": attendance_date, "docstatus": ("!=", "2")},
):
duplicate = get_duplicate_attendance_record(employee, attendance_date, shift)
overlapping = get_overlapping_shift_attendance(employee, attendance_date, shift)
if not duplicate and not overlapping:
doc_dict = {
"doctype": "Attendance",
"employee": employee,
@ -232,7 +236,7 @@ def calculate_working_hours(logs, check_in_out_type, working_hours_calc_type):
def time_diff_in_hours(start, end):
return round((end - start).total_seconds() / 3600, 1)
return round(float((end - start).total_seconds()) / 3600, 2)
def find_index_in_dict(dict_list, key, value):

View File

@ -2,10 +2,19 @@
# See license.txt
import unittest
from datetime import timedelta
from datetime import datetime, timedelta
import frappe
from frappe.utils import now_datetime, nowdate
from frappe.tests.utils import FrappeTestCase
from frappe.utils import (
add_days,
get_time,
get_year_ending,
get_year_start,
getdate,
now_datetime,
nowdate,
)
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.employee_checkin.employee_checkin import (
@ -13,9 +22,22 @@ from erpnext.hr.doctype.employee_checkin.employee_checkin import (
calculate_working_hours,
mark_attendance_and_link_log,
)
from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
from erpnext.hr.doctype.leave_application.test_leave_application import get_first_sunday
from erpnext.hr.doctype.shift_type.test_shift_type import make_shift_assignment, setup_shift_type
from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
class TestEmployeeCheckin(unittest.TestCase):
class TestEmployeeCheckin(FrappeTestCase):
def setUp(self):
frappe.db.delete("Shift Type")
frappe.db.delete("Shift Assignment")
frappe.db.delete("Employee Checkin")
from_date = get_year_start(getdate())
to_date = get_year_ending(getdate())
self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date)
def test_add_log_based_on_employee_field(self):
employee = make_employee("test_add_log_based_on_employee_field@example.com")
employee = frappe.get_doc("Employee", employee)
@ -103,6 +125,188 @@ class TestEmployeeCheckin(unittest.TestCase):
)
self.assertEqual(working_hours, (4.5, logs_type_2[1].time, logs_type_2[-1].time))
def test_fetch_shift(self):
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
# shift setup for 8-12
shift_type = setup_shift_type()
date = getdate()
make_shift_assignment(shift_type.name, employee, date)
# within shift time
timestamp = datetime.combine(date, get_time("08:45:00"))
log = make_checkin(employee, timestamp)
self.assertEqual(log.shift, shift_type.name)
# "begin checkin before shift time" = 60 mins, so should work for 7:00:00
timestamp = datetime.combine(date, get_time("07:00:00"))
log = make_checkin(employee, timestamp)
self.assertEqual(log.shift, shift_type.name)
# "allow checkout after shift end time" = 60 mins, so should work for 13:00:00
timestamp = datetime.combine(date, get_time("13:00:00"))
log = make_checkin(employee, timestamp)
self.assertEqual(log.shift, shift_type.name)
# should not fetch this shift beyond allowed time
timestamp = datetime.combine(date, get_time("13:01:00"))
log = make_checkin(employee, timestamp)
self.assertIsNone(log.shift)
def test_fetch_shift_for_assignment_with_end_date(self):
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
# shift setup for 8-12
shift1 = setup_shift_type()
# 12:30 - 16:30
shift2 = setup_shift_type(shift_type="Shift 2", start_time="12:30:00", end_time="16:30:00")
date = getdate()
make_shift_assignment(shift1.name, employee, date, add_days(date, 15))
make_shift_assignment(shift2.name, employee, date, add_days(date, 15))
timestamp = datetime.combine(date, get_time("08:45:00"))
log = make_checkin(employee, timestamp)
self.assertEqual(log.shift, shift1.name)
timestamp = datetime.combine(date, get_time("12:45:00"))
log = make_checkin(employee, timestamp)
self.assertEqual(log.shift, shift2.name)
# log after end date
timestamp = datetime.combine(add_days(date, 16), get_time("12:45:00"))
log = make_checkin(employee, timestamp)
self.assertIsNone(log.shift)
def test_shift_start_and_end_timings(self):
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
# shift setup for 8-12
shift_type = setup_shift_type()
date = getdate()
make_shift_assignment(shift_type.name, employee, date)
timestamp = datetime.combine(date, get_time("08:45:00"))
log = make_checkin(employee, timestamp)
self.assertEqual(log.shift, shift_type.name)
self.assertEqual(log.shift_start, datetime.combine(date, get_time("08:00:00")))
self.assertEqual(log.shift_end, datetime.combine(date, get_time("12:00:00")))
self.assertEqual(log.shift_actual_start, datetime.combine(date, get_time("07:00:00")))
self.assertEqual(log.shift_actual_end, datetime.combine(date, get_time("13:00:00")))
def test_fetch_shift_based_on_default_shift(self):
employee = make_employee("test_default_shift@example.com", company="_Test Company")
default_shift = setup_shift_type(
shift_type="Default Shift", start_time="14:00:00", end_time="16:00:00"
)
date = getdate()
frappe.db.set_value("Employee", employee, "default_shift", default_shift.name)
timestamp = datetime.combine(date, get_time("14:45:00"))
log = make_checkin(employee, timestamp)
# should consider default shift
self.assertEqual(log.shift, default_shift.name)
def test_fetch_shift_spanning_over_two_days(self):
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
shift_type = setup_shift_type(
shift_type="Midnight Shift", start_time="23:00:00", end_time="01:00:00"
)
date = getdate()
next_day = add_days(date, 1)
make_shift_assignment(shift_type.name, employee, date)
# log falls in the first day
timestamp = datetime.combine(date, get_time("23:00:00"))
log = make_checkin(employee, timestamp)
self.assertEqual(log.shift, shift_type.name)
self.assertEqual(log.shift_start, datetime.combine(date, get_time("23:00:00")))
self.assertEqual(log.shift_end, datetime.combine(next_day, get_time("01:00:00")))
self.assertEqual(log.shift_actual_start, datetime.combine(date, get_time("22:00:00")))
self.assertEqual(log.shift_actual_end, datetime.combine(next_day, get_time("02:00:00")))
log.delete()
# log falls in the second day
prev_day = add_days(date, -1)
timestamp = datetime.combine(date, get_time("01:30:00"))
log = make_checkin(employee, timestamp)
self.assertEqual(log.shift, shift_type.name)
self.assertEqual(log.shift_start, datetime.combine(prev_day, get_time("23:00:00")))
self.assertEqual(log.shift_end, datetime.combine(date, get_time("01:00:00")))
self.assertEqual(log.shift_actual_start, datetime.combine(prev_day, get_time("22:00:00")))
self.assertEqual(log.shift_actual_end, datetime.combine(date, get_time("02:00:00")))
def test_no_shift_fetched_on_holiday_as_per_shift_holiday_list(self):
date = getdate()
from_date = get_year_start(date)
to_date = get_year_ending(date)
holiday_list = make_holiday_list(from_date=from_date, to_date=to_date)
employee = make_employee("test_shift_with_holiday@example.com", company="_Test Company")
setup_shift_type(shift_type="Test Holiday Shift", holiday_list=holiday_list)
first_sunday = get_first_sunday(holiday_list, for_date=date)
timestamp = datetime.combine(first_sunday, get_time("08:00:00"))
log = make_checkin(employee, timestamp)
self.assertIsNone(log.shift)
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
def test_no_shift_fetched_on_holiday_as_per_employee_holiday_list(self):
employee = make_employee("test_shift_with_holiday@example.com", company="_Test Company")
shift_type = setup_shift_type(shift_type="Test Holiday Shift")
shift_type.holiday_list = None
shift_type.save()
date = getdate()
first_sunday = get_first_sunday(self.holiday_list, for_date=date)
timestamp = datetime.combine(first_sunday, get_time("08:00:00"))
log = make_checkin(employee, timestamp)
self.assertIsNone(log.shift)
def test_consecutive_shift_assignments_overlapping_within_grace_period(self):
# test adjustment for start and end times if they are overlapping
# within "begin_check_in_before_shift_start_time" and "allow_check_out_after_shift_end_time" periods
employee = make_employee("test_shift@example.com", company="_Test Company")
# 8 - 12
shift1 = setup_shift_type()
# 12:30 - 16:30
shift2 = setup_shift_type(
shift_type="Consecutive Shift", start_time="12:30:00", end_time="16:30:00"
)
# the actual start and end times (with grace) for these shifts are 7 - 13 and 11:30 - 17:30
date = getdate()
make_shift_assignment(shift1.name, employee, date)
make_shift_assignment(shift2.name, employee, date)
# log at 12:30 should set shift2 and actual start as 12 and not 11:30
timestamp = datetime.combine(date, get_time("12:30:00"))
log = make_checkin(employee, timestamp)
self.assertEqual(log.shift, shift2.name)
self.assertEqual(log.shift_start, datetime.combine(date, get_time("12:30:00")))
self.assertEqual(log.shift_actual_start, datetime.combine(date, get_time("12:00:00")))
# log at 12:00 should set shift1 and actual end as 12 and not 1 since the next shift's grace starts
timestamp = datetime.combine(date, get_time("12:00:00"))
log = make_checkin(employee, timestamp)
self.assertEqual(log.shift, shift1.name)
self.assertEqual(log.shift_end, datetime.combine(date, get_time("12:00:00")))
self.assertEqual(log.shift_actual_end, datetime.combine(date, get_time("12:00:00")))
# log at 12:01 should set shift2
timestamp = datetime.combine(date, get_time("12:01:00"))
log = make_checkin(employee, timestamp)
self.assertEqual(log.shift, shift2.name)
def make_n_checkins(employee, n, hours_to_reverse=1):
logs = [make_checkin(employee, now_datetime() - timedelta(hours=hours_to_reverse, minutes=n + 1))]

View File

@ -34,6 +34,15 @@ frappe.ui.form.on("Leave Allocation", {
});
}
}
// make new leaves allocated field read only if allocation is created via leave policy assignment
// and leave type is earned leave, since these leaves would be allocated via the scheduler
if (frm.doc.leave_policy_assignment) {
frappe.db.get_value("Leave Type", frm.doc.leave_type, "is_earned_leave", (r) => {
if (r && cint(r.is_earned_leave))
frm.set_df_property("new_leaves_allocated", "read_only", 1);
});
}
},
expire_allocation: function(frm) {

View File

@ -237,7 +237,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2022-01-18 19:15:53.262536",
"modified": "2022-04-07 09:50:33.145825",
"modified_by": "Administrator",
"module": "HR",
"name": "Leave Allocation",
@ -281,5 +281,6 @@
"sort_order": "DESC",
"states": [],
"timeline_field": "employee",
"title_field": "employee_name"
"title_field": "employee_name",
"track_changes": 1
}

View File

@ -3,88 +3,125 @@
from datetime import datetime, timedelta
from typing import Dict, List
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import cstr, getdate, now_datetime, nowdate
from frappe.query_builder import Criterion
from frappe.utils import cstr, get_datetime, get_link_to_form, get_time, getdate, now_datetime
from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee
from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
from erpnext.hr.utils import validate_active_employee
class OverlappingShiftError(frappe.ValidationError):
pass
class ShiftAssignment(Document):
def validate(self):
validate_active_employee(self.employee)
self.validate_overlapping_dates()
self.validate_overlapping_shifts()
if self.end_date:
self.validate_from_to_dates("start_date", "end_date")
def validate_overlapping_dates(self):
def validate_overlapping_shifts(self):
overlapping_dates = self.get_overlapping_dates()
if len(overlapping_dates):
# if dates are overlapping, check if timings are overlapping, else allow
overlapping_timings = has_overlapping_timings(self.shift_type, overlapping_dates[0].shift_type)
if overlapping_timings:
self.throw_overlap_error(overlapping_dates[0])
def get_overlapping_dates(self):
if not self.name:
self.name = "New Shift Assignment"
condition = """and (
end_date is null
or
%(start_date)s between start_date and end_date
"""
if self.end_date:
condition += """ or
%(end_date)s between start_date and end_date
or
start_date between %(start_date)s and %(end_date)s
) """
else:
condition += """ ) """
assigned_shifts = frappe.db.sql(
"""
select name, shift_type, start_date ,end_date, docstatus, status
from `tabShift Assignment`
where
employee=%(employee)s and docstatus = 1
and name != %(name)s
and status = "Active"
{0}
""".format(
condition
),
{
"employee": self.employee,
"shift_type": self.shift_type,
"start_date": self.start_date,
"end_date": self.end_date,
"name": self.name,
},
as_dict=1,
shift = frappe.qb.DocType("Shift Assignment")
query = (
frappe.qb.from_(shift)
.select(shift.name, shift.shift_type, shift.docstatus, shift.status)
.where(
(shift.employee == self.employee)
& (shift.docstatus == 1)
& (shift.name != self.name)
& (shift.status == "Active")
)
)
if len(assigned_shifts):
self.throw_overlap_error(assigned_shifts[0])
if self.end_date:
query = query.where(
Criterion.any(
[
Criterion.any(
[
shift.end_date.isnull(),
((self.start_date >= shift.start_date) & (self.start_date <= shift.end_date)),
]
),
Criterion.any(
[
((self.end_date >= shift.start_date) & (self.end_date <= shift.end_date)),
shift.start_date.between(self.start_date, self.end_date),
]
),
]
)
)
else:
query = query.where(
shift.end_date.isnull()
| ((self.start_date >= shift.start_date) & (self.start_date <= shift.end_date))
)
return query.run(as_dict=True)
def throw_overlap_error(self, shift_details):
shift_details = frappe._dict(shift_details)
if shift_details.docstatus == 1 and shift_details.status == "Active":
msg = _("Employee {0} already has Active Shift {1}: {2}").format(
frappe.bold(self.employee), frappe.bold(self.shift_type), frappe.bold(shift_details.name)
msg = _(
"Employee {0} already has an active Shift {1}: {2} that overlaps within this period."
).format(
frappe.bold(self.employee),
frappe.bold(shift_details.shift_type),
get_link_to_form("Shift Assignment", shift_details.name),
)
if shift_details.start_date:
msg += " " + _("from {0}").format(getdate(self.start_date).strftime("%d-%m-%Y"))
title = "Ongoing Shift"
if shift_details.end_date:
msg += " " + _("to {0}").format(getdate(self.end_date).strftime("%d-%m-%Y"))
title = "Active Shift"
if msg:
frappe.throw(msg, title=title)
frappe.throw(msg, title=_("Overlapping Shifts"), exc=OverlappingShiftError)
def has_overlapping_timings(shift_1: str, shift_2: str) -> bool:
"""
Accepts two shift types and checks whether their timings are overlapping
"""
curr_shift = frappe.db.get_value("Shift Type", shift_1, ["start_time", "end_time"], as_dict=True)
overlapping_shift = frappe.db.get_value(
"Shift Type", shift_2, ["start_time", "end_time"], as_dict=True
)
if (
(
curr_shift.start_time > overlapping_shift.start_time
and curr_shift.start_time < overlapping_shift.end_time
)
or (
curr_shift.end_time > overlapping_shift.start_time
and curr_shift.end_time < overlapping_shift.end_time
)
or (
curr_shift.start_time <= overlapping_shift.start_time
and curr_shift.end_time >= overlapping_shift.end_time
)
):
return True
return False
@frappe.whitelist()
def get_events(start, end, filters=None):
events = []
from frappe.desk.calendar import get_event_conditions
employee = frappe.db.get_value(
"Employee", {"user_id": frappe.session.user}, ["name", "company"], as_dict=True
@ -95,20 +132,22 @@ def get_events(start, end, filters=None):
employee = ""
company = frappe.db.get_value("Global Defaults", None, "default_company")
from frappe.desk.reportview import get_filters_cond
conditions = get_filters_cond("Shift Assignment", filters, [])
add_assignments(events, start, end, conditions=conditions)
conditions = get_event_conditions("Shift Assignment", filters)
events = add_assignments(start, end, conditions=conditions)
return events
def add_assignments(events, start, end, conditions=None):
def add_assignments(start, end, conditions=None):
events = []
query = """select name, start_date, end_date, employee_name,
employee, docstatus, shift_type
from `tabShift Assignment` where
start_date >= %(start_date)s
or end_date <= %(end_date)s
or (%(start_date)s between start_date and end_date and %(end_date)s between start_date and end_date)
(
start_date >= %(start_date)s
or end_date <= %(end_date)s
or (%(start_date)s between start_date and end_date and %(end_date)s between start_date and end_date)
)
and docstatus = 1"""
if conditions:
query += conditions
@ -155,102 +194,195 @@ def get_shift_type_timing(shift_types):
return shift_timing_map
def get_shift_for_time(shifts: List[Dict], for_timestamp: datetime) -> Dict:
"""Returns shift with details for given timestamp"""
valid_shifts = []
for entry in shifts:
shift_details = get_shift_details(entry.shift_type, for_timestamp=for_timestamp)
if (
get_datetime(shift_details.actual_start)
<= get_datetime(for_timestamp)
<= get_datetime(shift_details.actual_end)
):
valid_shifts.append(shift_details)
valid_shifts.sort(key=lambda x: x["actual_start"])
if len(valid_shifts) > 1:
for i in range(len(valid_shifts) - 1):
# comparing 2 consecutive shifts and adjusting start and end times
# if they are overlapping within grace period
curr_shift = valid_shifts[i]
next_shift = valid_shifts[i + 1]
if curr_shift and next_shift:
next_shift.actual_start = (
curr_shift.end_datetime
if next_shift.actual_start < curr_shift.end_datetime
else next_shift.actual_start
)
curr_shift.actual_end = (
next_shift.actual_start
if curr_shift.actual_end > next_shift.actual_start
else curr_shift.actual_end
)
valid_shifts[i] = curr_shift
valid_shifts[i + 1] = next_shift
return get_exact_shift(valid_shifts, for_timestamp) or {}
return (valid_shifts and valid_shifts[0]) or {}
def get_shifts_for_date(employee: str, for_timestamp: datetime) -> List[Dict[str, str]]:
"""Returns list of shifts with details for given date"""
assignment = frappe.qb.DocType("Shift Assignment")
return (
frappe.qb.from_(assignment)
.select(assignment.name, assignment.shift_type)
.where(
(assignment.employee == employee)
& (assignment.docstatus == 1)
& (assignment.status == "Active")
& (assignment.start_date <= getdate(for_timestamp.date()))
& (
Criterion.any(
[
assignment.end_date.isnull(),
(assignment.end_date.isnotnull() & (getdate(for_timestamp.date()) <= assignment.end_date)),
]
)
)
)
).run(as_dict=True)
def get_shift_for_timestamp(employee: str, for_timestamp: datetime) -> Dict:
shifts = get_shifts_for_date(employee, for_timestamp)
if shifts:
return get_shift_for_time(shifts, for_timestamp)
return {}
def get_employee_shift(
employee, for_date=None, consider_default_shift=False, next_shift_direction=None
):
employee: str,
for_timestamp: datetime = None,
consider_default_shift: bool = False,
next_shift_direction: str = None,
) -> Dict:
"""Returns a Shift Type for the given employee on the given date. (excluding the holidays)
:param employee: Employee for which shift is required.
:param for_date: Date on which shift are required
:param for_timestamp: DateTime on which shift is required
:param consider_default_shift: If set to true, default shift is taken when no shift assignment is found.
:param next_shift_direction: One of: None, 'forward', 'reverse'. Direction to look for next shift if shift not found on given date.
"""
if for_date is None:
for_date = nowdate()
if for_timestamp is None:
for_timestamp = now_datetime()
shift_details = get_shift_for_timestamp(employee, for_timestamp)
# if shift assignment is not found, consider default shift
default_shift = frappe.db.get_value("Employee", employee, "default_shift")
shift_type_name = None
shift_assignment_details = frappe.db.get_value(
"Shift Assignment",
{"employee": employee, "start_date": ("<=", for_date), "docstatus": "1", "status": "Active"},
["shift_type", "end_date"],
if not shift_details and consider_default_shift:
shift_details = get_shift_details(default_shift, for_timestamp)
# if its a holiday, reset
if shift_details and is_holiday_date(employee, shift_details):
shift_details = None
# if no shift is found, find next or prev shift assignment based on direction
if not shift_details and next_shift_direction:
shift_details = get_prev_or_next_shift(
employee, for_timestamp, consider_default_shift, default_shift, next_shift_direction
)
return shift_details or {}
def get_prev_or_next_shift(
employee: str,
for_timestamp: datetime,
consider_default_shift: bool,
default_shift: str,
next_shift_direction: str,
) -> Dict:
"""Returns a dict of shift details for the next or prev shift based on the next_shift_direction"""
MAX_DAYS = 366
shift_details = {}
if consider_default_shift and default_shift:
direction = -1 if next_shift_direction == "reverse" else 1
for i in range(MAX_DAYS):
date = for_timestamp + timedelta(days=direction * (i + 1))
shift_details = get_employee_shift(employee, date, consider_default_shift, None)
if shift_details:
break
else:
direction = "<" if next_shift_direction == "reverse" else ">"
sort_order = "desc" if next_shift_direction == "reverse" else "asc"
dates = frappe.db.get_all(
"Shift Assignment",
["start_date", "end_date"],
{
"employee": employee,
"start_date": (direction, for_timestamp.date()),
"docstatus": 1,
"status": "Active",
},
as_list=True,
limit=MAX_DAYS,
order_by="start_date " + sort_order,
)
if dates:
for date in dates:
if date[1] and date[1] < for_timestamp.date():
continue
shift_details = get_employee_shift(
employee, datetime.combine(date[0], for_timestamp.time()), consider_default_shift, None
)
if shift_details:
break
return shift_details or {}
def is_holiday_date(employee: str, shift_details: Dict) -> bool:
holiday_list_name = frappe.db.get_value(
"Shift Type", shift_details.shift_type.name, "holiday_list"
)
if shift_assignment_details:
shift_type_name = shift_assignment_details[0]
if not holiday_list_name:
holiday_list_name = get_holiday_list_for_employee(employee, False)
# if end_date present means that shift is over after end_date else it is a ongoing shift.
if shift_assignment_details[1] and for_date >= shift_assignment_details[1]:
shift_type_name = None
if not shift_type_name and consider_default_shift:
shift_type_name = default_shift
if shift_type_name:
holiday_list_name = frappe.db.get_value("Shift Type", shift_type_name, "holiday_list")
if not holiday_list_name:
holiday_list_name = get_holiday_list_for_employee(employee, False)
if holiday_list_name and is_holiday(holiday_list_name, for_date):
shift_type_name = None
if not shift_type_name and next_shift_direction:
MAX_DAYS = 366
if consider_default_shift and default_shift:
direction = -1 if next_shift_direction == "reverse" else +1
for i in range(MAX_DAYS):
date = for_date + timedelta(days=direction * (i + 1))
shift_details = get_employee_shift(employee, date, consider_default_shift, None)
if shift_details:
shift_type_name = shift_details.shift_type.name
for_date = date
break
else:
direction = "<" if next_shift_direction == "reverse" else ">"
sort_order = "desc" if next_shift_direction == "reverse" else "asc"
dates = frappe.db.get_all(
"Shift Assignment",
["start_date", "end_date"],
{
"employee": employee,
"start_date": (direction, for_date),
"docstatus": "1",
"status": "Active",
},
as_list=True,
limit=MAX_DAYS,
order_by="start_date " + sort_order,
)
if dates:
for date in dates:
if date[1] and date[1] < for_date:
continue
shift_details = get_employee_shift(employee, date[0], consider_default_shift, None)
if shift_details:
shift_type_name = shift_details.shift_type.name
for_date = date[0]
break
return get_shift_details(shift_type_name, for_date)
return holiday_list_name and is_holiday(holiday_list_name, shift_details.start_datetime.date())
def get_employee_shift_timings(employee, for_timestamp=None, consider_default_shift=False):
def get_employee_shift_timings(
employee: str, for_timestamp: datetime = None, consider_default_shift: bool = False
) -> List[Dict]:
"""Returns previous shift, current/upcoming shift, next_shift for the given timestamp and employee"""
if for_timestamp is None:
for_timestamp = now_datetime()
# write and verify a test case for midnight shift.
prev_shift = curr_shift = next_shift = None
curr_shift = get_employee_shift(employee, for_timestamp.date(), consider_default_shift, "forward")
curr_shift = get_employee_shift(employee, for_timestamp, consider_default_shift, "forward")
if curr_shift:
next_shift = get_employee_shift(
employee,
curr_shift.start_datetime.date() + timedelta(days=1),
consider_default_shift,
"forward",
employee, curr_shift.start_datetime + timedelta(days=1), consider_default_shift, "forward"
)
prev_shift = get_employee_shift(
employee, for_timestamp.date() + timedelta(days=-1), consider_default_shift, "reverse"
employee, for_timestamp + timedelta(days=-1), consider_default_shift, "reverse"
)
if curr_shift:
# adjust actual start and end times if they are overlapping with grace period (before start and after end)
if prev_shift:
curr_shift.actual_start = (
prev_shift.end_datetime
@ -273,31 +405,102 @@ def get_employee_shift_timings(employee, for_timestamp=None, consider_default_sh
if curr_shift.actual_end > next_shift.actual_start
else curr_shift.actual_end
)
return prev_shift, curr_shift, next_shift
def get_shift_details(shift_type_name, for_date=None):
"""Returns Shift Details which contain some additional information as described below.
'shift_details' contains the following keys:
'shift_type' - Object of DocType Shift Type,
'start_datetime' - Date and Time of shift start on given date,
'end_datetime' - Date and Time of shift end on given date,
'actual_start' - datetime of shift start after adding 'begin_check_in_before_shift_start_time',
'actual_end' - datetime of shift end after adding 'allow_check_out_after_shift_end_time'(None is returned if this is zero)
def get_actual_start_end_datetime_of_shift(
employee: str, for_timestamp: datetime, consider_default_shift: bool = False
) -> Dict:
"""Returns a Dict containing shift details with actual_start and actual_end datetime values
Here 'actual' means taking into account the "begin_check_in_before_shift_start_time" and "allow_check_out_after_shift_end_time".
Empty Dict is returned if the timestamp is outside any actual shift timings.
:param shift_type_name: shift type name for which shift_details is required.
:param for_date: Date on which shift_details are required
:param employee (str): Employee name
:param for_timestamp (datetime, optional): Datetime value of checkin, if not provided considers current datetime
:param consider_default_shift (bool, optional): Flag (defaults to False) to specify whether to consider
default shift in employee master if no shift assignment is found
"""
shift_timings_as_per_timestamp = get_employee_shift_timings(
employee, for_timestamp, consider_default_shift
)
return get_exact_shift(shift_timings_as_per_timestamp, for_timestamp)
def get_exact_shift(shifts: List, for_timestamp: datetime) -> Dict:
"""Returns the shift details (dict) for the exact shift in which the 'for_timestamp' value falls among multiple shifts"""
shift_details = dict()
timestamp_list = []
for shift in shifts:
if shift:
timestamp_list.extend([shift.actual_start, shift.actual_end])
else:
timestamp_list.extend([None, None])
timestamp_index = None
for index, timestamp in enumerate(timestamp_list):
if not timestamp:
continue
if for_timestamp < timestamp:
timestamp_index = index
elif for_timestamp == timestamp:
# on timestamp boundary
if index % 2 == 1:
timestamp_index = index
else:
timestamp_index = index + 1
if timestamp_index:
break
if timestamp_index and timestamp_index % 2 == 1:
shift_details = shifts[int((timestamp_index - 1) / 2)]
return shift_details
def get_shift_details(shift_type_name: str, for_timestamp: datetime = None) -> Dict:
"""Returns a Dict containing shift details with the following data:
'shift_type' - Object of DocType Shift Type,
'start_datetime' - datetime of shift start on given timestamp,
'end_datetime' - datetime of shift end on given timestamp,
'actual_start' - datetime of shift start after adding 'begin_check_in_before_shift_start_time',
'actual_end' - datetime of shift end after adding 'allow_check_out_after_shift_end_time' (None is returned if this is zero)
:param shift_type_name (str): shift type name for which shift_details are required.
:param for_timestamp (datetime, optional): Datetime value of checkin, if not provided considers current datetime
"""
if not shift_type_name:
return None
if not for_date:
for_date = nowdate()
return {}
if for_timestamp is None:
for_timestamp = now_datetime()
shift_type = frappe.get_doc("Shift Type", shift_type_name)
start_datetime = datetime.combine(for_date, datetime.min.time()) + shift_type.start_time
for_date = (
for_date + timedelta(days=1) if shift_type.start_time > shift_type.end_time else for_date
shift_actual_start = shift_type.start_time - timedelta(
minutes=shift_type.begin_check_in_before_shift_start_time
)
end_datetime = datetime.combine(for_date, datetime.min.time()) + shift_type.end_time
if shift_type.start_time > shift_type.end_time:
# shift spans accross 2 different days
if get_time(for_timestamp.time()) >= get_time(shift_actual_start):
# if for_timestamp is greater than start time, it's within the first day
start_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.start_time
for_timestamp = for_timestamp + timedelta(days=1)
end_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.end_time
elif get_time(for_timestamp.time()) < get_time(shift_actual_start):
# if for_timestamp is less than start time, it's within the second day
end_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.end_time
for_timestamp = for_timestamp + timedelta(days=-1)
start_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.start_time
else:
# start and end timings fall on the same day
start_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.start_time
end_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.end_time
actual_start = start_datetime - timedelta(
minutes=shift_type.begin_check_in_before_shift_start_time
)
@ -312,34 +515,3 @@ def get_shift_details(shift_type_name, for_date=None):
"actual_end": actual_end,
}
)
def get_actual_start_end_datetime_of_shift(employee, for_datetime, consider_default_shift=False):
"""Takes a datetime and returns the 'actual' start datetime and end datetime of the shift in which the timestamp belongs.
Here 'actual' means - taking in to account the "begin_check_in_before_shift_start_time" and "allow_check_out_after_shift_end_time".
None is returned if the timestamp is outside any actual shift timings.
Shift Details is also returned(current/upcoming i.e. if timestamp not in any actual shift then details of next shift returned)
"""
actual_shift_start = actual_shift_end = shift_details = None
shift_timings_as_per_timestamp = get_employee_shift_timings(
employee, for_datetime, consider_default_shift
)
timestamp_list = []
for shift in shift_timings_as_per_timestamp:
if shift:
timestamp_list.extend([shift.actual_start, shift.actual_end])
else:
timestamp_list.extend([None, None])
timestamp_index = None
for index, timestamp in enumerate(timestamp_list):
if timestamp and for_datetime <= timestamp:
timestamp_index = index
break
if timestamp_index and timestamp_index % 2 == 1:
shift_details = shift_timings_as_per_timestamp[int((timestamp_index - 1) / 2)]
actual_shift_start = shift_details.actual_start
actual_shift_end = shift_details.actual_end
elif timestamp_index:
shift_details = shift_timings_as_per_timestamp[int(timestamp_index / 2)]
return actual_shift_start, actual_shift_end, shift_details

View File

@ -4,16 +4,23 @@
import unittest
import frappe
from frappe.utils import add_days, nowdate
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, getdate, nowdate
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.shift_assignment.shift_assignment import OverlappingShiftError, get_events
from erpnext.hr.doctype.shift_type.test_shift_type import make_shift_assignment, setup_shift_type
test_dependencies = ["Shift Type"]
class TestShiftAssignment(unittest.TestCase):
class TestShiftAssignment(FrappeTestCase):
def setUp(self):
frappe.db.sql("delete from `tabShift Assignment`")
frappe.db.delete("Shift Assignment")
frappe.db.delete("Shift Type")
def test_make_shift_assignment(self):
setup_shift_type(shift_type="Day Shift")
shift_assignment = frappe.get_doc(
{
"doctype": "Shift Assignment",
@ -29,7 +36,7 @@ class TestShiftAssignment(unittest.TestCase):
def test_overlapping_for_ongoing_shift(self):
# shift should be Ongoing if Only start_date is present and status = Active
setup_shift_type(shift_type="Day Shift")
shift_assignment_1 = frappe.get_doc(
{
"doctype": "Shift Assignment",
@ -54,11 +61,11 @@ class TestShiftAssignment(unittest.TestCase):
}
)
self.assertRaises(frappe.ValidationError, shift_assignment.save)
self.assertRaises(OverlappingShiftError, shift_assignment.save)
def test_overlapping_for_fixed_period_shift(self):
# shift should is for Fixed period if Only start_date and end_date both are present and status = Active
setup_shift_type(shift_type="Day Shift")
shift_assignment_1 = frappe.get_doc(
{
"doctype": "Shift Assignment",
@ -85,4 +92,80 @@ class TestShiftAssignment(unittest.TestCase):
}
)
self.assertRaises(frappe.ValidationError, shift_assignment_3.save)
self.assertRaises(OverlappingShiftError, shift_assignment_3.save)
def test_overlapping_for_a_fixed_period_shift_and_ongoing_shift(self):
employee = make_employee("test_shift_assignment@example.com", company="_Test Company")
# shift setup for 8-12
shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
date = getdate()
# shift with end date
make_shift_assignment(shift_type.name, employee, date, add_days(date, 30))
# shift setup for 11-15
shift_type = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="15:00:00")
date = getdate()
# shift assignment without end date
shift2 = frappe.get_doc(
{
"doctype": "Shift Assignment",
"shift_type": shift_type.name,
"company": "_Test Company",
"employee": employee,
"start_date": date,
}
)
self.assertRaises(OverlappingShiftError, shift2.insert)
def test_overlap_validation_for_shifts_on_same_day_with_overlapping_timeslots(self):
employee = make_employee("test_shift_assignment@example.com", company="_Test Company")
# shift setup for 8-12
shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
date = getdate()
make_shift_assignment(shift_type.name, employee, date)
# shift setup for 11-15
shift_type = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="15:00:00")
date = getdate()
shift2 = frappe.get_doc(
{
"doctype": "Shift Assignment",
"shift_type": shift_type.name,
"company": "_Test Company",
"employee": employee,
"start_date": date,
}
)
self.assertRaises(OverlappingShiftError, shift2.insert)
def test_multiple_shift_assignments_for_same_day(self):
employee = make_employee("test_shift_assignment@example.com", company="_Test Company")
# shift setup for 8-12
shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
date = getdate()
make_shift_assignment(shift_type.name, employee, date)
# shift setup for 13-15
shift_type = setup_shift_type(shift_type="Shift 2", start_time="13:00:00", end_time="15:00:00")
date = getdate()
make_shift_assignment(shift_type.name, employee, date)
def test_shift_assignment_calendar(self):
employee1 = make_employee("test_shift_assignment1@example.com", company="_Test Company")
employee2 = make_employee("test_shift_assignment2@example.com", company="_Test Company")
shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
date = getdate()
shift1 = make_shift_assignment(shift_type.name, employee1, date)
make_shift_assignment(shift_type.name, employee2, date)
events = get_events(
start=date, end=date, filters=[["Shift Assignment", "employee", "=", employee1, False]]
)
self.assertEqual(len(events), 1)
self.assertEqual(events[0]["name"], shift1.name)

View File

@ -5,12 +5,14 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import formatdate, getdate
from frappe.query_builder import Criterion
from frappe.utils import get_link_to_form, getdate
from erpnext.hr.doctype.shift_assignment.shift_assignment import has_overlapping_timings
from erpnext.hr.utils import share_doc_with_approver, validate_active_employee
class OverlapError(frappe.ValidationError):
class OverlappingShiftRequestError(frappe.ValidationError):
pass
@ -18,7 +20,7 @@ class ShiftRequest(Document):
def validate(self):
validate_active_employee(self.employee)
self.validate_dates()
self.validate_shift_request_overlap_dates()
self.validate_overlapping_shift_requests()
self.validate_approver()
self.validate_default_shift()
@ -79,37 +81,60 @@ class ShiftRequest(Document):
if self.from_date and self.to_date and (getdate(self.to_date) < getdate(self.from_date)):
frappe.throw(_("To date cannot be before from date"))
def validate_shift_request_overlap_dates(self):
def validate_overlapping_shift_requests(self):
overlapping_dates = self.get_overlapping_dates()
if len(overlapping_dates):
# if dates are overlapping, check if timings are overlapping, else allow
overlapping_timings = has_overlapping_timings(self.shift_type, overlapping_dates[0].shift_type)
if overlapping_timings:
self.throw_overlap_error(overlapping_dates[0])
def get_overlapping_dates(self):
if not self.name:
self.name = "New Shift Request"
d = frappe.db.sql(
"""
select
name, shift_type, from_date, to_date
from `tabShift Request`
where employee = %(employee)s and docstatus < 2
and ((%(from_date)s >= from_date
and %(from_date)s <= to_date) or
( %(to_date)s >= from_date
and %(to_date)s <= to_date ))
and name != %(name)s""",
{
"employee": self.employee,
"shift_type": self.shift_type,
"from_date": self.from_date,
"to_date": self.to_date,
"name": self.name,
},
as_dict=1,
shift = frappe.qb.DocType("Shift Request")
query = (
frappe.qb.from_(shift)
.select(shift.name, shift.shift_type)
.where((shift.employee == self.employee) & (shift.docstatus < 2) & (shift.name != self.name))
)
for date_overlap in d:
if date_overlap["name"]:
self.throw_overlap_error(date_overlap)
if self.to_date:
query = query.where(
Criterion.any(
[
Criterion.any(
[
shift.to_date.isnull(),
((self.from_date >= shift.from_date) & (self.from_date <= shift.to_date)),
]
),
Criterion.any(
[
((self.to_date >= shift.from_date) & (self.to_date <= shift.to_date)),
shift.from_date.between(self.from_date, self.to_date),
]
),
]
)
)
else:
query = query.where(
shift.to_date.isnull()
| ((self.from_date >= shift.from_date) & (self.from_date <= shift.to_date))
)
def throw_overlap_error(self, d):
msg = _("Employee {0} has already applied for {1} between {2} and {3}").format(
self.employee, d["shift_type"], formatdate(d["from_date"]), formatdate(d["to_date"])
) + """ : <b><a href="/app/Form/Shift Request/{0}">{0}</a></b>""".format(d["name"])
frappe.throw(msg, OverlapError)
return query.run(as_dict=True)
def throw_overlap_error(self, shift_details):
shift_details = frappe._dict(shift_details)
msg = _(
"Employee {0} has already applied for Shift {1}: {2} that overlaps within this period"
).format(
frappe.bold(self.employee),
frappe.bold(shift_details.shift_type),
get_link_to_form("Shift Request", shift_details.name),
)
frappe.throw(msg, title=_("Overlapping Shift Requests"), exc=OverlappingShiftRequestError)

View File

@ -4,23 +4,24 @@
import unittest
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, nowdate
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.shift_request.shift_request import OverlappingShiftRequestError
from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type
test_dependencies = ["Shift Type"]
class TestShiftRequest(unittest.TestCase):
class TestShiftRequest(FrappeTestCase):
def setUp(self):
for doctype in ["Shift Request", "Shift Assignment"]:
frappe.db.sql("delete from `tab{doctype}`".format(doctype=doctype))
def tearDown(self):
frappe.db.rollback()
for doctype in ["Shift Request", "Shift Assignment", "Shift Type"]:
frappe.db.delete(doctype)
def test_make_shift_request(self):
"Test creation/updation of Shift Assignment from Shift Request."
setup_shift_type(shift_type="Day Shift")
department = frappe.get_value("Employee", "_T-Employee-00001", "department")
set_shift_approver(department)
approver = frappe.db.sql(
@ -48,6 +49,7 @@ class TestShiftRequest(unittest.TestCase):
self.assertEqual(shift_assignment_docstatus, 2)
def test_shift_request_approver_perms(self):
setup_shift_type(shift_type="Day Shift")
employee = frappe.get_doc("Employee", "_T-Employee-00001")
user = "test_approver_perm_emp@example.com"
make_employee(user, "_Test Company")
@ -87,6 +89,145 @@ class TestShiftRequest(unittest.TestCase):
employee.shift_request_approver = ""
employee.save()
def test_overlap_for_request_without_to_date(self):
# shift should be Ongoing if Only from_date is present
user = "test_shift_request@example.com"
employee = make_employee(user, company="_Test Company", shift_request_approver=user)
setup_shift_type(shift_type="Day Shift")
shift_request = frappe.get_doc(
{
"doctype": "Shift Request",
"shift_type": "Day Shift",
"company": "_Test Company",
"employee": employee,
"from_date": nowdate(),
"approver": user,
"status": "Approved",
}
).submit()
shift_request = frappe.get_doc(
{
"doctype": "Shift Request",
"shift_type": "Day Shift",
"company": "_Test Company",
"employee": employee,
"from_date": add_days(nowdate(), 2),
"approver": user,
"status": "Approved",
}
)
self.assertRaises(OverlappingShiftRequestError, shift_request.save)
def test_overlap_for_request_with_from_and_to_dates(self):
user = "test_shift_request@example.com"
employee = make_employee(user, company="_Test Company", shift_request_approver=user)
setup_shift_type(shift_type="Day Shift")
shift_request = frappe.get_doc(
{
"doctype": "Shift Request",
"shift_type": "Day Shift",
"company": "_Test Company",
"employee": employee,
"from_date": nowdate(),
"to_date": add_days(nowdate(), 30),
"approver": user,
"status": "Approved",
}
).submit()
shift_request = frappe.get_doc(
{
"doctype": "Shift Request",
"shift_type": "Day Shift",
"company": "_Test Company",
"employee": employee,
"from_date": add_days(nowdate(), 10),
"to_date": add_days(nowdate(), 35),
"approver": user,
"status": "Approved",
}
)
self.assertRaises(OverlappingShiftRequestError, shift_request.save)
def test_overlapping_for_a_fixed_period_shift_and_ongoing_shift(self):
user = "test_shift_request@example.com"
employee = make_employee(user, company="_Test Company", shift_request_approver=user)
# shift setup for 8-12
shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
date = nowdate()
# shift with end date
frappe.get_doc(
{
"doctype": "Shift Request",
"shift_type": shift_type.name,
"company": "_Test Company",
"employee": employee,
"from_date": date,
"to_date": add_days(date, 30),
"approver": user,
"status": "Approved",
}
).submit()
# shift setup for 11-15
shift_type = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="15:00:00")
shift2 = frappe.get_doc(
{
"doctype": "Shift Request",
"shift_type": shift_type.name,
"company": "_Test Company",
"employee": employee,
"from_date": date,
"approver": user,
"status": "Approved",
}
)
self.assertRaises(OverlappingShiftRequestError, shift2.insert)
def test_allow_non_overlapping_shift_requests_for_same_day(self):
user = "test_shift_request@example.com"
employee = make_employee(user, company="_Test Company", shift_request_approver=user)
# shift setup for 8-12
shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
date = nowdate()
# shift with end date
frappe.get_doc(
{
"doctype": "Shift Request",
"shift_type": shift_type.name,
"company": "_Test Company",
"employee": employee,
"from_date": date,
"to_date": add_days(date, 30),
"approver": user,
"status": "Approved",
}
).submit()
# shift setup for 13-15
shift_type = setup_shift_type(shift_type="Shift 2", start_time="13:00:00", end_time="15:00:00")
frappe.get_doc(
{
"doctype": "Shift Request",
"shift_type": shift_type.name,
"company": "_Test Company",
"employee": employee,
"from_date": date,
"approver": user,
"status": "Approved",
}
).submit()
def set_shift_approver(department):
department_doc = frappe.get_doc("Department", department)

View File

@ -3,21 +3,23 @@
import itertools
from datetime import timedelta
from datetime import datetime, timedelta
import frappe
from frappe.model.document import Document
from frappe.utils import cint, get_datetime, getdate
from frappe.utils import cint, get_datetime, get_time, getdate
from erpnext.buying.doctype.supplier_scorecard.supplier_scorecard import daterange
from erpnext.hr.doctype.attendance.attendance import mark_attendance
from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee
from erpnext.hr.doctype.employee_checkin.employee_checkin import (
calculate_working_hours,
mark_attendance_and_link_log,
)
from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
from erpnext.hr.doctype.shift_assignment.shift_assignment import (
get_actual_start_end_datetime_of_shift,
get_employee_shift,
get_shift_details,
)
@ -30,8 +32,9 @@ class ShiftType(Document):
or not self.last_sync_of_checkin
):
return
filters = {
"skip_auto_attendance": "0",
"skip_auto_attendance": 0,
"attendance": ("is", "not set"),
"time": (">=", self.process_attendance_after),
"shift_actual_end": ("<", self.last_sync_of_checkin),
@ -40,6 +43,7 @@ class ShiftType(Document):
logs = frappe.db.get_list(
"Employee Checkin", fields="*", filters=filters, order_by="employee,time"
)
for key, group in itertools.groupby(
logs, key=lambda x: (x["employee"], x["shift_actual_start"])
):
@ -52,6 +56,7 @@ class ShiftType(Document):
in_time,
out_time,
) = self.get_attendance(single_shift_logs)
mark_attendance_and_link_log(
single_shift_logs,
attendance_status,
@ -63,15 +68,16 @@ class ShiftType(Document):
out_time,
self.name,
)
for employee in self.get_assigned_employee(self.process_attendance_after, True):
self.mark_absent_for_dates_with_no_attendance(employee)
def get_attendance(self, logs):
"""Return attendance_status, working_hours, late_entry, early_exit, in_time, out_time
for a set of logs belonging to a single shift.
Assumtion:
1. These logs belongs to an single shift, single employee and is not in a holiday date.
2. Logs are in chronological order
Assumptions:
1. These logs belongs to a single shift, single employee and it's not in a holiday date.
2. Logs are in chronological order
"""
late_entry = early_exit = False
total_working_hours, in_time, out_time = calculate_working_hours(
@ -91,39 +97,68 @@ class ShiftType(Document):
):
early_exit = True
if (
self.working_hours_threshold_for_absent
and total_working_hours < self.working_hours_threshold_for_absent
):
return "Absent", total_working_hours, late_entry, early_exit, in_time, out_time
if (
self.working_hours_threshold_for_half_day
and total_working_hours < self.working_hours_threshold_for_half_day
):
return "Half Day", total_working_hours, late_entry, early_exit, in_time, out_time
if (
self.working_hours_threshold_for_absent
and total_working_hours < self.working_hours_threshold_for_absent
):
return "Absent", total_working_hours, late_entry, early_exit, in_time, out_time
return "Present", total_working_hours, late_entry, early_exit, in_time, out_time
def mark_absent_for_dates_with_no_attendance(self, employee):
"""Marks Absents for the given employee on working days in this shift which have no attendance marked.
The Absent is marked starting from 'process_attendance_after' or employee creation date.
"""
start_date, end_date = self.get_start_and_end_dates(employee)
# no shift assignment found, no need to process absent attendance records
if start_date is None:
return
holiday_list_name = self.holiday_list
if not holiday_list_name:
holiday_list_name = get_holiday_list_for_employee(employee, False)
start_time = get_time(self.start_time)
for date in daterange(getdate(start_date), getdate(end_date)):
if is_holiday(holiday_list_name, date):
# skip marking absent on a holiday
continue
timestamp = datetime.combine(date, start_time)
shift_details = get_employee_shift(employee, timestamp, True)
if shift_details and shift_details.shift_type.name == self.name:
mark_attendance(employee, date, "Absent", self.name)
def get_start_and_end_dates(self, employee):
"""Returns start and end dates for checking attendance and marking absent
return: start date = max of `process_attendance_after` and DOJ
return: end date = min of shift before `last_sync_of_checkin` and Relieving Date
"""
date_of_joining, relieving_date, employee_creation = frappe.db.get_value(
"Employee", employee, ["date_of_joining", "relieving_date", "creation"]
)
if not date_of_joining:
date_of_joining = employee_creation.date()
start_date = max(getdate(self.process_attendance_after), date_of_joining)
actual_shift_datetime = get_actual_start_end_datetime_of_shift(
employee, get_datetime(self.last_sync_of_checkin), True
)
end_date = None
shift_details = get_shift_details(self.name, get_datetime(self.last_sync_of_checkin))
last_shift_time = (
actual_shift_datetime[0]
if actual_shift_datetime[0]
else get_datetime(self.last_sync_of_checkin)
)
prev_shift = get_employee_shift(
employee, last_shift_time.date() - timedelta(days=1), True, "reverse"
shift_details.actual_start if shift_details else get_datetime(self.last_sync_of_checkin)
)
# check if shift is found for 1 day before the last sync of checkin
# absentees are auto-marked 1 day after the shift to wait for any manual attendance records
prev_shift = get_employee_shift(employee, last_shift_time - timedelta(days=1), True, "reverse")
if prev_shift:
end_date = (
min(prev_shift.start_datetime.date(), relieving_date)
@ -131,28 +166,21 @@ class ShiftType(Document):
else prev_shift.start_datetime.date()
)
else:
return
holiday_list_name = self.holiday_list
if not holiday_list_name:
holiday_list_name = get_holiday_list_for_employee(employee, False)
dates = get_filtered_date_list(employee, start_date, end_date, holiday_list=holiday_list_name)
for date in dates:
shift_details = get_employee_shift(employee, date, True)
if shift_details and shift_details.shift_type.name == self.name:
mark_attendance(employee, date, "Absent", self.name)
# no shift found
return None, None
return start_date, end_date
def get_assigned_employee(self, from_date=None, consider_default_shift=False):
filters = {"start_date": (">", from_date), "shift_type": self.name, "docstatus": "1"}
if not from_date:
del filters["start_date"]
filters = {"shift_type": self.name, "docstatus": "1"}
if from_date:
filters["start_date"] = (">", from_date)
assigned_employees = frappe.get_all("Shift Assignment", "employee", filters, as_list=True)
assigned_employees = [x[0] for x in assigned_employees]
assigned_employees = frappe.get_all("Shift Assignment", filters=filters, pluck="employee")
if consider_default_shift:
filters = {"default_shift": self.name, "status": ["!=", "Inactive"]}
default_shift_employees = frappe.get_all("Employee", "name", filters, as_list=True)
default_shift_employees = [x[0] for x in default_shift_employees]
default_shift_employees = frappe.get_all("Employee", filters=filters, pluck="name")
return list(set(assigned_employees + default_shift_employees))
return assigned_employees
@ -162,42 +190,3 @@ def process_auto_attendance_for_all_shifts():
for shift in shift_list:
doc = frappe.get_doc("Shift Type", shift[0])
doc.process_auto_attendance()
def get_filtered_date_list(
employee, start_date, end_date, filter_attendance=True, holiday_list=None
):
"""Returns a list of dates after removing the dates with attendance and holidays"""
base_dates_query = """select adddate(%(start_date)s, t2.i*100 + t1.i*10 + t0.i) selected_date from
(select 0 i union select 1 union select 2 union select 3 union select 4 union select 5 union select 6 union select 7 union select 8 union select 9) t0,
(select 0 i union select 1 union select 2 union select 3 union select 4 union select 5 union select 6 union select 7 union select 8 union select 9) t1,
(select 0 i union select 1 union select 2 union select 3 union select 4 union select 5 union select 6 union select 7 union select 8 union select 9) t2"""
condition_query = ""
if filter_attendance:
condition_query += """ and a.selected_date not in (
select attendance_date from `tabAttendance`
where docstatus = 1 and employee = %(employee)s
and attendance_date between %(start_date)s and %(end_date)s)"""
if holiday_list:
condition_query += """ and a.selected_date not in (
select holiday_date from `tabHoliday` where parenttype = 'Holiday List' and
parentfield = 'holidays' and parent = %(holiday_list)s
and holiday_date between %(start_date)s and %(end_date)s)"""
dates = frappe.db.sql(
"""select * from
({base_dates_query}) as a
where a.selected_date <= %(end_date)s {condition_query}
""".format(
base_dates_query=base_dates_query, condition_query=condition_query
),
{
"employee": employee,
"start_date": start_date,
"end_date": end_date,
"holiday_list": holiday_list,
},
as_list=True,
)
return [getdate(date[0]) for date in dates]

View File

@ -2,7 +2,381 @@
# See license.txt
import unittest
from datetime import datetime, timedelta
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, get_time, get_year_ending, get_year_start, getdate, now_datetime
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
from erpnext.hr.doctype.leave_application.test_leave_application import get_first_sunday
from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
class TestShiftType(unittest.TestCase):
pass
class TestShiftType(FrappeTestCase):
def setUp(self):
frappe.db.delete("Shift Type")
frappe.db.delete("Shift Assignment")
frappe.db.delete("Employee Checkin")
frappe.db.delete("Attendance")
from_date = get_year_start(getdate())
to_date = get_year_ending(getdate())
self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date)
def test_mark_attendance(self):
from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
shift_type = setup_shift_type()
date = getdate()
make_shift_assignment(shift_type.name, employee, date)
timestamp = datetime.combine(date, get_time("08:00:00"))
log_in = make_checkin(employee, timestamp)
self.assertEqual(log_in.shift, shift_type.name)
timestamp = datetime.combine(date, get_time("12:00:00"))
log_out = make_checkin(employee, timestamp)
self.assertEqual(log_out.shift, shift_type.name)
shift_type.process_auto_attendance()
attendance = frappe.db.get_value(
"Attendance", {"shift": shift_type.name}, ["status", "name"], as_dict=True
)
self.assertEqual(attendance.status, "Present")
def test_entry_and_exit_grace(self):
from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
# doesn't mark late entry until 60 mins after shift start i.e. till 9
# doesn't mark late entry until 60 mins before shift end i.e. 11
shift_type = setup_shift_type(
enable_entry_grace_period=1,
enable_exit_grace_period=1,
late_entry_grace_period=60,
early_exit_grace_period=60,
)
date = getdate()
make_shift_assignment(shift_type.name, employee, date)
timestamp = datetime.combine(date, get_time("09:30:00"))
log_in = make_checkin(employee, timestamp)
self.assertEqual(log_in.shift, shift_type.name)
timestamp = datetime.combine(date, get_time("10:30:00"))
log_out = make_checkin(employee, timestamp)
self.assertEqual(log_out.shift, shift_type.name)
shift_type.process_auto_attendance()
attendance = frappe.db.get_value(
"Attendance",
{"shift": shift_type.name},
["status", "name", "late_entry", "early_exit"],
as_dict=True,
)
self.assertEqual(attendance.status, "Present")
self.assertEqual(attendance.late_entry, 1)
self.assertEqual(attendance.early_exit, 1)
def test_working_hours_threshold_for_half_day(self):
from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
shift_type = setup_shift_type(shift_type="Half Day Test", working_hours_threshold_for_half_day=2)
date = getdate()
make_shift_assignment(shift_type.name, employee, date)
timestamp = datetime.combine(date, get_time("08:00:00"))
log_in = make_checkin(employee, timestamp)
self.assertEqual(log_in.shift, shift_type.name)
timestamp = datetime.combine(date, get_time("09:30:00"))
log_out = make_checkin(employee, timestamp)
self.assertEqual(log_out.shift, shift_type.name)
shift_type.process_auto_attendance()
attendance = frappe.db.get_value(
"Attendance", {"shift": shift_type.name}, ["status", "working_hours"], as_dict=True
)
self.assertEqual(attendance.status, "Half Day")
self.assertEqual(attendance.working_hours, 1.5)
def test_working_hours_threshold_for_absent(self):
from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
shift_type = setup_shift_type(shift_type="Absent Test", working_hours_threshold_for_absent=2)
date = getdate()
make_shift_assignment(shift_type.name, employee, date)
timestamp = datetime.combine(date, get_time("08:00:00"))
log_in = make_checkin(employee, timestamp)
self.assertEqual(log_in.shift, shift_type.name)
timestamp = datetime.combine(date, get_time("09:30:00"))
log_out = make_checkin(employee, timestamp)
self.assertEqual(log_out.shift, shift_type.name)
shift_type.process_auto_attendance()
attendance = frappe.db.get_value(
"Attendance", {"shift": shift_type.name}, ["status", "working_hours"], as_dict=True
)
self.assertEqual(attendance.status, "Absent")
self.assertEqual(attendance.working_hours, 1.5)
def test_working_hours_threshold_for_absent_and_half_day_1(self):
# considers half day over absent
from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
shift_type = setup_shift_type(
shift_type="Half Day + Absent Test",
working_hours_threshold_for_half_day=1,
working_hours_threshold_for_absent=2,
)
date = getdate()
make_shift_assignment(shift_type.name, employee, date)
timestamp = datetime.combine(date, get_time("08:00:00"))
log_in = make_checkin(employee, timestamp)
self.assertEqual(log_in.shift, shift_type.name)
timestamp = datetime.combine(date, get_time("08:45:00"))
log_out = make_checkin(employee, timestamp)
self.assertEqual(log_out.shift, shift_type.name)
shift_type.process_auto_attendance()
attendance = frappe.db.get_value(
"Attendance", {"shift": shift_type.name}, ["status", "working_hours"], as_dict=True
)
self.assertEqual(attendance.status, "Half Day")
self.assertEqual(attendance.working_hours, 0.75)
def test_working_hours_threshold_for_absent_and_half_day_2(self):
# considers absent over half day
from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
shift_type = setup_shift_type(
shift_type="Half Day + Absent Test",
working_hours_threshold_for_half_day=1,
working_hours_threshold_for_absent=2,
)
date = getdate()
make_shift_assignment(shift_type.name, employee, date)
timestamp = datetime.combine(date, get_time("08:00:00"))
log_in = make_checkin(employee, timestamp)
self.assertEqual(log_in.shift, shift_type.name)
timestamp = datetime.combine(date, get_time("09:30:00"))
log_out = make_checkin(employee, timestamp)
self.assertEqual(log_out.shift, shift_type.name)
shift_type.process_auto_attendance()
attendance = frappe.db.get_value("Attendance", {"shift": shift_type.name}, "status")
self.assertEqual(attendance, "Absent")
def test_mark_absent_for_dates_with_no_attendance(self):
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
shift_type = setup_shift_type(shift_type="Test Absent with no Attendance")
# absentees are auto-marked one day after to wait for any manual attendance records
date = add_days(getdate(), -1)
make_shift_assignment(shift_type.name, employee, date)
shift_type.process_auto_attendance()
attendance = frappe.db.get_value(
"Attendance", {"attendance_date": date, "employee": employee}, "status"
)
self.assertEqual(attendance, "Absent")
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
def test_skip_marking_absent_on_a_holiday(self):
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
shift_type = setup_shift_type(shift_type="Test Absent with no Attendance")
shift_type.holiday_list = None
shift_type.save()
# should not mark any attendance if no shift assignment is created
shift_type.process_auto_attendance()
attendance = frappe.db.get_value("Attendance", {"employee": employee}, "status")
self.assertIsNone(attendance)
first_sunday = get_first_sunday(self.holiday_list, for_date=getdate())
make_shift_assignment(shift_type.name, employee, first_sunday)
shift_type.process_auto_attendance()
attendance = frappe.db.get_value(
"Attendance", {"attendance_date": first_sunday, "employee": employee}, "status"
)
self.assertIsNone(attendance)
def test_get_start_and_end_dates(self):
date = getdate()
doj = add_days(date, -30)
relieving_date = add_days(date, -5)
employee = make_employee(
"test_employee_dates@example.com",
company="_Test Company",
date_of_joining=doj,
relieving_date=relieving_date,
)
shift_type = setup_shift_type(
shift_type="Test Absent with no Attendance", process_attendance_after=add_days(doj, 2)
)
make_shift_assignment(shift_type.name, employee, add_days(date, -25))
shift_type.process_auto_attendance()
# should not mark absent before shift assignment/process attendance after date
attendance = frappe.db.get_value(
"Attendance", {"attendance_date": doj, "employee": employee}, "name"
)
self.assertIsNone(attendance)
# mark absent on Relieving Date
attendance = frappe.db.get_value(
"Attendance", {"attendance_date": relieving_date, "employee": employee}, "status"
)
self.assertEquals(attendance, "Absent")
# should not mark absent after Relieving Date
attendance = frappe.db.get_value(
"Attendance", {"attendance_date": add_days(relieving_date, 1), "employee": employee}, "name"
)
self.assertIsNone(attendance)
def test_skip_auto_attendance_for_duplicate_record(self):
# Skip auto attendance in case of duplicate attendance record
from erpnext.hr.doctype.attendance.attendance import mark_attendance
from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
shift_type = setup_shift_type()
date = getdate()
# mark attendance
mark_attendance(employee, date, "Present")
make_shift_assignment(shift_type.name, employee, date)
timestamp = datetime.combine(date, get_time("08:00:00"))
log_in = make_checkin(employee, timestamp)
self.assertEqual(log_in.shift, shift_type.name)
timestamp = datetime.combine(date, get_time("12:00:00"))
log_out = make_checkin(employee, timestamp)
self.assertEqual(log_out.shift, shift_type.name)
# auto attendance should skip marking
shift_type.process_auto_attendance()
log_in.reload()
log_out.reload()
self.assertEqual(log_in.skip_auto_attendance, 1)
self.assertEqual(log_out.skip_auto_attendance, 1)
def test_skip_auto_attendance_for_overlapping_shift(self):
# Skip auto attendance in case of overlapping shift attendance record
# this case won't occur in case of shift assignment, since it will not allow overlapping shifts to be assigned
# can happen if manual attendance records are created
from erpnext.hr.doctype.attendance.attendance import mark_attendance
from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
shift_1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="10:00:00")
shift_2 = setup_shift_type(shift_type="Shift 2", start_time="09:30:00", end_time="11:00:00")
date = getdate()
# mark attendance
mark_attendance(employee, date, "Present", shift=shift_1.name)
make_shift_assignment(shift_2.name, employee, date)
timestamp = datetime.combine(date, get_time("09:30:00"))
log_in = make_checkin(employee, timestamp)
self.assertEqual(log_in.shift, shift_2.name)
timestamp = datetime.combine(date, get_time("11:00:00"))
log_out = make_checkin(employee, timestamp)
self.assertEqual(log_out.shift, shift_2.name)
# auto attendance should be skipped for shift 2
# since it is already marked for overlapping shift 1
shift_2.process_auto_attendance()
log_in.reload()
log_out.reload()
self.assertEqual(log_in.skip_auto_attendance, 1)
self.assertEqual(log_out.skip_auto_attendance, 1)
def setup_shift_type(**args):
args = frappe._dict(args)
date = getdate()
shift_type = frappe.get_doc(
{
"doctype": "Shift Type",
"__newname": args.shift_type or "_Test Shift",
"start_time": "08:00:00",
"end_time": "12:00:00",
"enable_auto_attendance": 1,
"determine_check_in_and_check_out": "Alternating entries as IN and OUT during the same shift",
"working_hours_calculation_based_on": "First Check-in and Last Check-out",
"begin_check_in_before_shift_start_time": 60,
"allow_check_out_after_shift_end_time": 60,
"process_attendance_after": add_days(date, -2),
"last_sync_of_checkin": now_datetime() + timedelta(days=1),
}
)
holiday_list = "Employee Checkin Test Holiday List"
if not frappe.db.exists("Holiday List", "Employee Checkin Test Holiday List"):
holiday_list = frappe.get_doc(
{
"doctype": "Holiday List",
"holiday_list_name": "Employee Checkin Test Holiday List",
"from_date": get_year_start(date),
"to_date": get_year_ending(date),
}
).insert()
holiday_list = holiday_list.name
shift_type.holiday_list = holiday_list
shift_type.update(args)
shift_type.save()
return shift_type
def make_shift_assignment(shift_type, employee, start_date, end_date=None):
shift_assignment = frappe.get_doc(
{
"doctype": "Shift Assignment",
"shift_type": shift_type,
"company": "_Test Company",
"employee": employee,
"start_date": start_date,
"end_date": end_date,
}
).insert()
shift_assignment.submit()
return shift_assignment

View File

@ -66,8 +66,7 @@ frappe.query_reports["Monthly Attendance Sheet"] = {
"Default": 0,
}
],
"onload": function() {
onload: function() {
return frappe.call({
method: "erpnext.hr.report.monthly_attendance_sheet.monthly_attendance_sheet.get_attendance_years",
callback: function(r) {
@ -78,5 +77,25 @@ frappe.query_reports["Monthly Attendance Sheet"] = {
year_filter.set_input(year_filter.df.default);
}
});
},
formatter: function(value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
const summarized_view = frappe.query_report.get_filter_value('summarized_view');
const group_by = frappe.query_report.get_filter_value('group_by');
if (!summarized_view) {
if ((group_by && column.colIndex > 3) || (!group_by && column.colIndex > 2)) {
if (value == 'P' || value == 'WFH')
value = "<span style='color:green'>" + value + "</span>";
else if (value == 'A')
value = "<span style='color:red'>" + value + "</span>";
else if (value == 'HD')
value = "<span style='color:orange'>" + value + "</span>";
else if (value == 'L')
value = "<span style='color:#318AD8'>" + value + "</span>";
}
}
return value;
}
}

View File

@ -3,365 +3,618 @@
from calendar import monthrange
from itertools import groupby
from typing import Dict, List, Optional, Tuple
import frappe
from frappe import _, msgprint
from frappe import _
from frappe.query_builder.functions import Count, Extract, Sum
from frappe.utils import cint, cstr, getdate
Filters = frappe._dict
status_map = {
"Present": "P",
"Absent": "A",
"Half Day": "HD",
"Holiday": "<b>H</b>",
"Weekly Off": "<b>WO</b>",
"On Leave": "L",
"Present": "P",
"Work From Home": "WFH",
"On Leave": "L",
"Holiday": "H",
"Weekly Off": "WO",
}
day_abbr = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
def execute(filters=None):
if not filters:
filters = {}
def execute(filters: Optional[Filters] = None) -> Tuple:
filters = frappe._dict(filters or {})
if filters.hide_year_field == 1:
filters.year = 2020
if not (filters.month and filters.year):
frappe.throw(_("Please select month and year."))
conditions, filters = get_conditions(filters)
columns, days = get_columns(filters)
att_map = get_attendance_list(conditions, filters)
if not att_map:
attendance_map = get_attendance_map(filters)
if not attendance_map:
frappe.msgprint(_("No attendance records found."), alert=True, indicator="orange")
return [], [], None, None
columns = get_columns(filters)
data = get_data(filters, attendance_map)
if not data:
frappe.msgprint(
_("No attendance records found for this criteria."), alert=True, indicator="orange"
)
return columns, [], None, None
if filters.group_by:
emp_map, group_by_parameters = get_employee_details(filters.group_by, filters.company)
holiday_list = []
for parameter in group_by_parameters:
h_list = [
emp_map[parameter][d]["holiday_list"]
for d in emp_map[parameter]
if emp_map[parameter][d]["holiday_list"]
]
holiday_list += h_list
else:
emp_map = get_employee_details(filters.group_by, filters.company)
holiday_list = [emp_map[d]["holiday_list"] for d in emp_map if emp_map[d]["holiday_list"]]
message = get_message() if not filters.summarized_view else ""
chart = get_chart_data(attendance_map, filters)
default_holiday_list = frappe.get_cached_value(
"Company", filters.get("company"), "default_holiday_list"
)
holiday_list.append(default_holiday_list)
holiday_list = list(set(holiday_list))
holiday_map = get_holiday(holiday_list, filters["month"])
data = []
leave_types = frappe.db.get_list("Leave Type")
leave_list = None
if filters.summarized_view:
leave_list = [d.name + ":Float:120" for d in leave_types]
columns.extend(leave_list)
columns.extend([_("Total Late Entries") + ":Float:120", _("Total Early Exits") + ":Float:120"])
if filters.group_by:
emp_att_map = {}
for parameter in group_by_parameters:
emp_map_set = set([key for key in emp_map[parameter].keys()])
att_map_set = set([key for key in att_map.keys()])
if att_map_set & emp_map_set:
parameter_row = ["<b>" + parameter + "</b>"] + [
"" for day in range(filters["total_days_in_month"] + 2)
]
data.append(parameter_row)
record, emp_att_data = add_data(
emp_map[parameter],
att_map,
filters,
holiday_map,
conditions,
default_holiday_list,
leave_types=leave_types,
)
emp_att_map.update(emp_att_data)
data += record
else:
record, emp_att_map = add_data(
emp_map,
att_map,
filters,
holiday_map,
conditions,
default_holiday_list,
leave_types=leave_types,
)
data += record
chart_data = get_chart_data(emp_att_map, days)
return columns, data, None, chart_data
return columns, data, message, chart
def get_chart_data(emp_att_map, days):
labels = []
datasets = [
{"name": "Absent", "values": []},
{"name": "Present", "values": []},
{"name": "Leave", "values": []},
]
for idx, day in enumerate(days, start=0):
p = day.replace("::65", "")
labels.append(day.replace("::65", ""))
total_absent_on_day = 0
total_leave_on_day = 0
total_present_on_day = 0
total_holiday = 0
for emp in emp_att_map.keys():
if emp_att_map[emp][idx]:
if emp_att_map[emp][idx] == "A":
total_absent_on_day += 1
if emp_att_map[emp][idx] in ["P", "WFH"]:
total_present_on_day += 1
if emp_att_map[emp][idx] == "HD":
total_present_on_day += 0.5
total_leave_on_day += 0.5
if emp_att_map[emp][idx] == "L":
total_leave_on_day += 1
def get_message() -> str:
message = ""
colors = ["green", "red", "orange", "green", "#318AD8", "", ""]
datasets[0]["values"].append(total_absent_on_day)
datasets[1]["values"].append(total_present_on_day)
datasets[2]["values"].append(total_leave_on_day)
count = 0
for status, abbr in status_map.items():
message += f"""
<span style='border-left: 2px solid {colors[count]}; padding-right: 12px; padding-left: 5px; margin-right: 3px;'>
{status} - {abbr}
</span>
"""
count += 1
chart = {"data": {"labels": labels, "datasets": datasets}}
chart["type"] = "line"
return chart
return message
def add_data(
employee_map, att_map, filters, holiday_map, conditions, default_holiday_list, leave_types=None
):
record = []
emp_att_map = {}
for emp in employee_map:
emp_det = employee_map.get(emp)
if not emp_det or emp not in att_map:
continue
row = []
if filters.group_by:
row += [" "]
row += [emp, emp_det.employee_name]
total_p = total_a = total_l = total_h = total_um = 0.0
emp_status_map = []
for day in range(filters["total_days_in_month"]):
status = None
status = att_map.get(emp).get(day + 1)
if status is None and holiday_map:
emp_holiday_list = emp_det.holiday_list if emp_det.holiday_list else default_holiday_list
if emp_holiday_list in holiday_map:
for idx, ele in enumerate(holiday_map[emp_holiday_list]):
if day + 1 == holiday_map[emp_holiday_list][idx][0]:
if holiday_map[emp_holiday_list][idx][1]:
status = "Weekly Off"
else:
status = "Holiday"
total_h += 1
abbr = status_map.get(status, "")
emp_status_map.append(abbr)
if filters.summarized_view:
if status == "Present" or status == "Work From Home":
total_p += 1
elif status == "Absent":
total_a += 1
elif status == "On Leave":
total_l += 1
elif status == "Half Day":
total_p += 0.5
total_a += 0.5
total_l += 0.5
elif not status:
total_um += 1
if not filters.summarized_view:
row += emp_status_map
if filters.summarized_view:
row += [total_p, total_l, total_a, total_h, total_um]
if not filters.get("employee"):
filters.update({"employee": emp})
conditions += " and employee = %(employee)s"
elif not filters.get("employee") == emp:
filters.update({"employee": emp})
if filters.summarized_view:
leave_details = frappe.db.sql(
"""select leave_type, status, count(*) as count from `tabAttendance`\
where leave_type is not NULL %s group by leave_type, status"""
% conditions,
filters,
as_dict=1,
)
time_default_counts = frappe.db.sql(
"""select (select count(*) from `tabAttendance` where \
late_entry = 1 %s) as late_entry_count, (select count(*) from tabAttendance where \
early_exit = 1 %s) as early_exit_count"""
% (conditions, conditions),
filters,
)
leaves = {}
for d in leave_details:
if d.status == "Half Day":
d.count = d.count * 0.5
if d.leave_type in leaves:
leaves[d.leave_type] += d.count
else:
leaves[d.leave_type] = d.count
for d in leave_types:
if d.name in leaves:
row.append(leaves[d.name])
else:
row.append("0.0")
row.extend([time_default_counts[0][0], time_default_counts[0][1]])
emp_att_map[emp] = emp_status_map
record.append(row)
return record, emp_att_map
def get_columns(filters):
def get_columns(filters: Filters) -> List[Dict]:
columns = []
if filters.group_by:
columns = [_(filters.group_by) + ":Link/Branch:120"]
columns.append(
{
"label": _(filters.group_by),
"fieldname": frappe.scrub(filters.group_by),
"fieldtype": "Link",
"options": "Branch",
"width": 120,
}
)
columns += [_("Employee") + ":Link/Employee:120", _("Employee Name") + ":Data/:120"]
days = []
for day in range(filters["total_days_in_month"]):
date = str(filters.year) + "-" + str(filters.month) + "-" + str(day + 1)
day_name = day_abbr[getdate(date).weekday()]
days.append(cstr(day + 1) + " " + day_name + "::65")
if not filters.summarized_view:
columns += days
if filters.summarized_view:
columns += [
_("Total Present") + ":Float:120",
_("Total Leaves") + ":Float:120",
_("Total Absent") + ":Float:120",
_("Total Holidays") + ":Float:120",
_("Unmarked Days") + ":Float:120",
columns.extend(
[
{
"label": _("Employee"),
"fieldname": "employee",
"fieldtype": "Link",
"options": "Employee",
"width": 135,
},
{"label": _("Employee Name"), "fieldname": "employee_name", "fieldtype": "Data", "width": 120},
]
return columns, days
def get_attendance_list(conditions, filters):
attendance_list = frappe.db.sql(
"""select employee, day(attendance_date) as day_of_month,
status from tabAttendance where docstatus = 1 %s order by employee, attendance_date"""
% conditions,
filters,
as_dict=1,
)
if not attendance_list:
msgprint(_("No attendance record found"), alert=True, indicator="orange")
if filters.summarized_view:
columns.extend(
[
{
"label": _("Total Present"),
"fieldname": "total_present",
"fieldtype": "Float",
"width": 110,
},
{"label": _("Total Leaves"), "fieldname": "total_leaves", "fieldtype": "Float", "width": 110},
{"label": _("Total Absent"), "fieldname": "total_absent", "fieldtype": "Float", "width": 110},
{
"label": _("Total Holidays"),
"fieldname": "total_holidays",
"fieldtype": "Float",
"width": 120,
},
{
"label": _("Unmarked Days"),
"fieldname": "unmarked_days",
"fieldtype": "Float",
"width": 130,
},
]
)
columns.extend(get_columns_for_leave_types())
columns.extend(
[
{
"label": _("Total Late Entries"),
"fieldname": "total_late_entries",
"fieldtype": "Float",
"width": 140,
},
{
"label": _("Total Early Exits"),
"fieldname": "total_early_exits",
"fieldtype": "Float",
"width": 140,
},
]
)
else:
columns.append({"label": _("Shift"), "fieldname": "shift", "fieldtype": "Data", "width": 120})
columns.extend(get_columns_for_days(filters))
return columns
def get_columns_for_leave_types() -> List[Dict]:
leave_types = frappe.db.get_all("Leave Type", pluck="name")
types = []
for entry in leave_types:
types.append(
{"label": entry, "fieldname": frappe.scrub(entry), "fieldtype": "Float", "width": 120}
)
return types
def get_columns_for_days(filters: Filters) -> List[Dict]:
total_days = get_total_days_in_month(filters)
days = []
for day in range(1, total_days + 1):
# forms the dates from selected year and month from filters
date = "{}-{}-{}".format(cstr(filters.year), cstr(filters.month), cstr(day))
# gets abbr from weekday number
weekday = day_abbr[getdate(date).weekday()]
# sets days as 1 Mon, 2 Tue, 3 Wed
label = "{} {}".format(cstr(day), weekday)
days.append({"label": label, "fieldtype": "Data", "fieldname": day, "width": 65})
return days
def get_total_days_in_month(filters: Filters) -> int:
return monthrange(cint(filters.year), cint(filters.month))[1]
def get_data(filters: Filters, attendance_map: Dict) -> List[Dict]:
employee_details, group_by_param_values = get_employee_related_details(
filters.group_by, filters.company
)
holiday_map = get_holiday_map(filters)
data = []
if filters.group_by:
group_by_column = frappe.scrub(filters.group_by)
for value in group_by_param_values:
if not value:
continue
records = get_rows(employee_details[value], filters, holiday_map, attendance_map)
if records:
data.append({group_by_column: frappe.bold(value)})
data.extend(records)
else:
data = get_rows(employee_details, filters, holiday_map, attendance_map)
return data
def get_attendance_map(filters: Filters) -> Dict:
"""Returns a dictionary of employee wise attendance map as per shifts for all the days of the month like
{
'employee1': {
'Morning Shift': {1: 'Present', 2: 'Absent', ...}
'Evening Shift': {1: 'Absent', 2: 'Present', ...}
},
'employee2': {
'Afternoon Shift': {1: 'Present', 2: 'Absent', ...}
'Night Shift': {1: 'Absent', 2: 'Absent', ...}
}
}
"""
Attendance = frappe.qb.DocType("Attendance")
query = (
frappe.qb.from_(Attendance)
.select(
Attendance.employee,
Extract("day", Attendance.attendance_date).as_("day_of_month"),
Attendance.status,
Attendance.shift,
)
.where(
(Attendance.docstatus == 1)
& (Attendance.company == filters.company)
& (Extract("month", Attendance.attendance_date) == filters.month)
& (Extract("year", Attendance.attendance_date) == filters.year)
)
)
if filters.employee:
query = query.where(Attendance.employee == filters.employee)
query = query.orderby(Attendance.employee, Attendance.attendance_date)
attendance_list = query.run(as_dict=1)
attendance_map = {}
att_map = {}
for d in attendance_list:
att_map.setdefault(d.employee, frappe._dict()).setdefault(d.day_of_month, "")
att_map[d.employee][d.day_of_month] = d.status
attendance_map.setdefault(d.employee, frappe._dict()).setdefault(d.shift, frappe._dict())
attendance_map[d.employee][d.shift][d.day_of_month] = d.status
return att_map
return attendance_map
def get_conditions(filters):
if not (filters.get("month") and filters.get("year")):
msgprint(_("Please select month and year"), raise_exception=1)
filters["total_days_in_month"] = monthrange(cint(filters.year), cint(filters.month))[1]
conditions = " and month(attendance_date) = %(month)s and year(attendance_date) = %(year)s"
if filters.get("company"):
conditions += " and company = %(company)s"
if filters.get("employee"):
conditions += " and employee = %(employee)s"
return conditions, filters
def get_employee_details(group_by, company):
emp_map = {}
query = """select name, employee_name, designation, department, branch, company,
holiday_list from `tabEmployee` where company = %s """ % frappe.db.escape(
company
def get_employee_related_details(group_by: str, company: str) -> Tuple[Dict, List]:
"""Returns
1. nested dict for employee details
2. list of values for the group by filter
"""
Employee = frappe.qb.DocType("Employee")
query = (
frappe.qb.from_(Employee)
.select(
Employee.name,
Employee.employee_name,
Employee.designation,
Employee.grade,
Employee.department,
Employee.branch,
Employee.company,
Employee.holiday_list,
)
.where(Employee.company == company)
)
if group_by:
group_by = group_by.lower()
query += " order by " + group_by + " ASC"
query = query.orderby(group_by)
employee_details = frappe.db.sql(query, as_dict=1)
employee_details = query.run(as_dict=True)
group_by_param_values = []
emp_map = {}
group_by_parameters = []
if group_by:
for parameter, employees in groupby(employee_details, key=lambda d: d[group_by]):
group_by_param_values.append(parameter)
emp_map.setdefault(parameter, frappe._dict())
group_by_parameters = list(
set(detail.get(group_by, "") for detail in employee_details if detail.get(group_by, ""))
)
for parameter in group_by_parameters:
emp_map[parameter] = {}
for d in employee_details:
if group_by and len(group_by_parameters):
if d.get(group_by, None):
emp_map[d.get(group_by)][d.name] = d
else:
emp_map[d.name] = d
if not group_by:
return emp_map
for emp in employees:
emp_map[parameter][emp.name] = emp
else:
return emp_map, group_by_parameters
for emp in employee_details:
emp_map[emp.name] = emp
return emp_map, group_by_param_values
def get_holiday(holiday_list, month):
def get_holiday_map(filters: Filters) -> Dict[str, List[Dict]]:
"""
Returns a dict of holidays falling in the filter month and year
with list name as key and list of holidays as values like
{
'Holiday List 1': [
{'day_of_month': '0' , 'weekly_off': 1},
{'day_of_month': '1', 'weekly_off': 0}
],
'Holiday List 2': [
{'day_of_month': '0' , 'weekly_off': 1},
{'day_of_month': '1', 'weekly_off': 0}
]
}
"""
# add default holiday list too
holiday_lists = frappe.db.get_all("Holiday List", pluck="name")
default_holiday_list = frappe.get_cached_value("Company", filters.company, "default_holiday_list")
holiday_lists.append(default_holiday_list)
holiday_map = frappe._dict()
for d in holiday_list:
if d:
holiday_map.setdefault(
d,
frappe.db.sql(
"""select day(holiday_date), weekly_off from `tabHoliday`
where parent=%s and month(holiday_date)=%s""",
(d, month),
),
Holiday = frappe.qb.DocType("Holiday")
for d in holiday_lists:
if not d:
continue
holidays = (
frappe.qb.from_(Holiday)
.select(Extract("day", Holiday.holiday_date).as_("day_of_month"), Holiday.weekly_off)
.where(
(Holiday.parent == d)
& (Extract("month", Holiday.holiday_date) == filters.month)
& (Extract("year", Holiday.holiday_date) == filters.year)
)
).run(as_dict=True)
holiday_map.setdefault(d, holidays)
return holiday_map
@frappe.whitelist()
def get_attendance_years():
year_list = frappe.db.sql_list(
"""select distinct YEAR(attendance_date) from tabAttendance ORDER BY YEAR(attendance_date) DESC"""
def get_rows(
employee_details: Dict, filters: Filters, holiday_map: Dict, attendance_map: Dict
) -> List[Dict]:
records = []
default_holiday_list = frappe.get_cached_value("Company", filters.company, "default_holiday_list")
for employee, details in employee_details.items():
emp_holiday_list = details.holiday_list or default_holiday_list
holidays = holiday_map.get(emp_holiday_list)
if filters.summarized_view:
attendance = get_attendance_status_for_summarized_view(employee, filters, holidays)
if not attendance:
continue
leave_summary = get_leave_summary(employee, filters)
entry_exits_summary = get_entry_exits_summary(employee, filters)
row = {"employee": employee, "employee_name": details.employee_name}
set_defaults_for_summarized_view(filters, row)
row.update(attendance)
row.update(leave_summary)
row.update(entry_exits_summary)
records.append(row)
else:
employee_attendance = attendance_map.get(employee)
if not employee_attendance:
continue
attendance_for_employee = get_attendance_status_for_detailed_view(
employee, filters, employee_attendance, holidays
)
# set employee details in the first row
attendance_for_employee[0].update(
{"employee": employee, "employee_name": details.employee_name}
)
records.extend(attendance_for_employee)
return records
def set_defaults_for_summarized_view(filters, row):
for entry in get_columns(filters):
if entry.get("fieldtype") == "Float":
row[entry.get("fieldname")] = 0.0
def get_attendance_status_for_summarized_view(
employee: str, filters: Filters, holidays: List
) -> Dict:
"""Returns dict of attendance status for employee like
{'total_present': 1.5, 'total_leaves': 0.5, 'total_absent': 13.5, 'total_holidays': 8, 'unmarked_days': 5}
"""
summary, attendance_days = get_attendance_summary_and_days(employee, filters)
if not any(summary.values()):
return {}
total_days = get_total_days_in_month(filters)
total_holidays = total_unmarked_days = 0
for day in range(1, total_days + 1):
if day in attendance_days:
continue
status = get_holiday_status(day, holidays)
if status in ["Weekly Off", "Holiday"]:
total_holidays += 1
elif not status:
total_unmarked_days += 1
return {
"total_present": summary.total_present + summary.total_half_days,
"total_leaves": summary.total_leaves + summary.total_half_days,
"total_absent": summary.total_absent + summary.total_half_days,
"total_holidays": total_holidays,
"unmarked_days": total_unmarked_days,
}
def get_attendance_summary_and_days(employee: str, filters: Filters) -> Tuple[Dict, List]:
Attendance = frappe.qb.DocType("Attendance")
present_case = (
frappe.qb.terms.Case()
.when(((Attendance.status == "Present") | (Attendance.status == "Work From Home")), 1)
.else_(0)
)
if not year_list:
sum_present = Sum(present_case).as_("total_present")
absent_case = frappe.qb.terms.Case().when(Attendance.status == "Absent", 1).else_(0)
sum_absent = Sum(absent_case).as_("total_absent")
leave_case = frappe.qb.terms.Case().when(Attendance.status == "On Leave", 1).else_(0)
sum_leave = Sum(leave_case).as_("total_leaves")
half_day_case = frappe.qb.terms.Case().when(Attendance.status == "Half Day", 0.5).else_(0)
sum_half_day = Sum(half_day_case).as_("total_half_days")
summary = (
frappe.qb.from_(Attendance)
.select(
sum_present,
sum_absent,
sum_leave,
sum_half_day,
)
.where(
(Attendance.docstatus == 1)
& (Attendance.employee == employee)
& (Attendance.company == filters.company)
& (Extract("month", Attendance.attendance_date) == filters.month)
& (Extract("year", Attendance.attendance_date) == filters.year)
)
).run(as_dict=True)
days = (
frappe.qb.from_(Attendance)
.select(Extract("day", Attendance.attendance_date).as_("day_of_month"))
.distinct()
.where(
(Attendance.docstatus == 1)
& (Attendance.employee == employee)
& (Attendance.company == filters.company)
& (Extract("month", Attendance.attendance_date) == filters.month)
& (Extract("year", Attendance.attendance_date) == filters.year)
)
).run(pluck=True)
return summary[0], days
def get_attendance_status_for_detailed_view(
employee: str, filters: Filters, employee_attendance: Dict, holidays: List
) -> List[Dict]:
"""Returns list of shift-wise attendance status for employee
[
{'shift': 'Morning Shift', 1: 'A', 2: 'P', 3: 'A'....},
{'shift': 'Evening Shift', 1: 'P', 2: 'A', 3: 'P'....}
]
"""
total_days = get_total_days_in_month(filters)
attendance_values = []
for shift, status_dict in employee_attendance.items():
row = {"shift": shift}
for day in range(1, total_days + 1):
status = status_dict.get(day)
if status is None and holidays:
status = get_holiday_status(day, holidays)
abbr = status_map.get(status, "")
row[day] = abbr
attendance_values.append(row)
return attendance_values
def get_holiday_status(day: int, holidays: List) -> str:
status = None
for holiday in holidays:
if day == holiday.get("day_of_month"):
if holiday.get("weekly_off"):
status = "Weekly Off"
else:
status = "Holiday"
break
return status
def get_leave_summary(employee: str, filters: Filters) -> Dict[str, float]:
"""Returns a dict of leave type and corresponding leaves taken by employee like:
{'leave_without_pay': 1.0, 'sick_leave': 2.0}
"""
Attendance = frappe.qb.DocType("Attendance")
day_case = frappe.qb.terms.Case().when(Attendance.status == "Half Day", 0.5).else_(1)
sum_leave_days = Sum(day_case).as_("leave_days")
leave_details = (
frappe.qb.from_(Attendance)
.select(Attendance.leave_type, sum_leave_days)
.where(
(Attendance.employee == employee)
& (Attendance.docstatus == 1)
& (Attendance.company == filters.company)
& ((Attendance.leave_type.isnotnull()) | (Attendance.leave_type != ""))
& (Extract("month", Attendance.attendance_date) == filters.month)
& (Extract("year", Attendance.attendance_date) == filters.year)
)
.groupby(Attendance.leave_type)
).run(as_dict=True)
leaves = {}
for d in leave_details:
leave_type = frappe.scrub(d.leave_type)
leaves[leave_type] = d.leave_days
return leaves
def get_entry_exits_summary(employee: str, filters: Filters) -> Dict[str, float]:
"""Returns total late entries and total early exits for employee like:
{'total_late_entries': 5, 'total_early_exits': 2}
"""
Attendance = frappe.qb.DocType("Attendance")
late_entry_case = frappe.qb.terms.Case().when(Attendance.late_entry == "1", "1")
count_late_entries = Count(late_entry_case).as_("total_late_entries")
early_exit_case = frappe.qb.terms.Case().when(Attendance.early_exit == "1", "1")
count_early_exits = Count(early_exit_case).as_("total_early_exits")
entry_exits = (
frappe.qb.from_(Attendance)
.select(count_late_entries, count_early_exits)
.where(
(Attendance.docstatus == 1)
& (Attendance.employee == employee)
& (Attendance.company == filters.company)
& (Extract("month", Attendance.attendance_date) == filters.month)
& (Extract("year", Attendance.attendance_date) == filters.year)
)
).run(as_dict=True)
return entry_exits[0]
@frappe.whitelist()
def get_attendance_years() -> str:
"""Returns all the years for which attendance records exist"""
Attendance = frappe.qb.DocType("Attendance")
year_list = (
frappe.qb.from_(Attendance)
.select(Extract("year", Attendance.attendance_date).as_("year"))
.distinct()
).run(as_dict=True)
if year_list:
year_list.sort(key=lambda d: d.year, reverse=True)
else:
year_list = [getdate().year]
return "\n".join(str(year) for year in year_list)
return "\n".join(cstr(entry.year) for entry in year_list)
def get_chart_data(attendance_map: Dict, filters: Filters) -> Dict:
days = get_columns_for_days(filters)
labels = []
absent = []
present = []
leave = []
for day in days:
labels.append(day["label"])
total_absent_on_day = total_leaves_on_day = total_present_on_day = 0
for employee, attendance_dict in attendance_map.items():
for shift, attendance in attendance_dict.items():
attendance_on_day = attendance.get(day["fieldname"])
if attendance_on_day == "Absent":
total_absent_on_day += 1
elif attendance_on_day in ["Present", "Work From Home"]:
total_present_on_day += 1
elif attendance_on_day == "Half Day":
total_present_on_day += 0.5
total_leaves_on_day += 0.5
elif attendance_on_day == "On Leave":
total_leaves_on_day += 1
absent.append(total_absent_on_day)
present.append(total_present_on_day)
leave.append(total_leaves_on_day)
return {
"data": {
"labels": labels,
"datasets": [
{"name": "Absent", "values": absent},
{"name": "Present", "values": present},
{"name": "Leave", "values": leave},
],
},
"type": "line",
"colors": ["red", "green", "blue"],
}

View File

@ -1,18 +1,32 @@
import frappe
from dateutil.relativedelta import relativedelta
from frappe.tests.utils import FrappeTestCase
from frappe.utils import now_datetime
from frappe.utils import get_year_ending, get_year_start, getdate, now_datetime
from erpnext.hr.doctype.attendance.attendance import mark_attendance
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
from erpnext.hr.doctype.leave_application.test_leave_application import make_allocation_record
from erpnext.hr.report.monthly_attendance_sheet.monthly_attendance_sheet import execute
from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
make_holiday_list,
make_leave_application,
)
test_dependencies = ["Shift Type"]
class TestMonthlyAttendanceSheet(FrappeTestCase):
def setUp(self):
self.employee = make_employee("test_employee@example.com")
frappe.db.delete("Attendance", {"employee": self.employee})
self.employee = make_employee("test_employee@example.com", company="_Test Company")
frappe.db.delete("Attendance")
date = getdate()
from_date = get_year_start(date)
to_date = get_year_ending(date)
make_holiday_list(from_date=from_date, to_date=to_date)
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
def test_monthly_attendance_sheet_report(self):
now = now_datetime()
previous_month = now.month - 1
@ -33,14 +47,203 @@ class TestMonthlyAttendanceSheet(FrappeTestCase):
}
)
report = execute(filters=filters)
employees = report[1][0]
record = report[1][0]
datasets = report[3]["data"]["datasets"]
absent = datasets[0]["values"]
present = datasets[1]["values"]
leaves = datasets[2]["values"]
# ensure correct attendance is reflect on the report
self.assertIn(self.employee, employees)
# ensure correct attendance is reflected on the report
self.assertEqual(self.employee, record.get("employee"))
self.assertEqual(absent[0], 1)
self.assertEqual(present[1], 1)
self.assertEqual(leaves[2], 1)
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
def test_monthly_attendance_sheet_with_detailed_view(self):
now = now_datetime()
previous_month = now.month - 1
previous_month_first = now.replace(day=1).replace(month=previous_month).date()
company = frappe.db.get_value("Employee", self.employee, "company")
# attendance with shift
mark_attendance(self.employee, previous_month_first, "Absent", "Day Shift")
mark_attendance(
self.employee, previous_month_first + relativedelta(days=1), "Present", "Day Shift"
)
# attendance without shift
mark_attendance(self.employee, previous_month_first + relativedelta(days=2), "On Leave")
mark_attendance(self.employee, previous_month_first + relativedelta(days=3), "Present")
filters = frappe._dict(
{
"month": previous_month,
"year": now.year,
"company": company,
}
)
report = execute(filters=filters)
day_shift_row = report[1][0]
row_without_shift = report[1][1]
self.assertEqual(day_shift_row["shift"], "Day Shift")
self.assertEqual(day_shift_row[1], "A") # absent on the 1st day of the month
self.assertEqual(day_shift_row[2], "P") # present on the 2nd day
self.assertEqual(row_without_shift["shift"], None)
self.assertEqual(row_without_shift[3], "L") # on leave on the 3rd day
self.assertEqual(row_without_shift[4], "P") # present on the 4th day
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
def test_monthly_attendance_sheet_with_summarized_view(self):
now = now_datetime()
previous_month = now.month - 1
previous_month_first = now.replace(day=1).replace(month=previous_month).date()
company = frappe.db.get_value("Employee", self.employee, "company")
# attendance with shift
mark_attendance(self.employee, previous_month_first, "Absent", "Day Shift")
mark_attendance(
self.employee, previous_month_first + relativedelta(days=1), "Present", "Day Shift"
)
mark_attendance(
self.employee, previous_month_first + relativedelta(days=2), "Half Day"
) # half day
mark_attendance(
self.employee, previous_month_first + relativedelta(days=3), "Present"
) # attendance without shift
mark_attendance(
self.employee, previous_month_first + relativedelta(days=4), "Present", late_entry=1
) # late entry
mark_attendance(
self.employee, previous_month_first + relativedelta(days=5), "Present", early_exit=1
) # early exit
leave_application = get_leave_application(self.employee)
filters = frappe._dict(
{"month": previous_month, "year": now.year, "company": company, "summarized_view": 1}
)
report = execute(filters=filters)
row = report[1][0]
self.assertEqual(row["employee"], self.employee)
# 4 present + half day absent 0.5
self.assertEqual(row["total_present"], 4.5)
# 1 present + half day absent 0.5
self.assertEqual(row["total_absent"], 1.5)
# leave days + half day leave 0.5
self.assertEqual(row["total_leaves"], leave_application.total_leave_days + 0.5)
self.assertEqual(row["_test_leave_type"], leave_application.total_leave_days)
self.assertEqual(row["total_late_entries"], 1)
self.assertEqual(row["total_early_exits"], 1)
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
def test_attendance_with_group_by_filter(self):
now = now_datetime()
previous_month = now.month - 1
previous_month_first = now.replace(day=1).replace(month=previous_month).date()
company = frappe.db.get_value("Employee", self.employee, "company")
# attendance with shift
mark_attendance(self.employee, previous_month_first, "Absent", "Day Shift")
mark_attendance(
self.employee, previous_month_first + relativedelta(days=1), "Present", "Day Shift"
)
# attendance without shift
mark_attendance(self.employee, previous_month_first + relativedelta(days=2), "On Leave")
mark_attendance(self.employee, previous_month_first + relativedelta(days=3), "Present")
filters = frappe._dict(
{"month": previous_month, "year": now.year, "company": company, "group_by": "Department"}
)
report = execute(filters=filters)
department = frappe.db.get_value("Employee", self.employee, "department")
department_row = report[1][0]
self.assertIn(department, department_row["department"])
day_shift_row = report[1][1]
row_without_shift = report[1][2]
self.assertEqual(day_shift_row["shift"], "Day Shift")
self.assertEqual(day_shift_row[1], "A") # absent on the 1st day of the month
self.assertEqual(day_shift_row[2], "P") # present on the 2nd day
self.assertEqual(row_without_shift["shift"], None)
self.assertEqual(row_without_shift[3], "L") # on leave on the 3rd day
self.assertEqual(row_without_shift[4], "P") # present on the 4th day
def test_attendance_with_employee_filter(self):
now = now_datetime()
previous_month = now.month - 1
previous_month_first = now.replace(day=1).replace(month=previous_month).date()
company = frappe.db.get_value("Employee", self.employee, "company")
# mark different attendance status on first 3 days of previous month
mark_attendance(self.employee, previous_month_first, "Absent")
mark_attendance(self.employee, previous_month_first + relativedelta(days=1), "Present")
mark_attendance(self.employee, previous_month_first + relativedelta(days=2), "On Leave")
filters = frappe._dict(
{"month": previous_month, "year": now.year, "company": company, "employee": self.employee}
)
report = execute(filters=filters)
record = report[1][0]
datasets = report[3]["data"]["datasets"]
absent = datasets[0]["values"]
present = datasets[1]["values"]
leaves = datasets[2]["values"]
# ensure correct attendance is reflected on the report
self.assertEqual(self.employee, record.get("employee"))
self.assertEqual(absent[0], 1)
self.assertEqual(present[1], 1)
self.assertEqual(leaves[2], 1)
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
def test_validations(self):
# validation error for filters without month and year
self.assertRaises(frappe.ValidationError, execute_report_with_invalid_filters)
# execute report without attendance record
now = now_datetime()
previous_month = now.month - 1
company = frappe.db.get_value("Employee", self.employee, "company")
filters = frappe._dict(
{"month": previous_month, "year": now.year, "company": company, "group_by": "Department"}
)
report = execute(filters=filters)
self.assertEqual(report, ([], [], None, None))
def get_leave_application(employee):
now = now_datetime()
previous_month = now.month - 1
date = getdate()
year_start = getdate(get_year_start(date))
year_end = getdate(get_year_ending(date))
make_allocation_record(employee=employee, from_date=year_start, to_date=year_end)
from_date = now.replace(day=7).replace(month=previous_month).date()
to_date = now.replace(day=8).replace(month=previous_month).date()
return make_leave_application(employee, from_date, to_date, "_Test Leave Type")
def execute_report_with_invalid_filters():
filters = frappe._dict({"company": "_Test Company", "group_by": "Department"})
execute(filters=filters)

View File

@ -330,6 +330,17 @@ def update_previous_leave_allocation(
allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False)
create_additional_leave_ledger_entry(allocation, earned_leaves, today_date)
if e_leave_type.based_on_date_of_joining:
text = _("allocated {0} leave(s) via scheduler on {1} based on the date of joining").format(
frappe.bold(earned_leaves), frappe.bold(formatdate(today_date))
)
else:
text = _("allocated {0} leave(s) via scheduler on {1}").format(
frappe.bold(earned_leaves), frappe.bold(formatdate(today_date))
)
allocation.add_comment(comment_type="Info", text=text)
def get_monthly_earned_leave(annual_leaves, frequency, rounding):
earned_leaves = 0.0

View File

@ -140,26 +140,6 @@ erpnext.maintenance.MaintenanceSchedule = class MaintenanceSchedule extends frap
}
}
start_date(doc, cdt, cdn) {
this.set_no_of_visits(doc, cdt, cdn);
}
end_date(doc, cdt, cdn) {
this.set_no_of_visits(doc, cdt, cdn);
}
periodicity(doc, cdt, cdn) {
this.set_no_of_visits(doc, cdt, cdn);
}
set_no_of_visits(doc, cdt, cdn) {
var item = frappe.get_doc(cdt, cdn);
let me = this;
if (item.start_date && item.periodicity) {
me.frm.call('validate_end_date_visits');
}
}
};
extend_cscript(cur_frm.cscript, new erpnext.maintenance.MaintenanceSchedule({frm: cur_frm}));

View File

@ -213,6 +213,26 @@ class MaintenanceSchedule(TransactionBase):
if chk:
throw(_("Maintenance Schedule {0} exists against {1}").format(chk[0][0], d.sales_order))
def validate_items_table_change(self):
doc_before_save = self.get_doc_before_save()
if not doc_before_save:
return
for prev_item, item in zip(doc_before_save.items, self.items):
fields = [
"item_code",
"start_date",
"end_date",
"periodicity",
"sales_person",
"no_of_visits",
"serial_no",
]
for field in fields:
b_doc = prev_item.as_dict()
doc = item.as_dict()
if cstr(b_doc[field]) != cstr(doc[field]):
return True
def validate_no_of_visits(self):
return len(self.schedules) != sum(d.no_of_visits for d in self.items)
@ -221,7 +241,7 @@ class MaintenanceSchedule(TransactionBase):
self.validate_maintenance_detail()
self.validate_dates_with_periodicity()
self.validate_sales_order()
if not self.schedules or self.validate_no_of_visits():
if not self.schedules or self.validate_items_table_change() or self.validate_no_of_visits():
self.generate_schedule()
def on_update(self):

View File

@ -123,6 +123,36 @@ class TestMaintenanceSchedule(unittest.TestCase):
frappe.db.rollback()
def test_schedule_with_serials(self):
# Checks whether serials are automatically updated when changing in items table.
# Also checks if other fields trigger generate schdeule if changed in items table.
item_code = "_Test Serial Item"
make_serial_item_with_serial(item_code)
ms = make_maintenance_schedule(item_code=item_code, serial_no="TEST001, TEST002")
ms.save()
# Before Save
self.assertEqual(ms.schedules[0].serial_no, "TEST001, TEST002")
self.assertEqual(ms.schedules[0].sales_person, "Sales Team")
self.assertEqual(len(ms.schedules), 4)
self.assertFalse(ms.validate_items_table_change())
# After Save
ms.items[0].serial_no = "TEST001"
ms.items[0].sales_person = "_Test Sales Person"
ms.items[0].no_of_visits = 2
self.assertTrue(ms.validate_items_table_change())
ms.save()
self.assertEqual(ms.schedules[0].serial_no, "TEST001")
self.assertEqual(ms.schedules[0].sales_person, "_Test Sales Person")
self.assertEqual(len(ms.schedules), 2)
# When user manually deleted a row from schedules table.
ms.schedules.pop()
self.assertEqual(len(ms.schedules), 1)
ms.save()
self.assertEqual(len(ms.schedules), 2)
frappe.db.rollback()
def make_serial_item_with_serial(item_code):
serial_item_doc = create_item(item_code, is_stock_item=1)

View File

@ -12,6 +12,9 @@ frappe.ui.form.on('Maintenance Visit', {
// filters for serial no based on item code
if (frm.doc.maintenance_type === "Scheduled") {
let item_code = frm.doc.purposes[0].item_code;
if (!item_code) {
return;
}
frappe.call({
method: "erpnext.maintenance.doctype.maintenance_schedule.maintenance_schedule.get_serial_nos_from_schedule",
args: {

View File

@ -109,7 +109,6 @@
"read_only": 1
},
{
"default": "5",
"depends_on": "eval:parent.doctype == 'BOM'",
"fieldname": "base_operating_cost",
"fieldtype": "Currency",
@ -187,7 +186,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2022-03-10 06:19:08.462027",
"modified": "2022-04-08 01:18:33.547481",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Operation",

View File

@ -73,10 +73,22 @@ frappe.ui.form.on('Job Card', {
if (frm.doc.docstatus == 0 && !frm.is_new() &&
(frm.doc.for_quantity > frm.doc.total_completed_qty || !frm.doc.for_quantity)
&& (frm.doc.items || !frm.doc.items.length || frm.doc.for_quantity == frm.doc.transferred_qty)) {
frm.trigger("prepare_timer_buttons");
// if Job Card is link to Work Order, the job card must not be able to start if Work Order not "Started"
// and if stock mvt for WIP is required
if (frm.doc.work_order) {
frappe.db.get_value('Work Order', frm.doc.work_order, ['skip_transfer', 'status'], (result) => {
if (result.skip_transfer === 1 || result.status == 'In Process') {
frm.trigger("prepare_timer_buttons");
}
});
} else {
frm.trigger("prepare_timer_buttons");
}
}
frm.trigger("setup_quality_inspection");
if (frm.doc.work_order) {
frappe.db.get_value('Work Order', frm.doc.work_order,
'transfer_material_against').then((r) => {

View File

@ -462,6 +462,7 @@ class ProductionPlan(Document):
work_order_data = {
"wip_warehouse": default_warehouses.get("wip_warehouse"),
"fg_warehouse": default_warehouses.get("fg_warehouse"),
"company": self.get("company"),
}
self.prepare_data_for_sub_assembly_items(row, work_order_data)
@ -499,6 +500,7 @@ class ProductionPlan(Document):
for supplier, po_list in subcontracted_po.items():
po = frappe.new_doc("Purchase Order")
po.company = self.company
po.supplier = supplier
po.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate()
po.is_subcontracted = 1

View File

@ -1144,6 +1144,56 @@ class TestWorkOrder(FrappeTestCase):
for index, row in enumerate(ste_manu.get("items"), start=1):
self.assertEqual(index, row.idx)
@change_settings(
"Manufacturing Settings",
{"backflush_raw_materials_based_on": "Material Transferred for Manufacture"},
)
def test_work_order_multiple_material_transfer(self):
"""
Test transferring multiple RMs in separate Stock Entries.
"""
work_order = make_wo_order_test_record(planned_start_date=now(), qty=1)
test_stock_entry.make_stock_entry( # stock up RM
item_code="_Test Item",
target="_Test Warehouse - _TC",
qty=1,
basic_rate=5000.0,
)
test_stock_entry.make_stock_entry( # stock up RM
item_code="_Test Item Home Desktop 100",
target="_Test Warehouse - _TC",
qty=2,
basic_rate=1000.0,
)
transfer_entry = frappe.get_doc(
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 1)
)
del transfer_entry.get("items")[0] # transfer only one RM
transfer_entry.submit()
# WO's "Material Transferred for Mfg" shows all is transferred, one RM is pending
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
self.assertEqual(work_order.required_items[0].transferred_qty, 0)
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
final_transfer_entry = frappe.get_doc( # transfer last RM with For Quantity = 0
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
)
final_transfer_entry.save()
self.assertEqual(final_transfer_entry.fg_completed_qty, 0.0)
self.assertEqual(final_transfer_entry.items[0].qty, 1)
final_transfer_entry.submit()
work_order.reload()
# WO's "Material Transferred for Mfg" shows all is transferred, no RM is pending
self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
self.assertEqual(work_order.required_items[0].transferred_qty, 1)
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
def update_job_card(job_card, jc_qty=None):
employee = frappe.db.get_value("Employee", {"status": "Active"}, "name")

View File

@ -540,8 +540,10 @@ erpnext.work_order = {
|| frm.doc.transfer_material_against == 'Job Card') ? 0 : 1;
if (show_start_btn) {
if ((flt(doc.material_transferred_for_manufacturing) < flt(doc.qty))
&& frm.doc.status != 'Stopped') {
let pending_to_transfer = frm.doc.required_items.some(
item => flt(item.transferred_qty) < flt(item.required_qty)
);
if (pending_to_transfer && frm.doc.status != 'Stopped') {
frm.has_start_btn = true;
frm.add_custom_button(__('Create Pick List'), function() {
erpnext.work_order.create_pick_list(frm);

View File

@ -1186,7 +1186,11 @@ def make_stock_entry(work_order_id, purpose, qty=None):
stock_entry.from_bom = 1
stock_entry.bom_no = work_order.bom_no
stock_entry.use_multi_level_bom = work_order.use_multi_level_bom
stock_entry.fg_completed_qty = qty or (flt(work_order.qty) - flt(work_order.produced_qty))
# accept 0 qty as well
stock_entry.fg_completed_qty = (
qty if qty is not None else (flt(work_order.qty) - flt(work_order.produced_qty))
)
if work_order.bom_no:
stock_entry.inspection_required = frappe.db.get_value(
"BOM", work_order.bom_no, "inspection_required"

View File

@ -262,8 +262,6 @@ erpnext.patches.v13_0.make_non_standard_user_type #13-04-2021 #17-01-2022
erpnext.patches.v13_0.update_shipment_status
erpnext.patches.v13_0.remove_attribute_field_from_item_variant_setting
erpnext.patches.v12_0.add_ewaybill_validity_field
erpnext.patches.v13_0.germany_make_custom_fields
erpnext.patches.v13_0.germany_fill_debtor_creditor_number
erpnext.patches.v13_0.set_pos_closing_as_failed
erpnext.patches.v13_0.rename_stop_to_send_birthday_reminders
execute:frappe.rename_doc("Workspace", "Loan Management", "Loans", force=True)
@ -343,6 +341,7 @@ erpnext.patches.v14_0.delete_shopify_doctypes
erpnext.patches.v14_0.delete_hub_doctypes
erpnext.patches.v14_0.delete_hospitality_doctypes # 20-01-2022
erpnext.patches.v14_0.delete_agriculture_doctypes
erpnext.patches.v14_0.delete_datev_doctypes
erpnext.patches.v14_0.rearrange_company_fields
erpnext.patches.v14_0.update_leave_notification_template
erpnext.patches.v14_0.restore_einvoice_fields
@ -364,3 +363,5 @@ erpnext.patches.v13_0.add_cost_center_in_loans
erpnext.patches.v13_0.set_return_against_in_pos_invoice_references
erpnext.patches.v13_0.remove_unknown_links_to_prod_plan_items # 24-03-2022
erpnext.patches.v13_0.update_expense_claim_status_for_paid_advances
erpnext.patches.v13_0.create_gst_custom_fields_in_quotation
erpnext.patches.v13_0.copy_custom_field_filters_to_website_item

View File

@ -0,0 +1,94 @@
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
def execute():
"Add Field Filters, that are not standard fields in Website Item, as Custom Fields."
def move_table_multiselect_data(docfield):
"Copy child table data (Table Multiselect) from Item to Website Item for a docfield."
table_multiselect_data = get_table_multiselect_data(docfield)
field = docfield.fieldname
for row in table_multiselect_data:
# add copied multiselect data rows in Website Item
web_item = frappe.db.get_value("Website Item", {"item_code": row.parent})
web_item_doc = frappe.get_doc("Website Item", web_item)
child_doc = frappe.new_doc(docfield.options, web_item_doc, field)
for field in ["name", "creation", "modified", "idx"]:
row[field] = None
child_doc.update(row)
child_doc.parenttype = "Website Item"
child_doc.parent = web_item
child_doc.insert()
def get_table_multiselect_data(docfield):
child_table = frappe.qb.DocType(docfield.options)
item = frappe.qb.DocType("Item")
table_multiselect_data = ( # query table data for field
frappe.qb.from_(child_table)
.join(item)
.on(item.item_code == child_table.parent)
.select(child_table.star)
.where((child_table.parentfield == docfield.fieldname) & (item.published_in_website == 1))
).run(as_dict=True)
return table_multiselect_data
settings = frappe.get_doc("E Commerce Settings")
if not (settings.enable_field_filters or settings.filter_fields):
return
item_meta = frappe.get_meta("Item")
valid_item_fields = [
df.fieldname for df in item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"]
]
web_item_meta = frappe.get_meta("Website Item")
valid_web_item_fields = [
df.fieldname for df in web_item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"]
]
for row in settings.filter_fields:
# skip if illegal field
if row.fieldname not in valid_item_fields:
continue
# if Item field is not in Website Item, add it as a custom field
if row.fieldname not in valid_web_item_fields:
df = item_meta.get_field(row.fieldname)
create_custom_field(
"Website Item",
dict(
owner="Administrator",
fieldname=df.fieldname,
label=df.label,
fieldtype=df.fieldtype,
options=df.options,
description=df.description,
read_only=df.read_only,
no_copy=df.no_copy,
insert_after="on_backorder",
),
)
# map field values
if df.fieldtype == "Table MultiSelect":
move_table_multiselect_data(df)
else:
frappe.db.sql( # nosemgrep
"""
UPDATE `tabWebsite Item` wi, `tabItem` i
SET wi.{0} = i.{0}
WHERE wi.item_code = i.item_code
""".format(
row.fieldname
)
)

View File

@ -0,0 +1,53 @@
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
def execute():
company = frappe.get_all("Company", filters={"country": "India"}, fields=["name"])
if not company:
return
sales_invoice_gst_fields = [
dict(
fieldname="billing_address_gstin",
label="Billing Address GSTIN",
fieldtype="Data",
insert_after="customer_address",
read_only=1,
fetch_from="customer_address.gstin",
print_hide=1,
length=15,
),
dict(
fieldname="customer_gstin",
label="Customer GSTIN",
fieldtype="Data",
insert_after="shipping_address_name",
fetch_from="shipping_address_name.gstin",
print_hide=1,
length=15,
),
dict(
fieldname="place_of_supply",
label="Place of Supply",
fieldtype="Data",
insert_after="customer_gstin",
print_hide=1,
read_only=1,
length=50,
),
dict(
fieldname="company_gstin",
label="Company GSTIN",
fieldtype="Data",
insert_after="company_address",
fetch_from="company_address.gstin",
print_hide=1,
read_only=1,
length=15,
),
]
custom_fields = {"Quotation": sales_invoice_gst_fields}
create_custom_fields(custom_fields, update=True)

View File

@ -1,36 +0,0 @@
# Copyright (c) 2019, Frappe and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
def execute():
"""Move account number into the new custom field debtor_creditor_number.
German companies used to use a dedicated payable/receivable account for
every party to mimick party accounts in the external accounting software
"DATEV". This is no longer necessary. The reference ID for DATEV will be
stored in a new custom field "debtor_creditor_number".
"""
company_list = frappe.get_all("Company", filters={"country": "Germany"})
for company in company_list:
party_account_list = frappe.get_all(
"Party Account",
filters={"company": company.name},
fields=["name", "account", "debtor_creditor_number"],
)
for party_account in party_account_list:
if (not party_account.account) or party_account.debtor_creditor_number:
# account empty or debtor_creditor_number already filled
continue
account_number = frappe.db.get_value("Account", party_account.account, "account_number")
if not account_number:
continue
frappe.db.set_value(
"Party Account", party_account.name, "debtor_creditor_number", account_number
)
frappe.db.set_value("Party Account", party_account.name, "account", "")

View File

@ -1,20 +0,0 @@
# Copyright (c) 2019, Frappe and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.regional.germany.setup import make_custom_fields
def execute():
"""Execute the make_custom_fields method for german companies.
It is usually run once at setup of a new company. Since it's new, run it
once for existing companies as well.
"""
company_list = frappe.get_all("Company", filters={"country": "Germany"})
if not company_list:
return
make_custom_fields()

View File

@ -10,54 +10,58 @@ def execute():
frappe.reload_doc("hr", "doctype", "Leave Encashment")
additional_salaries = frappe.get_all(
"Additional Salary",
fields=["name", "salary_slip", "type", "salary_component"],
filters={"salary_slip": ["!=", ""]},
group_by="salary_slip",
)
leave_encashments = frappe.get_all(
"Leave Encashment",
fields=["name", "additional_salary"],
filters={"additional_salary": ["!=", ""]},
)
employee_incentives = frappe.get_all(
"Employee Incentive",
fields=["name", "additional_salary"],
filters={"additional_salary": ["!=", ""]},
)
for incentive in employee_incentives:
frappe.db.sql(
""" UPDATE `tabAdditional Salary`
SET ref_doctype = 'Employee Incentive', ref_docname = %s
WHERE name = %s
""",
(incentive["name"], incentive["additional_salary"]),
if frappe.db.has_column("Leave Encashment", "additional_salary"):
leave_encashments = frappe.get_all(
"Leave Encashment",
fields=["name", "additional_salary"],
filters={"additional_salary": ["!=", ""]},
)
for leave_encashment in leave_encashments:
frappe.db.sql(
""" UPDATE `tabAdditional Salary`
SET ref_doctype = 'Leave Encashment', ref_docname = %s
WHERE name = %s
""",
(leave_encashment["name"], leave_encashment["additional_salary"]),
)
salary_slips = [sal["salary_slip"] for sal in additional_salaries]
for salary in additional_salaries:
comp_type = "earnings" if salary["type"] == "Earning" else "deductions"
if salary["salary_slip"] and salary_slips.count(salary["salary_slip"]) == 1:
for leave_encashment in leave_encashments:
frappe.db.sql(
"""
UPDATE `tabSalary Detail`
SET additional_salary = %s
WHERE parenttype = 'Salary Slip'
and parentfield = %s
and parent = %s
and salary_component = %s
""" UPDATE `tabAdditional Salary`
SET ref_doctype = 'Leave Encashment', ref_docname = %s
WHERE name = %s
""",
(salary["name"], comp_type, salary["salary_slip"], salary["salary_component"]),
(leave_encashment["name"], leave_encashment["additional_salary"]),
)
if frappe.db.has_column("Employee Incentive", "additional_salary"):
employee_incentives = frappe.get_all(
"Employee Incentive",
fields=["name", "additional_salary"],
filters={"additional_salary": ["!=", ""]},
)
for incentive in employee_incentives:
frappe.db.sql(
""" UPDATE `tabAdditional Salary`
SET ref_doctype = 'Employee Incentive', ref_docname = %s
WHERE name = %s
""",
(incentive["name"], incentive["additional_salary"]),
)
if frappe.db.has_column("Additional Salary", "salary_slip"):
additional_salaries = frappe.get_all(
"Additional Salary",
fields=["name", "salary_slip", "type", "salary_component"],
filters={"salary_slip": ["!=", ""]},
group_by="salary_slip",
)
salary_slips = [sal["salary_slip"] for sal in additional_salaries]
for salary in additional_salaries:
comp_type = "earnings" if salary["type"] == "Earning" else "deductions"
if salary["salary_slip"] and salary_slips.count(salary["salary_slip"]) == 1:
frappe.db.sql(
"""
UPDATE `tabSalary Detail`
SET additional_salary = %s
WHERE parenttype = 'Salary Slip'
and parentfield = %s
and parent = %s
and salary_component = %s
""",
(salary["name"], comp_type, salary["salary_slip"], salary["salary_component"]),
)

View File

@ -0,0 +1,13 @@
import frappe
def execute():
install_apps = frappe.get_installed_apps()
if "erpnext_datev_uo" in install_apps or "erpnext_datev" in install_apps:
return
# doctypes
frappe.delete_doc("DocType", "DATEV Settings", ignore_missing=True, force=True)
# reports
frappe.delete_doc("Report", "DATEV", ignore_missing=True, force=True)

View File

@ -24,7 +24,9 @@ class TestGratuity(unittest.TestCase):
frappe.db.delete("Gratuity")
frappe.db.delete("Additional Salary", {"ref_doctype": "Gratuity"})
make_earning_salary_component(setup=True, test_tax=True, company_list=["_Test Company"])
make_earning_salary_component(
setup=True, test_tax=True, company_list=["_Test Company"], include_flexi_benefits=True
)
make_deduction_salary_component(setup=True, test_tax=True, company_list=["_Test Company"])
def test_get_last_salary_slip_should_return_none_for_new_employee(self):

View File

@ -952,8 +952,12 @@ class SalarySlip(TransactionBase):
)
# Structured tax amount
total_structured_tax_amount = self.calculate_tax_by_tax_slab(
total_taxable_earnings_without_full_tax_addl_components, tax_slab
eval_locals = self.get_data_for_eval()
total_structured_tax_amount = calculate_tax_by_tax_slab(
total_taxable_earnings_without_full_tax_addl_components,
tax_slab,
self.whitelisted_globals,
eval_locals,
)
current_structured_tax_amount = (
total_structured_tax_amount - previous_total_paid_taxes
@ -962,7 +966,9 @@ class SalarySlip(TransactionBase):
# Total taxable earnings with additional earnings with full tax
full_tax_on_additional_earnings = 0.0
if current_additional_earnings_with_full_tax:
total_tax_amount = self.calculate_tax_by_tax_slab(total_taxable_earnings, tax_slab)
total_tax_amount = calculate_tax_by_tax_slab(
total_taxable_earnings, tax_slab, self.whitelisted_globals, eval_locals
)
full_tax_on_additional_earnings = total_tax_amount - total_structured_tax_amount
current_tax_amount = current_structured_tax_amount + full_tax_on_additional_earnings
@ -1278,50 +1284,6 @@ class SalarySlip(TransactionBase):
fields="SUM(amount) as total_amount",
)[0].total_amount
def calculate_tax_by_tax_slab(self, annual_taxable_earning, tax_slab):
data = self.get_data_for_eval()
data.update({"annual_taxable_earning": annual_taxable_earning})
tax_amount = 0
for slab in tax_slab.slabs:
cond = cstr(slab.condition).strip()
if cond and not self.eval_tax_slab_condition(cond, data):
continue
if not slab.to_amount and annual_taxable_earning >= slab.from_amount:
tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction * 0.01
continue
if annual_taxable_earning >= slab.from_amount and annual_taxable_earning < slab.to_amount:
tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction * 0.01
elif annual_taxable_earning >= slab.from_amount and annual_taxable_earning >= slab.to_amount:
tax_amount += (slab.to_amount - slab.from_amount + 1) * slab.percent_deduction * 0.01
# other taxes and charges on income tax
for d in tax_slab.other_taxes_and_charges:
if flt(d.min_taxable_income) and flt(d.min_taxable_income) > annual_taxable_earning:
continue
if flt(d.max_taxable_income) and flt(d.max_taxable_income) < annual_taxable_earning:
continue
tax_amount += tax_amount * flt(d.percent) / 100
return tax_amount
def eval_tax_slab_condition(self, condition, data):
try:
condition = condition.strip()
if condition:
return frappe.safe_eval(condition, self.whitelisted_globals, data)
except NameError as err:
frappe.throw(
_("{0} <br> This error can be due to missing or deleted field.").format(err),
title=_("Name error"),
)
except SyntaxError as err:
frappe.throw(_("Syntax error in condition: {0}").format(err))
except Exception as e:
frappe.throw(_("Error in formula or condition: {0}").format(e))
raise
def get_component_totals(self, component_type, depends_on_payment_days=0):
joining_date, relieving_date = frappe.get_cached_value(
"Employee", self.employee, ["date_of_joining", "relieving_date"]
@ -1705,3 +1667,60 @@ def get_payroll_payable_account(company, payroll_entry):
)
return payroll_payable_account
def calculate_tax_by_tax_slab(
annual_taxable_earning, tax_slab, eval_globals=None, eval_locals=None
):
eval_locals.update({"annual_taxable_earning": annual_taxable_earning})
tax_amount = 0
for slab in tax_slab.slabs:
cond = cstr(slab.condition).strip()
if cond and not eval_tax_slab_condition(cond, eval_globals, eval_locals):
continue
if not slab.to_amount and annual_taxable_earning >= slab.from_amount:
tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction * 0.01
continue
if annual_taxable_earning >= slab.from_amount and annual_taxable_earning < slab.to_amount:
tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction * 0.01
elif annual_taxable_earning >= slab.from_amount and annual_taxable_earning >= slab.to_amount:
tax_amount += (slab.to_amount - slab.from_amount + 1) * slab.percent_deduction * 0.01
# other taxes and charges on income tax
for d in tax_slab.other_taxes_and_charges:
if flt(d.min_taxable_income) and flt(d.min_taxable_income) > annual_taxable_earning:
continue
if flt(d.max_taxable_income) and flt(d.max_taxable_income) < annual_taxable_earning:
continue
tax_amount += tax_amount * flt(d.percent) / 100
return tax_amount
def eval_tax_slab_condition(condition, eval_globals=None, eval_locals=None):
if not eval_globals:
eval_globals = {
"int": int,
"float": float,
"long": int,
"round": round,
"date": datetime.date,
"getdate": getdate,
}
try:
condition = condition.strip()
if condition:
return frappe.safe_eval(condition, eval_globals, eval_locals)
except NameError as err:
frappe.throw(
_("{0} <br> This error can be due to missing or deleted field.").format(err),
title=_("Name error"),
)
except SyntaxError as err:
frappe.throw(_("Syntax error in condition: {0} in Income Tax Slab").format(err))
except Exception as e:
frappe.throw(_("Error in formula or condition: {0} in Income Tax Slab").format(e))
raise

View File

@ -772,6 +772,7 @@ class TestSalarySlip(unittest.TestCase):
"Monthly",
other_details={"max_benefits": 100000},
test_tax=True,
include_flexi_benefits=True,
employee=employee,
payroll_period=payroll_period,
)
@ -875,6 +876,7 @@ class TestSalarySlip(unittest.TestCase):
"Monthly",
other_details={"max_benefits": 100000},
test_tax=True,
include_flexi_benefits=True,
employee=employee,
payroll_period=payroll_period,
)
@ -1022,7 +1024,9 @@ def create_account(account_name, company, parent_account, account_type=None):
return account
def make_earning_salary_component(setup=False, test_tax=False, company_list=None):
def make_earning_salary_component(
setup=False, test_tax=False, company_list=None, include_flexi_benefits=False
):
data = [
{
"salary_component": "Basic Salary",
@ -1043,7 +1047,7 @@ def make_earning_salary_component(setup=False, test_tax=False, company_list=None
},
{"salary_component": "Leave Encashment", "abbr": "LE", "type": "Earning"},
]
if test_tax:
if include_flexi_benefits:
data.extend(
[
{
@ -1063,11 +1067,18 @@ def make_earning_salary_component(setup=False, test_tax=False, company_list=None
"type": "Earning",
"max_benefit_amount": 15000,
},
]
)
if test_tax:
data.extend(
[
{"salary_component": "Performance Bonus", "abbr": "B", "type": "Earning"},
]
)
if setup or test_tax:
make_salary_component(data, test_tax, company_list)
data.append(
{
"salary_component": "Basic Salary",

View File

@ -149,6 +149,7 @@ def make_salary_structure(
company=None,
currency=erpnext.get_default_currency(),
payroll_period=None,
include_flexi_benefits=False,
):
if test_tax:
frappe.db.sql("""delete from `tabSalary Structure` where name=%s""", (salary_structure))
@ -161,7 +162,10 @@ def make_salary_structure(
"name": salary_structure,
"company": company or erpnext.get_default_company(),
"earnings": make_earning_salary_component(
setup=True, test_tax=test_tax, company_list=["_Test Company"]
setup=True,
test_tax=test_tax,
company_list=["_Test Company"],
include_flexi_benefits=include_flexi_benefits,
),
"deductions": make_deduction_salary_component(
setup=True, test_tax=test_tax, company_list=["_Test Company"]

View File

@ -0,0 +1,47 @@
// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
/* eslint-disable */
frappe.query_reports["Income Tax Computation"] = {
"filters": [
{
"fieldname":"company",
"label": __("Company"),
"fieldtype": "Link",
"options": "Company",
"default": frappe.defaults.get_user_default("Company"),
"width": "100px",
"reqd": 1
},
{
"fieldname":"payroll_period",
"label": __("Payroll Period"),
"fieldtype": "Link",
"options": "Payroll Period",
"width": "100px",
"reqd": 1
},
{
"fieldname":"employee",
"label": __("Employee"),
"fieldtype": "Link",
"options": "Employee",
"width": "100px"
},
{
"fieldname":"department",
"label": __("Department"),
"fieldtype": "Link",
"options": "Department",
"width": "100px",
},
{
"fieldname":"consider_tax_exemption_declaration",
"label": __("Consider Tax Exemption Declaration"),
"fieldtype": "Check",
"width": "180px"
}
]
};

View File

@ -0,0 +1,36 @@
{
"add_total_row": 0,
"columns": [],
"creation": "2022-02-17 17:19:30.921422",
"disable_prepared_report": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"letter_head": "",
"modified": "2022-02-23 13:07:30.347861",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Income Tax Computation",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Salary Slip",
"report_name": "Income Tax Computation",
"report_type": "Script Report",
"roles": [
{
"role": "Employee"
},
{
"role": "HR User"
},
{
"role": "HR Manager"
},
{
"role": "Employee Self Service"
}
]
}

View File

@ -0,0 +1,513 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _, scrub
from frappe.query_builder.functions import Sum
from frappe.utils import add_days, flt, getdate, rounded
from erpnext.payroll.doctype.payroll_entry.payroll_entry import get_start_end_dates
from erpnext.payroll.doctype.salary_slip.salary_slip import calculate_tax_by_tax_slab
def execute(filters=None):
return IncomeTaxComputationReport(filters).run()
class IncomeTaxComputationReport(object):
def __init__(self, filters=None):
self.filters = frappe._dict(filters or {})
self.columns = []
self.data = []
self.employees = frappe._dict()
self.payroll_period_start_date = None
self.payroll_period_end_date = None
if self.filters.payroll_period:
self.payroll_period_start_date, self.payroll_period_end_date = frappe.db.get_value(
"Payroll Period", self.filters.payroll_period, ["start_date", "end_date"]
)
def run(self):
self.get_fixed_columns()
self.get_data()
return self.columns, self.data
def get_data(self):
self.get_employee_details()
self.get_future_salary_slips()
self.get_ctc()
self.get_tax_exempted_earnings_and_deductions()
self.get_employee_tax_exemptions()
self.get_hra()
self.get_standard_tax_exemption()
self.get_total_taxable_amount()
self.get_applicable_tax()
self.get_total_deducted_tax()
self.get_payable_tax()
self.data = list(self.employees.values())
def get_employee_details(self):
filters, or_filters = self.get_employee_filters()
fields = [
"name as employee",
"employee_name",
"department",
"designation",
"date_of_joining",
"relieving_date",
]
employees = frappe.get_all("Employee", filters=filters, or_filters=or_filters, fields=fields)
ss_assignments = self.get_ss_assignments([d.employee for d in employees])
for d in employees:
if d.employee in list(ss_assignments.keys()):
d.update(ss_assignments[d.employee])
self.employees.setdefault(d.employee, d)
if not self.employees:
frappe.throw(_("No employees found with selected filters and active salary structure"))
def get_employee_filters(self):
filters = {"company": self.filters.company}
or_filters = {
"status": "Active",
"relieving_date": ["between", [self.payroll_period_start_date, self.payroll_period_end_date]],
}
if self.filters.employee:
filters = {"name": self.filters.employee}
elif self.filters.department:
filters.update({"department": self.filters.department})
return filters, or_filters
def get_ss_assignments(self, employees):
ss_assignments = frappe.get_all(
"Salary Structure Assignment",
filters={
"employee": ["in", employees],
"docstatus": 1,
"salary_structure": ["is", "set"],
"income_tax_slab": ["is", "set"],
},
fields=["employee", "income_tax_slab", "salary_structure"],
order_by="from_date desc",
)
employee_ss_assignments = frappe._dict()
for d in ss_assignments:
if d.employee not in list(employee_ss_assignments.keys()):
tax_slab = frappe.get_cached_value(
"Income Tax Slab", d.income_tax_slab, ["allow_tax_exemption", "disabled"], as_dict=1
)
if tax_slab and not tax_slab.disabled:
employee_ss_assignments.setdefault(
d.employee,
{
"salary_structure": d.salary_structure,
"income_tax_slab": d.income_tax_slab,
"allow_tax_exemption": tax_slab.allow_tax_exemption,
},
)
return employee_ss_assignments
def get_future_salary_slips(self):
self.future_salary_slips = frappe._dict()
for employee in list(self.employees.keys()):
last_ss = self.get_last_salary_slip(employee)
if last_ss and last_ss.end_date == self.payroll_period_end_date:
continue
relieving_date = self.employees[employee].get("relieving_date", "")
if last_ss:
ss_start_date = add_days(last_ss.end_date, 1)
else:
ss_start_date = self.payroll_period_start_date
last_ss = frappe._dict(
{
"payroll_frequency": "Monthly",
"salary_structure": self.employees[employee].get("salary_structure"),
}
)
while getdate(ss_start_date) < getdate(self.payroll_period_end_date) and (
not relieving_date or getdate(ss_start_date) < relieving_date
):
ss_end_date = get_start_end_dates(last_ss.payroll_frequency, ss_start_date).end_date
ss = frappe.new_doc("Salary Slip")
ss.employee = employee
ss.start_date = ss_start_date
ss.end_date = ss_end_date
ss.salary_structure = last_ss.salary_structure
ss.payroll_frequency = last_ss.payroll_frequency
ss.company = self.filters.company
try:
ss.process_salary_structure(for_preview=1)
self.future_salary_slips.setdefault(employee, []).append(ss.as_dict())
except Exception:
break
ss_start_date = add_days(ss_end_date, 1)
def get_last_salary_slip(self, employee):
last_salary_slip = frappe.db.get_value(
"Salary Slip",
{
"employee": employee,
"docstatus": 1,
"start_date": ["between", [self.payroll_period_start_date, self.payroll_period_end_date]],
},
["start_date", "end_date", "salary_structure", "payroll_frequency"],
order_by="start_date desc",
as_dict=1,
)
return last_salary_slip
def get_ctc(self):
# Get total earnings from existing salary slip
ss = frappe.qb.DocType("Salary Slip")
existing_ss = frappe._dict(
(
frappe.qb.from_(ss)
.select(ss.employee, Sum(ss.base_gross_pay).as_("amount"))
.where(ss.docstatus == 1)
.where(ss.employee.isin(list(self.employees.keys())))
.where(ss.start_date >= self.payroll_period_start_date)
.where(ss.end_date <= self.payroll_period_end_date)
.groupby(ss.employee)
).run()
)
for employee in list(self.employees.keys()):
future_ss_earnings = self.get_future_earnings(employee)
ctc = flt(existing_ss.get(employee)) + future_ss_earnings
self.employees[employee].setdefault("ctc", ctc)
def get_future_earnings(self, employee):
future_earnings = 0.0
for ss in self.future_salary_slips.get(employee, []):
future_earnings += flt(ss.base_gross_pay)
return future_earnings
def get_tax_exempted_earnings_and_deductions(self):
tax_exempted_components = self.get_tax_exempted_components()
# Get component totals from existing salary slips
ss = frappe.qb.DocType("Salary Slip")
ss_comps = frappe.qb.DocType("Salary Detail")
records = (
frappe.qb.from_(ss)
.inner_join(ss_comps)
.on(ss.name == ss_comps.parent)
.select(ss.name, ss.employee, ss_comps.salary_component, Sum(ss_comps.amount).as_("amount"))
.where(ss.docstatus == 1)
.where(ss.employee.isin(list(self.employees.keys())))
.where(ss_comps.salary_component.isin(tax_exempted_components))
.where(ss.start_date >= self.payroll_period_start_date)
.where(ss.end_date <= self.payroll_period_end_date)
.groupby(ss.employee, ss_comps.salary_component)
).run(as_dict=True)
existing_ss_exemptions = frappe._dict()
for d in records:
existing_ss_exemptions.setdefault(d.employee, {}).setdefault(
scrub(d.salary_component), d.amount
)
for employee in list(self.employees.keys()):
if not self.employees[employee]["allow_tax_exemption"]:
continue
exemptions = existing_ss_exemptions.get(employee, {})
self.add_exemptions_from_future_salary_slips(employee, exemptions)
self.employees[employee].update(exemptions)
total_exemptions = sum(list(exemptions.values()))
self.add_to_total_exemption(employee, total_exemptions)
def add_exemptions_from_future_salary_slips(self, employee, exemptions):
for ss in self.future_salary_slips.get(employee, []):
for e in ss.earnings:
if not e.is_tax_applicable:
exemptions.setdefault(scrub(e.salary_component), 0)
exemptions[scrub(e.salary_component)] += flt(e.amount)
for d in ss.deductions:
if d.exempted_from_income_tax:
exemptions.setdefault(scrub(d.salary_component), 0)
exemptions[scrub(d.salary_component)] += flt(d.amount)
return exemptions
def get_tax_exempted_components(self):
# nontaxable earning components
nontaxable_earning_components = [
d.name
for d in frappe.get_all(
"Salary Component", {"type": "Earning", "is_tax_applicable": 0, "disabled": 0}
)
]
# tax exempted deduction components
tax_exempted_deduction_components = [
d.name
for d in frappe.get_all(
"Salary Component", {"type": "Deduction", "exempted_from_income_tax": 1, "disabled": 0}
)
]
tax_exempted_components = nontaxable_earning_components + tax_exempted_deduction_components
# Add columns
for d in tax_exempted_components:
self.add_column(d)
return tax_exempted_components
def add_to_total_exemption(self, employee, amount):
self.employees[employee].setdefault("total_exemption", 0)
self.employees[employee]["total_exemption"] += amount
def get_employee_tax_exemptions(self):
# add columns
exemption_categories = frappe.get_all("Employee Tax Exemption Category", {"is_active": 1})
for d in exemption_categories:
self.add_column(d.name)
self.employees_with_proofs = []
self.get_tax_exemptions("Employee Tax Exemption Proof Submission")
if self.filters.consider_tax_exemption_declaration:
self.get_tax_exemptions("Employee Tax Exemption Declaration")
def get_tax_exemptions(self, source):
# Get category-wise exmeptions based on submitted proofs or declarations
if source == "Employee Tax Exemption Proof Submission":
child_doctype = "Employee Tax Exemption Proof Submission Detail"
else:
child_doctype = "Employee Tax Exemption Declaration Category"
max_exemptions = self.get_max_exemptions_based_on_category()
par = frappe.qb.DocType(source)
child = frappe.qb.DocType(child_doctype)
records = (
frappe.qb.from_(par)
.inner_join(child)
.on(par.name == child.parent)
.select(par.employee, child.exemption_category, Sum(child.amount).as_("amount"))
.where(par.docstatus == 1)
.where(par.employee.isin(list(self.employees.keys())))
.where(par.payroll_period == self.filters.payroll_period)
.groupby(par.employee, child.exemption_category)
).run(as_dict=True)
for d in records:
if not self.employees[d.employee]["allow_tax_exemption"]:
continue
if source == "Employee Tax Exemption Declaration" and d.employee in self.employees_with_proofs:
continue
amount = flt(d.amount)
max_eligible_amount = flt(max_exemptions.get(d.exemption_category))
if max_eligible_amount and amount > max_eligible_amount:
amount = max_eligible_amount
self.employees[d.employee].setdefault(scrub(d.exemption_category), amount)
self.add_to_total_exemption(d.employee, amount)
if (
source == "Employee Tax Exemption Proof Submission"
and d.employee not in self.employees_with_proofs
):
self.employees_with_proofs.append(d.employee)
def get_max_exemptions_based_on_category(self):
return dict(
frappe.get_all(
"Employee Tax Exemption Category",
filters={"is_active": 1},
fields=["name", "max_amount"],
as_list=1,
)
)
def get_hra(self):
if not frappe.get_meta("Employee Tax Exemption Declaration").has_field("monthly_house_rent"):
return
self.add_column("HRA")
self.employees_with_proofs = []
self.get_eligible_hra("Employee Tax Exemption Proof Submission")
if self.filters.consider_tax_exemption_declaration:
self.get_eligible_hra("Employee Tax Exemption Declaration")
def get_eligible_hra(self, source):
if source == "Employee Tax Exemption Proof Submission":
hra_amount_field = "total_eligible_hra_exemption"
else:
hra_amount_field = "annual_hra_exemption"
records = frappe.get_all(
source,
filters={
"docstatus": 1,
"employee": ["in", list(self.employees.keys())],
"payroll_period": self.filters.payroll_period,
},
fields=["employee", hra_amount_field],
as_list=1,
)
for d in records:
if not self.employees[d[0]]["allow_tax_exemption"]:
continue
if d[0] not in self.employees_with_proofs:
self.employees[d[0]].setdefault("hra", d[1])
self.add_to_total_exemption(d[0], d[1])
self.employees_with_proofs.append(d[0])
def get_standard_tax_exemption(self):
self.add_column("Standard Tax Exemption")
standard_exemptions_per_slab = dict(
frappe.get_all(
"Income Tax Slab",
filters={"company": self.filters.company, "docstatus": 1, "disabled": 0},
fields=["name", "standard_tax_exemption_amount"],
as_list=1,
)
)
for emp, emp_details in self.employees.items():
if not self.employees[emp]["allow_tax_exemption"]:
continue
income_tax_slab = emp_details.get("income_tax_slab")
standard_exemption = standard_exemptions_per_slab.get(income_tax_slab, 0)
emp_details["standard_tax_exemption"] = standard_exemption
self.add_to_total_exemption(emp, standard_exemption)
self.add_column("Total Exemption")
def get_total_taxable_amount(self):
self.add_column("Total Taxable Amount")
for emp, emp_details in self.employees.items():
emp_details["total_taxable_amount"] = flt(emp_details.get("ctc")) - flt(
emp_details.get("total_exemption")
)
def get_applicable_tax(self):
self.add_column("Applicable Tax")
is_tax_rounded = frappe.db.get_value(
"Salary Component",
{"variable_based_on_taxable_salary": 1, "disabled": 0},
"round_to_the_nearest_integer",
)
for emp, emp_details in self.employees.items():
tax_slab = emp_details.get("income_tax_slab")
if tax_slab:
tax_slab = frappe.get_cached_doc("Income Tax Slab", tax_slab)
employee_dict = frappe.get_doc("Employee", emp).as_dict()
tax_amount = calculate_tax_by_tax_slab(
emp_details["total_taxable_amount"], tax_slab, eval_globals=None, eval_locals=employee_dict
)
else:
tax_amount = 0.0
if is_tax_rounded:
tax_amount = rounded(tax_amount)
emp_details["applicable_tax"] = tax_amount
def get_total_deducted_tax(self):
self.add_column("Total Tax Deducted")
ss = frappe.qb.DocType("Salary Slip")
ss_ded = frappe.qb.DocType("Salary Detail")
records = (
frappe.qb.from_(ss)
.inner_join(ss_ded)
.on(ss.name == ss_ded.parent)
.select(ss.employee, Sum(ss_ded.amount).as_("amount"))
.where(ss.docstatus == 1)
.where(ss.employee.isin(list(self.employees.keys())))
.where(ss_ded.parentfield == "deductions")
.where(ss_ded.variable_based_on_taxable_salary == 1)
.where(ss.start_date >= self.payroll_period_start_date)
.where(ss.end_date <= self.payroll_period_end_date)
.groupby(ss.employee)
).run(as_dict=True)
for d in records:
self.employees[d.employee].setdefault("total_tax_deducted", d.amount)
def get_payable_tax(self):
self.add_column("Payable Tax")
for emp, emp_details in self.employees.items():
emp_details["payable_tax"] = flt(emp_details.get("applicable_tax")) - flt(
emp_details.get("total_tax_deducted")
)
def add_column(self, label, fieldname=None, fieldtype=None, options=None, width=None):
col = {
"label": _(label),
"fieldname": fieldname or scrub(label),
"fieldtype": fieldtype or "Currency",
"options": options,
"width": width or "140px",
}
self.columns.append(col)
def get_fixed_columns(self):
self.columns = [
{
"label": _("Employee"),
"fieldname": "employee",
"fieldtype": "Link",
"options": "Employee",
"width": "140px",
},
{
"label": _("Employee Name"),
"fieldname": "employee_name",
"fieldtype": "Data",
"width": "160px",
},
{
"label": _("Department"),
"fieldname": "department",
"fieldtype": "Link",
"options": "Department",
"width": "140px",
},
{
"label": _("Designation"),
"fieldname": "designation",
"fieldtype": "Link",
"options": "Designation",
"width": "140px",
},
{"label": _("Date of Joining"), "fieldname": "date_of_joining", "fieldtype": "Date"},
{
"label": _("Income Tax Slab"),
"fieldname": "income_tax_slab",
"fieldtype": "Link",
"options": "Income Tax Slab",
"width": "140px",
},
{"label": _("CTC"), "fieldname": "ctc", "fieldtype": "Currency", "width": "140px"},
]

View File

@ -0,0 +1,115 @@
import unittest
import frappe
from frappe.utils import getdate
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.payroll.doctype.employee_tax_exemption_declaration.test_employee_tax_exemption_declaration import (
create_payroll_period,
)
from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
create_exemption_declaration,
create_salary_slips_for_payroll_period,
create_tax_slab,
)
from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
from erpnext.payroll.report.income_tax_computation.income_tax_computation import execute
class TestIncomeTaxComputation(unittest.TestCase):
def setUp(self):
self.cleanup_records()
self.create_records()
def tearDown(self):
frappe.db.rollback()
def cleanup_records(self):
frappe.db.sql("delete from `tabEmployee Tax Exemption Declaration`")
frappe.db.sql("delete from `tabPayroll Period`")
frappe.db.sql("delete from `tabIncome Tax Slab`")
frappe.db.sql("delete from `tabSalary Component`")
frappe.db.sql("delete from `tabEmployee Benefit Application`")
frappe.db.sql("delete from `tabEmployee Benefit Claim`")
frappe.db.sql("delete from `tabEmployee` where company='_Test Company'")
frappe.db.sql("delete from `tabSalary Slip`")
def create_records(self):
self.employee = make_employee(
"employee_tax_computation@example.com",
company="_Test Company",
date_of_joining=getdate("01-10-2021"),
)
self.payroll_period = create_payroll_period(
name="_Test Payroll Period 1", company="_Test Company"
)
self.income_tax_slab = create_tax_slab(
self.payroll_period,
allow_tax_exemption=True,
effective_date=getdate("2019-04-01"),
company="_Test Company",
)
salary_structure = make_salary_structure(
"Monthly Salary Structure Test Income Tax Computation",
"Monthly",
employee=self.employee,
company="_Test Company",
currency="INR",
payroll_period=self.payroll_period,
test_tax=True,
)
create_exemption_declaration(self.employee, self.payroll_period.name)
create_salary_slips_for_payroll_period(
self.employee, salary_structure.name, self.payroll_period, deduct_random=False, num=3
)
def test_report(self):
filters = frappe._dict(
{
"company": "_Test Company",
"payroll_period": self.payroll_period.name,
"employee": self.employee,
}
)
result = execute(filters)
expected_data = {
"employee": self.employee,
"employee_name": "employee_tax_computation@example.com",
"department": "All Departments",
"income_tax_slab": self.income_tax_slab,
"ctc": 936000.0,
"professional_tax": 2400.0,
"standard_tax_exemption": 50000,
"total_exemption": 52400.0,
"total_taxable_amount": 883600.0,
"applicable_tax": 92789.0,
"total_tax_deducted": 17997.0,
"payable_tax": 74792,
}
for key, val in expected_data.items():
self.assertEqual(result[1][0].get(key), val)
# Run report considering tax exemption declaration
filters.consider_tax_exemption_declaration = 1
result = execute(filters)
expected_data.update(
{
"_test_category": 100000.0,
"total_exemption": 152400.0,
"total_taxable_amount": 783600.0,
"applicable_tax": 71989.0,
"payable_tax": 53992.0,
}
)
for key, val in expected_data.items():
self.assertEqual(result[1][0].get(key), val)

View File

@ -245,6 +245,17 @@
"onboard": 0,
"type": "Link"
},
{
"dependencies": "Salary Structure",
"hidden": 0,
"is_query_report": 1,
"label": "Income Tax Computation",
"link_count": 0,
"link_to": "Income Tax Computation",
"link_type": "Report",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "Salary Slip",
"hidden": 0,
@ -312,7 +323,7 @@
"type": "Link"
}
],
"modified": "2022-01-13 17:41:19.098813",
"modified": "2022-02-23 17:41:19.098813",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Payroll",

View File

@ -1,76 +1,31 @@
{
"allow_copy": 0,
"allow_events_in_timeline": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"beta": 0,
"creation": "2018-12-31 17:06:08.716134",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
"actions": [],
"creation": "2018-12-31 17:06:08.716134",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"fieldname"
],
"fields": [
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "fieldname",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Fieldname",
"length": 0,
"no_copy": 0,
"options": "",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"fieldname": "fieldname",
"fieldtype": "Autocomplete",
"in_list_view": 1,
"label": "Fieldname"
}
],
"has_web_view": 0,
"hide_heading": 0,
"hide_toolbar": 0,
"idx": 0,
"image_view": 0,
"in_create": 0,
"is_submittable": 0,
"issingle": 0,
"istable": 1,
"max_attachments": 0,
"modified": "2019-01-01 18:26:11.550380",
"modified_by": "Administrator",
"module": "Portal",
"name": "Website Filter Field",
"name_case": "",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1,
"track_seen": 0,
"track_views": 0
],
"istable": 1,
"links": [],
"modified": "2022-04-18 18:55:17.835666",
"modified_by": "Administrator",
"module": "Portal",
"name": "Website Filter Field",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@ -326,21 +326,39 @@ def get_timeline_data(doctype, name):
def get_project_list(
doctype, txt, filters, limit_start, limit_page_length=20, order_by="modified"
):
return frappe.db.sql(
"""select distinct project.*
from tabProject project, `tabProject User` project_user
where
(project_user.user = %(user)s
and project_user.parent = project.name)
or project.owner = %(user)s
order by project.modified desc
limit {0}, {1}
""".format(
limit_start, limit_page_length
),
{"user": frappe.session.user},
as_dict=True,
update={"doctype": "Project"},
meta = frappe.get_meta(doctype)
if not filters:
filters = []
fields = "distinct *"
or_filters = []
if txt:
if meta.search_fields:
for f in meta.get_search_fields():
if f == "name" or meta.get_field(f).fieldtype in (
"Data",
"Text",
"Small Text",
"Text Editor",
"select",
):
or_filters.append([doctype, f, "like", "%" + txt + "%"])
else:
if isinstance(filters, dict):
filters["name"] = ("like", "%" + txt + "%")
else:
filters.append([doctype, "name", "like", "%" + txt + "%"])
return frappe.get_list(
doctype,
fields=fields,
filters=filters,
or_filters=or_filters,
limit_start=limit_start,
limit_page_length=limit_page_length,
order_by=order_by,
)

View File

@ -140,6 +140,14 @@ class CallPopup {
}, {
'fieldtype': 'Section Break',
'hide_border': 1,
}, {
'fieldname': 'call_type',
'label': 'Call Type',
'fieldtype': 'Link',
'options': 'Telephony Call Type',
}, {
'fieldtype': 'Section Break',
'hide_border': 1,
}, {
'fieldtype': 'Small Text',
'label': __('Call Summary'),
@ -149,10 +157,12 @@ class CallPopup {
'label': __('Save'),
'click': () => {
const call_summary = this.call_details.get_value('call_summary');
const call_type = this.call_details.get_value('call_type');
if (!call_summary) return;
frappe.xcall('erpnext.telephony.doctype.call_log.call_log.add_call_summary', {
frappe.xcall('erpnext.telephony.doctype.call_log.call_log.add_call_summary_and_call_type', {
'call_log': this.call_log.name,
'summary': call_summary,
'call_type': call_type,
}).then(() => {
this.close_modal();
frappe.show_alert({

View File

@ -34,12 +34,12 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
frappe.model.set_value(item.doctype, item.name, "rate", item_rate);
}
calculate_taxes_and_totals(update_paid_amount) {
async calculate_taxes_and_totals(update_paid_amount) {
this.discount_amount_applied = false;
this._calculate_taxes_and_totals();
this.calculate_discount_amount();
this.calculate_shipping_charges();
await this.calculate_shipping_charges();
// Advance calculation applicable to Sales /Purchase Invoice
if(in_list(["Sales Invoice", "POS Invoice", "Purchase Invoice"], this.frm.doc.doctype)
@ -273,10 +273,14 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
}
calculate_shipping_charges() {
// Do not apply shipping rule for POS
if (this.frm.doc.is_pos) {
return;
}
frappe.model.round_floats_in(this.frm.doc, ["total", "base_total", "net_total", "base_net_total"]);
if (frappe.meta.get_docfield(this.frm.doc.doctype, "shipping_rule", this.frm.doc.name)) {
this.shipping_rule();
this._calculate_taxes_and_totals();
return this.shipping_rule();
}
}

View File

@ -974,6 +974,9 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
return this.frm.call({
doc: this.frm.doc,
method: "apply_shipping_rule",
callback: function(r) {
me._calculate_taxes_and_totals();
}
}).fail(() => this.frm.set_value('shipping_rule', ''));
}
}
@ -1385,6 +1388,11 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
return;
}
// Target doc created from a mapped doc
if (this.frm.doc.__onload && this.frm.doc.__onload.ignore_price_list) {
return;
}
return this.frm.call({
method: "erpnext.accounts.doctype.pricing_rule.pricing_rule.apply_pricing_rule",
args: { args: args, doc: me.frm.doc },
@ -1501,7 +1509,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
me.remove_pricing_rule(frappe.get_doc(d.doctype, d.name));
}
if (d.free_item_data) {
if (d.free_item_data.length > 0) {
me.apply_product_discount(d);
}

View File

@ -68,7 +68,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
row = this.get_batch_row_to_modify(batch_no);
} else {
// serial or barcode scan
row = this.get_row_to_modify_on_scan(row, item_code);
row = this.get_row_to_modify_on_scan(item_code);
}
if (!row) {
@ -177,21 +177,17 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
get_batch_row_to_modify(batch_no) {
// get row if batch already exists in table
const existing_batch_row = this.items_table.find((d) => d.batch_no === batch_no);
return existing_batch_row || null;
return existing_batch_row || this.get_existing_blank_row();
}
get_row_to_modify_on_scan(row_to_modify, item_code) {
get_row_to_modify_on_scan(item_code) {
// get an existing item row to increment or blank row to modify
const existing_item_row = this.items_table.find((d) => d.item_code === item_code);
const blank_item_row = this.items_table.find((d) => !d.item_code);
return existing_item_row || this.get_existing_blank_row();
}
if (existing_item_row) {
row_to_modify = existing_item_row;
} else if (blank_item_row) {
row_to_modify = blank_item_row;
}
return row_to_modify;
get_existing_blank_row() {
return this.items_table.find((d) => !d.item_code);
}
clean_up() {

View File

@ -19,7 +19,7 @@ class TestQualityProcedure(unittest.TestCase):
)
).insert()
frappe.form_dict = dict(
frappe.local.form_dict = frappe._dict(
doctype="Quality Procedure",
quality_procedure_name="Test Child 1",
parent_quality_procedure=procedure.name,

View File

@ -1,8 +0,0 @@
// Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on('DATEV Settings', {
refresh: function(frm) {
frm.add_custom_button(__('Show Report'), () => frappe.set_route('query-report', 'DATEV'), "fa fa-table");
}
});

View File

@ -1,125 +0,0 @@
{
"actions": [],
"autoname": "field:client",
"creation": "2019-08-13 23:56:34.259906",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"client",
"client_number",
"column_break_2",
"consultant_number",
"consultant",
"section_break_4",
"account_number_length",
"column_break_6",
"temporary_against_account_number"
],
"fields": [
{
"fieldname": "client",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Client",
"options": "Company",
"reqd": 1,
"unique": 1
},
{
"fieldname": "client_number",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Client ID",
"length": 5,
"reqd": 1
},
{
"fieldname": "consultant",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Consultant",
"options": "Supplier"
},
{
"fieldname": "consultant_number",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Consultant ID",
"length": 7,
"reqd": 1
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_4",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_6",
"fieldtype": "Column Break"
},
{
"default": "4",
"fieldname": "account_number_length",
"fieldtype": "Int",
"label": "Account Number Length",
"reqd": 1
},
{
"allow_in_quick_entry": 1,
"fieldname": "temporary_against_account_number",
"fieldtype": "Data",
"label": "Temporary Against Account Number",
"reqd": 1
}
],
"links": [],
"modified": "2020-11-19 19:00:09.088816",
"modified_by": "Administrator",
"module": "Regional",
"name": "DATEV Settings",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts User",
"share": 1
}
],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@ -1,9 +0,0 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import unittest
class TestDATEVSettings(unittest.TestCase):
pass

View File

@ -1,35 +0,0 @@
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
def setup(company=None, patch=True):
make_custom_fields()
add_custom_roles_for_reports()
def make_custom_fields():
custom_fields = {
"Party Account": [
dict(
fieldname="debtor_creditor_number",
label="Debtor/Creditor Number",
fieldtype="Data",
insert_after="account",
translatable=0,
)
]
}
create_custom_fields(custom_fields)
def add_custom_roles_for_reports():
"""Add Access Control to UAE VAT 201."""
if not frappe.db.get_value("Custom Role", dict(report="DATEV")):
frappe.get_doc(
dict(
doctype="Custom Role",
report="DATEV",
roles=[dict(role="Accounts User"), dict(role="Accounts Manager")],
)
).insert()

View File

@ -1,501 +0,0 @@
"""Constants used in datev.py."""
TRANSACTION_COLUMNS = [
# All possible columns must tbe listed here, because DATEV requires them to
# be present in the CSV.
# ---
# Umsatz
"Umsatz (ohne Soll/Haben-Kz)",
"Soll/Haben-Kennzeichen",
"WKZ Umsatz",
"Kurs",
"Basis-Umsatz",
"WKZ Basis-Umsatz",
# Konto/Gegenkonto
"Konto",
"Gegenkonto (ohne BU-Schlüssel)",
"BU-Schlüssel",
# Datum
"Belegdatum",
# Rechnungs- / Belegnummer
"Belegfeld 1",
# z.B. Fälligkeitsdatum Format: TTMMJJ
"Belegfeld 2",
# Skonto-Betrag / -Abzug (Der Wert 0 ist unzulässig)
"Skonto",
# Beschreibung des Buchungssatzes
"Buchungstext",
# Mahn- / Zahl-Sperre (1 = Postensperre)
"Postensperre",
"Diverse Adressnummer",
"Geschäftspartnerbank",
"Sachverhalt",
# Keine Mahnzinsen
"Zinssperre",
# Link auf den Buchungsbeleg (Programmkürzel + GUID)
"Beleglink",
# Beleginfo
"Beleginfo - Art 1",
"Beleginfo - Inhalt 1",
"Beleginfo - Art 2",
"Beleginfo - Inhalt 2",
"Beleginfo - Art 3",
"Beleginfo - Inhalt 3",
"Beleginfo - Art 4",
"Beleginfo - Inhalt 4",
"Beleginfo - Art 5",
"Beleginfo - Inhalt 5",
"Beleginfo - Art 6",
"Beleginfo - Inhalt 6",
"Beleginfo - Art 7",
"Beleginfo - Inhalt 7",
"Beleginfo - Art 8",
"Beleginfo - Inhalt 8",
# Zuordnung des Geschäftsvorfalls für die Kostenrechnung
"KOST1 - Kostenstelle",
"KOST2 - Kostenstelle",
"KOST-Menge",
# USt-ID-Nummer (Beispiel: DE133546770)
"EU-Mitgliedstaat u. USt-IdNr.",
# Der im EU-Bestimmungsland gültige Steuersatz
"EU-Steuersatz",
# I = Ist-Versteuerung,
# K = keine Umsatzsteuerrechnung
# P = Pauschalierung (z. B. für Land- und Forstwirtschaft),
# S = Soll-Versteuerung
"Abw. Versteuerungsart",
# Sachverhalte gem. § 13b Abs. 1 Satz 1 Nrn. 1.-5. UStG
"Sachverhalt L+L",
# Steuersatz / Funktion zum L+L-Sachverhalt (Beispiel: Wert 190 für 19%)
"Funktionsergänzung L+L",
# Bei Verwendung des BU-Schlüssels 49 für „andere Steuersätze“ muss der
# steuerliche Sachverhalt mitgegeben werden
"BU 49 Hauptfunktionstyp",
"BU 49 Hauptfunktionsnummer",
"BU 49 Funktionsergänzung",
# Zusatzinformationen, besitzen den Charakter eines Notizzettels und können
# frei erfasst werden.
"Zusatzinformation - Art 1",
"Zusatzinformation - Inhalt 1",
"Zusatzinformation - Art 2",
"Zusatzinformation - Inhalt 2",
"Zusatzinformation - Art 3",
"Zusatzinformation - Inhalt 3",
"Zusatzinformation - Art 4",
"Zusatzinformation - Inhalt 4",
"Zusatzinformation - Art 5",
"Zusatzinformation - Inhalt 5",
"Zusatzinformation - Art 6",
"Zusatzinformation - Inhalt 6",
"Zusatzinformation - Art 7",
"Zusatzinformation - Inhalt 7",
"Zusatzinformation - Art 8",
"Zusatzinformation - Inhalt 8",
"Zusatzinformation - Art 9",
"Zusatzinformation - Inhalt 9",
"Zusatzinformation - Art 10",
"Zusatzinformation - Inhalt 10",
"Zusatzinformation - Art 11",
"Zusatzinformation - Inhalt 11",
"Zusatzinformation - Art 12",
"Zusatzinformation - Inhalt 12",
"Zusatzinformation - Art 13",
"Zusatzinformation - Inhalt 13",
"Zusatzinformation - Art 14",
"Zusatzinformation - Inhalt 14",
"Zusatzinformation - Art 15",
"Zusatzinformation - Inhalt 15",
"Zusatzinformation - Art 16",
"Zusatzinformation - Inhalt 16",
"Zusatzinformation - Art 17",
"Zusatzinformation - Inhalt 17",
"Zusatzinformation - Art 18",
"Zusatzinformation - Inhalt 18",
"Zusatzinformation - Art 19",
"Zusatzinformation - Inhalt 19",
"Zusatzinformation - Art 20",
"Zusatzinformation - Inhalt 20",
# Wirkt sich nur bei Sachverhalt mit SKR 14 Land- und Forstwirtschaft aus,
# für andere SKR werden die Felder beim Import / Export überlesen bzw.
# leer exportiert.
"Stück",
"Gewicht",
# 1 = Lastschrift
# 2 = Mahnung
# 3 = Zahlung
"Zahlweise",
"Forderungsart",
# JJJJ
"Veranlagungsjahr",
# TTMMJJJJ
"Zugeordnete Fälligkeit",
# 1 = Einkauf von Waren
# 2 = Erwerb von Roh-Hilfs- und Betriebsstoffen
"Skontotyp",
# Allgemeine Bezeichnung, des Auftrags / Projekts.
"Auftragsnummer",
# AA = Angeforderte Anzahlung / Abschlagsrechnung
# AG = Erhaltene Anzahlung (Geldeingang)
# AV = Erhaltene Anzahlung (Verbindlichkeit)
# SR = Schlussrechnung
# SU = Schlussrechnung (Umbuchung)
# SG = Schlussrechnung (Geldeingang)
# SO = Sonstige
"Buchungstyp",
"USt-Schlüssel (Anzahlungen)",
"EU-Mitgliedstaat (Anzahlungen)",
"Sachverhalt L+L (Anzahlungen)",
"EU-Steuersatz (Anzahlungen)",
"Erlöskonto (Anzahlungen)",
# Wird beim Import durch SV (Stapelverarbeitung) ersetzt.
"Herkunft-Kz",
# Wird von DATEV verwendet.
"Leerfeld",
# Format TTMMJJJJ
"KOST-Datum",
# Vom Zahlungsempfänger individuell vergebenes Kennzeichen eines Mandats
# (z.B. Rechnungs- oder Kundennummer).
"SEPA-Mandatsreferenz",
# 1 = Skontosperre
# 0 = Keine Skontosperre
"Skontosperre",
# Gesellschafter und Sonderbilanzsachverhalt
"Gesellschaftername",
# Amtliche Nummer aus der Feststellungserklärung
"Beteiligtennummer",
"Identifikationsnummer",
"Zeichnernummer",
# Format TTMMJJJJ
"Postensperre bis",
# Gesellschafter und Sonderbilanzsachverhalt
"Bezeichnung SoBil-Sachverhalt",
"Kennzeichen SoBil-Buchung",
# 0 = keine Festschreibung
# 1 = Festschreibung
"Festschreibung",
# Format TTMMJJJJ
"Leistungsdatum",
# Format TTMMJJJJ
"Datum Zuord. Steuerperiode",
# OPOS-Informationen, Format TTMMJJJJ
"Fälligkeit",
# G oder 1 = Generalumkehr
# 0 = keine Generalumkehr
"Generalumkehr (GU)",
# Steuersatz für Steuerschlüssel
"Steuersatz",
# Beispiel: DE für Deutschland
"Land",
]
DEBTOR_CREDITOR_COLUMNS = [
# All possible columns must tbe listed here, because DATEV requires them to
# be present in the CSV.
# Columns "Leerfeld" have been replaced with "Leerfeld #" to not confuse pandas
# ---
"Konto",
"Name (Adressatentyp Unternehmen)",
"Unternehmensgegenstand",
"Name (Adressatentyp natürl. Person)",
"Vorname (Adressatentyp natürl. Person)",
"Name (Adressatentyp keine Angabe)",
"Adressatentyp",
"Kurzbezeichnung",
"EU-Land",
"EU-USt-IdNr.",
"Anrede",
"Titel/Akad. Grad",
"Adelstitel",
"Namensvorsatz",
"Adressart",
"Straße",
"Postfach",
"Postleitzahl",
"Ort",
"Land",
"Versandzusatz",
"Adresszusatz",
"Abweichende Anrede",
"Abw. Zustellbezeichnung 1",
"Abw. Zustellbezeichnung 2",
"Kennz. Korrespondenzadresse",
"Adresse gültig von",
"Adresse gültig bis",
"Telefon",
"Bemerkung (Telefon)",
"Telefon Geschäftsleitung",
"Bemerkung (Telefon GL)",
"E-Mail",
"Bemerkung (E-Mail)",
"Internet",
"Bemerkung (Internet)",
"Fax",
"Bemerkung (Fax)",
"Sonstige",
"Bemerkung (Sonstige)",
"Bankleitzahl 1",
"Bankbezeichnung 1",
"Bankkonto-Nummer 1",
"Länderkennzeichen 1",
"IBAN 1",
"Leerfeld 1",
"SWIFT-Code 1",
"Abw. Kontoinhaber 1",
"Kennz. Haupt-Bankverb. 1",
"Bankverb. 1 Gültig von",
"Bankverb. 1 Gültig bis",
"Bankleitzahl 2",
"Bankbezeichnung 2",
"Bankkonto-Nummer 2",
"Länderkennzeichen 2",
"IBAN 2",
"Leerfeld 2",
"SWIFT-Code 2",
"Abw. Kontoinhaber 2",
"Kennz. Haupt-Bankverb. 2",
"Bankverb. 2 gültig von",
"Bankverb. 2 gültig bis",
"Bankleitzahl 3",
"Bankbezeichnung 3",
"Bankkonto-Nummer 3",
"Länderkennzeichen 3",
"IBAN 3",
"Leerfeld 3",
"SWIFT-Code 3",
"Abw. Kontoinhaber 3",
"Kennz. Haupt-Bankverb. 3",
"Bankverb. 3 gültig von",
"Bankverb. 3 gültig bis",
"Bankleitzahl 4",
"Bankbezeichnung 4",
"Bankkonto-Nummer 4",
"Länderkennzeichen 4",
"IBAN 4",
"Leerfeld 4",
"SWIFT-Code 4",
"Abw. Kontoinhaber 4",
"Kennz. Haupt-Bankverb. 4",
"Bankverb. 4 Gültig von",
"Bankverb. 4 Gültig bis",
"Bankleitzahl 5",
"Bankbezeichnung 5",
"Bankkonto-Nummer 5",
"Länderkennzeichen 5",
"IBAN 5",
"Leerfeld 5",
"SWIFT-Code 5",
"Abw. Kontoinhaber 5",
"Kennz. Haupt-Bankverb. 5",
"Bankverb. 5 gültig von",
"Bankverb. 5 gültig bis",
"Leerfeld 6",
"Briefanrede",
"Grußformel",
"Kundennummer",
"Steuernummer",
"Sprache",
"Ansprechpartner",
"Vertreter",
"Sachbearbeiter",
"Diverse-Konto",
"Ausgabeziel",
"Währungssteuerung",
"Kreditlimit (Debitor)",
"Zahlungsbedingung",
"Fälligkeit in Tagen (Debitor)",
"Skonto in Prozent (Debitor)",
"Kreditoren-Ziel 1 (Tage)",
"Kreditoren-Skonto 1 (%)",
"Kreditoren-Ziel 2 (Tage)",
"Kreditoren-Skonto 2 (%)",
"Kreditoren-Ziel 3 Brutto (Tage)",
"Kreditoren-Ziel 4 (Tage)",
"Kreditoren-Skonto 4 (%)",
"Kreditoren-Ziel 5 (Tage)",
"Kreditoren-Skonto 5 (%)",
"Mahnung",
"Kontoauszug",
"Mahntext 1",
"Mahntext 2",
"Mahntext 3",
"Kontoauszugstext",
"Mahnlimit Betrag",
"Mahnlimit %",
"Zinsberechnung",
"Mahnzinssatz 1",
"Mahnzinssatz 2",
"Mahnzinssatz 3",
"Lastschrift",
"Verfahren",
"Mandantenbank",
"Zahlungsträger",
"Indiv. Feld 1",
"Indiv. Feld 2",
"Indiv. Feld 3",
"Indiv. Feld 4",
"Indiv. Feld 5",
"Indiv. Feld 6",
"Indiv. Feld 7",
"Indiv. Feld 8",
"Indiv. Feld 9",
"Indiv. Feld 10",
"Indiv. Feld 11",
"Indiv. Feld 12",
"Indiv. Feld 13",
"Indiv. Feld 14",
"Indiv. Feld 15",
"Abweichende Anrede (Rechnungsadresse)",
"Adressart (Rechnungsadresse)",
"Straße (Rechnungsadresse)",
"Postfach (Rechnungsadresse)",
"Postleitzahl (Rechnungsadresse)",
"Ort (Rechnungsadresse)",
"Land (Rechnungsadresse)",
"Versandzusatz (Rechnungsadresse)",
"Adresszusatz (Rechnungsadresse)",
"Abw. Zustellbezeichnung 1 (Rechnungsadresse)",
"Abw. Zustellbezeichnung 2 (Rechnungsadresse)",
"Adresse Gültig von (Rechnungsadresse)",
"Adresse Gültig bis (Rechnungsadresse)",
"Bankleitzahl 6",
"Bankbezeichnung 6",
"Bankkonto-Nummer 6",
"Länderkennzeichen 6",
"IBAN 6",
"Leerfeld 7",
"SWIFT-Code 6",
"Abw. Kontoinhaber 6",
"Kennz. Haupt-Bankverb. 6",
"Bankverb 6 gültig von",
"Bankverb 6 gültig bis",
"Bankleitzahl 7",
"Bankbezeichnung 7",
"Bankkonto-Nummer 7",
"Länderkennzeichen 7",
"IBAN 7",
"Leerfeld 8",
"SWIFT-Code 7",
"Abw. Kontoinhaber 7",
"Kennz. Haupt-Bankverb. 7",
"Bankverb 7 gültig von",
"Bankverb 7 gültig bis",
"Bankleitzahl 8",
"Bankbezeichnung 8",
"Bankkonto-Nummer 8",
"Länderkennzeichen 8",
"IBAN 8",
"Leerfeld 9",
"SWIFT-Code 8",
"Abw. Kontoinhaber 8",
"Kennz. Haupt-Bankverb. 8",
"Bankverb 8 gültig von",
"Bankverb 8 gültig bis",
"Bankleitzahl 9",
"Bankbezeichnung 9",
"Bankkonto-Nummer 9",
"Länderkennzeichen 9",
"IBAN 9",
"Leerfeld 10",
"SWIFT-Code 9",
"Abw. Kontoinhaber 9",
"Kennz. Haupt-Bankverb. 9",
"Bankverb 9 gültig von",
"Bankverb 9 gültig bis",
"Bankleitzahl 10",
"Bankbezeichnung 10",
"Bankkonto-Nummer 10",
"Länderkennzeichen 10",
"IBAN 10",
"Leerfeld 11",
"SWIFT-Code 10",
"Abw. Kontoinhaber 10",
"Kennz. Haupt-Bankverb. 10",
"Bankverb 10 gültig von",
"Bankverb 10 gültig bis",
"Nummer Fremdsystem",
"Insolvent",
"SEPA-Mandatsreferenz 1",
"SEPA-Mandatsreferenz 2",
"SEPA-Mandatsreferenz 3",
"SEPA-Mandatsreferenz 4",
"SEPA-Mandatsreferenz 5",
"SEPA-Mandatsreferenz 6",
"SEPA-Mandatsreferenz 7",
"SEPA-Mandatsreferenz 8",
"SEPA-Mandatsreferenz 9",
"SEPA-Mandatsreferenz 10",
"Verknüpftes OPOS-Konto",
"Mahnsperre bis",
"Lastschriftsperre bis",
"Zahlungssperre bis",
"Gebührenberechnung",
"Mahngebühr 1",
"Mahngebühr 2",
"Mahngebühr 3",
"Pauschalberechnung",
"Verzugspauschale 1",
"Verzugspauschale 2",
"Verzugspauschale 3",
"Alternativer Suchname",
"Status",
"Anschrift manuell geändert (Korrespondenzadresse)",
"Anschrift individuell (Korrespondenzadresse)",
"Anschrift manuell geändert (Rechnungsadresse)",
"Anschrift individuell (Rechnungsadresse)",
"Fristberechnung bei Debitor",
"Mahnfrist 1",
"Mahnfrist 2",
"Mahnfrist 3",
"Letzte Frist",
]
ACCOUNT_NAME_COLUMNS = [
# Account number
"Konto",
# Account name
"Kontenbeschriftung",
# Language of the account name
# "de-DE" or "en-GB"
"Sprach-ID",
]
class DataCategory:
"""Field of the CSV Header."""
DEBTORS_CREDITORS = "16"
ACCOUNT_NAMES = "20"
TRANSACTIONS = "21"
POSTING_TEXT_CONSTANTS = "67"
class FormatName:
"""Field of the CSV Header, corresponds to DataCategory."""
DEBTORS_CREDITORS = "Debitoren/Kreditoren"
ACCOUNT_NAMES = "Kontenbeschriftungen"
TRANSACTIONS = "Buchungsstapel"
POSTING_TEXT_CONSTANTS = "Buchungstextkonstanten"
class Transactions:
DATA_CATEGORY = DataCategory.TRANSACTIONS
FORMAT_NAME = FormatName.TRANSACTIONS
FORMAT_VERSION = "9"
COLUMNS = TRANSACTION_COLUMNS
class DebtorsCreditors:
DATA_CATEGORY = DataCategory.DEBTORS_CREDITORS
FORMAT_NAME = FormatName.DEBTORS_CREDITORS
FORMAT_VERSION = "5"
COLUMNS = DEBTOR_CREDITOR_COLUMNS
class AccountNames:
DATA_CATEGORY = DataCategory.ACCOUNT_NAMES
FORMAT_NAME = FormatName.ACCOUNT_NAMES
FORMAT_VERSION = "2"
COLUMNS = ACCOUNT_NAME_COLUMNS

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