Merge branch 'develop' into fix-depr-after-sale

This commit is contained in:
Ganga Manoj 2021-10-25 19:11:19 +05:30 committed by GitHub
commit 0515b4b6f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 713 additions and 815 deletions

View File

@ -37,6 +37,9 @@ sed -i 's/socketio:/# socketio:/g' Procfile
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
bench get-app erpnext "${GITHUB_WORKSPACE}"
if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
bench start &> bench_run_logs.txt &
bench --site test_site reinstall --yes
bench build --app frappe

View File

@ -1,38 +0,0 @@
# Semgrep linting
## What is semgrep?
Semgrep or "semantic grep" is language agnostic static analysis tool. In simple terms semgrep is syntax-aware `grep`, so unlike regex it doesn't get confused by different ways of writing same thing or whitespaces or code split in multiple lines etc.
Example:
To check if a translate function is using f-string or not the regex would be `r"_\(\s*f[\"']"` while equivalent rule in semgrep would be `_(f"...")`. As semgrep knows grammer of language it takes care of unnecessary whitespace, type of quotation marks etc.
You can read more such examples in `.github/helper/semgrep_rules` directory.
# Why/when to use this?
We want to maintain quality of contributions, at the same time remembering all the good practices can be pain to deal with while evaluating contributions. Using semgrep if you can translate "best practice" into a rule then it can automate the task for us.
## Running locally
Install semgrep using homebrew `brew install semgrep` or pip `pip install semgrep`.
To run locally use following command:
`semgrep --config=.github/helper/semgrep_rules [file/folder names]`
## Testing
semgrep allows testing the tests. Refer to this page: https://semgrep.dev/docs/writing-rules/testing-rules/
When writing new rules you should write few positive and few negative cases as shown in the guide and current tests.
To run current tests: `semgrep --test --test-ignore-todo .github/helper/semgrep_rules`
## Reference
If you are new to Semgrep read following pages to get started on writing/modifying rules:
- https://semgrep.dev/docs/getting-started/
- https://semgrep.dev/docs/writing-rules/rule-syntax
- https://semgrep.dev/docs/writing-rules/pattern-examples/
- https://semgrep.dev/docs/writing-rules/rule-ideas/#common-use-cases

View File

@ -1,64 +0,0 @@
import frappe
from frappe import _
from frappe.model.document import Document
# ruleid: frappe-modifying-but-not-comitting
def on_submit(self):
if self.value_of_goods == 0:
frappe.throw(_('Value of goods cannot be 0'))
self.status = 'Submitted'
# ok: frappe-modifying-but-not-comitting
def on_submit(self):
if self.value_of_goods == 0:
frappe.throw(_('Value of goods cannot be 0'))
self.status = 'Submitted'
self.db_set('status', 'Submitted')
# ok: frappe-modifying-but-not-comitting
def on_submit(self):
if self.value_of_goods == 0:
frappe.throw(_('Value of goods cannot be 0'))
x = "y"
self.status = x
self.db_set('status', x)
# ok: frappe-modifying-but-not-comitting
def on_submit(self):
x = "y"
self.status = x
self.save()
# ruleid: frappe-modifying-but-not-comitting-other-method
class DoctypeClass(Document):
def on_submit(self):
self.good_method()
self.tainted_method()
def tainted_method(self):
self.status = "uptate"
# ok: frappe-modifying-but-not-comitting-other-method
class DoctypeClass(Document):
def on_submit(self):
self.good_method()
self.tainted_method()
def tainted_method(self):
self.status = "update"
self.db_set("status", "update")
# ok: frappe-modifying-but-not-comitting-other-method
class DoctypeClass(Document):
def on_submit(self):
self.good_method()
self.tainted_method()
self.save()
def tainted_method(self):
self.status = "uptate"

View File

@ -1,151 +0,0 @@
# This file specifies rules for correctness according to how frappe doctype data model works.
rules:
- id: frappe-modifying-but-not-comitting
patterns:
- pattern: |
def $METHOD(self, ...):
...
self.$ATTR = ...
- pattern-not: |
def $METHOD(self, ...):
...
self.$ATTR = ...
...
self.db_set(..., self.$ATTR, ...)
- pattern-not: |
def $METHOD(self, ...):
...
self.$ATTR = $SOME_VAR
...
self.db_set(..., $SOME_VAR, ...)
- pattern-not: |
def $METHOD(self, ...):
...
self.$ATTR = $SOME_VAR
...
self.save()
- metavariable-regex:
metavariable: '$ATTR'
# this is negative look-ahead, add more attrs to ignore like (ignore|ignore_this_too|ignore_me)
regex: '^(?!ignore_linked_doctypes|status_updater)(.*)$'
- metavariable-regex:
metavariable: "$METHOD"
regex: "(on_submit|on_cancel)"
message: |
DocType modified in self.$METHOD. Please check if modification of self.$ATTR is commited to database.
languages: [python]
severity: ERROR
- id: frappe-modifying-but-not-comitting-other-method
patterns:
- pattern: |
class $DOCTYPE(...):
def $METHOD(self, ...):
...
self.$ANOTHER_METHOD()
...
def $ANOTHER_METHOD(self, ...):
...
self.$ATTR = ...
- pattern-not: |
class $DOCTYPE(...):
def $METHOD(self, ...):
...
self.$ANOTHER_METHOD()
...
def $ANOTHER_METHOD(self, ...):
...
self.$ATTR = ...
...
self.db_set(..., self.$ATTR, ...)
- pattern-not: |
class $DOCTYPE(...):
def $METHOD(self, ...):
...
self.$ANOTHER_METHOD()
...
def $ANOTHER_METHOD(self, ...):
...
self.$ATTR = $SOME_VAR
...
self.db_set(..., $SOME_VAR, ...)
- pattern-not: |
class $DOCTYPE(...):
def $METHOD(self, ...):
...
self.$ANOTHER_METHOD()
...
self.save()
def $ANOTHER_METHOD(self, ...):
...
self.$ATTR = ...
- metavariable-regex:
metavariable: "$METHOD"
regex: "(on_submit|on_cancel)"
message: |
self.$ANOTHER_METHOD is called from self.$METHOD, check if changes to self.$ATTR are commited to database.
languages: [python]
severity: ERROR
- id: frappe-print-function-in-doctypes
pattern: print(...)
message: |
Did you mean to leave this print statement in? Consider using msgprint or logger instead of print statement.
languages: [python]
severity: WARNING
paths:
include:
- "*/**/doctype/*"
- id: frappe-modifying-child-tables-while-iterating
pattern-either:
- pattern: |
for $ROW in self.$TABLE:
...
self.remove(...)
- pattern: |
for $ROW in self.$TABLE:
...
self.append(...)
message: |
Child table being modified while iterating on it.
languages: [python]
severity: ERROR
paths:
include:
- "*/**/doctype/*"
- id: frappe-same-key-assigned-twice
pattern-either:
- pattern: |
{..., $X: $A, ..., $X: $B, ...}
- pattern: |
dict(..., ($X, $A), ..., ($X, $B), ...)
- pattern: |
_dict(..., ($X, $A), ..., ($X, $B), ...)
message: |
key `$X` is uselessly assigned twice. This could be a potential bug.
languages: [python]
severity: ERROR
- id: frappe-manual-commit
patterns:
- pattern: frappe.db.commit()
- pattern-not-inside: |
try:
...
except ...:
...
message: |
Manually commiting a transaction is highly discouraged. Read about the transaction model implemented by Frappe Framework before adding manual commits: https://frappeframework.com/docs/user/en/api/database#database-transaction-model If you think manual commit is required then add a comment explaining why and `// nosemgrep` on the same line.
paths:
exclude:
- "**/patches/**"
- "**/demo/**"
languages: [python]
severity: ERROR

View File

@ -1,15 +0,0 @@
from frappe import _
# ruleid: frappe-missing-translate-function-in-report-python
{"label": "Field Label"}
# ruleid: frappe-missing-translate-function-in-report-python
dict(label="Field Label")
# ok: frappe-missing-translate-function-in-report-python
{"label": _("Field Label")}
# ok: frappe-missing-translate-function-in-report-python
dict(label=_("Field Label"))

View File

@ -1,34 +0,0 @@
rules:
- id: frappe-missing-translate-function-in-report-python
paths:
include:
- "**/report"
exclude:
- "**/regional"
pattern-either:
- patterns:
- pattern: |
{..., "label": "...", ...}
- pattern-not: |
{..., "label": _("..."), ...}
- patterns:
- pattern: dict(..., label="...", ...)
- pattern-not: dict(..., label=_("..."), ...)
message: |
All user facing text must be wrapped in translate function. Please refer to translation documentation. https://frappeframework.com/docs/user/en/guides/basics/translations
languages: [python]
severity: ERROR
- id: frappe-translated-values-in-business-logic
paths:
include:
- "**/report"
patterns:
- pattern-inside: |
{..., filters: [...], ...}
- pattern: |
{..., options: [..., __("..."), ...], ...}
message: |
Using translated values in options field will require you to translate the values while comparing in business logic. Instead of passing translated labels provide objects that contain both label and value. e.g. { label: __("Option value"), value: "Option value"}
languages: [javascript]
severity: ERROR

View File

@ -1,6 +0,0 @@
def function_name(input):
# ruleid: frappe-codeinjection-eval
eval(input)
# ok: frappe-codeinjection-eval
eval("1 + 1")

View File

@ -1,10 +0,0 @@
rules:
- id: frappe-codeinjection-eval
patterns:
- pattern-not: eval("...")
- pattern: eval(...)
message: |
Detected the use of eval(). eval() can be dangerous if used to evaluate
dynamic content. Avoid it or use safe_eval().
languages: [python]
severity: ERROR

View File

@ -1,44 +0,0 @@
// ruleid: frappe-translation-empty-string
__("")
// ruleid: frappe-translation-empty-string
__('')
// ok: frappe-translation-js-formatting
__('Welcome {0}, get started with ERPNext in just a few clicks.', [full_name]);
// ruleid: frappe-translation-js-formatting
__(`Welcome ${full_name}, get started with ERPNext in just a few clicks.`);
// ok: frappe-translation-js-formatting
__('This is fine');
// ok: frappe-translation-trailing-spaces
__('This is fine');
// ruleid: frappe-translation-trailing-spaces
__(' this is not ok ');
// ruleid: frappe-translation-trailing-spaces
__('this is not ok ');
// ruleid: frappe-translation-trailing-spaces
__(' this is not ok');
// ok: frappe-translation-js-splitting
__('You have {0} subscribers in your mailing list.', [subscribers.length])
// todoruleid: frappe-translation-js-splitting
__('You have') + subscribers.length + __('subscribers in your mailing list.')
// ruleid: frappe-translation-js-splitting
__('You have' + 'subscribers in your mailing list.')
// ruleid: frappe-translation-js-splitting
__('You have {0} subscribers' +
'in your mailing list', [subscribers.length])
// ok: frappe-translation-js-splitting
__("Ctrl+Enter to add comment")
// ruleid: frappe-translation-js-splitting
__('You have {0} subscribers \
in your mailing list', [subscribers.length])

View File

@ -1,61 +0,0 @@
# Examples taken from https://frappeframework.com/docs/user/en/translations
# This file is used for testing the tests.
from frappe import _
full_name = "Jon Doe"
# ok: frappe-translation-python-formatting
_('Welcome {0}, get started with ERPNext in just a few clicks.').format(full_name)
# ruleid: frappe-translation-python-formatting
_('Welcome %s, get started with ERPNext in just a few clicks.' % full_name)
# ruleid: frappe-translation-python-formatting
_('Welcome %(name)s, get started with ERPNext in just a few clicks.' % {'name': full_name})
# ruleid: frappe-translation-python-formatting
_('Welcome {0}, get started with ERPNext in just a few clicks.'.format(full_name))
subscribers = ["Jon", "Doe"]
# ok: frappe-translation-python-formatting
_('You have {0} subscribers in your mailing list.').format(len(subscribers))
# ruleid: frappe-translation-python-splitting
_('You have') + len(subscribers) + _('subscribers in your mailing list.')
# ruleid: frappe-translation-python-splitting
_('You have {0} subscribers \
in your mailing list').format(len(subscribers))
# ok: frappe-translation-python-splitting
_('You have {0} subscribers') \
+ 'in your mailing list'
# ruleid: frappe-translation-trailing-spaces
msg = _(" You have {0} pending invoice ")
# ruleid: frappe-translation-trailing-spaces
msg = _("You have {0} pending invoice ")
# ruleid: frappe-translation-trailing-spaces
msg = _(" You have {0} pending invoice")
# ok: frappe-translation-trailing-spaces
msg = ' ' + _("You have {0} pending invoices") + ' '
# ruleid: frappe-translation-python-formatting
_(f"can not format like this - {subscribers}")
# ruleid: frappe-translation-python-splitting
_(f"what" + f"this is also not cool")
# ruleid: frappe-translation-empty-string
_("")
# ruleid: frappe-translation-empty-string
_('')
class Test:
# ok: frappe-translation-python-splitting
def __init__(
args
):
pass

View File

@ -1,64 +0,0 @@
rules:
- id: frappe-translation-empty-string
pattern-either:
- pattern: _("")
- pattern: __("")
message: |
Empty string is useless for translation.
Please refer: https://frappeframework.com/docs/user/en/translations
languages: [python, javascript, json]
severity: ERROR
- id: frappe-translation-trailing-spaces
pattern-either:
- pattern: _("=~/(^[ \t]+|[ \t]+$)/")
- pattern: __("=~/(^[ \t]+|[ \t]+$)/")
message: |
Trailing or leading whitespace not allowed in translate strings.
Please refer: https://frappeframework.com/docs/user/en/translations
languages: [python, javascript, json]
severity: ERROR
- id: frappe-translation-python-formatting
pattern-either:
- pattern: _("..." % ...)
- pattern: _("...".format(...))
- pattern: _(f"...")
message: |
Only positional formatters are allowed and formatting should not be done before translating.
Please refer: https://frappeframework.com/docs/user/en/translations
languages: [python]
severity: ERROR
- id: frappe-translation-js-formatting
patterns:
- pattern: __(`...`)
- pattern-not: __("...")
message: |
Template strings are not allowed for text formatting.
Please refer: https://frappeframework.com/docs/user/en/translations
languages: [javascript, json]
severity: ERROR
- id: frappe-translation-python-splitting
pattern-either:
- pattern: _(...) + _(...)
- pattern: _("..." + "...")
- pattern-regex: '[\s\.]_\([^\)]*\\\s*' # lines broken by `\`
- pattern-regex: '[\s\.]_\(\s*\n' # line breaks allowed by python for using ( )
message: |
Do not split strings inside translate function. Do not concatenate using translate functions.
Please refer: https://frappeframework.com/docs/user/en/translations
languages: [python]
severity: ERROR
- id: frappe-translation-js-splitting
pattern-either:
- pattern-regex: '__\([^\)]*[\\]\s+'
- pattern: __('...' + '...', ...)
- pattern: __('...') + __('...')
message: |
Do not split strings inside translate function. Do not concatenate using translate functions.
Please refer: https://frappeframework.com/docs/user/en/translations
languages: [javascript, json]
severity: ERROR

View File

@ -1,9 +0,0 @@
// ok: frappe-missing-translate-function-js
frappe.msgprint('{{ _("Both login and password required") }}');
// ruleid: frappe-missing-translate-function-js
frappe.msgprint('What');
// ok: frappe-missing-translate-function-js
frappe.throw(' {{ _("Both login and password required") }}. ');

View File

@ -1,31 +0,0 @@
import frappe
from frappe import msgprint, throw, _
# ruleid: frappe-missing-translate-function-python
throw("Error Occured")
# ruleid: frappe-missing-translate-function-python
frappe.throw("Error Occured")
# ruleid: frappe-missing-translate-function-python
frappe.msgprint("Useful message")
# ruleid: frappe-missing-translate-function-python
msgprint("Useful message")
# ok: frappe-missing-translate-function-python
translatedmessage = _("Hello")
# ok: frappe-missing-translate-function-python
throw(translatedmessage)
# ok: frappe-missing-translate-function-python
msgprint(translatedmessage)
# ok: frappe-missing-translate-function-python
msgprint(_("Helpful message"))
# ok: frappe-missing-translate-function-python
frappe.throw(_("Error occured"))

View File

@ -1,30 +0,0 @@
rules:
- id: frappe-missing-translate-function-python
pattern-either:
- patterns:
- pattern: frappe.msgprint("...", ...)
- pattern-not: frappe.msgprint(_("..."), ...)
- patterns:
- pattern: frappe.throw("...", ...)
- pattern-not: frappe.throw(_("..."), ...)
message: |
All user facing text must be wrapped in translate function. Please refer to translation documentation. https://frappeframework.com/docs/user/en/guides/basics/translations
languages: [python]
severity: ERROR
- id: frappe-missing-translate-function-js
pattern-either:
- patterns:
- pattern: frappe.msgprint("...", ...)
- pattern-not: frappe.msgprint(__("..."), ...)
# ignore microtemplating e.g. msgprint("{{ _("server side translation") }}")
- pattern-not: frappe.msgprint("=~/\{\{.*\_.*\}\}/i", ...)
- patterns:
- pattern: frappe.throw("...", ...)
- pattern-not: frappe.throw(__("..."), ...)
# ignore microtemplating
- pattern-not: frappe.throw("=~/\{\{.*\_.*\}\}/i", ...)
message: |
All user facing text must be wrapped in translate function. Please refer to translation documentation. https://frappeframework.com/docs/user/en/guides/basics/translations
languages: [javascript]
severity: ERROR

View File

@ -10,13 +10,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: returntocorp/semgrep-action@v1
env:
SEMGREP_TIMEOUT: 120
with:
config: >-
r/python.lang.correctness
.github/helper/semgrep_rules
- name: Set up Python 3.8
uses: actions/setup-python@v2
@ -25,3 +18,14 @@ jobs:
- name: Install and Run Pre-commit
uses: pre-commit/action@v2.0.3
- name: Download Semgrep rules
run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules
- uses: returntocorp/semgrep-action@v1
env:
SEMGREP_TIMEOUT: 120
with:
config: >-
r/python.lang.correctness
./frappe-semgrep-rules/rules

View File

@ -91,6 +91,8 @@ jobs:
- name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env:
TYPE: server
- name: Run Tests
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator --with-coverage

View File

@ -24,7 +24,7 @@ context('Organizational Chart', () => {
cy.get('.frappe-control[data-fieldname=company] input').focus().as('input');
cy.get('@input')
.clear({ force: true })
.type('Test Org Chart{enter}', { force: true })
.type('Test Org Chart{downarrow}{enter}', { force: true })
.blur({ force: true });
});
});

View File

@ -25,7 +25,7 @@ context('Organizational Chart Mobile', () => {
cy.get('.frappe-control[data-fieldname=company] input').focus().as('input');
cy.get('@input')
.clear({ force: true })
.type('Test Org Chart{enter}', { force: true })
.type('Test Org Chart{downarrow}{enter}', { force: true })
.blur({ force: true });
});
});

View File

@ -12,7 +12,7 @@ from six import iteritems
from unidecode import unidecode
def create_charts(company, chart_template=None, existing_company=None, custom_chart=None):
def create_charts(company, chart_template=None, existing_company=None, custom_chart=None, from_coa_importer=None):
chart = custom_chart or get_chart(chart_template, existing_company)
if chart:
accounts = []
@ -22,7 +22,7 @@ def create_charts(company, chart_template=None, existing_company=None, custom_ch
if root_account:
root_type = child.get("root_type")
if account_name not in ["account_number", "account_type",
if account_name not in ["account_name", "account_number", "account_type",
"root_type", "is_group", "tax_rate"]:
account_number = cstr(child.get("account_number")).strip()
@ -35,7 +35,7 @@ def create_charts(company, chart_template=None, existing_company=None, custom_ch
account = frappe.get_doc({
"doctype": "Account",
"account_name": account_name,
"account_name": child.get('account_name') if from_coa_importer else account_name,
"company": company,
"parent_account": parent,
"is_group": is_group,
@ -213,7 +213,7 @@ def validate_bank_account(coa, bank_account):
return (bank_account in accounts)
@frappe.whitelist()
def build_tree_from_json(chart_template, chart_data=None):
def build_tree_from_json(chart_template, chart_data=None, from_coa_importer=False):
''' get chart template from its folder and parse the json to be rendered as tree '''
chart = chart_data or get_chart(chart_template)
@ -226,9 +226,12 @@ def build_tree_from_json(chart_template, chart_data=None):
''' recursively called to form a parent-child based list of dict from chart template '''
for account_name, child in iteritems(children):
account = {}
if account_name in ["account_number", "account_type",\
if account_name in ["account_name", "account_number", "account_type",\
"root_type", "is_group", "tax_rate"]: continue
if from_coa_importer:
account_name = child['account_name']
account['parent_account'] = parent
account['expandable'] = True if identify_is_group(child) else False
account['value'] = (cstr(child.get('account_number')).strip() + ' - ' + account_name) \

View File

@ -69,7 +69,7 @@ def import_coa(file_name, company):
frappe.local.flags.ignore_root_company_validation = True
forest = build_forest(data)
create_charts(company, custom_chart=forest)
create_charts(company, custom_chart=forest, from_coa_importer=True)
# trigger on_update for company to reset default accounts
set_default_accounts(company)
@ -148,7 +148,7 @@ def get_coa(doctype, parent, is_root=False, file_name=None, for_validate=0):
if not for_validate:
forest = build_forest(data)
accounts = build_tree_from_json("", chart_data=forest) # returns a list of dict in a tree render-able form
accounts = build_tree_from_json("", chart_data=forest, from_coa_importer=True) # returns a list of dict in a tree render-able form
# filter out to show data for the selected node only
accounts = [d for d in accounts if d['parent_account']==parent]
@ -212,11 +212,14 @@ def build_forest(data):
if not account_name:
error_messages.append("Row {0}: Please enter Account Name".format(line_no))
name = account_name
if account_number:
account_number = cstr(account_number).strip()
account_name = "{} - {}".format(account_number, account_name)
charts_map[account_name] = {}
charts_map[account_name]['account_name'] = name
if account_number: charts_map[account_name]["account_number"] = account_number
if cint(is_group) == 1: charts_map[account_name]["is_group"] = is_group
if account_type: charts_map[account_name]["account_type"] = account_type
if root_type: charts_map[account_name]["root_type"] = root_type

View File

@ -180,8 +180,7 @@
"fieldname": "pos_transactions",
"fieldtype": "Table",
"label": "POS Transactions",
"options": "POS Invoice Reference",
"reqd": 1
"options": "POS Invoice Reference"
},
{
"fieldname": "pos_opening_entry",
@ -229,7 +228,7 @@
"link_fieldname": "pos_closing_entry"
}
],
"modified": "2021-05-05 16:59:49.723261",
"modified": "2021-10-20 16:19:25.340565",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Closing Entry",

View File

@ -246,7 +246,10 @@ def get_invoice_customer_map(pos_invoices):
return pos_invoice_customer_map
def consolidate_pos_invoices(pos_invoices=None, closing_entry=None):
invoices = pos_invoices or (closing_entry and closing_entry.get('pos_transactions')) or get_all_unconsolidated_invoices()
invoices = pos_invoices or (closing_entry and closing_entry.get('pos_transactions'))
if frappe.flags.in_test and not invoices:
invoices = get_all_unconsolidated_invoices()
invoice_by_customer = get_invoice_customer_map(invoices)
if len(invoices) >= 10 and closing_entry:

View File

@ -120,6 +120,7 @@
{
"fieldname": "payments",
"fieldtype": "Table",
"label": "Payment Methods",
"options": "POS Payment Method",
"reqd": 1
},
@ -377,7 +378,7 @@
"link_fieldname": "pos_profile"
}
],
"modified": "2021-02-01 13:52:51.081311",
"modified": "2021-10-14 14:17:00.469298",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Profile",

View File

@ -15,6 +15,7 @@ from erpnext.accounts.deferred_revenue import validate_service_stop_date
from erpnext.accounts.doctype.gl_entry.gl_entry import update_outstanding_amt
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
check_if_return_invoice_linked_with_payment_entry,
get_total_in_party_account_currency,
is_overdue,
unlink_inter_company_doc,
update_linked_doc,
@ -1183,6 +1184,7 @@ class PurchaseInvoice(BuyingController):
return
outstanding_amount = flt(self.outstanding_amount, self.precision("outstanding_amount"))
total = get_total_in_party_account_currency(self)
if not status:
if self.docstatus == 2:
@ -1190,9 +1192,9 @@ class PurchaseInvoice(BuyingController):
elif self.docstatus == 1:
if self.is_internal_transfer():
self.status = 'Internal Transfer'
elif is_overdue(self):
elif is_overdue(self, total):
self.status = "Overdue"
elif 0 < outstanding_amount < flt(self.grand_total, self.precision("grand_total")):
elif 0 < outstanding_amount < total:
self.status = "Partly Paid"
elif outstanding_amount > 0 and getdate(self.due_date) >= getdate():
self.status = "Unpaid"

View File

@ -1439,6 +1439,7 @@ class SalesInvoice(SellingController):
return
outstanding_amount = flt(self.outstanding_amount, self.precision("outstanding_amount"))
total = get_total_in_party_account_currency(self)
if not status:
if self.docstatus == 2:
@ -1446,9 +1447,9 @@ class SalesInvoice(SellingController):
elif self.docstatus == 1:
if self.is_internal_transfer():
self.status = 'Internal Transfer'
elif is_overdue(self):
elif is_overdue(self, total):
self.status = "Overdue"
elif 0 < outstanding_amount < flt(self.grand_total, self.precision("grand_total")):
elif 0 < outstanding_amount < total:
self.status = "Partly Paid"
elif outstanding_amount > 0 and getdate(self.due_date) >= getdate():
self.status = "Unpaid"
@ -1475,27 +1476,42 @@ class SalesInvoice(SellingController):
if update:
self.db_set('status', self.status, update_modified = update_modified)
def is_overdue(doc):
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
def get_total_in_party_account_currency(doc):
total_fieldname = (
"grand_total"
if doc.disable_rounded_total
else "rounded_total"
)
if doc.party_account_currency != doc.currency:
total_fieldname = "base_" + total_fieldname
return flt(doc.get(total_fieldname), doc.precision(total_fieldname))
def is_overdue(doc, total):
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
if outstanding_amount <= 0:
return
grand_total = flt(doc.grand_total, doc.precision("grand_total"))
nowdate = getdate()
if doc.payment_schedule:
# calculate payable amount till date
payable_amount = sum(
payment.payment_amount
for payment in doc.payment_schedule
if getdate(payment.due_date) < nowdate
)
today = getdate()
if doc.get('is_pos') or not doc.get('payment_schedule'):
return getdate(doc.due_date) < today
if (grand_total - outstanding_amount) < payable_amount:
return True
# calculate payable amount till date
payment_amount_field = (
"base_payment_amount"
if doc.party_account_currency != doc.currency
else "payment_amount"
)
payable_amount = sum(
payment.get(payment_amount_field)
for payment in doc.payment_schedule
if getdate(payment.due_date) < today
)
return (total - outstanding_amount) < payable_amount
elif getdate(doc.due_date) < nowdate:
return True
def get_discounting_status(sales_invoice):
status = None

View File

@ -203,6 +203,9 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N
# then chargeable value is "prev invoices + advances" value which cross the threshold
tax_amount = get_tcs_amount(parties, inv, tax_details, vouchers, advance_vouchers)
if cint(tax_details.round_off_tax_amount):
tax_amount = round(tax_amount)
return tax_amount, tax_deducted
def get_invoice_vouchers(parties, tax_details, company, party_type='Supplier'):
@ -322,9 +325,6 @@ def get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers):
else:
tds_amount = supp_credit_amt * tax_details.rate / 100 if supp_credit_amt > 0 else 0
if cint(tax_details.round_off_tax_amount):
tds_amount = round(tds_amount)
return tds_amount
def get_tcs_amount(parties, inv, tax_details, vouchers, adv_vouchers):

View File

@ -115,7 +115,7 @@ def prepare_companywise_opening_balance(asset_data, liability_data, equity_data,
# opening_value = Aseet - liability - equity
for data in [asset_data, liability_data, equity_data]:
account_name = get_root_account_name(data[0].root_type, company)
opening_value += get_opening_balance(account_name, data, company)
opening_value += (get_opening_balance(account_name, data, company) or 0.0)
opening_balance[company] = opening_value

View File

@ -421,8 +421,6 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map):
update_value_in_dict(totals, 'closing', gle)
elif gle.posting_date <= to_date:
update_value_in_dict(gle_map[gle.get(group_by)].totals, 'total', gle)
update_value_in_dict(totals, 'total', gle)
if filters.get("group_by") != 'Group by Voucher (Consolidated)':
gle_map[gle.get(group_by)].entries.append(gle)
elif filters.get("group_by") == 'Group by Voucher (Consolidated)':
@ -436,10 +434,11 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map):
else:
update_value_in_dict(consolidated_gle, key, gle)
update_value_in_dict(gle_map[gle.get(group_by)].totals, 'closing', gle)
update_value_in_dict(totals, 'closing', gle)
for key, value in consolidated_gle.items():
update_value_in_dict(gle_map[value.get(group_by)].totals, 'total', value)
update_value_in_dict(totals, 'total', value)
update_value_in_dict(gle_map[value.get(group_by)].totals, 'closing', value)
update_value_in_dict(totals, 'closing', value)
entries.append(value)
return totals, entries

View File

@ -44,16 +44,16 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map):
if rate and tds_deducted:
row = {
'pan' if frappe.db.has_column('Supplier', 'pan') else 'tax_id': supplier_map.get(supplier).pan,
'supplier': supplier_map.get(supplier).name
'pan' if frappe.db.has_column('Supplier', 'pan') else 'tax_id': supplier_map.get(supplier, {}).get('pan'),
'supplier': supplier_map.get(supplier, {}).get('name')
}
if filters.naming_series == 'Naming Series':
row.update({'supplier_name': supplier_map.get(supplier).supplier_name})
row.update({'supplier_name': supplier_map.get(supplier, {}).get('supplier_name')})
row.update({
'section_code': tax_withholding_category,
'entity_type': supplier_map.get(supplier).supplier_type,
'entity_type': supplier_map.get(supplier, {}).get('supplier_type'),
'tds_rate': rate,
'total_amount_credited': total_amount_credited,
'tds_deducted': tds_deducted,

View File

@ -1686,17 +1686,58 @@ def get_advance_payment_entries(party_type, party, party_account, order_doctype,
def update_invoice_status():
"""Updates status as Overdue for applicable invoices. Runs daily."""
today = getdate()
for doctype in ("Sales Invoice", "Purchase Invoice"):
frappe.db.sql("""
update `tab{}` as dt set dt.status = 'Overdue'
where dt.docstatus = 1
and dt.status != 'Overdue'
and dt.outstanding_amount > 0
and (dt.grand_total - dt.outstanding_amount) <
(select sum(payment_amount) from `tabPayment Schedule` as ps
where ps.parent = dt.name and ps.due_date < %s)
""".format(doctype), getdate())
UPDATE `tab{doctype}` invoice SET invoice.status = 'Overdue'
WHERE invoice.docstatus = 1
AND invoice.status REGEXP '^Unpaid|^Partly Paid'
AND invoice.outstanding_amount > 0
AND (
{or_condition}
(
(
CASE
WHEN invoice.party_account_currency = invoice.currency
THEN (
CASE
WHEN invoice.disable_rounded_total
THEN invoice.grand_total
ELSE invoice.rounded_total
END
)
ELSE (
CASE
WHEN invoice.disable_rounded_total
THEN invoice.base_grand_total
ELSE invoice.base_rounded_total
END
)
END
) - invoice.outstanding_amount
) < (
SELECT SUM(
CASE
WHEN invoice.party_account_currency = invoice.currency
THEN ps.payment_amount
ELSE ps.base_payment_amount
END
)
FROM `tabPayment Schedule` ps
WHERE ps.parent = invoice.name
AND ps.due_date < %(today)s
)
)
""".format(
doctype=doctype,
or_condition=(
"invoice.is_pos AND invoice.due_date < %(today)s OR"
if doctype == "Sales Invoice"
else ""
)
), {"today": today}
)
@frappe.whitelist()
def get_payment_terms(terms_template, posting_date=None, grand_total=None, base_grand_total=None, bill_date=None):

View File

@ -33,6 +33,7 @@ class Opportunity(TransactionBase):
self.validate_item_details()
self.validate_uom_is_integer("uom", "qty")
self.validate_cust_name()
self.map_fields()
if not self.title:
self.title = self.customer_name
@ -43,6 +44,15 @@ class Opportunity(TransactionBase):
else:
self.calculate_totals()
def map_fields(self):
for field in self.meta.fields:
if not self.get(field.fieldname):
try:
value = frappe.db.get_value(self.opportunity_from, self.party_name, field.fieldname)
frappe.db.set(self, field.fieldname, value)
except Exception:
continue
def calculate_totals(self):
total = base_total = 0
for item in self.get('items'):

View File

@ -55,6 +55,7 @@ def make_employee(user, company=None, **kwargs):
"email": user,
"first_name": user,
"new_password": "password",
"send_welcome_email": 0,
"roles": [{"doctype": "Has Role", "role": "Employee"}]
}).insert()

View File

@ -9,7 +9,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.utils import getdate
from erpnext.hr.utils import update_employee, validate_active_employee
from erpnext.hr.utils import update_employee_work_history, validate_active_employee
class EmployeePromotion(Document):
@ -23,10 +23,10 @@ class EmployeePromotion(Document):
def on_submit(self):
employee = frappe.get_doc("Employee", self.employee)
employee = update_employee(employee, self.promotion_details, date=self.promotion_date)
employee = update_employee_work_history(employee, self.promotion_details, date=self.promotion_date)
employee.save()
def on_cancel(self):
employee = frappe.get_doc("Employee", self.employee)
employee = update_employee(employee, self.promotion_details, cancel=True)
employee = update_employee_work_history(employee, self.promotion_details, cancel=True)
employee.save()

View File

@ -9,7 +9,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.utils import getdate
from erpnext.hr.utils import update_employee
from erpnext.hr.utils import update_employee_work_history
class EmployeeTransfer(Document):
@ -24,7 +24,7 @@ class EmployeeTransfer(Document):
new_employee = frappe.copy_doc(employee)
new_employee.name = None
new_employee.employee_number = None
new_employee = update_employee(new_employee, self.transfer_details, date=self.transfer_date)
new_employee = update_employee_work_history(new_employee, self.transfer_details, date=self.transfer_date)
if self.new_company and self.company != self.new_company:
new_employee.internal_work_history = []
new_employee.date_of_joining = self.transfer_date
@ -39,7 +39,7 @@ class EmployeeTransfer(Document):
employee.db_set("relieving_date", self.transfer_date)
employee.db_set("status", "Left")
else:
employee = update_employee(employee, self.transfer_details, date=self.transfer_date)
employee = update_employee_work_history(employee, self.transfer_details, date=self.transfer_date)
if self.new_company and self.company != self.new_company:
employee.company = self.new_company
employee.date_of_joining = self.transfer_date
@ -56,7 +56,7 @@ class EmployeeTransfer(Document):
employee.status = "Active"
employee.relieving_date = ''
else:
employee = update_employee(employee, self.transfer_details, cancel=True)
employee = update_employee_work_history(employee, self.transfer_details, date=self.transfer_date, cancel=True)
if self.new_company != self.company:
employee.company = self.company
employee.save()

View File

@ -4,6 +4,7 @@
from __future__ import unicode_literals
import unittest
from datetime import date
import frappe
from frappe.utils import add_days, getdate
@ -15,7 +16,12 @@ class TestEmployeeTransfer(unittest.TestCase):
def setUp(self):
make_employee("employee2@transfers.com")
make_employee("employee3@transfers.com")
frappe.db.sql("""delete from `tabEmployee Transfer`""")
create_company()
create_employee()
create_employee_transfer()
def tearDown(self):
frappe.db.rollback()
def test_submit_before_transfer_date(self):
transfer_obj = frappe.get_doc({
@ -57,3 +63,77 @@ class TestEmployeeTransfer(unittest.TestCase):
self.assertTrue(transfer.new_employee_id)
self.assertEqual(frappe.get_value("Employee", transfer.new_employee_id, "status"), "Active")
self.assertEqual(frappe.get_value("Employee", transfer.employee, "status"), "Left")
def test_employee_history(self):
name = frappe.get_value("Employee", {"first_name": "John", "company": "Test Company"}, "name")
doc = frappe.get_doc("Employee",name)
count = 0
department = ["Accounts - TC", "Management - TC"]
designation = ["Accountant", "Manager"]
dt = [getdate("01-10-2021"), date.today()]
for data in doc.internal_work_history:
self.assertEqual(data.department, department[count])
self.assertEqual(data.designation, designation[count])
self.assertEqual(data.from_date, dt[count])
count = count + 1
data = frappe.db.get_list("Employee Transfer", filters={"employee":name}, fields=["*"])
doc = frappe.get_doc("Employee Transfer", data[0]["name"])
doc.cancel()
employee_doc = frappe.get_doc("Employee",name)
for data in employee_doc.internal_work_history:
self.assertEqual(data.designation, designation[0])
self.assertEqual(data.department, department[0])
self.assertEqual(data.from_date, dt[0])
def create_employee():
doc = frappe.get_doc({
"doctype": "Employee",
"first_name": "John",
"company": "Test Company",
"gender": "Male",
"date_of_birth": getdate("30-09-1980"),
"date_of_joining": getdate("01-10-2021"),
"department": "Accounts - TC",
"designation": "Accountant"
})
doc.save()
def create_company():
exists = frappe.db.exists("Company", "Test Company")
if not exists:
doc = frappe.get_doc({
"doctype": "Company",
"company_name": "Test Company",
"default_currency": "INR",
"country": "India"
})
doc.save()
def create_employee_transfer():
doc = frappe.get_doc({
"doctype": "Employee Transfer",
"employee": frappe.get_value("Employee", {"first_name": "John", "company": "Test Company"}, "name"),
"transfer_date": date.today(),
"transfer_details": [
{
"property": "Designation",
"current": "Accountant",
"new": "Manager",
"fieldname": "designation"
},
{
"property": "Department",
"current": "Accounts - TC",
"new": "Management - TC",
"fieldname": "department"
}
]
})
doc.save()
doc.submit()

View File

@ -182,10 +182,11 @@ def get_allocated_and_expired_leaves(from_date, to_date, employee, leave_type):
records= frappe.db.sql("""
SELECT
employee, leave_type, from_date, to_date, leaves, transaction_name,
is_carry_forward, is_expired
transaction_type, is_carry_forward, is_expired
FROM `tabLeave Ledger Entry`
WHERE employee=%(employee)s AND leave_type=%(leave_type)s
AND docstatus=1
AND transaction_type = 'Leave Allocation'
AND (from_date between %(from_date)s AND %(to_date)s
OR to_date between %(from_date)s AND %(to_date)s
OR (from_date < %(from_date)s AND to_date > %(to_date)s))

View File

@ -29,7 +29,15 @@ def set_employee_name(doc):
if doc.employee and not doc.employee_name:
doc.employee_name = frappe.db.get_value("Employee", doc.employee, "employee_name")
def update_employee(employee, details, date=None, cancel=False):
def update_employee_work_history(employee, details, date=None, cancel=False):
if not employee.internal_work_history and not cancel:
employee.append("internal_work_history", {
"branch": employee.branch,
"designation": employee.designation,
"department": employee.department,
"from_date": employee.date_of_joining
})
internal_work_history = {}
for item in details:
field = frappe.get_meta("Employee").get_field(item.fieldname)
@ -44,11 +52,35 @@ def update_employee(employee, details, date=None, cancel=False):
setattr(employee, item.fieldname, new_data)
if item.fieldname in ["department", "designation", "branch"]:
internal_work_history[item.fieldname] = item.new
if internal_work_history and not cancel:
internal_work_history["from_date"] = date
employee.append("internal_work_history", internal_work_history)
if cancel:
delete_employee_work_history(details, employee, date)
return employee
def delete_employee_work_history(details, employee, date):
filters = {}
for d in details:
for history in employee.internal_work_history:
if d.property == "Department" and history.department == d.new:
department = d.new
filters["department"] = department
if d.property == "Designation" and history.designation == d.new:
designation = d.new
filters["designation"] = designation
if d.property == "Branch" and history.branch == d.new:
branch = d.new
filters["branch"] = branch
if date and date == history.from_date:
filters["from_date"] = date
if filters:
frappe.db.delete("Employee Internal Work History", filters)
@frappe.whitelist()
def get_employee_fields_label():
fields = []

View File

@ -199,12 +199,16 @@ class MaintenanceSchedule(TransactionBase):
if chk:
throw(_("Maintenance Schedule {0} exists against {1}").format(chk[0][0], d.sales_order))
def validate_no_of_visits(self):
return len(self.schedules) != sum(d.no_of_visits for d in self.items)
def validate(self):
self.validate_end_date_visits()
self.validate_maintenance_detail()
self.validate_dates_with_periodicity()
self.validate_sales_order()
self.generate_schedule()
if not self.schedules or self.validate_no_of_visits():
self.generate_schedule()
def on_update(self):
frappe.db.set(self, 'status', 'Draft')

View File

@ -424,7 +424,7 @@ class ProductionPlan(Document):
po = frappe.new_doc('Purchase Order')
po.supplier = supplier
po.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate()
po.is_subcontracted_item = 'Yes'
po.is_subcontracted = 'Yes'
for row in po_list:
args = {
'item_code': row.production_item,

View File

@ -24,7 +24,7 @@ def get_data(filters):
}
fields = ["name", "status", "work_order", "production_item", "item_name", "posting_date",
"total_completed_qty", "workstation", "operation", "employee_name", "total_time_in_mins"]
"total_completed_qty", "workstation", "operation", "total_time_in_mins"]
for field in ["work_order", "workstation", "operation", "company"]:
if filters.get(field):
@ -45,7 +45,7 @@ def get_data(filters):
job_card_time_details = {}
for job_card_data in frappe.get_all("Job Card Time Log",
fields=["min(from_time) as from_time", "max(to_time) as to_time", "parent"],
filters=job_card_time_filter, group_by="parent", debug=1):
filters=job_card_time_filter, group_by="parent"):
job_card_time_details[job_card_data.parent] = job_card_data
res = []
@ -172,12 +172,6 @@ def get_columns(filters):
"options": "Operation",
"width": 110
},
{
"label": _("Employee Name"),
"fieldname": "employee_name",
"fieldtype": "Data",
"width": 110
},
{
"label": _("Total Completed Qty"),
"fieldname": "total_completed_qty",

View File

@ -9,9 +9,9 @@
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2021-08-24 16:38:15.233395",
"modified": "2021-10-20 22:03:57.606612",
"modified_by": "Administrator",
"module": "Stock",
"module": "Manufacturing",
"name": "Process Loss Report",
"owner": "Administrator",
"prepared_report": 0,
@ -21,9 +21,6 @@
"roles": [
{
"role": "Manufacturing User"
},
{
"role": "Stock User"
}
]
}

View File

@ -111,7 +111,7 @@ def run_query(query_args: QueryArgs) -> Data:
{work_order_filter}
GROUP BY
se.work_order
""".format(**query_args), query_args, as_dict=1, debug=1)
""".format(**query_args), query_args, as_dict=1)
def update_data_with_total_pl_value(data: Data) -> None:
for row in data:

View File

@ -0,0 +1,64 @@
import unittest
from typing import List, Tuple
import frappe
from erpnext.tests.utils import ReportFilters, ReportName, execute_script_report
DEFAULT_FILTERS = {
"company": "_Test Company",
"from_date": "2010-01-01",
"to_date": "2030-01-01",
"warehouse": "_Test Warehouse - _TC",
}
REPORT_FILTER_TEST_CASES: List[Tuple[ReportName, ReportFilters]] = [
("BOM Explorer", {"bom": frappe.get_last_doc("BOM").name}),
("BOM Operations Time", {}),
("BOM Stock Calculated", {"bom": frappe.get_last_doc("BOM").name, "qty_to_make": 2}),
("BOM Stock Report", {"bom": frappe.get_last_doc("BOM").name, "qty_to_produce": 2}),
("Cost of Poor Quality Report", {}),
("Downtime Analysis", {}),
(
"Exponential Smoothing Forecasting",
{
"based_on_document": "Sales Order",
"based_on_field": "Qty",
"no_of_years": 3,
"periodicity": "Yearly",
"smoothing_constant": 0.3,
},
),
("Job Card Summary", {"fiscal_year": "2021-2022"}),
("Production Analytics", {"range": "Monthly"}),
("Quality Inspection Summary", {}),
("Process Loss Report", {}),
("Work Order Stock Report", {}),
("Work Order Summary", {"fiscal_year": "2021-2022", "age": 0}),
]
if frappe.db.a_row_exists("Production Plan"):
REPORT_FILTER_TEST_CASES.append(
("Production Plan Summary", {"production_plan": frappe.get_last_doc("Production Plan").name})
)
OPTIONAL_FILTERS = {
"warehouse": "_Test Warehouse - _TC",
"item": "_Test Item",
"item_group": "_Test Item Group",
}
class TestManufacturingReports(unittest.TestCase):
def test_execute_all_manufacturing_reports(self):
"""Test that all script report in manufacturing modules are executable with supported filters"""
for report, filter in REPORT_FILTER_TEST_CASES:
execute_script_report(
report_name=report,
module="Manufacturing",
filters=filter,
default_filters=DEFAULT_FILTERS,
optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None,
)

View File

@ -294,6 +294,7 @@ erpnext.patches.v13_0.set_operation_time_based_on_operating_cost
erpnext.patches.v13_0.validate_options_for_data_field
erpnext.patches.v13_0.create_gst_payment_entry_fields
erpnext.patches.v14_0.delete_shopify_doctypes
erpnext.patches.v13_0.fix_invoice_statuses
erpnext.patches.v13_0.replace_supplier_item_group_with_party_specific_item
erpnext.patches.v13_0.update_dates_in_tax_withholding_category
erpnext.patches.v14_0.update_opportunity_currency_fields
@ -307,3 +308,5 @@ erpnext.patches.v13_0.set_status_in_maintenance_schedule_table
erpnext.patches.v13_0.add_default_interview_notification_templates
erpnext.patches.v13_0.enable_scheduler_job_for_item_reposting
erpnext.patches.v13_0.requeue_failed_reposts
erpnext.patches.v13_0.healthcare_deprecation_warning
erpnext.patches.v14_0.delete_healthcare_doctypes

View File

@ -0,0 +1,113 @@
import frappe
from frappe.utils import flt, getdate
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
get_total_in_party_account_currency,
is_overdue,
)
TODAY = getdate()
def execute():
# This fix is not related to Party Specific Item,
# but it is needed for code introduced after Party Specific Item was
# If your DB doesn't have this doctype yet, you should be fine
if not frappe.db.exists("DocType", "Party Specific Item"):
return
for doctype in ("Purchase Invoice", "Sales Invoice"):
fields = [
"name",
"status",
"due_date",
"outstanding_amount",
"grand_total",
"base_grand_total",
"rounded_total",
"base_rounded_total",
"disable_rounded_total",
]
if doctype == "Sales Invoice":
fields.append("is_pos")
invoices_to_update = frappe.get_all(
doctype,
fields=fields,
filters={
"docstatus": 1,
"status": ("in", (
"Overdue",
"Overdue and Discounted",
"Partly Paid",
"Partly Paid and Discounted"
)),
"outstanding_amount": (">", 0),
"modified": (">", "2021-01-01")
# an assumption is being made that only invoices modified
# after 2021 got affected as incorrectly overdue.
# required for performance reasons.
}
)
invoices_to_update = {
invoice.name: invoice for invoice in invoices_to_update
}
payment_schedule_items = frappe.get_all(
"Payment Schedule",
fields=(
"due_date",
"payment_amount",
"base_payment_amount",
"parent"
),
filters={"parent": ("in", invoices_to_update)}
)
for item in payment_schedule_items:
invoices_to_update[item.parent].setdefault(
"payment_schedule", []
).append(item)
status_map = {}
for invoice in invoices_to_update.values():
invoice.doctype = doctype
doc = frappe.get_doc(invoice)
correct_status = get_correct_status(doc)
if not correct_status or doc.status == correct_status:
continue
status_map.setdefault(correct_status, []).append(doc.name)
for status, docs in status_map.items():
frappe.db.set_value(
doctype, {"name": ("in", docs)},
"status",
status,
update_modified=False
)
def get_correct_status(doc):
outstanding_amount = flt(
doc.outstanding_amount, doc.precision("outstanding_amount")
)
total = get_total_in_party_account_currency(doc)
status = ""
if is_overdue(doc, total):
status = "Overdue"
elif 0 < outstanding_amount < total:
status = "Partly Paid"
elif outstanding_amount > 0 and getdate(doc.due_date) >= TODAY:
status = "Unpaid"
if not status:
return
if doc.status.endswith(" and Discounted"):
status += " and Discounted"
return status

View File

@ -0,0 +1,49 @@
import frappe
def execute():
if "healthcare" in frappe.get_installed_apps():
return
frappe.delete_doc("Workspace", "Healthcare", ignore_missing=True, force=True)
pages = frappe.get_all("Page", {"module": "healthcare"}, pluck='name')
for page in pages:
frappe.delete_doc("Page", page, ignore_missing=True, force=True)
reports = frappe.get_all("Report", {"module": "healthcare", "is_standard": "Yes"}, pluck='name')
for report in reports:
frappe.delete_doc("Report", report, ignore_missing=True, force=True)
print_formats = frappe.get_all("Print Format", {"module": "healthcare", "standard": "Yes"}, pluck='name')
for print_format in print_formats:
frappe.delete_doc("Print Format", print_format, ignore_missing=True, force=True)
frappe.reload_doc("website", "doctype", "website_settings")
forms = frappe.get_all("Web Form", {"module": "healthcare", "is_standard": 1}, pluck='name')
for form in forms:
frappe.delete_doc("Web Form", form, ignore_missing=True, force=True)
dashboards = frappe.get_all("Dashboard", {"module": "healthcare", "is_standard": 1}, pluck='name')
for dashboard in dashboards:
frappe.delete_doc("Dashboard", dashboard, ignore_missing=True, force=True)
dashboards = frappe.get_all("Dashboard Chart", {"module": "healthcare", "is_standard": 1}, pluck='name')
for dashboard in dashboards:
frappe.delete_doc("Dashboard Chart", dashboard, ignore_missing=True, force=True)
frappe.reload_doc("desk", "doctype", "number_card")
cards = frappe.get_all("Number Card", {"module": "healthcare", "is_standard": 1}, pluck='name')
for card in cards:
frappe.delete_doc("Number Card", card, ignore_missing=True, force=True)
titles = ['Lab Test', 'Prescription', 'Patient Appointment']
items = frappe.get_all('Portal Menu Item', filters=[['title', 'in', titles]], pluck='name')
for item in items:
frappe.delete_doc("Portal Menu Item", item, ignore_missing=True, force=True)
doctypes = frappe.get_all("DocType", {"module": "healthcare", "custom": 0}, pluck='name')
for doctype in doctypes:
frappe.delete_doc("DocType", doctype, ignore_missing=True)
frappe.delete_doc("Module Def", "Healthcare", ignore_missing=True, force=True)

View File

@ -125,27 +125,28 @@ class AdditionalSalary(Document):
no_of_days = date_diff(getdate(end_date), getdate(start_date)) + 1
return amount_per_day * no_of_days
@frappe.whitelist()
def get_additional_salaries(employee, start_date, end_date, component_type):
additional_salary_list = frappe.db.sql("""
select name, salary_component as component, type, amount,
overwrite_salary_structure_amount as overwrite,
deduct_full_tax_on_selected_payroll_date
from `tabAdditional Salary`
where employee=%(employee)s
and docstatus = 1
and (
payroll_date between %(from_date)s and %(to_date)s
or
from_date <= %(to_date)s and to_date >= %(to_date)s
)
and type = %(component_type)s
order by salary_component, overwrite ASC
""", {
'employee': employee,
'from_date': start_date,
'to_date': end_date,
'component_type': "Earning" if component_type == "earnings" else "Deduction"
}, as_dict=1)
comp_type = 'Earning' if component_type == 'earnings' else 'Deduction'
additional_sal = frappe.qb.DocType('Additional Salary')
component_field = additional_sal.salary_component.as_('component')
overwrite_field = additional_sal.overwrite_salary_structure_amount.as_('overwrite')
additional_salary_list = frappe.qb.from_(
additional_sal
).select(
additional_sal.name, component_field, additional_sal.type,
additional_sal.amount, additional_sal.is_recurring, overwrite_field,
additional_sal.deduct_full_tax_on_selected_payroll_date
).where(
(additional_sal.employee == employee)
& (additional_sal.docstatus == 1)
& (additional_sal.type == comp_type)
).where(
additional_sal.payroll_date[start_date: end_date]
| ((additional_sal.from_date <= end_date) & (additional_sal.to_date >= end_date))
).run(as_dict=True)
additional_salaries = []
components_to_overwrite = []

View File

@ -12,6 +12,7 @@
"year_to_date",
"section_break_5",
"additional_salary",
"is_recurring_additional_salary",
"statistical_component",
"depends_on_payment_days",
"exempted_from_income_tax",
@ -235,11 +236,19 @@
"label": "Year To Date",
"options": "currency",
"read_only": 1
}
},
{
"default": "0",
"depends_on": "eval:doc.parenttype=='Salary Slip' && doc.additional_salary",
"fieldname": "is_recurring_additional_salary",
"fieldtype": "Check",
"label": "Is Recurring Additional Salary",
"read_only": 1
}
],
"istable": 1,
"links": [],
"modified": "2021-01-14 13:39:15.847158",
"modified": "2021-08-30 13:39:15.847158",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Salary Detail",

View File

@ -172,7 +172,6 @@ class SalarySlip(TransactionBase):
and employee = %s and name != %s {0}""".format(cond),
(self.start_date, self.end_date, self.employee, self.name))
if ret_exist:
self.employee = ''
frappe.throw(_("Salary Slip of employee {0} already created for this period").format(self.employee))
else:
for data in self.timesheets:
@ -630,7 +629,8 @@ class SalarySlip(TransactionBase):
get_salary_component_data(additional_salary.component),
additional_salary.amount,
component_type,
additional_salary
additional_salary,
is_recurring = additional_salary.is_recurring
)
def add_tax_components(self, payroll_period):
@ -651,7 +651,7 @@ class SalarySlip(TransactionBase):
tax_row = get_salary_component_data(d)
self.update_component_row(tax_row, tax_amount, "deductions")
def update_component_row(self, component_data, amount, component_type, additional_salary=None):
def update_component_row(self, component_data, amount, component_type, additional_salary=None, is_recurring = 0):
component_row = None
for d in self.get(component_type):
if d.salary_component != component_data.salary_component:
@ -698,6 +698,8 @@ class SalarySlip(TransactionBase):
else:
component_row.default_amount = 0
component_row.additional_amount = amount
component_row.is_recurring_additional_salary = is_recurring
component_row.additional_salary = additional_salary.name
component_row.deduct_full_tax_on_selected_payroll_date = \
additional_salary.deduct_full_tax_on_selected_payroll_date
@ -894,25 +896,33 @@ class SalarySlip(TransactionBase):
amount, additional_amount = earning.default_amount, earning.additional_amount
if earning.is_tax_applicable:
if additional_amount:
taxable_earnings += (amount - additional_amount)
additional_income += additional_amount
if earning.deduct_full_tax_on_selected_payroll_date:
additional_income_with_full_tax += additional_amount
continue
if earning.is_flexible_benefit:
flexi_benefits += amount
else:
taxable_earnings += amount
taxable_earnings += (amount - additional_amount)
additional_income += additional_amount
# Get additional amount based on future recurring additional salary
if additional_amount and earning.is_recurring_additional_salary:
additional_income += self.get_future_recurring_additional_amount(earning.additional_salary,
earning.additional_amount) # Used earning.additional_amount to consider the amount for the full month
if earning.deduct_full_tax_on_selected_payroll_date:
additional_income_with_full_tax += additional_amount
if allow_tax_exemption:
for ded in self.deductions:
if ded.exempted_from_income_tax:
amount = ded.amount
amount, additional_amount = ded.amount, ded.additional_amount
if based_on_payment_days:
amount = self.get_amount_based_on_payment_days(ded, joining_date, relieving_date)[0]
taxable_earnings -= flt(amount)
amount, additional_amount = self.get_amount_based_on_payment_days(ded, joining_date, relieving_date)
taxable_earnings -= flt(amount - additional_amount)
additional_income -= additional_amount
if additional_amount and ded.is_recurring_additional_salary:
additional_income -= self.get_future_recurring_additional_amount(ded.additional_salary,
ded.additional_amount) # Used ded.additional_amount to consider the amount for the full month
return frappe._dict({
"taxable_earnings": taxable_earnings,
@ -921,11 +931,21 @@ class SalarySlip(TransactionBase):
"flexi_benefits": flexi_benefits
})
def get_future_recurring_additional_amount(self, additional_salary, monthly_additional_amount):
future_recurring_additional_amount = 0
to_date = frappe.db.get_value("Additional Salary", additional_salary, 'to_date')
# future month count excluding current
future_recurring_period = (getdate(to_date).month - getdate(self.start_date).month)
if future_recurring_period > 0:
future_recurring_additional_amount = monthly_additional_amount * future_recurring_period # Used earning.additional_amount to consider the amount for the full month
return future_recurring_additional_amount
def get_amount_based_on_payment_days(self, row, joining_date, relieving_date):
amount, additional_amount = row.amount, row.additional_amount
if (self.salary_structure and
cint(row.depends_on_payment_days) and cint(self.total_working_days) and
(not self.salary_slip_based_on_timesheet or
cint(row.depends_on_payment_days) and cint(self.total_working_days)
and not (row.additional_salary and row.default_amount) # to identify overwritten additional salary
and (not self.salary_slip_based_on_timesheet or
getdate(self.start_date) < joining_date or
(relieving_date and getdate(self.end_date) > relieving_date)
)):
@ -1244,7 +1264,7 @@ class SalarySlip(TransactionBase):
salary_slip_sum = frappe.get_list('Salary Slip',
fields = ['sum(net_pay) as net_sum', 'sum(gross_pay) as gross_sum'],
filters = {'employee_name' : self.employee_name,
filters = {'employee' : self.employee,
'start_date' : ['>=', period_start_date],
'end_date' : ['<', period_end_date],
'name': ['!=', self.name],
@ -1264,7 +1284,7 @@ class SalarySlip(TransactionBase):
first_day_of_the_month = get_first_day(self.start_date)
salary_slip_sum = frappe.get_list('Salary Slip',
fields = ['sum(net_pay) as sum'],
filters = {'employee_name' : self.employee_name,
filters = {'employee' : self.employee,
'start_date' : ['>=', first_day_of_the_month],
'end_date' : ['<', self.start_date],
'name': ['!=', self.name],
@ -1288,13 +1308,13 @@ class SalarySlip(TransactionBase):
INNER JOIN `tabSalary Slip` as salary_slip
ON detail.parent = salary_slip.name
WHERE
salary_slip.employee_name = %(employee_name)s
salary_slip.employee = %(employee)s
AND detail.salary_component = %(component)s
AND salary_slip.start_date >= %(period_start_date)s
AND salary_slip.end_date < %(period_end_date)s
AND salary_slip.name != %(docname)s
AND salary_slip.docstatus = 1""",
{'employee_name': self.employee_name, 'component': component.salary_component, 'period_start_date': period_start_date,
{'employee': self.employee, 'component': component.salary_component, 'period_start_date': period_start_date,
'period_end_date': period_end_date, 'docname': self.name}
)

View File

@ -536,6 +536,61 @@ class TestSalarySlip(unittest.TestCase):
# undelete fixture data
frappe.db.rollback()
def test_tax_for_recurring_additional_salary(self):
frappe.db.sql("""delete from `tabPayroll Period`""")
frappe.db.sql("""delete from `tabSalary Component`""")
payroll_period = create_payroll_period()
create_tax_slab(payroll_period, allow_tax_exemption=True)
employee = make_employee("test_tax@salary.slip")
delete_docs = [
"Salary Slip",
"Additional Salary",
"Employee Tax Exemption Declaration",
"Employee Tax Exemption Proof Submission",
"Employee Benefit Claim",
"Salary Structure Assignment"
]
for doc in delete_docs:
frappe.db.sql("delete from `tab%s` where employee='%s'" % (doc, employee))
from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
salary_structure = make_salary_structure("Stucture to test tax", "Monthly",
other_details={"max_benefits": 100000}, test_tax=True,
employee=employee, payroll_period=payroll_period)
create_salary_slips_for_payroll_period(employee, salary_structure.name,
payroll_period, deduct_random=False, num=3)
tax_paid = get_tax_paid_in_period(employee)
annual_tax = 23196.0
self.assertEqual(tax_paid, annual_tax)
frappe.db.sql("""delete from `tabSalary Slip` where employee=%s""", (employee))
#------------------------------------
# Recurring additional salary
start_date = add_months(payroll_period.start_date, 3)
end_date = add_months(payroll_period.start_date, 5)
create_recurring_additional_salary(employee, "Performance Bonus", 20000, start_date, end_date)
frappe.db.sql("""delete from `tabSalary Slip` where employee=%s""", (employee))
create_salary_slips_for_payroll_period(employee, salary_structure.name,
payroll_period, deduct_random=False, num=4)
tax_paid = get_tax_paid_in_period(employee)
annual_tax = 32315.0
self.assertEqual(tax_paid, annual_tax)
frappe.db.rollback()
def make_activity_for_employee(self):
activity_type = frappe.get_doc("Activity Type", "_Test Activity Type")
activity_type.billing_rate = 50
@ -1007,3 +1062,17 @@ def make_salary_slip_for_payment_days_dependency_test(employee, salary_structure
salary_slip = frappe.get_doc("Salary Slip", salary_slip_name)
return salary_slip
def create_recurring_additional_salary(employee, salary_component, amount, from_date, to_date, company=None):
frappe.get_doc({
"doctype": "Additional Salary",
"employee": employee,
"company": company or erpnext.get_default_company(),
"salary_component": salary_component,
"is_recurring": 1,
"from_date": from_date,
"to_date": to_date,
"amount": amount,
"type": "Earning",
"currency": erpnext.get_default_currency()
}).submit()

View File

@ -32,12 +32,12 @@ frappe.ui.form.on("Timesheet", {
};
},
onload: function(frm){
onload: function(frm) {
if (frm.doc.__islocal && frm.doc.time_logs) {
calculate_time_and_amount(frm);
}
if (frm.is_new()) {
if (frm.is_new() && !frm.doc.employee) {
set_employee_and_company(frm);
}
},
@ -283,7 +283,9 @@ frappe.ui.form.on("Timesheet Detail", {
calculate_time_and_amount(frm);
},
activity_type: function(frm, cdt, cdn) {
activity_type: function (frm, cdt, cdn) {
if (!frappe.get_doc(cdt, cdn).activity_type) return;
frappe.call({
method: "erpnext.projects.doctype.timesheet.timesheet.get_activity_cost",
args: {
@ -291,10 +293,10 @@ frappe.ui.form.on("Timesheet Detail", {
activity_type: frm.selected_doc.activity_type,
currency: frm.doc.currency
},
callback: function(r){
if(r.message){
frappe.model.set_value(cdt, cdn, 'billing_rate', r.message['billing_rate']);
frappe.model.set_value(cdt, cdn, 'costing_rate', r.message['costing_rate']);
callback: function (r) {
if (r.message) {
frappe.model.set_value(cdt, cdn, "billing_rate", r.message["billing_rate"]);
frappe.model.set_value(cdt, cdn, "costing_rate", r.message["costing_rate"]);
calculate_billing_costing_amount(frm, cdt, cdn);
}
}

View File

@ -137,7 +137,9 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
var me = this;
$.each(this.frm.doc["taxes"] || [], function(i, tax) {
tax.item_wise_tax_detail = {};
if (!tax.dont_recompute_tax) {
tax.item_wise_tax_detail = {};
}
var tax_fields = ["total", "tax_amount_after_discount_amount",
"tax_amount_for_current_item", "grand_total_for_current_item",
"tax_fraction_for_current_item", "grand_total_fraction_for_current_item"];
@ -421,7 +423,9 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
current_tax_amount = tax_rate * item.qty;
}
this.set_item_wise_tax(item, tax, tax_rate, current_tax_amount);
if (!tax.dont_recompute_tax) {
this.set_item_wise_tax(item, tax, tax_rate, current_tax_amount);
}
return current_tax_amount;
}
@ -589,7 +593,9 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
delete tax[fieldname];
});
tax.item_wise_tax_detail = JSON.stringify(tax.item_wise_tax_detail);
if (!tax.dont_recompute_tax) {
tax.item_wise_tax_detail = JSON.stringify(tax.item_wise_tax_detail);
}
});
}
}

View File

@ -334,10 +334,12 @@ erpnext.HierarchyChart = class {
if (child_nodes) {
$.each(child_nodes, (_i, data) => {
this.add_node(node, data);
setTimeout(() => {
this.add_connector(node.id, data.id);
}, 250);
if (!$(`[id="${data.id}"]`).length) {
this.add_node(node, data);
setTimeout(() => {
this.add_connector(node.id, data.id);
}, 250);
}
});
}
}

View File

@ -172,13 +172,6 @@ class Gstr1Report(object):
self.invoices = frappe._dict()
conditions = self.get_conditions()
company_gstins = get_company_gstin_number(self.filters.get('company'), all_gstins=True)
if company_gstins:
self.filters.update({
'company_gstins': company_gstins
})
invoice_data = frappe.db.sql("""
select
{select_columns}
@ -242,7 +235,7 @@ class Gstr1Report(object):
elif self.filters.get("type_of_business") == "EXPORT":
conditions += """ AND is_return !=1 and gst_category = 'Overseas' """
conditions += " AND IFNULL(billing_address_gstin, '') NOT IN %(company_gstins)s"
conditions += " AND IFNULL(billing_address_gstin, '') != company_gstin"
return conditions

View File

@ -1,164 +1,82 @@
{
"allow_copy": 0,
"allow_guest_to_view": 0,
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "field:uom_name",
"beta": 0,
"creation": "2013-01-10 16:34:24",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "Setup",
"editable_grid": 0,
"engine": "InnoDB",
"field_order": [
"enabled",
"uom_name",
"must_be_whole_number"
],
"fields": [
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "uom_name",
"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": "UOM Name",
"length": 0,
"no_copy": 0,
"oldfieldname": "uom_name",
"oldfieldtype": "Data",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 1
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "0",
"description": "Check this to disallow fractions. (for Nos)",
"fieldname": "must_be_whole_number",
"fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Must be Whole Number",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"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
"label": "Must be Whole Number"
},
{
"default": "1",
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled"
}
],
"has_web_view": 0,
"hide_heading": 0,
"hide_toolbar": 0,
"icon": "fa fa-compass",
"idx": 1,
"image_view": 0,
"in_create": 0,
"is_submittable": 0,
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2018-08-29 06:35:56.143361",
"links": [],
"modified": "2021-10-18 14:07:43.722144",
"modified_by": "Administrator",
"module": "Setup",
"name": "UOM",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"amend": 0,
"cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 0,
"import": 1,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "Item Manager",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"write": 1
},
{
"amend": 0,
"cancel": 0,
"create": 0,
"delete": 0,
"email": 1,
"export": 0,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "Stock Manager",
"set_user_permissions": 0,
"share": 0,
"submit": 0,
"write": 0
"role": "Stock Manager"
},
{
"amend": 0,
"cancel": 0,
"create": 0,
"delete": 0,
"email": 1,
"export": 0,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "Stock User",
"set_user_permissions": 0,
"share": 0,
"submit": 0,
"write": 0
"role": "Stock User"
}
],
"quick_entry": 1,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 1,
"sort_order": "ASC",
"track_changes": 0,
"track_seen": 0,
"track_views": 0
"sort_field": "modified",
"sort_order": "ASC"
}

View File

@ -202,7 +202,9 @@ def get_item_warehouse_map(filters, sle):
value_diff = flt(d.stock_value_difference)
if d.posting_date < from_date:
if d.posting_date < from_date or (d.posting_date == from_date
and d.voucher_type == "Stock Reconciliation" and
frappe.db.get_value("Stock Reconciliation", d.voucher_no, "purpose") == "Opening Stock"):
qty_dict.opening_qty += qty_diff
qty_dict.opening_val += value_diff

View File

@ -21,7 +21,7 @@ def execute(filters=None):
items = get_items(filters)
sl_entries = get_stock_ledger_entries(filters, items)
item_details = get_item_details(items, sl_entries, include_uom)
opening_row = get_opening_balance(filters, columns)
opening_row = get_opening_balance(filters, columns, sl_entries)
precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
data = []
@ -218,7 +218,7 @@ def get_sle_conditions(filters):
return "and {}".format(" and ".join(conditions)) if conditions else ""
def get_opening_balance(filters, columns):
def get_opening_balance(filters, columns, sl_entries):
if not (filters.item_code and filters.warehouse and filters.from_date):
return
@ -230,6 +230,15 @@ def get_opening_balance(filters, columns):
"posting_time": "00:00:00"
})
# check if any SLEs are actually Opening Stock Reconciliation
for sle in sl_entries:
if (sle.get("voucher_type") == "Stock Reconciliation"
and sle.get("date").split()[0] == filters.from_date
and frappe.db.get_value("Stock Reconciliation", sle.voucher_no, "purpose") == "Opening Stock"
):
last_entry = sle
sl_entries.remove(sle)
row = {
"item_code": _("'Opening'"),
"qty_after_transaction": last_entry.get("qty_after_transaction", 0),