Compare commits

..

133 Commits

Author SHA1 Message Date
Rohit Waghchaure
dfe2140f70 Merge branch 'version-13-pre-release' into version-13 2022-01-06 23:59:30 +05:30
Rohit Waghchaure
c4dab3eef3 bumped to version 13.18.0 2022-01-07 00:19:30 +05:50
rohitwaghchaure
e967aafc28 Merge pull request #29178 from rohitwaghchaure/change-log-for-v13-18
chore: change log for v13.18.0
2022-01-06 23:58:02 +05:30
Rohit Waghchaure
0e925c6ea2 chore: change log for v13.18.0 2022-01-06 23:56:24 +05:30
mergify[bot]
49c33a67e1 fix: incorrect serial no valuation report showing cancelled entries (#29172) (#29174)
(cherry picked from commit 24fc487dd8)

Co-authored-by: Ankush Menat <ankush@frappe.io>
2022-01-06 17:07:05 +05:30
mergify[bot]
2f98aa121a fix: Earned Leave allocation from Leave Policy Assignment (backport #29163) (#29166)
Co-authored-by: Rucha Mahabal <ruchamahabal2@gmail.com>
2022-01-06 11:46:37 +05:30
Subin Tom
d3820f6ce3 feat: 'Invoice Number' field in Opening Invoice Creation Tool (#29147) 2022-01-05 11:21:15 +05:30
rohitwaghchaure
2f07d38caf Merge pull request #29150 from frappe/mergify/bp/version-13-pre-release/pr-29103
fix: incorrect posting time fetching incorrect qty (backport #29103)
2022-01-05 10:36:37 +05:30
rohitwaghchaure
48f98a68b8 fix: incorrect posting time fetching incorrect qty (#29103)
(cherry picked from commit f02e6b4631)
2022-01-05 04:23:01 +00:00
Deepesh Garg
f90b49a1b5 Merge pull request #29145 from frappe/mergify/bp/version-13-pre-release/pr-29142
fix: map Accounting Dimensions for Bank Entry against Payroll Entry (backport #29142)
2022-01-04 21:02:16 +05:30
Rucha Mahabal
1eccb83904 fix: map Accounting Dimensions for Bank Entry against Payroll Entry
(cherry picked from commit 5eeb94db31)
2022-01-04 14:58:45 +00:00
Subin Tom
0b07fbd5a2 fix: Modifying Opening invoice creation tool timestamp (#29139) 2022-01-04 19:15:59 +05:30
mergify[bot]
3898b19086 fix: future recurring period calculation (backport #29083) (#29119)
Co-authored-by: Rucha Mahabal <ruchamahabal2@gmail.com>
Co-authored-by: Nabin Hait <nabinhait@gmail.com>
2022-01-04 12:00:42 +05:30
mergify[bot]
8a8648e6ec fix: filter query in bank reconciliation tool (#29100) 2022-01-03 11:41:38 +05:30
Deepesh Garg
19154074c1 Merge pull request #29095 from frappe/mergify/bp/version-13-pre-release/pr-29092
fix(India): Tax and Charges template not getting fetched based on tax category assigned (backport #29092)
2022-01-02 19:30:46 +05:30
Deepesh Garg
9a32bb6b51 fix: Test Case
(cherry picked from commit 342658ea70)
2022-01-02 13:37:23 +00:00
Deepesh Garg
cf4cac05ab fix(India): Tax and Charges template not getting fetched based on tax category assigned
(cherry picked from commit 7a5937a98c)
2022-01-02 13:37:23 +00:00
rohitwaghchaure
0703c1f996 Merge pull request #29073 from rohitwaghchaure/merge-hotfix-to-pre-release-for-13-18
chore: Merge branch 'version-13-hotfix' into 'version-13-pre-release'
2021-12-29 23:29:28 +05:30
Rohit Waghchaure
b378a225d0 chore: Merge branch 'version-13-hotfix' into 'version-13-pre-release' 2021-12-29 23:10:55 +05:30
rohitwaghchaure
12f5464a3b Merge pull request #29066 from frappe/mergify/bp/version-13-hotfix/pr-29064
fix: optimize patch for update bom in SO and MR (backport #29064)
2021-12-29 13:59:40 +05:30
Rohit Waghchaure
9d575e6e27 fix: optimize patch for update bom in SO and MR
(cherry picked from commit d9b9f4e8b7)
2021-12-29 06:39:14 +00:00
Deepesh Garg
098b3614e1 Merge pull request #29061 from frappe/mergify/bp/version-13-hotfix/pr-28034
fix: Updates in term loan processing (backport #28034)
2021-12-28 19:36:04 +05:30
Deepesh Garg
bbe4bc1fc0 fix: Resolve conflicts 2021-12-28 19:10:27 +05:30
Deepesh Garg
0f8a7d7a6c fix: Update modified timestamp 2021-12-28 18:53:10 +05:30
Deepesh Garg
a4396d7f31 fix: Add test for loan repayment cancellation
(cherry picked from commit 68d49817a1)
2021-12-28 13:10:38 +00:00
Deepesh Garg
4248bebe48 fix: Test cases
(cherry picked from commit f78bf4c6ef)
2021-12-28 13:10:37 +00:00
Deepesh Garg
b616443809 fix: Book unaccrued interest check
(cherry picked from commit c572a4cb88)
2021-12-28 13:10:36 +00:00
Deepesh Garg
3c803c46d3 fix: Repayment schedule revert on cancel
(cherry picked from commit 8f6600b27a)
2021-12-28 13:10:35 +00:00
Deepesh Garg
dc3a0337d1 fix: Unsecured loan status update
(cherry picked from commit dcae9ba86e)

# Conflicts:
#	erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
2021-12-28 13:10:34 +00:00
Deepesh Garg
430617cb68 fix: Loan repayment schedule date
(cherry picked from commit 1a5f0da6ca)
2021-12-28 13:10:32 +00:00
Deepesh Garg
2b1b831560 fix: Updates in term loan processing
(cherry picked from commit 8116b9b62f)

# Conflicts:
#	erpnext/loan_management/doctype/loan/loan.json
2021-12-28 13:10:31 +00:00
mergify[bot]
165114ba4c fix: tests (#29051) (#29060)
* fix(test): create salary slip as per timesheet dates for Project Profitability

* fix(test): reload salary slip before checking for payment days

(cherry picked from commit 88df5ce530)

Co-authored-by: Rucha Mahabal <ruchamahabal2@gmail.com>
2021-12-28 17:28:31 +05:30
Marica
964b19e1b1 Merge pull request #29034 from frappe/mergify/bp/version-13-hotfix/pr-29033
fix: avoid `"string" in None` condition (backport #29033)
2021-12-28 15:02:28 +05:30
Marica
b9e327515b Merge pull request #29058 from frappe/mergify/bp/version-13-hotfix/pr-28555
fix: Validate Finished Goods for Independent Manufacture entries as well (backport #28555)
2021-12-28 15:01:11 +05:30
Deepesh Garg
e0a88d99f4 Merge pull request #29052 from frappe/mergify/bp/version-13-hotfix/pr-29009
fix: Start date validation for deferred invoices (backport #29009)
2021-12-28 14:45:58 +05:30
marination
00b9f1fbef fix: Merge conflict 2021-12-28 14:41:03 +05:30
marination
59aaef3719 fix: Avoid Impact on Repack Entry
- Repack entries must be considered for fg/scrap checkbox auto set
- This must be avoided only for Manufacture entries, due to confusion between scrap and fg
- No scrap in repack, so its straight forward

(cherry picked from commit 22809a2838)
2021-12-28 09:07:08 +00:00
marination
fdf5811c27 fix: Sider
(cherry picked from commit 7d0340e660)
2021-12-28 09:07:07 +00:00
marination
66a5b3f34d fix: Linter (imports)
(cherry picked from commit dfff972a78)
2021-12-28 09:07:07 +00:00
marination
d8b781bb1e test: Independent Manufacture Entry
- Check validations
- Check if FG basic rate is calculated correctly

(cherry picked from commit affb29194b)

# Conflicts:
#	erpnext/stock/doctype/stock_entry/test_stock_entry.py
2021-12-28 09:07:07 +00:00
marination
6c6785e284 fix: Dont auto set is finished item or is scrap itm checkbox for independent entry
- System cant differentiate between scrap and FG when WO is absent, so dont auto set
- User must set it manually
- `validate_finished_goods` checks this

(cherry picked from commit 8c9779d69d)
2021-12-28 09:07:06 +00:00
marination
fc57ed1ca8 fix: Validate Finished Goods for Independent Manufacture entries as well
- Check if FG exists even if WO is absent
- Check if multiple FGs are there, block.

(cherry picked from commit d6bc121999)
2021-12-28 09:07:05 +00:00
mergify[bot]
aeb7bbc629 fix: rename non existent doctype field to the right one (#29055) (#29056)
(cherry picked from commit 095a6e186f)

Co-authored-by: Development for People <47140294+developmentforpeople@users.noreply.github.com>
2021-12-28 14:13:09 +05:30
Deepesh Garg
bdb6c4170b fix: Test case 2021-12-28 14:13:04 +05:30
Deepesh Garg
701bca5845 Merge pull request #28996 from deepeshgarg007/bank_reco_tool_fixes
fix: Issues in Bank Reconciliation tool
2021-12-28 13:40:28 +05:30
Deepesh Garg
becb39da90 fix: Remove debug statement 2021-12-28 13:18:17 +05:30
Marica
aa58653b75 Merge pull request #29053 from frappe/mergify/bp/version-13-hotfix/pr-28966
fix: Stock Ageing Report - Negative Opening Stock (backport #28966)
2021-12-28 13:04:33 +05:30
marination
bae184fc2f fix: Merge Conflicts 2021-12-28 12:43:14 +05:30
marination
24727dbadb fix: Linter (extra line after import)
(cherry picked from commit 6ca978c18b)
2021-12-28 06:54:48 +00:00
marination
ba5f4843c0 test: Stock Ageing FIFO Slot generation
(cherry picked from commit 098f72e7ec)
2021-12-28 06:54:47 +00:00
marination
bf266d82e7 chore: Added .md file to explain stock ageing business logic
(cherry picked from commit c007f84ade)
2021-12-28 06:54:47 +00:00
marination
e30c52861d chore: Use frappe.qb for query
(cherry picked from commit 878fd377c2)
2021-12-28 06:54:46 +00:00
marination
57b7fe6627 fix: Sider and Server side test
- args passed to wrong function
- missing space around '='

(cherry picked from commit 24a35c69c0)
2021-12-28 06:54:46 +00:00
marination
c04b62a7dd fix: Linter (imports)
(cherry picked from commit 8951a5c267)
2021-12-28 06:54:46 +00:00
marination
19fd1fb4b1 fix: Stock Ageing Report - Negative Opening Stock
- Consider negative opening stock in logic and neutralise it with +ve stock
- minor code refactor: class for FIFOSlots to generate chronological FIFO queue

(cherry picked from commit 0f43792dbb)

# Conflicts:
#	erpnext/stock/report/stock_ageing/stock_ageing.py
2021-12-28 06:54:46 +00:00
Deepesh Garg
b00f6676f1 fix: Start date validation for deferred invoices
(cherry picked from commit 7ad149f9fe)
2021-12-28 06:36:26 +00:00
Deepesh Garg
8be6bd978e Merge branch 'version-13-hotfix' into mergify/bp/version-13-hotfix/pr-29033 2021-12-28 11:03:34 +05:30
mergify[bot]
bffe329ade fix: filter out Claimed employee advances in Expense Claim (#29046) (#29047)
(cherry picked from commit 25f4de80b3)

Co-authored-by: Rucha Mahabal <ruchamahabal2@gmail.com>
2021-12-27 21:23:50 +05:30
Rucha Mahabal
b0bc3994c3 fix: flaky HR tests (#29045)
* fix(test): Leave Allocation validation against Leave Application after submit (#29005)

* fix(test): Leave Allocation validation against Leave Application after submit

* chore: clean-up Leave Allocation tests

* fix(test): set holiday list for leave allocation test

* fix: flaky HR tests (#29017)

* fix(test): use root company in Expense Claim tests

* fix(test): set Holiday List for Leave Allocation

* fix(test): set Holiday List for company
2021-12-27 17:26:16 +05:30
Rucha Mahabal
d0d82bc34a fix: ignore links while setting default notification templates in Settings (#29042) 2021-12-27 15:16:58 +05:30
Marica
1c2a8c679c Merge pull request #28951 from dj12djdjs/fix-ecommerce-selling-price-with-rule
fix(e-commerce): fetch selling price with pricing rule
2021-12-27 12:49:33 +05:30
Marica
39fd9ee40d Merge branch 'version-13-hotfix' into fix-ecommerce-selling-price-with-rule 2021-12-27 12:19:41 +05:30
Raffael Meyer
f92cdbbbf3 fix: avoid "string" in None condition
(cherry picked from commit 52397c9771)
2021-12-26 04:50:10 +00:00
Deepesh Garg
3f6d9336b5 Merge pull request #28953 from deepeshgarg007/price_fetch_optimize
fix(UX): Optimize rate updation on changing price list
2021-12-24 19:33:29 +05:30
mergify[bot]
b2e42aa16c chore: add running stock value difference in invariant report (#29012) (#29013)
[skip ci]

(cherry picked from commit 7987a46509)

Co-authored-by: Ankush Menat <ankush@frappe.io>
2021-12-23 21:17:42 +05:30
Devin Slauenwhite
6783fc808b Merge remote-tracking branch 'upstream/version-13-hotfix' into fix-ecommerce-selling-price-with-rule 2021-12-22 20:58:34 -05:00
Devin Slauenwhite
bcc436e63e fix(test): e-commerce fetch logged-in user item price. 2021-12-22 20:20:57 -05:00
Devin Slauenwhite
7eafc9ffe8 test: e-commerce customer pricing rule 2021-12-22 13:11:02 -05:00
Deepesh Garg
ff183ee895 fix: Issues in Bank Reconciliation tool 2021-12-22 18:04:49 +05:30
Marica
808e40cc4d Merge pull request #28985 from marination/item-card-group-links
fix: Convert Item links to Website Item links in `Item Card Group` template data
2021-12-22 13:53:25 +05:30
Marica
2777b501b5 Merge branch 'version-13-hotfix' into item-card-group-links 2021-12-22 13:25:18 +05:30
marination
a6e7b43f0b fix: Faulty 'continue' not setting value in db 2021-12-22 13:23:00 +05:30
marination
ffdaa849fb fix: Code cleanup 2021-12-22 12:26:43 +05:30
marination
85abb95df6 fix: Sider 2021-12-22 12:24:09 +05:30
Deepesh Garg
b60fbf5ba9 fix: Add round floats for price list rate 2021-12-22 12:01:39 +05:30
Deepesh Garg
233f79bf96 fix: Recalculate taxes irrespective of price list rate changed or not 2021-12-22 11:26:19 +05:30
mergify[bot]
dd01c0e854 fix: Currency fix for cost field in subscription plan (#28821) (#28898)
(cherry picked from commit be103ab8e6)

Co-authored-by: Subin Tom <36098155+nemesis189@users.noreply.github.com>
2021-12-22 10:43:00 +05:30
Devin Slauenwhite
de697a7aee Merge remote-tracking branch 'upstream/version-13-hotfix' into version-13-hotfix 2021-12-21 11:24:53 -05:00
mergify[bot]
b59df1118e fix: missed colon (#28979) (#28984)
(cherry picked from commit 076cb408db)

Co-authored-by: Development for People <47140294+developmentforpeople@users.noreply.github.com>
2021-12-21 16:04:22 +05:30
marination
242cc7e23e Merge branch 'version-13-hotfix' of https://github.com/frappe/erpnext into item-card-group-links 2021-12-21 15:51:50 +05:30
marination
dfe718ef79 fix: Convert Item links to Website Item links in Item Card Group template data
- Changed link option to Website Item in Item card group template
- patch to convert pre-existing data
2021-12-21 15:43:48 +05:30
mergify[bot]
84404bf6e3 fix: Reset value_after_depreciation on reversing journal entry during Asset return (#28980)
(cherry picked from commit 1ed30ee7c7)

Co-authored-by: Ganga Manoj <ganga.manoj98@gmail.com>
2021-12-21 15:13:11 +05:30
Deepesh Garg
56d72764b7 Merge pull request #28935 from deepeshgarg007/rcm_tax_template_fetch_issue
fix: Is Reverse Charge check in Tax Category
2021-12-21 13:41:22 +05:30
Deepesh Garg
84cebca254 Merge pull request #28974 from frappe/mergify/bp/version-13-hotfix/pr-28937
fix: Added filter for dispatch address (backport #28937)
2021-12-21 13:04:07 +05:30
Deepesh Garg
7e912db4b1 fix: Add is reverse charge in country wise tax 2021-12-21 12:54:40 +05:30
Deepesh Garg
3fed740142 Merge branch 'version-13-hotfix' of https://github.com/frappe/erpnext into rcm_tax_template_fetch_issue 2021-12-21 12:54:13 +05:30
Subin Tom
824c90fd11 fix: Added filter for dispatch address (#28937)
(cherry picked from commit 98d417602f)
2021-12-21 07:06:25 +00:00
mergify[bot]
ef47570977 test: remove unnecessary creation of companies #28965 (#28967)
test: remove unnecessary creation of companies
(cherry picked from commit d7148adee5)

Co-authored-by: Ankush Menat <ankush@frappe.io>
2021-12-20 23:22:34 +05:30
mergify[bot]
87e53e9e46 fix: fetch appointment letter content in the same order as template (#28968) (#28969)
(cherry picked from commit 92e86f2f09)

Co-authored-by: Rucha Mahabal <ruchamahabal2@gmail.com>
2021-12-20 23:17:02 +05:30
Ankush Menat
4107de4514 Merge pull request #28963 from frappe/mergify/bp/version-13-hotfix/pr-28588
fix: incorrect bin qty on backdated reconciliation (backport #28588)
2021-12-20 21:22:56 +05:30
Ankush Menat
9338843dac test: bin qty on backdated SR
(cherry picked from commit 2bb7bca464)
2021-12-20 14:36:40 +00:00
Ankush Menat
2d5f477720 fix: correct bin qty on backdated transactions
When making a backdated transactions current balance qty depends on
evaluation of whole ledger inbetween, instead of doing that just fetch
the last sle's qty_after_transaction when future transactions are
detected against SLE

fix: don't update bin's actual_qty

1. it's already updated by repost_current_voucher
2. update if future sle exists

(cherry picked from commit ff9a6e8e89)
2021-12-20 14:36:40 +00:00
Deepesh Garg
fa7948b5e5 Merge pull request #28961 from frappe/mergify/bp/version-13-hotfix/pr-28959
fix: Error on viewing consolidated financial statement (backport #28959)
2021-12-20 17:15:13 +05:30
Deepesh Garg
aad7a10a7b fix: Error on viewing consolidated financial statement
(cherry picked from commit 5d6199cf35)
2021-12-20 11:43:50 +00:00
Deepesh Garg
c12b00cb4b Merge pull request #28901 from frappe/mergify/bp/version-13-hotfix/pr-28822
feat: Deferred Revenue and Expense report with actual and upcoming postings (backport #28822)
2021-12-20 17:03:50 +05:30
Deepesh Garg
0980c2f981 fix: Linting issues 2021-12-20 13:26:16 +05:30
Deepesh Garg
4b3ef24378 Merge branch 'version-13-hotfix' of https://github.com/frappe/erpnext into price_fetch_optimize 2021-12-20 12:17:37 +05:30
Deepesh Garg
6087d5a603 fix(UX): Optimize rate updation of changing price list 2021-12-20 12:16:14 +05:30
Devin Slauenwhite
372f91c923 fix: fetch correct selling price. 2021-12-18 16:03:16 -05:00
Ankush Menat
0724a148e6 fix: incorrect rounding off near zero (#28948) 2021-12-18 21:34:25 +05:30
mergify[bot]
d3c62821fb fix: Add Invoice Number field to list view in Opening Invoice Creation Tool (#28941) 2021-12-18 12:07:45 +05:30
mro-admin
c5931e4b1e fix: convert asynchronous field update to synchronous (#28906)
Co-authored-by: Dan Navarro on Ubuntu Work <danjeremynavarro@gmail.com>
2021-12-17 19:06:08 +05:30
Deepesh Garg
a1d5e0bef6 Merge branch 'version-13-hotfix' into mergify/bp/version-13-hotfix/pr-28822 2021-12-17 16:06:21 +05:30
Deepesh Garg
10a95b3a98 Merge branch 'version-13-hotfix' of https://github.com/frappe/erpnext into rcm_tax_template_fetch_issue 2021-12-17 16:03:22 +05:30
Deepesh Garg
7c1bfe6b46 chore: Remove patch comment 2021-12-17 16:02:40 +05:30
Deepesh Garg
b33fd6acc7 fix: Is Reverse Charge check in Tax Category 2021-12-17 15:59:21 +05:30
mergify[bot]
9c2a9c28df fix(ksa): enable ksa pos invoice print format (backport #28911) 2021-12-17 15:27:05 +05:30
Deepesh Garg
349785dcbd Merge pull request #28932 from frappe/mergify/bp/version-13-hotfix/pr-28916
fix: Multicurrency invoices using subscription (backport #28916)
2021-12-17 13:52:30 +05:30
Deepesh Garg
94d3e66428 fix: Add tests for multicurrency subscription
(cherry picked from commit 6ab753015e)
2021-12-17 06:59:59 +00:00
Deepesh Garg
eb36742427 fix: Multicurrency invoices using subscription
(cherry picked from commit 0e4842b986)
2021-12-17 06:59:59 +00:00
mergify[bot]
fcb08d54ea fix: mapping to maintenance visit gets erased (#28917) (#28925)
* fix: mappings to maintenance visit gets erased

* test: mapping of schedule to visit

(cherry picked from commit 4cb5b627a1)

Co-authored-by: Noah Jacob <noahjacobkurian@gmail.com>
2021-12-16 23:33:56 +05:30
mergify[bot]
167e8d3dde chore: ledger invariant check report (#28921) (#28922)
(cherry picked from commit 6ba8f7644d)

Co-authored-by: Ankush Menat <ankush@frappe.io>
2021-12-16 22:48:12 +05:30
mergify[bot]
85ded9e7ce perf: remove forcing of posting sort index on stock balance (#28902) (#28904)
mysql is generally smart enough to figure out which index is better
based on cardinality of index. While posting sort index is better for
low item high # of SLE scenario it's bad for high item variety each with
lower count of SLEs.

(cherry picked from commit 228e011819)

Co-authored-by: Ankush Menat <ankush@frappe.io>
2021-12-15 19:23:12 +05:30
ruthra
74ac5e7829 refactor: rename checkbox and translations
- rename checkbox and make it
 - added translation text for labels
 - fixes for linter error

(cherry picked from commit 9ec8841b91)
2021-12-15 09:33:46 +00:00
ruthra
804f2ccf3d refactor: remove unused function 'get_postings'
(cherry picked from commit a8a598ace7)
2021-12-15 09:33:45 +00:00
ruthra
039b1fbb76 feat: Deferred Revenue and Expense report
- show deferred revenue and expense with actual and expected postings
 - unit tests added

(cherry picked from commit c3453cd73c)
2021-12-15 09:33:45 +00:00
mergify[bot]
970cdef911 feat: added required_date field to set date in child table (backport #28432)
feat: added required_date field to set date in child table (backport #28432)
2021-12-15 12:49:23 +05:30
mergify[bot]
1988e72710 revert: "fix: expected value after useful life validation" (backport #28853) 2021-12-15 11:09:21 +05:30
Saqib
cd075ce3be Merge pull request #28854 from frappe/mergify/bp/version-13-hotfix/pr-28783
fix: hsn-wise summary is incorrect if an invoice has repeated item code (backport #28783)
2021-12-15 10:35:25 +05:30
Ankush Menat
bdd43274b2 fix: incorrect logic for "Reserved Qty for Production" (#28880)
* fix: reservation for production incorrect

The query uses case to decide what fields to compute reservation on,
this case is outermost case hence the very first Work order's "Skip
transfer" is considered for ALL work orders.

Solution: move the case inside Sum.

Steps to reproduce:
1. Make work order for more than 1 qty (with | without skip transfer)
2. Create manufacture and transfer entries.
3. Keep checking reserved quantities during this process.

* test: use default warehouse for testing reservation

(cherry picked from commit 80f1a8c645)
2021-12-14 18:22:10 +05:30
Ankush Menat
25fd11e24f test: timeout certain tests in work order to avoid stuck tests (#28666) 2021-12-14 18:21:48 +05:30
Ankush Menat
73693ac153 test: remove commits from tests (#27939)
* test: remove commits from tests in selling module

* test: remove commits from manufacturing module

* test: remove commits from accounts module tests

* test: remove commits from controller tests

* test: keep commits in accounting dimension test

* Revert "test: remove commits from controller tests"

This reverts commit f5cfbee5da.

* Revert "test: remove commits from accounts module tests"

This reverts commit df4c27efdd.

(cherry picked from commit 96ef88d935)
2021-12-14 18:21:48 +05:30
mergify[bot]
a4814f5519 fix: remove set only once from valuation method (#28876) (#28882)
(cherry picked from commit 66960cf050)

Co-authored-by: Ankush Menat <ankush@frappe.io>
2021-12-14 17:29:40 +05:30
Deepesh Garg
a6f9b523d9 Merge pull request #28867 from frappe/mergify/bp/version-13-hotfix/pr-28832
fix: KSA QR field rename patch (backport #28832)
2021-12-14 16:01:57 +05:30
mergify[bot]
d0d5236a1a Merge pull request #28845 from ruchamahabal/fix-salary-slip-timesheet (#28874)
fix: incorrect amount based on payment days in timesheet salary slip
(cherry picked from commit b027050f77)

Co-authored-by: Rucha Mahabal <ruchamahabal2@gmail.com>
2021-12-14 15:32:22 +05:30
mergify[bot]
b3720f62d8 fix: Nonstock item showing in Itemwise Recommended Reorder Level report bp (#28873)
(cherry picked from commit 304aa44feb)

Co-authored-by: aaronmenezes <ron2805@gmail.com>
2021-12-14 15:19:11 +05:30
mergify[bot]
187a69e390 fix: reorder patch to fix custom field options (backport #28866)
* fix: reorder patch to fix custom field options (#28866)

(cherry picked from commit 7fd06108bf)

# Conflicts:
#	erpnext/patches.txt

* fix: resolve conflict

Co-authored-by: Ankush Menat <ankush@frappe.io>
2021-12-14 14:03:14 +05:30
Deepesh Garg
55db2f8808 fix: KSA QR field rename patch
(cherry picked from commit 14d03a9446)
2021-12-14 07:18:33 +00:00
mergify[bot]
1a986f0b11 fix(UX): hide RM table(Job Card) if material transfer is against work order (backport #28746)
* fix(UX): hide RM table(Job Card) if material transfer is against work order (#28746)

(cherry picked from commit cc8c0595e2)

# Conflicts:
#	erpnext/manufacturing/doctype/job_card/job_card.js

* chore: resolve conflicts

Co-authored-by: Noah Jacob <noahjacobkurian@gmail.com>
Co-authored-by: Ankush Menat <ankush@frappe.io>
2021-12-14 12:44:42 +05:30
mergify[bot]
5798687951 fix: packing slip incorrect item value (bp #28862)
(cherry picked from commit 534c64abcd)

Co-authored-by: Ankush Menat <ankush@frappe.io>
2021-12-14 12:16:42 +05:30
mergify[bot]
46a6c8dbfe fix: migration hashes (#28850)
Co-authored-by: Ankush Menat <ankush@frappe.io>
2021-12-14 11:10:57 +05:30
Saqib Ansari
1f41c7fed1 test: test_hsn_summary_for_invoice_with_duplicate_items
(cherry picked from commit 6a239c8ea3)
2021-12-13 17:10:20 +00:00
Saqib Ansari
1c96c6fe08 fix: hsn-wise summary is incorrect if an invoice has repeated item code
(cherry picked from commit e74739a677)
2021-12-13 17:10:19 +00:00
99 changed files with 2520 additions and 921 deletions

View File

@@ -5,7 +5,7 @@ import frappe
from erpnext.hooks import regional_overrides
__version__ = '13.17.0'
__version__ = '13.18.0'
def get_default_company(user=None):
'''Get default company for user'''
@@ -56,9 +56,9 @@ def set_perpetual_inventory(enable=1, company=None):
company.enable_perpetual_inventory = enable
company.save()
def encode_company_abbr(name, company):
def encode_company_abbr(name, company=None, abbr=None):
'''Returns name encoded with company abbreviation'''
company_abbr = frappe.get_cached_value('Company', company, "abbr")
company_abbr = abbr or frappe.get_cached_value('Company', company, "abbr")
parts = name.rsplit(" - ", 1)
if parts[-1].lower() != company_abbr.lower():

View File

@@ -7,7 +7,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
frm.set_query("bank_account", function () {
return {
filters: {
company: ["in", frm.doc.company],
company: frm.doc.company,
'is_company_account': 1
},
};

View File

@@ -218,6 +218,8 @@ def reconcile_vouchers(bank_transaction_name, vouchers):
# updated clear date of all the vouchers based on the bank transaction
vouchers = json.loads(vouchers)
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
company_account = frappe.db.get_value('Bank Account', transaction.bank_account, 'account')
if transaction.unallocated_amount == 0:
frappe.throw(_("This bank transaction is already fully reconciled"))
total_amount = 0
@@ -226,7 +228,7 @@ def reconcile_vouchers(bank_transaction_name, vouchers):
total_amount += get_paid_amount(frappe._dict({
'payment_document': voucher['payment_doctype'],
'payment_entry': voucher['payment_name'],
}), transaction.currency)
}), transaction.currency, company_account)
if total_amount > transaction.unallocated_amount:
frappe.throw(_("The Sum Total of Amounts of All Selected Vouchers Should be Less than the Unallocated Amount of the Bank Transaction"))
@@ -261,7 +263,7 @@ def get_linked_payments(bank_transaction_name, document_types = None):
return matching
def check_matching(bank_account, company, transaction, document_types):
# combine all types of vocuhers
# combine all types of vouchers
subquery = get_queries(bank_account, company, transaction, document_types)
filters = {
"amount": transaction.unallocated_amount,
@@ -343,13 +345,11 @@ def get_pe_matching_query(amount_condition, account_from_to, transaction):
def get_je_matching_query(amount_condition, transaction):
# get matching journal entry query
# We have mapping at the bank level
# So one bank could have both types of bank accounts like asset and liability
# So cr_or_dr should be judged only on basis of withdrawal and deposit and not account type
company_account = frappe.get_value("Bank Account", transaction.bank_account, "account")
root_type = frappe.get_value("Account", company_account, "root_type")
if root_type == "Liability":
cr_or_dr = "debit" if transaction.withdrawal > 0 else "credit"
else:
cr_or_dr = "credit" if transaction.withdrawal > 0 else "debit"
cr_or_dr = "credit" if transaction.withdrawal > 0 else "debit"
return f"""

View File

@@ -102,7 +102,7 @@ def get_total_allocated_amount(payment_entry):
AND
bt.docstatus = 1""", (payment_entry.payment_document, payment_entry.payment_entry), as_dict=True)
def get_paid_amount(payment_entry, currency):
def get_paid_amount(payment_entry, currency, bank_account):
if payment_entry.payment_document in ["Payment Entry", "Sales Invoice", "Purchase Invoice"]:
paid_amount_field = "paid_amount"
@@ -115,7 +115,7 @@ def get_paid_amount(payment_entry, currency):
payment_entry.payment_entry, paid_amount_field)
elif payment_entry.payment_document == "Journal Entry":
return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "total_credit")
return frappe.db.get_value('Journal Entry Account', {'parent': payment_entry.payment_entry, 'account': bank_account}, "sum(credit_in_account_currency)")
elif payment_entry.payment_document == "Expense Claim":
return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "total_amount_reimbursed")

View File

@@ -75,7 +75,7 @@
],
"hide_toolbar": 1,
"issingle": 1,
"modified": "2019-07-25 14:57:33.187689",
"modified": "2022-01-04 13:40:15.927675",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Opening Invoice Creation Tool",

View File

@@ -159,7 +159,8 @@ class OpeningInvoiceCreationTool(Document):
frappe.scrub(row.party_type): row.party,
"is_pos": 0,
"doctype": "Sales Invoice" if self.invoice_type == "Sales" else "Purchase Invoice",
"update_stock": 0
"update_stock": 0,
"invoice_number": row.invoice_number
})
accounting_dimension = get_accounting_dimensions()
@@ -200,10 +201,13 @@ def start_import(invoices):
names = []
for idx, d in enumerate(invoices):
try:
invoice_number = None
if d.invoice_number:
invoice_number = d.invoice_number
publish(idx, len(invoices), d.doctype)
doc = frappe.get_doc(d)
doc.flags.ignore_mandatory = True
doc.insert()
doc.insert(set_name=invoice_number)
doc.submit()
frappe.db.commit()
names.append(doc.name)

View File

@@ -18,10 +18,10 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase):
if not frappe.db.exists("Company", "_Test Opening Invoice Company"):
make_company()
def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None):
def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None, invoice_number=None):
doc = frappe.get_single("Opening Invoice Creation Tool")
args = get_opening_invoice_creation_dict(invoice_type=invoice_type, company=company,
party_1=party_1, party_2=party_2)
party_1=party_1, party_2=party_2, invoice_number=invoice_number)
doc.update(args)
return doc.make_invoices()
@@ -92,6 +92,20 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase):
# teardown
frappe.db.set_value("Company", company, "default_receivable_account", old_default_receivable_account)
def test_renaming_of_invoice_using_invoice_number_field(self):
company = "_Test Opening Invoice Company"
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
self.make_invoices(company=company, party_1=party_1, party_2=party_2, invoice_number="TEST-NEW-INV-11")
sales_inv1 = frappe.get_all('Sales Invoice', filters={'customer':'Customer A'})[0].get("name")
sales_inv2 = frappe.get_all('Sales Invoice', filters={'customer':'Customer B'})[0].get("name")
self.assertEqual(sales_inv1, "TEST-NEW-INV-11")
#teardown
for inv in [sales_inv1, sales_inv2]:
doc = frappe.get_doc('Sales Invoice', inv)
doc.cancel()
def get_opening_invoice_creation_dict(**args):
party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier"
company = args.get("company", "_Test Company")
@@ -107,7 +121,8 @@ def get_opening_invoice_creation_dict(**args):
"item_name": "Opening Item",
"due_date": "2016-09-10",
"posting_date": "2016-09-05",
"temporary_opening_account": get_temporary_opening_account(company)
"temporary_opening_account": get_temporary_opening_account(company),
"invoice_number": args.get("invoice_number")
},
{
"qty": 2.0,
@@ -116,7 +131,8 @@ def get_opening_invoice_creation_dict(**args):
"item_name": "Opening Item",
"due_date": "2016-09-10",
"posting_date": "2016-09-05",
"temporary_opening_account": get_temporary_opening_account(company)
"temporary_opening_account": get_temporary_opening_account(company),
"invoice_number": None
}
]
})
@@ -132,7 +148,7 @@ def make_company():
company.company_name = "_Test Opening Invoice Company"
company.abbr = "_TOIC"
company.default_currency = "INR"
company.country = "India"
company.country = "Pakistan"
company.insert()
return company

View File

@@ -1,9 +1,11 @@
{
"actions": [],
"creation": "2017-08-29 04:26:36.159247",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"invoice_number",
"party_type",
"party",
"temporary_opening_account",
@@ -103,10 +105,18 @@
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
},
{
"description": "Reference number of the invoice from the previous system",
"fieldname": "invoice_number",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Invoice Number"
}
],
"istable": 1,
"modified": "2019-07-25 15:00:00.460695",
"links": [],
"modified": "2022-01-04 18:40:15.927675",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Opening Invoice Creation Tool Item",
@@ -116,4 +126,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}
}

View File

@@ -963,7 +963,7 @@ class TestPurchaseInvoice(unittest.TestCase):
pi = make_purchase_invoice(item=item.name, qty=1, rate=100, do_not_save=True)
pi.set_posting_time = 1
pi.posting_date = '2019-03-15'
pi.posting_date = '2019-01-10'
pi.items[0].enable_deferred_expense = 1
pi.items[0].service_start_date = "2019-01-10"
pi.items[0].service_end_date = "2019-03-15"
@@ -1213,7 +1213,7 @@ def check_gl_entries(doc, voucher_no, expected_gle, posting_date):
def update_tax_witholding_category(company, account):
from erpnext.accounts.utils import get_fiscal_year
fiscal_year = get_fiscal_year(fiscal_year='_Test Fiscal Year 2021')
fiscal_year = get_fiscal_year(date=nowdate())
if not frappe.db.get_value('Tax Withholding Rate',
{'parent': 'TDS - 194 - Dividends - Individual', 'from_date': ('>=', fiscal_year[1]),

View File

@@ -24,6 +24,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.accounts.party import get_party_account_currency
class Subscription(Document):
@@ -356,7 +357,10 @@ class Subscription(Document):
if frappe.db.get_value('Supplier', self.party, 'tax_withholding_category'):
invoice.apply_tds = 1
## Add dimensions in invoice for subscription:
# Add party currency to invoice
invoice.currency = get_party_account_currency(self.party_type, self.party, self.company)
# Add dimensions in invoice for subscription:
accounting_dimensions = get_accounting_dimensions()
for dimension in accounting_dimensions:

View File

@@ -60,15 +60,38 @@ def create_plan():
plan.billing_interval_count = 3
plan.insert()
if not frappe.db.exists('Subscription Plan', '_Test Plan Multicurrency'):
plan = frappe.new_doc('Subscription Plan')
plan.plan_name = '_Test Plan Multicurrency'
plan.item = '_Test Non Stock Item'
plan.price_determination = "Fixed Rate"
plan.cost = 50
plan.currency = 'USD'
plan.billing_interval = 'Month'
plan.billing_interval_count = 1
plan.insert()
def create_parties():
if not frappe.db.exists('Supplier', '_Test Supplier'):
supplier = frappe.new_doc('Supplier')
supplier.supplier_name = '_Test Supplier'
supplier.supplier_group = 'All Supplier Groups'
supplier.insert()
if not frappe.db.exists('Customer', '_Test Subscription Customer'):
customer = frappe.new_doc('Customer')
customer.customer_name = '_Test Subscription Customer'
customer.billing_currency = 'USD'
customer.append('accounts', {
'company': '_Test Company',
'account': '_Test Receivable USD - _TC'
})
customer.insert()
class TestSubscription(unittest.TestCase):
def setUp(self):
create_plan()
create_parties()
def test_create_subscription_with_trial_with_correct_period(self):
subscription = frappe.new_doc('Subscription')
@@ -637,3 +660,22 @@ class TestSubscription(unittest.TestCase):
subscription.process()
self.assertEqual(len(subscription.invoices), 1)
def test_multicurrency_subscription(self):
subscription = frappe.new_doc('Subscription')
subscription.party_type = 'Customer'
subscription.party = '_Test Subscription Customer'
subscription.generate_invoice_at_period_start = 1
subscription.company = '_Test Company'
# select subscription start date as '2018-01-15'
subscription.start_date = '2018-01-01'
subscription.append('plans', {'plan': '_Test Plan Multicurrency', 'qty': 1})
subscription.save()
subscription.process()
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, 'Unpaid')
# Check the currency of the created invoice
currency = frappe.db.get_value('Sales Invoice', subscription.invoices[0].invoice, 'currency')
self.assertEqual(currency, 'USD')

View File

@@ -75,7 +75,8 @@
"fieldname": "cost",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Cost"
"label": "Cost",
"options": "currency"
},
{
"depends_on": "eval:doc.price_determination==\"Based On Price List\"",
@@ -147,7 +148,7 @@
}
],
"links": [],
"modified": "2021-08-13 10:53:44.205774",
"modified": "2021-12-10 15:24:15.794477",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Subscription Plan",

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
/* eslint-disable */
function get_filters() {
let filters = [
{
"fieldname":"company",
"label": __("Company"),
"fieldtype": "Link",
"options": "Company",
"default": frappe.defaults.get_user_default("Company"),
"reqd": 1
},
{
"fieldname":"filter_based_on",
"label": __("Filter Based On"),
"fieldtype": "Select",
"options": ["Fiscal Year", "Date Range"],
"default": ["Fiscal Year"],
"reqd": 1,
on_change: function() {
let filter_based_on = frappe.query_report.get_filter_value('filter_based_on');
frappe.query_report.toggle_filter_display('from_fiscal_year', filter_based_on === 'Date Range');
frappe.query_report.toggle_filter_display('to_fiscal_year', filter_based_on === 'Date Range');
frappe.query_report.toggle_filter_display('period_start_date', filter_based_on === 'Fiscal Year');
frappe.query_report.toggle_filter_display('period_end_date', filter_based_on === 'Fiscal Year');
frappe.query_report.refresh();
}
},
{
"fieldname":"period_start_date",
"label": __("Start Date"),
"fieldtype": "Date",
"hidden": 1,
"reqd": 1
},
{
"fieldname":"period_end_date",
"label": __("End Date"),
"fieldtype": "Date",
"hidden": 1,
"reqd": 1
},
{
"fieldname":"from_fiscal_year",
"label": __("Start Year"),
"fieldtype": "Link",
"options": "Fiscal Year",
"default": frappe.defaults.get_user_default("fiscal_year"),
"reqd": 1
},
{
"fieldname":"to_fiscal_year",
"label": __("End Year"),
"fieldtype": "Link",
"options": "Fiscal Year",
"default": frappe.defaults.get_user_default("fiscal_year"),
"reqd": 1
},
{
"fieldname": "periodicity",
"label": __("Periodicity"),
"fieldtype": "Select",
"options": [
{ "value": "Monthly", "label": __("Monthly") },
{ "value": "Quarterly", "label": __("Quarterly") },
{ "value": "Half-Yearly", "label": __("Half-Yearly") },
{ "value": "Yearly", "label": __("Yearly") }
],
"default": "Monthly",
"reqd": 1
},
{
"fieldname": "type",
"label": __("Invoice Type"),
"fieldtype": "Select",
"options": [
{ "value": "Revenue", "label": __("Revenue") },
{ "value": "Expense", "label": __("Expense") }
],
"default": "Revenue",
"reqd": 1
},
{
"fieldname" : "with_upcoming_postings",
"label": __("Show with upcoming revenue/expense"),
"fieldtype": "Check",
"default": 1
}
]
return filters;
}
frappe.query_reports["Deferred Revenue and Expense"] = {
"filters": get_filters(),
"formatter": function(value, row, column, data, default_formatter){
return default_formatter(value, row, column, data);
},
onload: function(report){
let fiscal_year = frappe.defaults.get_user_default("fiscal_year");
frappe.model.with_doc("Fiscal Year", fiscal_year, function(r) {
var fy = frappe.model.get_doc("Fiscal Year", fiscal_year);
frappe.query_report.set_filter_value({
period_start_date: fy.year_start_date,
period_end_date: fy.year_end_date
});
});
}
};

View File

@@ -0,0 +1,32 @@
{
"add_total_row": 0,
"columns": [],
"creation": "2021-12-10 19:27:14.654220",
"disable_prepared_report": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2021-12-10 19:27:14.654220",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Deferred Revenue and Expense",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "GL Entry",
"report_name": "Deferred Revenue and Expense",
"report_type": "Script Report",
"roles": [
{
"role": "Accounts User"
},
{
"role": "Accounts Manager"
},
{
"role": "Auditor"
}
]
}

View File

@@ -0,0 +1,440 @@
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
# License: MIT. See LICENSE
import frappe
from frappe import _, qb
from frappe.query_builder import Column, functions
from frappe.utils import add_days, date_diff, flt, get_first_day, get_last_day, rounded
from erpnext.accounts.report.financial_statements import get_period_list
class Deferred_Item(object):
"""
Helper class for processing items with deferred revenue/expense
"""
def __init__(self, item, inv, gle_entries):
self.name = item
self.parent = inv.name
self.item_name = gle_entries[0].item_name
self.service_start_date = gle_entries[0].service_start_date
self.service_end_date = gle_entries[0].service_end_date
self.base_net_amount = gle_entries[0].base_net_amount
self.filters = inv.filters
self.period_list = inv.period_list
if gle_entries[0].deferred_revenue_account:
self.type = "Deferred Sale Item"
self.deferred_account = gle_entries[0].deferred_revenue_account
elif gle_entries[0].deferred_expense_account:
self.type = "Deferred Purchase Item"
self.deferred_account = gle_entries[0].deferred_expense_account
self.gle_entries = []
# holds period wise total for item
self.period_total = []
self.last_entry_date = self.service_start_date
if gle_entries:
self.gle_entries = gle_entries
for x in self.gle_entries:
if self.get_amount(x):
self.last_entry_date = x.gle_posting_date
def report_data(self):
"""
Generate report data for output
"""
ret_data = frappe._dict({"name": self.item_name})
for period in self.period_total:
ret_data[period.key] = period.total
ret_data.indent = 1
return ret_data
def get_amount(self, entry):
"""
For a given GL/Journal posting, get balance based on item type
"""
if self.type == "Deferred Sale Item":
return entry.debit - entry.credit
elif self.type == "Deferred Purchase Item":
return -(entry.credit - entry.debit)
return 0
def get_item_total(self):
"""
Helper method - calculate booked amount. Includes simulated postings as well
"""
total = 0
for gle_posting in self.gle_entries:
total += self.get_amount(gle_posting)
return total
def calculate_amount(self, start_date, end_date):
"""
start_date, end_date - datetime.datetime.date
return - estimated amount to post for given period
Calculated based on already booked amount and item service period
"""
total_months = (
(self.service_end_date.year - self.service_start_date.year) * 12
+ (self.service_end_date.month - self.service_start_date.month)
+ 1
)
prorate = date_diff(self.service_end_date, self.service_start_date) / date_diff(
get_last_day(self.service_end_date), get_first_day(self.service_start_date)
)
actual_months = rounded(total_months * prorate, 1)
already_booked_amount = self.get_item_total()
base_amount = self.base_net_amount / actual_months
if base_amount + already_booked_amount > self.base_net_amount:
base_amount = self.base_net_amount - already_booked_amount
if not (get_first_day(start_date) == start_date and get_last_day(end_date) == end_date):
partial_month = flt(date_diff(end_date, start_date)) / flt(
date_diff(get_last_day(end_date), get_first_day(start_date))
)
base_amount *= rounded(partial_month, 1)
return base_amount
def make_dummy_gle(self, name, date, amount):
"""
return - frappe._dict() of a dummy gle entry
"""
entry = frappe._dict(
{"name": name, "gle_posting_date": date, "debit": 0, "credit": 0, "posted": "not"}
)
if self.type == "Deferred Sale Item":
entry.debit = amount
elif self.type == "Deferred Purchase Item":
entry.credit = amount
return entry
def simulate_future_posting(self):
"""
simulate future posting by creating dummy gl entries. starts from the last posting date.
"""
if add_days(self.last_entry_date, 1) < self.period_list[-1].to_date:
self.estimate_for_period_list = get_period_list(
self.filters.from_fiscal_year,
self.filters.to_fiscal_year,
add_days(self.last_entry_date, 1),
self.period_list[-1].to_date,
"Date Range",
"Monthly",
company=self.filters.company,
)
for period in self.estimate_for_period_list:
amount = self.calculate_amount(period.from_date, period.to_date)
gle = self.make_dummy_gle(period.key, period.to_date, amount)
self.gle_entries.append(gle)
def calculate_item_revenue_expense_for_period(self):
"""
calculate item postings for each period and update period_total list
"""
for period in self.period_list:
period_sum = 0
actual = 0
for posting in self.gle_entries:
# if period.from_date <= posting.posting_date <= period.to_date:
if period.from_date <= posting.gle_posting_date <= period.to_date:
period_sum += self.get_amount(posting)
if posting.posted == "posted":
actual += self.get_amount(posting)
self.period_total.append(
frappe._dict({"key": period.key, "total": period_sum, "actual": actual})
)
return self.period_total
class Deferred_Invoice(object):
def __init__(self, invoice, items, filters, period_list):
"""
Helper class for processing invoices with deferred revenue/expense items
invoice - string : invoice name
items - list : frappe._dict() with item details. Refer Deferred_Item for required fields
"""
self.name = invoice
self.posting_date = items[0].posting_date
self.filters = filters
self.period_list = period_list
# holds period wise total for invoice
self.period_total = []
if items[0].deferred_revenue_account:
self.type = "Sales"
elif items[0].deferred_expense_account:
self.type = "Purchase"
self.items = []
# for each uniq items
self.uniq_items = set([x.item for x in items])
for item in self.uniq_items:
self.items.append(Deferred_Item(item, self, [x for x in items if x.item == item]))
def calculate_invoice_revenue_expense_for_period(self):
"""
calculate deferred revenue/expense for all items in invoice
"""
# initialize period_total list for invoice
for period in self.period_list:
self.period_total.append(frappe._dict({"key": period.key, "total": 0, "actual": 0}))
for item in self.items:
item_total = item.calculate_item_revenue_expense_for_period()
# update invoice total
for idx, period in enumerate(self.period_list, 0):
self.period_total[idx].total += item_total[idx].total
self.period_total[idx].actual += item_total[idx].actual
return self.period_total
def estimate_future(self):
"""
create dummy GL entries for upcoming months for all items in invoice
"""
[item.simulate_future_posting() for item in self.items]
def report_data(self):
"""
generate report data for invoice, includes invoice total
"""
ret_data = []
inv_total = frappe._dict({"name": self.name})
for x in self.period_total:
inv_total[x.key] = x.total
inv_total.indent = 0
ret_data.append(inv_total)
list(map(lambda item: ret_data.append(item.report_data()), self.items))
return ret_data
class Deferred_Revenue_and_Expense_Report(object):
def __init__(self, filters=None):
"""
Initialize deferred revenue/expense report with user provided filters or system defaults, if none is provided
"""
# If no filters are provided, get user defaults
if not filters:
fiscal_year = frappe.get_doc("Fiscal Year", frappe.defaults.get_user_default("fiscal_year"))
self.filters = frappe._dict(
{
"company": frappe.defaults.get_user_default("Company"),
"filter_based_on": "Fiscal Year",
"period_start_date": fiscal_year.year_start_date,
"period_end_date": fiscal_year.year_end_date,
"from_fiscal_year": fiscal_year.year,
"to_fiscal_year": fiscal_year.year,
"periodicity": "Monthly",
"type": "Revenue",
"with_upcoming_postings": True,
}
)
else:
self.filters = frappe._dict(filters)
self.period_list = None
self.deferred_invoices = []
# holds period wise total for report
self.period_total = []
def get_period_list(self):
"""
Figure out selected period based on filters
"""
self.period_list = get_period_list(
self.filters.from_fiscal_year,
self.filters.to_fiscal_year,
self.filters.period_start_date,
self.filters.period_end_date,
self.filters.filter_based_on,
self.filters.periodicity,
company=self.filters.company,
)
def get_invoices(self):
"""
Get all sales and purchase invoices which has deferred revenue/expense items
"""
gle = qb.DocType("GL Entry")
# column doesn't have an alias option
posted = Column("posted")
if self.filters.type == "Revenue":
inv = qb.DocType("Sales Invoice")
inv_item = qb.DocType("Sales Invoice Item")
deferred_flag_field = inv_item["enable_deferred_revenue"]
deferred_account_field = inv_item["deferred_revenue_account"]
elif self.filters.type == "Expense":
inv = qb.DocType("Purchase Invoice")
inv_item = qb.DocType("Purchase Invoice Item")
deferred_flag_field = inv_item["enable_deferred_expense"]
deferred_account_field = inv_item["deferred_expense_account"]
query = (
qb.from_(inv_item)
.join(inv)
.on(inv.name == inv_item.parent)
.join(gle)
.on((inv_item.name == gle.voucher_detail_no) & (deferred_account_field == gle.account))
.select(
inv.name.as_("doc"),
inv.posting_date,
inv_item.name.as_("item"),
inv_item.item_name,
inv_item.service_start_date,
inv_item.service_end_date,
inv_item.base_net_amount,
deferred_account_field,
gle.posting_date.as_("gle_posting_date"),
functions.Sum(gle.debit).as_("debit"),
functions.Sum(gle.credit).as_("credit"),
posted,
)
.where(
(inv.docstatus == 1)
& (deferred_flag_field == 1)
& (
(
(self.period_list[0].from_date >= inv_item.service_start_date)
& (inv_item.service_end_date >= self.period_list[0].from_date)
)
| (
(inv_item.service_start_date >= self.period_list[0].from_date)
& (inv_item.service_start_date <= self.period_list[-1].to_date)
)
)
)
.groupby(inv.name, inv_item.name, gle.posting_date)
.orderby(gle.posting_date)
)
self.invoices = query.run(as_dict=True)
uniq_invoice = set([x.doc for x in self.invoices])
for inv in uniq_invoice:
self.deferred_invoices.append(
Deferred_Invoice(
inv, [x for x in self.invoices if x.doc == inv], self.filters, self.period_list
)
)
def estimate_future(self):
"""
For all Invoices estimate upcoming postings
"""
for x in self.deferred_invoices:
x.estimate_future()
def calculate_revenue_and_expense(self):
"""
calculate the deferred revenue/expense for all invoices
"""
# initialize period_total list for report
for period in self.period_list:
self.period_total.append(frappe._dict({"key": period.key, "total": 0, "actual": 0}))
for inv in self.deferred_invoices:
inv_total = inv.calculate_invoice_revenue_expense_for_period()
# calculate total for whole report
for idx, period in enumerate(self.period_list, 0):
self.period_total[idx].total += inv_total[idx].total
self.period_total[idx].actual += inv_total[idx].actual
def get_columns(self):
columns = []
columns.append({"label": _("Name"), "fieldname": "name", "fieldtype": "Data", "read_only": 1})
for period in self.period_list:
columns.append(
{
"label": _(period.label),
"fieldname": period.key,
"fieldtype": "Currency",
"read_only": 1,
})
return columns
def generate_report_data(self):
"""
Generate report data for all invoices. Adds total rows for revenue and expense
"""
ret = []
for inv in self.deferred_invoices:
ret += inv.report_data()
# empty row for padding
ret += [{}]
# add total row
if ret is not []:
if self.filters.type == "Revenue":
total_row = frappe._dict({"name": "Total Deferred Income"})
elif self.filters.type == "Expense":
total_row = frappe._dict({"name": "Total Deferred Expense"})
for idx, period in enumerate(self.period_list, 0):
total_row[period.key] = self.period_total[idx].total
ret.append(total_row)
return ret
def prepare_chart(self):
chart = {
"data": {
"labels": [period.label for period in self.period_list],
"datasets": [
{
"name": "Actual Posting",
"chartType": "bar",
"values": [x.actual for x in self.period_total],
}
],
},
"type": "axis-mixed",
"height": 500,
"axisOptions": {"xAxisMode": "Tick", "xIsSeries": True},
"barOptions": {"stacked": False, "spaceRatio": 0.5},
}
if self.filters.with_upcoming_postings:
chart["data"]["datasets"].append({
"name": "Expected",
"chartType": "line",
"values": [x.total for x in self.period_total]
})
return chart
def run(self, *args, **kwargs):
"""
Run report and generate data
"""
self.deferred_invoices.clear()
self.get_period_list()
self.get_invoices()
if self.filters.with_upcoming_postings:
self.estimate_future()
self.calculate_revenue_and_expense()
def execute(filters=None):
report = Deferred_Revenue_and_Expense_Report(filters=filters)
report.run()
columns = report.get_columns()
data = report.generate_report_data()
message = []
chart = report.prepare_chart()
return columns, data, message, chart

View File

@@ -0,0 +1,253 @@
import unittest
import frappe
from frappe import qb
from frappe.utils import nowdate
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.deferred_revenue_and_expense.deferred_revenue_and_expense import (
Deferred_Revenue_and_Expense_Report,
)
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
from erpnext.stock.doctype.item.test_item import create_item
class TestDeferredRevenueAndExpense(unittest.TestCase):
@classmethod
def setUpClass(self):
clear_old_entries()
create_company()
def test_deferred_revenue(self):
# created deferred expense accounts, if not found
deferred_revenue_account = create_account(
account_name="Deferred Revenue",
parent_account="Current Liabilities - _CD",
company="_Test Company DR",
)
acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings")
acc_settings.book_deferred_entries_based_on = "Months"
acc_settings.save()
customer = frappe.new_doc("Customer")
customer.customer_name = "_Test Customer DR"
customer.type = "Individual"
customer.insert()
item = create_item(
"_Test Internet Subscription",
is_stock_item=0,
warehouse="All Warehouses - _CD",
company="_Test Company DR",
)
item.enable_deferred_revenue = 1
item.deferred_revenue_account = deferred_revenue_account
item.no_of_months = 3
item.save()
si = create_sales_invoice(
item=item.name,
company="_Test Company DR",
customer="_Test Customer DR",
debit_to="Debtors - _CD",
posting_date="2021-05-01",
parent_cost_center="Main - _CD",
cost_center="Main - _CD",
do_not_submit=True,
rate=300,
price_list_rate=300,
)
si.items[0].enable_deferred_revenue = 1
si.items[0].service_start_date = "2021-05-01"
si.items[0].service_end_date = "2021-08-01"
si.items[0].deferred_revenue_account = deferred_revenue_account
si.items[0].income_account = "Sales - _CD"
si.save()
si.submit()
pda = frappe.get_doc(
dict(
doctype="Process Deferred Accounting",
posting_date=nowdate(),
start_date="2021-05-01",
end_date="2021-08-01",
type="Income",
company="_Test Company DR",
)
)
pda.insert()
pda.submit()
# execute report
fiscal_year = frappe.get_doc("Fiscal Year", frappe.defaults.get_user_default("fiscal_year"))
self.filters = frappe._dict(
{
"company": frappe.defaults.get_user_default("Company"),
"filter_based_on": "Date Range",
"period_start_date": "2021-05-01",
"period_end_date": "2021-08-01",
"from_fiscal_year": fiscal_year.year,
"to_fiscal_year": fiscal_year.year,
"periodicity": "Monthly",
"type": "Revenue",
"with_upcoming_postings": False,
}
)
report = Deferred_Revenue_and_Expense_Report(filters=self.filters)
report.run()
expected = [
{"key": "may_2021", "total": 100.0, "actual": 100.0},
{"key": "jun_2021", "total": 100.0, "actual": 100.0},
{"key": "jul_2021", "total": 100.0, "actual": 100.0},
{"key": "aug_2021", "total": 0, "actual": 0},
]
self.assertEqual(report.period_total, expected)
def test_deferred_expense(self):
# created deferred expense accounts, if not found
deferred_expense_account = create_account(
account_name="Deferred Expense",
parent_account="Current Assets - _CD",
company="_Test Company DR",
)
acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings")
acc_settings.book_deferred_entries_based_on = "Months"
acc_settings.save()
supplier = create_supplier(
supplier_name="_Test Furniture Supplier", supplier_group="Local", supplier_type="Company"
)
supplier.save()
item = create_item(
"_Test Office Desk",
is_stock_item=0,
warehouse="All Warehouses - _CD",
company="_Test Company DR",
)
item.enable_deferred_expense = 1
item.deferred_expense_account = deferred_expense_account
item.no_of_months_exp = 3
item.save()
pi = make_purchase_invoice(
item=item.name,
company="_Test Company DR",
supplier="_Test Furniture Supplier",
is_return=False,
update_stock=False,
posting_date=frappe.utils.datetime.date(2021, 5, 1),
parent_cost_center="Main - _CD",
cost_center="Main - _CD",
do_not_save=True,
rate=300,
price_list_rate=300,
warehouse="All Warehouses - _CD",
qty=1,
)
pi.set_posting_time = True
pi.items[0].enable_deferred_expense = 1
pi.items[0].service_start_date = "2021-05-01"
pi.items[0].service_end_date = "2021-08-01"
pi.items[0].deferred_expense_account = deferred_expense_account
pi.items[0].expense_account = "Office Maintenance Expenses - _CD"
pi.save()
pi.submit()
pda = frappe.get_doc(
dict(
doctype="Process Deferred Accounting",
posting_date=nowdate(),
start_date="2021-05-01",
end_date="2021-08-01",
type="Expense",
company="_Test Company DR",
)
)
pda.insert()
pda.submit()
# execute report
fiscal_year = frappe.get_doc("Fiscal Year", frappe.defaults.get_user_default("fiscal_year"))
self.filters = frappe._dict(
{
"company": frappe.defaults.get_user_default("Company"),
"filter_based_on": "Date Range",
"period_start_date": "2021-05-01",
"period_end_date": "2021-08-01",
"from_fiscal_year": fiscal_year.year,
"to_fiscal_year": fiscal_year.year,
"periodicity": "Monthly",
"type": "Expense",
"with_upcoming_postings": False,
}
)
report = Deferred_Revenue_and_Expense_Report(filters=self.filters)
report.run()
expected = [
{"key": "may_2021", "total": -100.0, "actual": -100.0},
{"key": "jun_2021", "total": -100.0, "actual": -100.0},
{"key": "jul_2021", "total": -100.0, "actual": -100.0},
{"key": "aug_2021", "total": 0, "actual": 0},
]
self.assertEqual(report.period_total, expected)
def create_company():
company = frappe.db.exists("Company", "_Test Company DR")
if not company:
company = frappe.new_doc("Company")
company.company_name = "_Test Company DR"
company.default_currency = "INR"
company.chart_of_accounts = "Standard"
company.insert()
def clear_old_entries():
item = qb.DocType("Item")
account = qb.DocType("Account")
customer = qb.DocType("Customer")
supplier = qb.DocType("Supplier")
sinv = qb.DocType("Sales Invoice")
sinv_item = qb.DocType("Sales Invoice Item")
pinv = qb.DocType("Purchase Invoice")
pinv_item = qb.DocType("Purchase Invoice Item")
qb.from_(account).delete().where(
(account.account_name == "Deferred Revenue")
| (account.account_name == "Deferred Expense") & (account.company == "_Test Company DR")
).run()
qb.from_(item).delete().where(
(item.item_code == "_Test Internet Subscription") | (item.item_code == "_Test Office Rent")
).run()
qb.from_(customer).delete().where(customer.customer_name == "_Test Customer DR").run()
qb.from_(supplier).delete().where(supplier.supplier_name == "_Test Furniture Supplier").run()
# delete existing invoices with deferred items
deferred_invoices = (
qb.from_(sinv)
.join(sinv_item)
.on(sinv.name == sinv_item.parent)
.select(sinv.name)
.where(sinv_item.enable_deferred_revenue == 1)
.run()
)
if deferred_invoices:
qb.from_(sinv).delete().where(sinv.name.isin(deferred_invoices)).run()
deferred_invoices = (
qb.from_(pinv)
.join(pinv_item)
.on(pinv.name == pinv_item.parent)
.select(pinv.name)
.where(pinv_item.enable_deferred_expense == 1)
.run()
)
if deferred_invoices:
qb.from_(pinv).delete().where(pinv.name.isin(deferred_invoices)).run()

View File

@@ -124,6 +124,14 @@ frappe.ui.form.on("Request for Quotation",{
dialog.show()
},
schedule_date(frm) {
if(frm.doc.schedule_date){
frm.doc.items.forEach((item) => {
item.schedule_date = frm.doc.schedule_date;
})
}
refresh_field("items");
},
preview: (frm) => {
let dialog = new frappe.ui.Dialog({
title: __('Preview Email'),
@@ -184,7 +192,13 @@ frappe.ui.form.on("Request for Quotation",{
dialog.show();
}
})
frappe.ui.form.on("Request for Quotation Item", {
items_add(frm, cdt, cdn) {
if (frm.doc.schedule_date) {
frappe.model.set_value(cdt, cdn, 'schedule_date', frm.doc.schedule_date);
}
}
});
frappe.ui.form.on("Request for Quotation Supplier",{
supplier: function(frm, cdt, cdn) {
var d = locals[cdt][cdn]

View File

@@ -12,6 +12,7 @@
"vendor",
"column_break1",
"transaction_date",
"schedule_date",
"status",
"amended_from",
"suppliers_section",
@@ -246,16 +247,22 @@
"fieldname": "sec_break_email_2",
"fieldtype": "Section Break",
"hide_border": 1
},
{
"fieldname": "schedule_date",
"fieldtype": "Date",
"label": "Required Date"
}
],
"icon": "fa fa-shopping-cart",
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2020-11-05 22:04:29.017134",
"modified": "2021-11-24 17:47:49.909000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{

View File

@@ -0,0 +1,43 @@
# Version 13.18.0 Release Notes
### Features & Enhancements
- Deferred Revenue and Expense report with actual and upcoming postings ([#28822](https://github.com/frappe/erpnext/pull/28822))
- 'Invoice Number' field in Opening Invoice Creation Tool ([#29147](https://github.com/frappe/erpnext/pull/29147))
- Added required_date field to set date in child table ([#28432](https://github.com/frappe/erpnext/pull/28432))
### Fixes
- Enable ksa POS Invoice print format ([#28911](https://github.com/frappe/erpnext/pull/28911))
- Rename non existent doctype field to the right one ([#29055](https://github.com/frappe/erpnext/pull/29055))
- Mapped accounting dimensions for Bank Entry against Payroll Entry ([#29142](https://github.com/frappe/erpnext/pull/29142))
- Validate Finished Goods for independent Manufacture entries ([#28555](https://github.com/frappe/erpnext/pull/28555))
- Incorrect posting time fetching incorrect stock quantity in stock reconciliation ([#29103](https://github.com/frappe/erpnext/pull/29103))
- Stock Ageing Report - Negative Opening Stock ([#28966](https://github.com/frappe/erpnext/pull/28966))
- Can't change valuation_method on item ([#28876](https://github.com/frappe/erpnext/pull/28876))
- Optimize rate updation on changing price list ([#28953](https://github.com/frappe/erpnext/pull/28953))
- Added filter for dispatch address ([#28937](https://github.com/frappe/erpnext/pull/28937))
- Convert Item links to Website Item links in `Item Card Group` template data ([#28985](https://github.com/frappe/erpnext/pull/28985))
- Earned Leave allocation from Leave Policy Assignment ([#29163](https://github.com/frappe/erpnext/pull/29163))
- Items not mapped when trying to create a Maintenance Visit via Maintenance Schedule ([#28917](https://github.com/frappe/erpnext/pull/28917))
- For performance improvement, removed forcing of posting sort index on stock balance report ([#28902](https://github.com/frappe/erpnext/pull/28902))
- Future recurring period calculation ([#29083](https://github.com/frappe/erpnext/pull/29083))
- Nonstock items are showing in the Itemwise Recommended Reorder Level report ([#28873](https://github.com/frappe/erpnext/pull/28873))
- Incorrect amount based on payment days in timesheet salary slip ([#28845](https://github.com/frappe/erpnext/pull/28845))
- Currency fix for `cost` field in subscription plan ([#28821](https://github.com/frappe/erpnext/pull/28821))
- Fetch selling price with pricing rule ([#28951](https://github.com/frappe/erpnext/pull/28951))
- Filter out Claimed employee advances in Expense Claim ([#29046](https://github.com/frappe/erpnext/pull/29046))
- Tax and Charges template not getting fetched based on tax category assigned ([#29092](https://github.com/frappe/erpnext/pull/29092))
- Ignore links while setting default notification templates in Settings ([#29042](https://github.com/frappe/erpnext/pull/29042))
- Reset "Value After Depreciation" on reversing journal entry during Asset return ([#28975](https://github.com/frappe/erpnext/pull/28975))
- Multicurrency invoices using subscription ([#28916](https://github.com/frappe/erpnext/pull/28916))
- Fetch the appointment letter content in the same order as template ([#28968](https://github.com/frappe/erpnext/pull/28968))
- Incorrect serial no valuation report showing cancelled entries ([#29172](https://github.com/frappe/erpnext/pull/29172))
- Start date validation for deferred invoices ([#29009](https://github.com/frappe/erpnext/pull/29009))
- HSN-Wise summary report is incorrect if an invoice has same item code multiple times ([#28783](https://github.com/frappe/erpnext/pull/28783))
- Incorrect logic for the "Reserved Qty for Production" field in BIN ([#28880](https://github.com/frappe/erpnext/pull/28880))
- Issues in Bank Reconciliation tool ([#28996](https://github.com/frappe/erpnext/pull/28996))
- Hide Raw Material table in the Job Card if material transfer is against work order ([#28746](https://github.com/frappe/erpnext/pull/28746))
- Added "Is Reverse Charge" checkbox in Tax Category for Indian Companies ([#28935](https://github.com/frappe/erpnext/pull/28935))
- Updates in term loan processing ([#28034](https://github.com/frappe/erpnext/pull/28034))
- Incorrect bin qty on backdated reconciliation ([#28588](https://github.com/frappe/erpnext/pull/28588))

View File

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

View File

@@ -46,12 +46,26 @@ class TestWebsiteItem(unittest.TestCase):
]
})
elif self._testMethodName in WEBITEM_PRICE_TESTS:
create_user_and_customer_if_not_exists("test_contact_customer@example.com", "_Test Contact For _Test Customer")
create_regular_web_item()
make_web_item_price(item_code="Test Mobile Phone")
# Note: When testing web item pricing rule logged-in user pricing rule must differ from guest pricing rule or test will falsely pass.
# This is because make_web_pricing_rule creates a pricing rule "selling": 1, without specifying "applicable_for". Therefor,
# when testing for logged-in user the test will get the previous pricing rule because "selling" is still true.
#
# I've attempted to mitigate this by setting applicable_for=Customer, and customer=Guest however, this only results in PermissionError failing the test.
make_web_pricing_rule(
title="Test Pricing Rule for Test Mobile Phone",
item_code="Test Mobile Phone",
selling=1)
make_web_pricing_rule(
title="Test Pricing Rule for Test Mobile Phone (Customer)",
item_code="Test Mobile Phone",
selling=1,
discount_percentage="25",
applicable_for="Customer",
customer="_Test Customer")
def test_index_creation(self):
"Check if index is getting created in db."
@@ -188,22 +202,27 @@ class TestWebsiteItem(unittest.TestCase):
# price and pricing rule added via setUp
# login as customer with pricing rule
frappe.set_user("test_contact_customer@example.com")
# check if price and slashed price is fetched correctly
frappe.local.shopping_cart_settings = None
data = get_product_info_for_website(item_code, skip_quotation_creation=True)
self.assertTrue(bool(data.product_info["price"]))
price_object = data.product_info["price"]
self.assertEqual(price_object.get("discount_percent"), 10)
self.assertEqual(price_object.get("price_list_rate"), 900)
self.assertEqual(price_object.get("discount_percent"), 25)
self.assertEqual(price_object.get("price_list_rate"), 750)
self.assertEqual(price_object.get("formatted_mrp"), "₹ 1,000.00")
self.assertEqual(price_object.get("formatted_price"), "900.00")
self.assertEqual(price_object.get("formatted_discount_percent"), "10%")
self.assertEqual(price_object.get("formatted_price"), "750.00")
self.assertEqual(price_object.get("formatted_discount_percent"), "25%")
# disable show price
# switch to admin and disable show price
frappe.set_user("Administrator")
setup_e_commerce_settings({"show_price": 0})
# price should not be fetched
# price should not be fetched for logged in user.
frappe.set_user("test_contact_customer@example.com")
frappe.local.shopping_cart_settings = None
data = get_product_info_for_website(item_code, skip_quotation_creation=True)
self.assertFalse(bool(data.product_info["price"]))
@@ -485,10 +504,34 @@ def make_web_pricing_rule(**kwargs):
"discount_percentage": kwargs.get("discount_percentage") or 10,
"company": kwargs.get("company") or "_Test Company",
"currency": kwargs.get("currency") or "INR",
"for_price_list": kwargs.get("price_list") or "_Test Price List India"
"for_price_list": kwargs.get("price_list") or "_Test Price List India",
"applicable_for": kwargs.get("applicable_for") or "",
"customer": kwargs.get("customer") or "",
})
pricing_rule.insert()
else:
pricing_rule = frappe.get_doc("Pricing Rule", {"title": title})
return pricing_rule
return pricing_rule
def create_user_and_customer_if_not_exists(email, first_name = None):
if frappe.db.exists("User", email):
return
frappe.get_doc({
"doctype": "User",
"user_type": "Website User",
"email": email,
"send_welcome_email": 0,
"first_name": first_name or email.split("@")[0]
}).insert(ignore_permissions=True)
contact = frappe.get_last_doc("Contact", filters={"email_id": email})
link = contact.append('links', {})
link.link_doctype = "Customer"
link.link_name = "_Test Customer"
link.link_title = "_Test Customer"
contact.save()
test_dependencies = ["Price List", "Item Price", "Customer", "Contact", "Item"]

View File

@@ -23,9 +23,9 @@
{%- for index in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'] -%}
{%- set item = values['card_' + index + '_item'] -%}
{%- if item -%}
{%- set item = frappe.get_doc("Item", item) -%}
{%- set web_item = frappe.get_doc("Website Item", item) -%}
{{ item_card(
item, is_featured=values['card_' + index + '_featured'],
web_item, is_featured=values['card_' + index + '_featured'],
is_full_width=True, align="Center"
) }}
{%- endif -%}

View File

@@ -37,8 +37,8 @@
{
"fieldname": "card_1_item",
"fieldtype": "Link",
"label": "Item",
"options": "Item",
"label": "Website Item",
"options": "Website Item",
"reqd": 0
},
{
@@ -56,8 +56,8 @@
{
"fieldname": "card_2_item",
"fieldtype": "Link",
"label": "Item",
"options": "Item",
"label": "Website Item",
"options": "Website Item",
"reqd": 0
},
{
@@ -76,8 +76,8 @@
{
"fieldname": "card_3_item",
"fieldtype": "Link",
"label": "Item",
"options": "Item",
"label": "Website Item",
"options": "Website Item",
"reqd": 0
},
{
@@ -95,8 +95,8 @@
{
"fieldname": "card_4_item",
"fieldtype": "Link",
"label": "Item",
"options": "Item",
"label": "Website Item",
"options": "Website Item",
"reqd": 0
},
{
@@ -114,8 +114,8 @@
{
"fieldname": "card_5_item",
"fieldtype": "Link",
"label": "Item",
"options": "Item",
"label": "Website Item",
"options": "Website Item",
"reqd": 0
},
{
@@ -133,8 +133,8 @@
{
"fieldname": "card_6_item",
"fieldtype": "Link",
"label": "Item",
"options": "Item",
"label": "Website Item",
"options": "Website Item",
"reqd": 0
},
{
@@ -152,8 +152,8 @@
{
"fieldname": "card_7_item",
"fieldtype": "Link",
"label": "Item",
"options": "Item",
"label": "Website Item",
"options": "Website Item",
"reqd": 0
},
{
@@ -171,8 +171,8 @@
{
"fieldname": "card_8_item",
"fieldtype": "Link",
"label": "Item",
"options": "Item",
"label": "Website Item",
"options": "Website Item",
"reqd": 0
},
{
@@ -190,8 +190,8 @@
{
"fieldname": "card_9_item",
"fieldtype": "Link",
"label": "Item",
"options": "Item",
"label": "Website Item",
"options": "Website Item",
"reqd": 0
},
{
@@ -209,8 +209,8 @@
{
"fieldname": "card_10_item",
"fieldtype": "Link",
"label": "Item",
"options": "Item",
"label": "Website Item",
"options": "Website Item",
"reqd": 0
},
{
@@ -228,8 +228,8 @@
{
"fieldname": "card_11_item",
"fieldtype": "Link",
"label": "Item",
"options": "Item",
"label": "Website Item",
"options": "Website Item",
"reqd": 0
},
{
@@ -247,8 +247,8 @@
{
"fieldname": "card_12_item",
"fieldtype": "Link",
"label": "Item",
"options": "Item",
"label": "Website Item",
"options": "Website Item",
"reqd": 0
},
{
@@ -259,7 +259,7 @@
}
],
"idx": 0,
"modified": "2021-02-24 16:05:31.242342",
"modified": "2021-12-21 14:44:59.821335",
"modified_by": "Administrator",
"module": "E-commerce",
"name": "Item Card Group",

View File

@@ -388,7 +388,7 @@ scheduler_events = {
"erpnext.healthcare.doctype.patient_appointment.patient_appointment.update_appointment_status",
"erpnext.buying.doctype.supplier_quotation.supplier_quotation.set_expired_status",
"erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts.send_auto_email",
"erpnext.non_profit.doctype.membership.membership.set_expired_status"
"erpnext.non_profit.doctype.membership.membership.set_expired_status",
"erpnext.hr.doctype.interview.interview.send_daily_feedback_reminder"
],
"daily_long": [

View File

@@ -12,14 +12,15 @@ class AppointmentLetter(Document):
@frappe.whitelist()
def get_appointment_letter_details(template):
body = []
intro= frappe.get_list("Appointment Letter Template",
fields = ['introduction', 'closing_notes'],
filters={'name': template
})[0]
content = frappe.get_list("Appointment Letter content",
fields = ['title', 'description'],
filters={'parent': template
})
intro = frappe.get_list('Appointment Letter Template',
fields=['introduction', 'closing_notes'],
filters={'name': template}
)[0]
content = frappe.get_all('Appointment Letter content',
fields=['title', 'description'],
filters={'parent': template},
order_by='idx'
)
body.append(intro)
body.append({'description': content})
return body

View File

@@ -171,7 +171,7 @@ frappe.ui.form.on("Expense Claim", {
['docstatus', '=', 1],
['employee', '=', frm.doc.employee],
['paid_amount', '>', 0],
['paid_amount', '>', 'claimed_amount']
['status', '!=', 'Claimed']
]
};
});

View File

@@ -10,15 +10,17 @@ from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.expense_claim.expense_claim import make_bank_entry
test_records = frappe.get_test_records('Expense Claim')
test_dependencies = ['Employee']
company_name = '_Test Company 4'
company_name = '_Test Company 3'
class TestExpenseClaim(unittest.TestCase):
def tearDown(self):
frappe.db.rollback()
def test_total_expense_claim_for_project(self):
frappe.db.sql("""delete from `tabTask` where project = "_Test Project 1" """)
frappe.db.sql("""delete from `tabProject` where name = "_Test Project 1" """)
frappe.db.sql("""delete from `tabTask`""")
frappe.db.sql("""delete from `tabProject`""")
frappe.db.sql("update `tabExpense Claim` set project = '', task = ''")
project = frappe.get_doc({
@@ -37,12 +39,12 @@ class TestExpenseClaim(unittest.TestCase):
task_name = task.name
payable_account = get_payable_account(company_name)
make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4", project.name, task_name)
make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC3", project.name, task_name)
self.assertEqual(frappe.db.get_value("Task", task_name, "total_expense_claim"), 200)
self.assertEqual(frappe.db.get_value("Project", project.name, "total_expense_claim"), 200)
expense_claim2 = make_expense_claim(payable_account, 600, 500, company_name, "Travel Expenses - _TC4", project.name, task_name)
expense_claim2 = make_expense_claim(payable_account, 600, 500, company_name, "Travel Expenses - _TC3", project.name, task_name)
self.assertEqual(frappe.db.get_value("Task", task_name, "total_expense_claim"), 700)
self.assertEqual(frappe.db.get_value("Project", project.name, "total_expense_claim"), 700)
@@ -54,7 +56,7 @@ class TestExpenseClaim(unittest.TestCase):
def test_expense_claim_status(self):
payable_account = get_payable_account(company_name)
expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4")
expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC3")
je_dict = make_bank_entry("Expense Claim", expense_claim.name)
je = frappe.get_doc(je_dict)
@@ -73,7 +75,7 @@ class TestExpenseClaim(unittest.TestCase):
def test_expense_claim_gl_entry(self):
payable_account = get_payable_account(company_name)
taxes = generate_taxes()
expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4",
expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC3",
do_not_submit=True, taxes=taxes)
expense_claim.submit()
@@ -84,9 +86,9 @@ class TestExpenseClaim(unittest.TestCase):
self.assertTrue(gl_entries)
expected_values = dict((d[0], d) for d in [
['Output Tax CGST - _TC4',18.0, 0.0],
['Output Tax CGST - _TC3',18.0, 0.0],
[payable_account, 0.0, 218.0],
["Travel Expenses - _TC4", 200.0, 0.0]
["Travel Expenses - _TC3", 200.0, 0.0]
])
for gle in gl_entries:
@@ -102,7 +104,7 @@ class TestExpenseClaim(unittest.TestCase):
"payable_account": payable_account,
"approval_status": "Rejected",
"expenses":
[{ "expense_type": "Travel", "default_account": "Travel Expenses - _TC4", "amount": 300, "sanctioned_amount": 200 }]
[{"expense_type": "Travel", "default_account": "Travel Expenses - _TC3", "amount": 300, "sanctioned_amount": 200}]
})
expense_claim.submit()

View File

@@ -5,6 +5,7 @@ import frappe
from frappe.utils import add_days, add_months, getdate, nowdate
import erpnext
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import process_expired_allocation
from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type
@@ -14,16 +15,19 @@ class TestLeaveAllocation(unittest.TestCase):
def setUpClass(cls):
frappe.db.sql("delete from `tabLeave Period`")
def test_overlapping_allocation(self):
frappe.db.sql("delete from `tabLeave Allocation`")
emp_id = make_employee("test_emp_leave_allocation@salary.com")
cls.employee = frappe.get_doc("Employee", emp_id)
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
def tearDown(self):
frappe.db.rollback()
def test_overlapping_allocation(self):
leaves = [
{
"doctype": "Leave Allocation",
"__islocal": 1,
"employee": employee.name,
"employee_name": employee.employee_name,
"employee": self.employee.name,
"employee_name": self.employee.employee_name,
"leave_type": "_Test Leave Type",
"from_date": getdate("2015-10-01"),
"to_date": getdate("2015-10-31"),
@@ -33,8 +37,8 @@ class TestLeaveAllocation(unittest.TestCase):
{
"doctype": "Leave Allocation",
"__islocal": 1,
"employee": employee.name,
"employee_name": employee.employee_name,
"employee": self.employee.name,
"employee_name": self.employee.employee_name,
"leave_type": "_Test Leave Type",
"from_date": getdate("2015-09-01"),
"to_date": getdate("2015-11-30"),
@@ -46,40 +50,36 @@ class TestLeaveAllocation(unittest.TestCase):
self.assertRaises(frappe.ValidationError, frappe.get_doc(leaves[1]).save)
def test_invalid_period(self):
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
doc = frappe.get_doc({
"doctype": "Leave Allocation",
"__islocal": 1,
"employee": employee.name,
"employee_name": employee.employee_name,
"employee": self.employee.name,
"employee_name": self.employee.employee_name,
"leave_type": "_Test Leave Type",
"from_date": getdate("2015-09-30"),
"to_date": getdate("2015-09-1"),
"new_leaves_allocated": 5
})
#invalid period
# invalid period
self.assertRaises(frappe.ValidationError, doc.save)
def test_allocated_leave_days_over_period(self):
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
doc = frappe.get_doc({
"doctype": "Leave Allocation",
"__islocal": 1,
"employee": employee.name,
"employee_name": employee.employee_name,
"employee": self.employee.name,
"employee_name": self.employee.employee_name,
"leave_type": "_Test Leave Type",
"from_date": getdate("2015-09-1"),
"to_date": getdate("2015-09-30"),
"new_leaves_allocated": 35
})
#allocated leave more than period
# allocated leave more than period
self.assertRaises(frappe.ValidationError, doc.save)
def test_carry_forward_calculation(self):
frappe.db.sql("delete from `tabLeave Allocation`")
frappe.db.sql("delete from `tabLeave Ledger Entry`")
leave_type = create_leave_type(leave_type_name="_Test_CF_leave", is_carry_forward=1)
leave_type.maximum_carry_forwarded_leaves = 10
leave_type.max_leaves_allowed = 30
@@ -87,6 +87,8 @@ class TestLeaveAllocation(unittest.TestCase):
# initial leave allocation = 15
leave_allocation = create_leave_allocation(
employee=self.employee.name,
employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave",
from_date=add_months(nowdate(), -12),
to_date=add_months(nowdate(), -1),
@@ -96,6 +98,8 @@ class TestLeaveAllocation(unittest.TestCase):
# carry forwarded leaves considering maximum_carry_forwarded_leaves
# new_leaves = 15, carry_forwarded = 10
leave_allocation_1 = create_leave_allocation(
employee=self.employee.name,
employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave",
carry_forward=1)
leave_allocation_1.submit()
@@ -107,6 +111,8 @@ class TestLeaveAllocation(unittest.TestCase):
# carry forwarded leaves considering max_leave_allowed
# max_leave_allowed = 30, new_leaves = 25, carry_forwarded = 5
leave_allocation_2 = create_leave_allocation(
employee=self.employee.name,
employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave",
carry_forward=1,
new_leaves_allocated=25)
@@ -115,8 +121,6 @@ class TestLeaveAllocation(unittest.TestCase):
self.assertEqual(leave_allocation_2.unused_leaves, 5)
def test_carry_forward_leaves_expiry(self):
frappe.db.sql("delete from `tabLeave Allocation`")
frappe.db.sql("delete from `tabLeave Ledger Entry`")
leave_type = create_leave_type(
leave_type_name="_Test_CF_leave_expiry",
is_carry_forward=1,
@@ -125,6 +129,8 @@ class TestLeaveAllocation(unittest.TestCase):
# initial leave allocation
leave_allocation = create_leave_allocation(
employee=self.employee.name,
employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave_expiry",
from_date=add_months(nowdate(), -24),
to_date=add_months(nowdate(), -12),
@@ -132,6 +138,8 @@ class TestLeaveAllocation(unittest.TestCase):
leave_allocation.submit()
leave_allocation = create_leave_allocation(
employee=self.employee.name,
employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave_expiry",
from_date=add_days(nowdate(), -90),
to_date=add_days(nowdate(), 100),
@@ -143,6 +151,8 @@ class TestLeaveAllocation(unittest.TestCase):
# leave allocation with carry forward of only new leaves allocated
leave_allocation_1 = create_leave_allocation(
employee=self.employee.name,
employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave_expiry",
carry_forward=1,
from_date=add_months(nowdate(), 6),
@@ -152,9 +162,10 @@ class TestLeaveAllocation(unittest.TestCase):
self.assertEqual(leave_allocation_1.unused_leaves, leave_allocation.new_leaves_allocated)
def test_creation_of_leave_ledger_entry_on_submit(self):
frappe.db.sql("delete from `tabLeave Allocation`")
leave_allocation = create_leave_allocation()
leave_allocation = create_leave_allocation(
employee=self.employee.name,
employee_name=self.employee.employee_name
)
leave_allocation.submit()
leave_ledger_entry = frappe.get_all('Leave Ledger Entry', fields='*', filters=dict(transaction_name=leave_allocation.name))
@@ -169,10 +180,10 @@ class TestLeaveAllocation(unittest.TestCase):
self.assertFalse(frappe.db.exists("Leave Ledger Entry", {'transaction_name':leave_allocation.name}))
def test_leave_addition_after_submit(self):
frappe.db.sql("delete from `tabLeave Allocation`")
frappe.db.sql("delete from `tabLeave Ledger Entry`")
leave_allocation = create_leave_allocation()
leave_allocation = create_leave_allocation(
employee=self.employee.name,
employee_name=self.employee.employee_name
)
leave_allocation.submit()
self.assertTrue(leave_allocation.total_leaves_allocated, 15)
leave_allocation.new_leaves_allocated = 40
@@ -180,44 +191,55 @@ class TestLeaveAllocation(unittest.TestCase):
self.assertTrue(leave_allocation.total_leaves_allocated, 40)
def test_leave_subtraction_after_submit(self):
frappe.db.sql("delete from `tabLeave Allocation`")
frappe.db.sql("delete from `tabLeave Ledger Entry`")
leave_allocation = create_leave_allocation()
leave_allocation = create_leave_allocation(
employee=self.employee.name,
employee_name=self.employee.employee_name
)
leave_allocation.submit()
self.assertTrue(leave_allocation.total_leaves_allocated, 15)
leave_allocation.new_leaves_allocated = 10
leave_allocation.submit()
self.assertTrue(leave_allocation.total_leaves_allocated, 10)
def test_against_leave_application_validation_after_submit(self):
frappe.db.sql("delete from `tabLeave Allocation`")
frappe.db.sql("delete from `tabLeave Ledger Entry`")
def test_validation_against_leave_application_after_submit(self):
from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
leave_allocation = create_leave_allocation()
make_holiday_list()
frappe.db.set_value("Company", self.employee.company, "default_holiday_list", "Salary Slip Test Holiday List")
leave_allocation = create_leave_allocation(
employee=self.employee.name,
employee_name=self.employee.employee_name
)
leave_allocation.submit()
self.assertTrue(leave_allocation.total_leaves_allocated, 15)
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
leave_application = frappe.get_doc({
"doctype": 'Leave Application',
"employee": employee.name,
"employee": self.employee.name,
"leave_type": "_Test Leave Type",
"from_date": add_months(nowdate(), 2),
"to_date": add_months(add_days(nowdate(), 10), 2),
"company": erpnext.get_default_company() or "_Test Company",
"company": self.employee.company,
"docstatus": 1,
"status": "Approved",
"leave_approver": 'test@example.com'
})
leave_application.submit()
leave_allocation.new_leaves_allocated = 8
leave_allocation.total_leaves_allocated = 8
leave_application.reload()
# allocate less leaves than the ones which are already approved
leave_allocation.new_leaves_allocated = leave_application.total_leave_days - 1
leave_allocation.total_leaves_allocated = leave_application.total_leave_days - 1
self.assertRaises(frappe.ValidationError, leave_allocation.submit)
def create_leave_allocation(**args):
args = frappe._dict(args)
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
leave_allocation = frappe.get_doc({
emp_id = make_employee("test_emp_leave_allocation@salary.com")
employee = frappe.get_doc("Employee", emp_id)
return frappe.get_doc({
"doctype": "Leave Allocation",
"__islocal": 1,
"employee": args.employee or employee.name,
@@ -228,6 +250,5 @@ def create_leave_allocation(**args):
"carry_forward": args.carry_forward or 0,
"to_date": args.to_date or add_months(nowdate(), 12)
})
return leave_allocation
test_dependencies = ["Employee", "Leave Type"]

View File

@@ -131,6 +131,8 @@ class LeavePolicyAssignment(Document):
monthly_earned_leave = get_monthly_earned_leave(new_leaves_allocated,
leave_type_details.get(leave_type).earned_leave_frequency, leave_type_details.get(leave_type).rounding)
new_leaves_allocated = monthly_earned_leave * months_passed
else:
new_leaves_allocated = 0
return new_leaves_allocated

View File

@@ -4,6 +4,7 @@
import unittest
import frappe
from frappe.utils import add_months, get_first_day, getdate
from erpnext.hr.doctype.leave_application.test_leave_application import (
get_employee,
@@ -17,9 +18,8 @@ from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import (
test_dependencies = ["Employee"]
class TestLeavePolicyAssignment(unittest.TestCase):
def setUp(self):
for doctype in ["Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]:
for doctype in ["Leave Period", "Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]:
frappe.db.sql("delete from `tab{0}`".format(doctype)) #nosec
def test_grant_leaves(self):
@@ -54,8 +54,8 @@ class TestLeavePolicyAssignment(unittest.TestCase):
self.assertEqual(leave_alloc_doc.new_leaves_allocated, 10)
self.assertEqual(leave_alloc_doc.leave_type, "_Test Leave Type")
self.assertEqual(leave_alloc_doc.from_date, leave_period.from_date)
self.assertEqual(leave_alloc_doc.to_date, leave_period.to_date)
self.assertEqual(getdate(leave_alloc_doc.from_date), getdate(leave_period.from_date))
self.assertEqual(getdate(leave_alloc_doc.to_date), getdate(leave_period.to_date))
self.assertEqual(leave_alloc_doc.leave_policy, leave_policy.name)
self.assertEqual(leave_alloc_doc.leave_policy_assignment, leave_policy_assignments[0])
@@ -101,6 +101,55 @@ class TestLeavePolicyAssignment(unittest.TestCase):
# User are now allowed to grant leave
self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 0)
def test_earned_leave_allocation(self):
leave_period = create_leave_period("Test Earned Leave Period")
employee = get_employee()
leave_type = create_earned_leave_type("Test Earned Leave")
leave_policy = frappe.get_doc({
"doctype": "Leave Policy",
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 6}]
}).insert()
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
# leaves allocated should be 0 since it is an earned leave and allocation happens via scheduler based on set frequency
leaves_allocated = frappe.db.get_value("Leave Allocation", {
"leave_policy_assignment": leave_policy_assignments[0]
}, "total_leaves_allocated")
self.assertEqual(leaves_allocated, 0)
def tearDown(self):
for doctype in ["Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]:
frappe.db.sql("delete from `tab{0}`".format(doctype)) #nosec
frappe.db.rollback()
def create_earned_leave_type(leave_type):
frappe.delete_doc_if_exists("Leave Type", leave_type, force=1)
return frappe.get_doc(dict(
leave_type_name=leave_type,
doctype="Leave Type",
is_earned_leave=1,
earned_leave_frequency="Monthly",
rounding=0.5,
max_leaves_allowed=6
)).insert()
def create_leave_period(name):
frappe.delete_doc_if_exists("Leave Period", name, force=1)
start_date = get_first_day(getdate())
return frappe.get_doc(dict(
name=name,
doctype="Leave Period",
from_date=start_date,
to_date=add_months(start_date, 12),
company="_Test Company",
is_active=1
)).insert()

View File

@@ -240,12 +240,14 @@
"label": "Repayment Schedule"
},
{
"allow_on_submit": 1,
"depends_on": "eval:doc.is_term_loan == 1",
"fieldname": "repayment_schedule",
"fieldtype": "Table",
"label": "Repayment Schedule",
"no_copy": 1,
"options": "Repayment Schedule"
"options": "Repayment Schedule",
"read_only": 1
},
{
"fieldname": "section_break_17",
@@ -359,10 +361,11 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2021-10-12 18:10:32.360818",
"modified": "2021-10-20 08:28:16.796105",
"modified_by": "Administrator",
"module": "Loan Management",
"name": "Loan",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -389,4 +392,4 @@
"sort_field": "creation",
"sort_order": "DESC",
"track_changes": 1
}
}

View File

@@ -7,7 +7,7 @@ import math
import frappe
from frappe import _
from frappe.utils import add_months, flt, getdate, now_datetime, nowdate
from frappe.utils import add_months, flt, get_last_day, getdate, now_datetime, nowdate
from six import string_types
import erpnext
@@ -63,7 +63,7 @@ class Loan(AccountsController):
self.rate_of_interest = frappe.db.get_value("Loan Type", self.loan_type, "rate_of_interest")
if self.repayment_method == "Repay Over Number of Periods":
self.monthly_repayment_amount = get_monthly_repayment_amount(self.repayment_method, self.loan_amount, self.rate_of_interest, self.repayment_periods)
self.monthly_repayment_amount = get_monthly_repayment_amount(self.loan_amount, self.rate_of_interest, self.repayment_periods)
def check_sanctioned_amount_limit(self):
sanctioned_amount_limit = get_sanctioned_amount_limit(self.applicant_type, self.applicant, self.company)
@@ -100,7 +100,7 @@ class Loan(AccountsController):
"total_payment": total_payment,
"balance_loan_amount": balance_amount
})
next_payment_date = add_months(payment_date, 1)
next_payment_date = add_single_month(payment_date)
payment_date = next_payment_date
def set_repayment_period(self):
@@ -212,7 +212,7 @@ def validate_repayment_method(repayment_method, loan_amount, monthly_repayment_a
if monthly_repayment_amount > loan_amount:
frappe.throw(_("Monthly Repayment Amount cannot be greater than Loan Amount"))
def get_monthly_repayment_amount(repayment_method, loan_amount, rate_of_interest, repayment_periods):
def get_monthly_repayment_amount(loan_amount, rate_of_interest, repayment_periods):
if rate_of_interest:
monthly_interest_rate = flt(rate_of_interest) / (12 *100)
monthly_repayment_amount = math.ceil((loan_amount * monthly_interest_rate *
@@ -396,3 +396,9 @@ def get_shortfall_applicants():
"value": len(applicants),
"fieldtype": "Int"
}
def add_single_month(date):
if getdate(date) == get_last_day(date):
return get_last_day(add_months(date, 1))
else:
return add_months(date, 1)

View File

@@ -218,6 +218,14 @@ class TestLoan(unittest.TestCase):
self.assertEqual(flt(loan.total_principal_paid, 0), flt(repayment_entry.amount_paid -
penalty_amount - total_interest_paid, 0))
# Check Repayment Entry cancel
repayment_entry.load_from_db()
repayment_entry.cancel()
loan.load_from_db()
self.assertEqual(loan.total_principal_paid, 0)
self.assertEqual(loan.total_principal_paid, 0)
def test_loan_closure(self):
pledge = [{
"loan_security": "Test Security 1",
@@ -295,6 +303,27 @@ class TestLoan(unittest.TestCase):
self.assertEqual(amounts[0], 11250.00)
self.assertEqual(amounts[1], 78303.00)
def test_repayment_schedule_update(self):
loan = create_loan(self.applicant2, "Personal Loan", 200000, "Repay Over Number of Periods", 4,
applicant_type='Customer', repayment_start_date='2021-04-30', posting_date='2021-04-01')
loan.submit()
make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date='2021-04-01')
process_loan_interest_accrual_for_term_loans(posting_date='2021-05-01')
process_loan_interest_accrual_for_term_loans(posting_date='2021-06-01')
repayment_entry = create_repayment_entry(loan.name, self.applicant2, '2021-06-05', 120000)
repayment_entry.submit()
loan.load_from_db()
self.assertEqual(flt(loan.get('repayment_schedule')[3].principal_amount, 2), 41369.83)
self.assertEqual(flt(loan.get('repayment_schedule')[3].interest_amount, 2), 289.59)
self.assertEqual(flt(loan.get('repayment_schedule')[3].total_payment, 2), 41659.41)
self.assertEqual(flt(loan.get('repayment_schedule')[3].balance_loan_amount, 2), 0)
def test_security_shortfall(self):
pledges = [{
"loan_security": "Test Security 2",
@@ -938,18 +967,18 @@ def create_loan_application(company, applicant, loan_type, proposed_pledges, rep
def create_loan(applicant, loan_type, loan_amount, repayment_method, repayment_periods,
repayment_start_date=None, posting_date=None):
applicant_type=None, repayment_start_date=None, posting_date=None):
loan = frappe.get_doc({
"doctype": "Loan",
"applicant_type": "Employee",
"applicant_type": applicant_type or "Employee",
"company": "_Test Company",
"applicant": applicant,
"loan_type": loan_type,
"loan_amount": loan_amount,
"repayment_method": repayment_method,
"repayment_periods": repayment_periods,
"repayment_start_date": nowdate(),
"repayment_start_date": repayment_start_date or nowdate(),
"is_term_loan": 1,
"posting_date": posting_date or nowdate()
})

View File

@@ -81,7 +81,7 @@ class LoanApplication(Document):
if self.is_term_loan:
if self.repayment_method == "Repay Over Number of Periods":
self.repayment_amount = get_monthly_repayment_amount(self.repayment_method, self.loan_amount, self.rate_of_interest, self.repayment_periods)
self.repayment_amount = get_monthly_repayment_amount(self.loan_amount, self.rate_of_interest, self.repayment_periods)
if self.repayment_method == "Repay Fixed Amount per Period":
monthly_interest_rate = flt(self.rate_of_interest) / (12 *100)

View File

@@ -176,20 +176,19 @@ def get_total_pledged_security_value(loan):
@frappe.whitelist()
def get_disbursal_amount(loan, on_current_security_price=0):
from erpnext.loan_management.doctype.loan_repayment.loan_repayment import (
get_pending_principal_amount,
)
loan_details = frappe.get_value("Loan", loan, ["loan_amount", "disbursed_amount", "total_payment",
"total_principal_paid", "total_interest_payable", "status", "is_term_loan", "is_secured_loan",
"maximum_loan_amount"], as_dict=1)
"maximum_loan_amount", "written_off_amount"], as_dict=1)
if loan_details.is_secured_loan and frappe.get_all('Loan Security Shortfall', filters={'loan': loan,
'status': 'Pending'}):
return 0
if loan_details.status == 'Disbursed':
pending_principal_amount = flt(loan_details.total_payment) - flt(loan_details.total_interest_payable) \
- flt(loan_details.total_principal_paid)
else:
pending_principal_amount = flt(loan_details.disbursed_amount) - flt(loan_details.total_interest_payable) \
- flt(loan_details.total_principal_paid)
pending_principal_amount = get_pending_principal_amount(loan_details)
security_value = 0.0
if loan_details.is_secured_loan and on_current_security_price:

View File

@@ -74,6 +74,39 @@ class LoanInterestAccrual(AccountsController):
})
)
if self.payable_principal_amount:
gle_map.append(
self.get_gl_dict({
"account": self.loan_account,
"party_type": self.applicant_type,
"party": self.applicant,
"against": self.interest_income_account,
"debit": self.payable_principal_amount,
"debit_in_account_currency": self.interest_amount,
"against_voucher_type": "Loan",
"against_voucher": self.loan,
"remarks": _("Interest accrued from {0} to {1} against loan: {2}").format(
self.last_accrual_date, self.posting_date, self.loan),
"cost_center": erpnext.get_default_cost_center(self.company),
"posting_date": self.posting_date
})
)
gle_map.append(
self.get_gl_dict({
"account": self.interest_income_account,
"against": self.loan_account,
"credit": self.payable_principal_amount,
"credit_in_account_currency": self.interest_amount,
"against_voucher_type": "Loan",
"against_voucher": self.loan,
"remarks": ("Interest accrued from {0} to {1} against loan: {2}").format(
self.last_accrual_date, self.posting_date, self.loan),
"cost_center": erpnext.get_default_cost_center(self.company),
"posting_date": self.posting_date
})
)
if gle_map:
make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj)
@@ -82,7 +115,10 @@ class LoanInterestAccrual(AccountsController):
# rate of interest is 13.5 then first loan interest accural will be on '01-10-2019'
# which means interest will be accrued for 30 days which should be equal to 11095.89
def calculate_accrual_amount_for_demand_loans(loan, posting_date, process_loan_interest, accrual_type):
from erpnext.loan_management.doctype.loan_repayment.loan_repayment import calculate_amounts
from erpnext.loan_management.doctype.loan_repayment.loan_repayment import (
calculate_amounts,
get_pending_principal_amount,
)
no_of_days = get_no_of_days_for_interest_accural(loan, posting_date)
precision = cint(frappe.db.get_default("currency_precision")) or 2
@@ -90,12 +126,7 @@ def calculate_accrual_amount_for_demand_loans(loan, posting_date, process_loan_i
if no_of_days <= 0:
return
if loan.status == 'Disbursed':
pending_principal_amount = flt(loan.total_payment) - flt(loan.total_interest_payable) \
- flt(loan.total_principal_paid) - flt(loan.written_off_amount)
else:
pending_principal_amount = flt(loan.disbursed_amount) - flt(loan.total_interest_payable) \
- flt(loan.total_principal_paid) - flt(loan.written_off_amount)
pending_principal_amount = get_pending_principal_amount(loan)
interest_per_day = get_per_day_interest(pending_principal_amount, loan.rate_of_interest, posting_date)
payable_interest = interest_per_day * no_of_days
@@ -133,7 +164,7 @@ def make_accrual_interest_entry_for_demand_loans(posting_date, process_loan_inte
if not open_loans:
open_loans = frappe.get_all("Loan",
fields=["name", "total_payment", "total_amount_paid", "loan_account", "interest_income_account",
fields=["name", "total_payment", "total_amount_paid", "loan_account", "interest_income_account", "loan_amount",
"is_term_loan", "status", "disbursement_date", "disbursed_amount", "applicant_type", "applicant",
"rate_of_interest", "total_interest_payable", "written_off_amount", "total_principal_paid", "repayment_start_date"],
filters=query_filters)
@@ -190,7 +221,8 @@ def get_term_loans(date, term_loan=None, loan_type=None):
AND l.is_term_loan =1
AND rs.payment_date <= %s
AND rs.is_accrued=0 {0}
AND l.status = 'Disbursed'""".format(condition), (getdate(date)), as_dict=1)
AND l.status = 'Disbursed'
ORDER BY rs.payment_date""".format(condition), (getdate(date)), as_dict=1)
return term_loans

View File

@@ -36,9 +36,12 @@ class LoanRepayment(AccountsController):
def on_submit(self):
self.update_paid_amount()
self.update_repayment_schedule()
self.make_gl_entries()
def on_cancel(self):
self.check_future_accruals()
self.update_repayment_schedule(cancel=1)
self.mark_as_unpaid()
self.ignore_linked_doctypes = ['GL Entry']
self.make_gl_entries(cancel=1)
@@ -91,7 +94,7 @@ class LoanRepayment(AccountsController):
def book_unaccrued_interest(self):
precision = cint(frappe.db.get_default("currency_precision")) or 2
if self.total_interest_paid > self.interest_payable:
if flt(self.total_interest_paid, precision) > flt(self.interest_payable, precision):
if not self.is_term_loan:
# get last loan interest accrual date
last_accrual_date = get_last_accrual_date(self.against_loan)
@@ -122,7 +125,18 @@ class LoanRepayment(AccountsController):
})
def update_paid_amount(self):
loan = frappe.get_doc("Loan", self.against_loan)
loan = frappe.get_value("Loan", self.against_loan, ['total_amount_paid', 'total_principal_paid',
'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'total_interest_payable',
'written_off_amount'], as_dict=1)
loan.update({
'total_amount_paid': loan.total_amount_paid + self.amount_paid,
'total_principal_paid': loan.total_principal_paid + self.principal_amount_paid
})
pending_principal_amount = get_pending_principal_amount(loan)
if not loan.is_secured_loan and pending_principal_amount <= 0:
loan.update({'status': 'Loan Closure Requested'})
for payment in self.repayment_details:
frappe.db.sql(""" UPDATE `tabLoan Interest Accrual`
@@ -131,17 +145,31 @@ class LoanRepayment(AccountsController):
WHERE name = %s""",
(flt(payment.paid_principal_amount), flt(payment.paid_interest_amount), payment.loan_interest_accrual))
frappe.db.sql(""" UPDATE `tabLoan` SET total_amount_paid = %s, total_principal_paid = %s
WHERE name = %s """, (loan.total_amount_paid + self.amount_paid,
loan.total_principal_paid + self.principal_amount_paid, self.against_loan))
frappe.db.sql(""" UPDATE `tabLoan`
SET total_amount_paid = %s, total_principal_paid = %s, status = %s
WHERE name = %s """, (loan.total_amount_paid, loan.total_principal_paid, loan.status,
self.against_loan))
update_shortfall_status(self.against_loan, self.principal_amount_paid)
def mark_as_unpaid(self):
loan = frappe.get_doc("Loan", self.against_loan)
loan = frappe.get_value("Loan", self.against_loan, ['total_amount_paid', 'total_principal_paid',
'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'total_interest_payable',
'written_off_amount'], as_dict=1)
no_of_repayments = len(self.repayment_details)
loan.update({
'total_amount_paid': loan.total_amount_paid - self.amount_paid,
'total_principal_paid': loan.total_principal_paid - self.principal_amount_paid
})
if loan.status == 'Loan Closure Requested':
if loan.disbursed_amount >= loan.loan_amount:
loan['status'] = 'Disbursed'
else:
loan['status'] = 'Partially Disbursed'
for payment in self.repayment_details:
frappe.db.sql(""" UPDATE `tabLoan Interest Accrual`
SET paid_principal_amount = `paid_principal_amount` - %s,
@@ -155,12 +183,20 @@ class LoanRepayment(AccountsController):
lia_doc = frappe.get_doc('Loan Interest Accrual', payment.loan_interest_accrual)
lia_doc.cancel()
frappe.db.sql(""" UPDATE `tabLoan` SET total_amount_paid = %s, total_principal_paid = %s
WHERE name = %s """, (loan.total_amount_paid - self.amount_paid,
loan.total_principal_paid - self.principal_amount_paid, self.against_loan))
frappe.db.sql(""" UPDATE `tabLoan`
SET total_amount_paid = %s, total_principal_paid = %s, status = %s
WHERE name = %s """, (loan.total_amount_paid, loan.total_principal_paid, loan.status, self.against_loan))
if loan.status == "Loan Closure Requested":
frappe.db.set_value("Loan", self.against_loan, "status", "Disbursed")
def check_future_accruals(self):
future_accrual_date = frappe.db.get_value("Loan Interest Accrual", {"posting_date": (">", self.posting_date),
"docstatus": 1, "loan": self.against_loan}, 'posting_date')
if future_accrual_date:
frappe.throw("Cannot cancel. Interest accruals already processed till {0}".format(get_datetime(future_accrual_date)))
def update_repayment_schedule(self, cancel=0):
if self.is_term_loan and self.principal_amount_paid > self.payable_principal_amount:
regenerate_repayment_schedule(self.against_loan, cancel)
def allocate_amounts(self, repayment_details):
self.set('repayment_details', [])
@@ -183,50 +219,93 @@ class LoanRepayment(AccountsController):
interest_paid -= self.total_penalty_paid
total_interest_paid = 0
# interest_paid = self.amount_paid - self.principal_amount_paid - self.penalty_amount
if self.is_term_loan:
interest_paid, updated_entries = self.allocate_interest_amount(interest_paid, repayment_details)
self.allocate_principal_amount_for_term_loans(interest_paid, repayment_details, updated_entries)
else:
interest_paid, updated_entries = self.allocate_interest_amount(interest_paid, repayment_details)
self.allocate_excess_payment_for_demand_loans(interest_paid, repayment_details)
def allocate_interest_amount(self, interest_paid, repayment_details):
updated_entries = {}
self.total_interest_paid = 0
idx = 1
if interest_paid > 0:
for lia, amounts in iteritems(repayment_details.get('pending_accrual_entries', [])):
if amounts['interest_amount'] + amounts['payable_principal_amount'] <= interest_paid:
interest_amount = 0
if amounts['interest_amount'] <= interest_paid:
interest_amount = amounts['interest_amount']
paid_principal = amounts['payable_principal_amount']
self.principal_amount_paid += paid_principal
interest_paid -= (interest_amount + paid_principal)
self.total_interest_paid += interest_amount
interest_paid -= interest_amount
elif interest_paid:
if interest_paid >= amounts['interest_amount']:
interest_amount = amounts['interest_amount']
paid_principal = interest_paid - interest_amount
self.principal_amount_paid += paid_principal
self.total_interest_paid += interest_amount
interest_paid = 0
else:
interest_amount = interest_paid
self.total_interest_paid += interest_amount
interest_paid = 0
paid_principal=0
total_interest_paid += interest_amount
self.append('repayment_details', {
'loan_interest_accrual': lia,
'paid_interest_amount': interest_amount,
'paid_principal_amount': paid_principal
})
if interest_amount:
self.append('repayment_details', {
'loan_interest_accrual': lia,
'paid_interest_amount': interest_amount,
'paid_principal_amount': 0
})
updated_entries[lia] = idx
idx += 1
return interest_paid, updated_entries
def allocate_principal_amount_for_term_loans(self, interest_paid, repayment_details, updated_entries):
if interest_paid > 0:
for lia, amounts in iteritems(repayment_details.get('pending_accrual_entries', [])):
paid_principal = 0
if amounts['payable_principal_amount'] <= interest_paid:
paid_principal = amounts['payable_principal_amount']
self.principal_amount_paid += paid_principal
interest_paid -= paid_principal
elif interest_paid:
if interest_paid >= amounts['payable_principal_amount']:
paid_principal = amounts['payable_principal_amount']
self.principal_amount_paid += paid_principal
interest_paid = 0
else:
paid_principal = interest_paid
self.principal_amount_paid += paid_principal
interest_paid = 0
if updated_entries.get(lia):
idx = updated_entries.get(lia)
self.get('repayment_details')[idx-1].paid_principal_amount += paid_principal
else:
self.append('repayment_details', {
'loan_interest_accrual': lia,
'paid_interest_amount': 0,
'paid_principal_amount': paid_principal
})
if interest_paid > 0:
self.principal_amount_paid += interest_paid
def allocate_excess_payment_for_demand_loans(self, interest_paid, repayment_details):
if repayment_details['unaccrued_interest'] and interest_paid > 0:
# no of days for which to accrue interest
# Interest can only be accrued for an entire day and not partial
if interest_paid > repayment_details['unaccrued_interest']:
interest_paid -= repayment_details['unaccrued_interest']
total_interest_paid += repayment_details['unaccrued_interest']
self.total_interest_paid += repayment_details['unaccrued_interest']
else:
# get no of days for which interest can be paid
per_day_interest = get_per_day_interest(self.pending_principal_amount,
self.rate_of_interest, self.posting_date)
no_of_days = cint(interest_paid/per_day_interest)
total_interest_paid += no_of_days * per_day_interest
self.total_interest_paid += no_of_days * per_day_interest
interest_paid -= no_of_days * per_day_interest
self.total_interest_paid = total_interest_paid
if interest_paid > 0:
self.principal_amount_paid += interest_paid
@@ -362,6 +441,76 @@ def get_penalty_details(against_loan):
else:
return None, 0
def regenerate_repayment_schedule(loan, cancel=0):
from erpnext.loan_management.doctype.loan.loan import (
add_single_month,
get_monthly_repayment_amount,
)
loan_doc = frappe.get_doc('Loan', loan)
next_accrual_date = None
accrued_entries = 0
last_repayment_amount = 0
last_balance_amount = 0
for term in reversed(loan_doc.get('repayment_schedule')):
if not term.is_accrued:
next_accrual_date = term.payment_date
loan_doc.remove(term)
else:
accrued_entries += 1
if not last_repayment_amount:
last_repayment_amount = term.total_payment
if not last_balance_amount:
last_balance_amount = term.balance_loan_amount
loan_doc.save()
balance_amount = get_pending_principal_amount(loan_doc)
if loan_doc.repayment_method == 'Repay Fixed Amount per Period':
monthly_repayment_amount = flt(balance_amount/len(loan_doc.get('repayment_schedule')) - accrued_entries)
else:
if not cancel:
monthly_repayment_amount = get_monthly_repayment_amount(balance_amount,
loan_doc.rate_of_interest, loan_doc.repayment_periods - accrued_entries)
else:
monthly_repayment_amount = last_repayment_amount
balance_amount = last_balance_amount
payment_date = next_accrual_date
while(balance_amount > 0):
interest_amount = flt(balance_amount * flt(loan_doc.rate_of_interest) / (12*100))
principal_amount = monthly_repayment_amount - interest_amount
balance_amount = flt(balance_amount + interest_amount - monthly_repayment_amount)
if balance_amount < 0:
principal_amount += balance_amount
balance_amount = 0.0
total_payment = principal_amount + interest_amount
loan_doc.append("repayment_schedule", {
"payment_date": payment_date,
"principal_amount": principal_amount,
"interest_amount": interest_amount,
"total_payment": total_payment,
"balance_loan_amount": balance_amount
})
next_payment_date = add_single_month(payment_date)
payment_date = next_payment_date
loan_doc.save()
def get_pending_principal_amount(loan):
if loan.status in ('Disbursed', 'Closed') or loan.disbursed_amount >= loan.loan_amount:
pending_principal_amount = flt(loan.total_payment) - flt(loan.total_principal_paid) \
- flt(loan.total_interest_payable) - flt(loan.written_off_amount)
else:
pending_principal_amount = flt(loan.disbursed_amount) - flt(loan.total_principal_paid) \
- flt(loan.total_interest_payable) - flt(loan.written_off_amount)
return pending_principal_amount
# This function returns the amounts that are payable at the time of loan repayment based on posting date
# So it pulls all the unpaid Loan Interest Accrual Entries and calculates the penalty if applicable
@@ -409,12 +558,7 @@ def get_amounts(amounts, against_loan, posting_date):
if due_date and not final_due_date:
final_due_date = add_days(due_date, loan_type_details.grace_period_in_days)
if against_loan_doc.status in ('Disbursed', 'Closed') or against_loan_doc.disbursed_amount >= against_loan_doc.loan_amount:
pending_principal_amount = against_loan_doc.total_payment - against_loan_doc.total_principal_paid \
- against_loan_doc.total_interest_payable - against_loan_doc.written_off_amount
else:
pending_principal_amount = against_loan_doc.disbursed_amount - against_loan_doc.total_principal_paid \
- against_loan_doc.total_interest_payable - against_loan_doc.written_off_amount
pending_principal_amount = get_pending_principal_amount(against_loan_doc)
unaccrued_interest = 0
if due_date:

View File

@@ -28,6 +28,9 @@ class LoanSecurityUnpledge(Document):
d.idx, frappe.bold(d.loan_security)))
def validate_unpledge_qty(self):
from erpnext.loan_management.doctype.loan_repayment.loan_repayment import (
get_pending_principal_amount,
)
from erpnext.loan_management.doctype.loan_security_shortfall.loan_security_shortfall import (
get_ltv_ratio,
)
@@ -44,15 +47,10 @@ class LoanSecurityUnpledge(Document):
"valid_upto": (">=", get_datetime())
}, as_list=1))
loan_details = frappe.get_value("Loan", self.loan, ['total_payment', 'total_principal_paid',
loan_details = frappe.get_value("Loan", self.loan, ['total_payment', 'total_principal_paid', 'loan_amount',
'total_interest_payable', 'written_off_amount', 'disbursed_amount', 'status'], as_dict=1)
if loan_details.status == 'Disbursed':
pending_principal_amount = flt(loan_details.total_payment) - flt(loan_details.total_interest_payable) \
- flt(loan_details.total_principal_paid) - flt(loan_details.written_off_amount)
else:
pending_principal_amount = flt(loan_details.disbursed_amount) - flt(loan_details.total_interest_payable) \
- flt(loan_details.total_principal_paid) - flt(loan_details.written_off_amount)
pending_principal_amount = get_pending_principal_amount(loan_details)
security_value = 0
unpledge_qty_map = {}

View File

@@ -1,17 +1,15 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import unittest
import frappe
from frappe.utils import add_months, today
from erpnext import get_company_currency
from erpnext.tests.utils import ERPNextTestCase
from .blanket_order import make_order
class TestBlanketOrder(unittest.TestCase):
class TestBlanketOrder(ERPNextTestCase):
def setUp(self):
frappe.flags.args = frappe._dict()

View File

@@ -2,7 +2,6 @@
# License: GNU General Public License v3. See license.txt
import unittest
from collections import deque
from functools import partial
@@ -18,10 +17,11 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import
create_stock_reconciliation,
)
from erpnext.tests.test_subcontracting import set_backflush_based_on
from erpnext.tests.utils import ERPNextTestCase
test_records = frappe.get_test_records('BOM')
class TestBOM(unittest.TestCase):
class TestBOM(ERPNextTestCase):
def setUp(self):
if not frappe.get_value('Item', '_Test Item'):
make_test_records('Item')

View File

@@ -1,19 +1,16 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import unittest
import frappe
from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.tests.utils import ERPNextTestCase
test_records = frappe.get_test_records('BOM')
class TestBOMUpdateTool(unittest.TestCase):
class TestBOMUpdateTool(ERPNextTestCase):
def test_replace_bom(self):
current_bom = "BOM-_Test Item Home Desktop Manufactured-001"

View File

@@ -76,6 +76,15 @@ frappe.ui.form.on('Job Card', {
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) => {
if (r.message.transfer_material_against == 'Work Order') {
frm.set_df_property('items', 'hidden', 1);
}
});
}
},
setup_quality_inspection: function(frm) {

View File

@@ -1,6 +1,5 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import unittest
import frappe
from frappe.utils import random_string
@@ -12,9 +11,10 @@ from erpnext.manufacturing.doctype.job_card.job_card import (
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestCase
class TestJobCard(unittest.TestCase):
class TestJobCard(ERPNextTestCase):
def setUp(self):
make_bom_for_jc_tests()
@@ -329,4 +329,4 @@ def make_bom_for_jc_tests():
bom.rm_cost_as_per = "Valuation Rate"
bom.items[0].uom = "_Test UOM 1"
bom.items[0].conversion_factor = 5
bom.insert()
bom.insert()

View File

@@ -1,8 +1,5 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import unittest
import frappe
from frappe.utils import add_to_date, flt, now_datetime, nowdate
@@ -17,9 +14,10 @@ from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
from erpnext.tests.utils import ERPNextTestCase
class TestProductionPlan(unittest.TestCase):
class TestProductionPlan(ERPNextTestCase):
def setUp(self):
for item in ['Test Production Item 1', 'Subassembly Item 1',
'Raw Material Item 1', 'Raw Material Item 2']:

View File

@@ -1,17 +1,15 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import unittest
import frappe
from frappe.test_runner import make_test_records
from erpnext.manufacturing.doctype.job_card.job_card import OperationSequenceError
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestCase
class TestRouting(unittest.TestCase):
class TestRouting(ERPNextTestCase):
@classmethod
def setUpClass(cls):
cls.item_code = "Test Routing Item - A"

View File

@@ -1,6 +1,5 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import unittest
import frappe
from frappe.utils import add_months, cint, flt, now, today
@@ -21,9 +20,10 @@ from erpnext.stock.doctype.item.test_item import create_item, make_item
from erpnext.stock.doctype.stock_entry import test_stock_entry
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.utils import get_bin
from erpnext.tests.utils import ERPNextTestCase, timeout
class TestWorkOrder(unittest.TestCase):
class TestWorkOrder(ERPNextTestCase):
def setUp(self):
self.warehouse = '_Test Warehouse 2 - _TC'
self.item = '_Test Item'
@@ -91,7 +91,7 @@ class TestWorkOrder(unittest.TestCase):
def test_reserved_qty_for_partial_completion(self):
item = "_Test Item"
warehouse = create_warehouse("Test Warehouse for reserved_qty - _TC")
warehouse = "_Test Warehouse - _TC"
bin1_at_start = get_bin(item, warehouse)
@@ -196,8 +196,6 @@ class TestWorkOrder(unittest.TestCase):
# no change in reserved / projected
self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production),
cint(bin1_on_start_production.reserved_qty_for_production))
self.assertEqual(cint(bin1_on_end_production.projected_qty),
cint(bin1_on_end_production.projected_qty))
def test_backflush_qty_for_overpduction_manufacture(self):
cancel_stock_entry = []
@@ -376,6 +374,7 @@ class TestWorkOrder(unittest.TestCase):
self.assertEqual(len(ste.additional_costs), 1)
self.assertEqual(ste.total_additional_costs, 1000)
@timeout(seconds=60)
def test_job_card(self):
stock_entries = []
bom = frappe.get_doc('BOM', {
@@ -769,6 +768,7 @@ class TestWorkOrder(unittest.TestCase):
total_pl_qty
)
@timeout(seconds=60)
def test_job_card_scrap_item(self):
items = ['Test FG Item for Scrap Item Test', 'Test RM Item 1 for Scrap Item Test',
'Test RM Item 2 for Scrap Item Test']

View File

@@ -1,8 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors
# See license.txt
import unittest
import frappe
from frappe.test_runner import make_test_records
@@ -13,12 +10,13 @@ from erpnext.manufacturing.doctype.workstation.workstation import (
WorkstationHolidayError,
check_if_within_operating_hours,
)
from erpnext.tests.utils import ERPNextTestCase
test_dependencies = ["Warehouse"]
test_records = frappe.get_test_records('Workstation')
make_test_records('Workstation')
class TestWorkstation(unittest.TestCase):
class TestWorkstation(ERPNextTestCase):
def test_validate_timings(self):
check_if_within_operating_hours("_Test Workstation 1", "Operation 1", "2013-02-02 11:00:00", "2013-02-02 19:00:00")
check_if_within_operating_hours("_Test Workstation 1", "Operation 1", "2013-02-02 10:00:00", "2013-02-02 20:00:00")

View File

@@ -410,7 +410,7 @@ def get_plan_from_razorpay_id(plan_id):
def set_expired_status():
frappe.db.sql("""
UPDATE
`tabMembership` SET `status` = 'Expired'
`tabMembership` SET `membership_status` = 'Expired'
WHERE
`status` not in ('Cancelled') AND `to_date` < %s
`membership_status` not in ('Cancelled') AND `to_date` < %s
""", (nowdate()))

View File

@@ -336,4 +336,6 @@ erpnext.patches.v13_0.item_naming_series_not_mandatory
erpnext.patches.v13_0.update_category_in_ltds_certificate
erpnext.patches.v13_0.create_ksa_vat_custom_fields
erpnext.patches.v13_0.rename_ksa_qr_field
erpnext.patches.v13_0.disable_ksa_print_format_for_others #16-12-2021
erpnext.patches.v13_0.disable_ksa_print_format_for_others # 16-12-2021
erpnext.patches.v13_0.update_tax_category_for_rcm
erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template

View File

@@ -23,4 +23,5 @@ def execute():
delivery_settings = frappe.get_doc("Delivery Settings")
delivery_settings.dispatch_template = _("Dispatch Notification")
delivery_settings.flags.ignore_links = True
delivery_settings.save()

View File

@@ -98,6 +98,8 @@ def execute():
'itc_central_tax': 0,
'itc_cess_amount': 0
})
if not gst_accounts:
continue
if d.account_head in gst_accounts.get('igst_account'):
amount_map[d.parent]['itc_integrated_tax'] += d.amount

View File

@@ -7,7 +7,7 @@ def execute():
frappe.reload_doc("selling", "doctype", "sales_order_item")
for doctype in ["Sales Order", "Material Request"]:
condition = " and child_doc.stock_qty > child_doc.produced_qty"
condition = " and child_doc.stock_qty > child_doc.produced_qty and doc.per_delivered < 100"
if doctype == "Material Request":
condition = " and doc.per_ordered < 100 and doc.material_request_type = 'Manufacture'"
@@ -16,5 +16,6 @@ def execute():
child_doc.bom_no = item.default_bom
WHERE
child_doc.item_code = item.name and child_doc.docstatus < 2
and child_doc.parent = doc.name
and item.default_bom is not null and item.default_bom != '' {cond}
""".format(doc = doctype, cond = condition))

View File

@@ -33,4 +33,5 @@ def execute():
hr_settings = frappe.get_doc('HR Settings')
hr_settings.interview_reminder_template = _('Interview Reminder')
hr_settings.feedback_reminder_notification_template = _('Interview Feedback Reminder')
hr_settings.flags.ignore_links = True
hr_settings.save()

View File

@@ -0,0 +1,57 @@
import json
from typing import List, Union
import frappe
from erpnext.e_commerce.doctype.website_item.website_item import make_website_item
def execute():
"""
Convert all Item links to Website Item link values in
exisitng 'Item Card Group' Web Page Block data.
"""
frappe.reload_doc("e_commerce", "web_template", "item_card_group")
blocks = frappe.db.get_all(
"Web Page Block",
filters={"web_template": "Item Card Group"},
fields=["parent", "web_template_values", "name"]
)
fields = generate_fields_to_edit()
for block in blocks:
web_template_value = json.loads(block.get('web_template_values'))
for field in fields:
item = web_template_value.get(field)
if not item:
continue
if frappe.db.exists("Website Item", {"item_code": item}):
website_item = frappe.db.get_value("Website Item", {"item_code": item})
else:
website_item = make_new_website_item(item)
if website_item:
web_template_value[field] = website_item
frappe.db.set_value("Web Page Block", block.name, "web_template_values", json.dumps(web_template_value))
def generate_fields_to_edit() -> List:
fields = []
for i in range(1, 13):
fields.append(f"card_{i}_item") # fields like 'card_1_item', etc.
return fields
def make_new_website_item(item: str) -> Union[str, None]:
try:
doc = frappe.get_doc("Item", item)
web_item = make_website_item(doc) # returns [website_item.name, item_name]
return web_item[0]
except Exception:
title = f"{item}: Error while converting to Website Item "
frappe.log_error(title + "for Item Card Group Template" + "\n\n" + frappe.get_traceback(), title=title)
return None

View File

@@ -0,0 +1,31 @@
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from erpnext.regional.india import states
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
create_custom_fields({
'Tax Category': [
dict(fieldname='is_inter_state', label='Is Inter State',
fieldtype='Check', insert_after='disabled', print_hide=1),
dict(fieldname='is_reverse_charge', label='Is Reverse Charge', fieldtype='Check',
insert_after='is_inter_state', print_hide=1),
dict(fieldname='tax_category_column_break', fieldtype='Column Break',
insert_after='is_reverse_charge'),
dict(fieldname='gst_state', label='Source State', fieldtype='Select',
options='\n'.join(states), insert_after='company')
]
}, update=True)
tax_category = frappe.qb.DocType("Tax Category")
frappe.qb.update(tax_category).set(
tax_category.is_reverse_charge, 1
).where(
tax_category.name.isin(['Reverse Charge Out-State', 'Reverse Charge In-State'])
).run()

View File

@@ -350,23 +350,24 @@ class PayrollEntry(Document):
currencies = []
multi_currency = 0
company_currency = erpnext.get_company_currency(self.company)
accounting_dimensions = get_accounting_dimensions() or []
exchange_rate, amount = self.get_amount_and_exchange_rate_for_journal_entry(self.payment_account, je_payment_amount, company_currency, currencies)
accounts.append({
accounts.append(self.update_accounting_dimensions({
"account": self.payment_account,
"bank_account": self.bank_account,
"credit_in_account_currency": flt(amount, precision),
"exchange_rate": flt(exchange_rate),
})
}, accounting_dimensions))
exchange_rate, amount = self.get_amount_and_exchange_rate_for_journal_entry(payroll_payable_account, je_payment_amount, company_currency, currencies)
accounts.append({
accounts.append(self.update_accounting_dimensions({
"account": payroll_payable_account,
"debit_in_account_currency": flt(amount, precision),
"exchange_rate": flt(exchange_rate),
"reference_type": self.doctype,
"reference_name": self.name
})
}, accounting_dimensions))
if len(currencies) > 1:
multi_currency = 1

View File

@@ -936,18 +936,23 @@ class SalarySlip(TransactionBase):
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)
from_date, to_date = getdate(self.start_date), getdate(to_date)
future_recurring_period = ((to_date.year - from_date.year) * 12) + (to_date.month - from_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
timesheet_component = frappe.db.get_value("Salary Structure", self.salary_structure, "salary_component")
if (self.salary_structure and
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
and (row.salary_component != timesheet_component or
getdate(self.start_date) < joining_date or
(relieving_date and getdate(self.end_date) > relieving_date)
)):
@@ -956,7 +961,7 @@ class SalarySlip(TransactionBase):
amount = flt((flt(row.default_amount) * flt(self.payment_days)
/ cint(self.total_working_days)), row.precision("amount")) + additional_amount
elif not self.payment_days and not self.salary_slip_based_on_timesheet and cint(row.depends_on_payment_days):
elif not self.payment_days and row.salary_component != timesheet_component and cint(row.depends_on_payment_days):
amount, additional_amount = 0, 0
elif not row.amount:
amount = flt(row.default_amount) + flt(row.additional_amount)
@@ -1034,7 +1039,8 @@ class SalarySlip(TransactionBase):
data.update({"annual_taxable_earning": annual_taxable_earning})
tax_amount = 0
for slab in tax_slab.slabs:
if slab.condition and not self.eval_tax_slab_condition(slab.condition, data):
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 *.01

View File

@@ -134,6 +134,58 @@ class TestSalarySlip(unittest.TestCase):
frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave")
def test_payment_days_in_salary_slip_based_on_timesheet(self):
from erpnext.hr.doctype.attendance.attendance import mark_attendance
from erpnext.projects.doctype.timesheet.test_timesheet import (
make_salary_structure_for_timesheet,
make_timesheet,
)
from erpnext.projects.doctype.timesheet.timesheet import (
make_salary_slip as make_salary_slip_for_timesheet,
)
# Payroll based on attendance
frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Attendance")
emp = make_employee("test_employee_timesheet@salary.com", company="_Test Company")
frappe.db.set_value("Employee", emp, {"relieving_date": None, "status": "Active"})
# mark attendance
month_start_date = get_first_day(nowdate())
month_end_date = get_last_day(nowdate())
first_sunday = frappe.db.sql("""
select holiday_date from `tabHoliday`
where parent = 'Salary Slip Test Holiday List'
and holiday_date between %s and %s
order by holiday_date
""", (month_start_date, month_end_date))[0][0]
mark_attendance(emp, add_days(first_sunday, 1), 'Absent', ignore_validate=True) # counted as absent
# salary structure based on timesheet
make_salary_structure_for_timesheet(emp)
timesheet = make_timesheet(emp, simulate=True, is_billable=1)
salary_slip = make_salary_slip_for_timesheet(timesheet.name)
salary_slip.start_date = month_start_date
salary_slip.end_date = month_end_date
salary_slip.save()
salary_slip.submit()
salary_slip.reload()
no_of_days = self.get_no_of_days()
days_in_month = no_of_days[0]
no_of_holidays = no_of_days[1]
self.assertEqual(salary_slip.payment_days, days_in_month - no_of_holidays - 1)
# gross pay calculation based on attendance (payment days)
gross_pay = 78100 - ((78000 / (days_in_month - no_of_holidays)) * flt(salary_slip.leave_without_pay + salary_slip.absent_days))
self.assertEqual(salary_slip.gross_pay, flt(gross_pay, 2))
frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave")
def test_component_amount_dependent_on_another_payment_days_based_component(self):
from erpnext.hr.doctype.attendance.attendance import mark_attendance
from erpnext.payroll.doctype.salary_structure.test_salary_structure import (

View File

@@ -34,10 +34,6 @@ class TestTimesheet(unittest.TestCase):
for dt in ["Salary Slip", "Salary Structure", "Salary Structure Assignment", "Timesheet"]:
frappe.db.sql("delete from `tab%s`" % dt)
if not frappe.db.exists("Salary Component", "Timesheet Component"):
frappe.get_doc({"doctype": "Salary Component", "salary_component": "Timesheet Component"}).insert()
def test_timesheet_billing_amount(self):
emp = make_employee("test_employee_6@salary.com")
@@ -160,6 +156,9 @@ def make_salary_structure_for_timesheet(employee, company=None):
salary_structure_name = "Timesheet Salary Structure Test"
frequency = "Monthly"
if not frappe.db.exists("Salary Component", "Timesheet Component"):
frappe.get_doc({"doctype": "Salary Component", "salary_component": "Timesheet Component"}).insert()
salary_structure = make_salary_structure(salary_structure_name, frequency, company=company, dont_submit=True)
salary_structure.salary_component = "Timesheet Component"
salary_structure.salary_slip_based_on_timesheet = 1

View File

@@ -26,6 +26,7 @@ class TestProjectProfitability(unittest.TestCase):
self.timesheet = make_timesheet(emp, is_billable=1)
self.salary_slip = make_salary_slip(self.timesheet.name)
self.salary_slip.start_date = self.timesheet.start_date
holidays = self.salary_slip.get_holidays_for_employee(date, date)
if holidays:
@@ -42,8 +43,8 @@ class TestProjectProfitability(unittest.TestCase):
def test_project_profitability(self):
filters = {
'company': '_Test Company',
'start_date': add_days(getdate(), -3),
'end_date': getdate()
'start_date': add_days(self.timesheet.start_date, -3),
'end_date': self.timesheet.start_date
}
report = execute(filters)

View File

@@ -680,7 +680,7 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
var item = frappe.get_doc(cdt, cdn);
frappe.model.round_floats_in(item, ["price_list_rate", "discount_percentage"]);
// check if child doctype is Sales Order Item/Qutation Item and calculate the rate
// check if child doctype is Sales Order Item/Quotation Item and calculate the rate
if (in_list(["Quotation Item", "Sales Order Item", "Delivery Note Item", "Sales Invoice Item", "POS Invoice Item", "Purchase Invoice Item", "Purchase Order Item", "Purchase Receipt Item"]), cdt)
this.apply_pricing_rule_on_item(item);
else
@@ -1562,25 +1562,27 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
_set_values_for_item_list: function(children) {
var me = this;
var price_list_rate_changed = false;
var items_rule_dict = {};
for(var i=0, l=children.length; i<l; i++) {
var d = children[i];
var d = children[i] ;
let item_row = frappe.get_doc(d.doctype, d.name);
var existing_pricing_rule = frappe.model.get_value(d.doctype, d.name, "pricing_rules");
for(var k in d) {
var v = d[k];
if (["doctype", "name"].indexOf(k)===-1) {
if(k=="price_list_rate") {
if(flt(v) != flt(d.price_list_rate)) price_list_rate_changed = true;
item_row['rate'] = v;
}
if (k !== 'free_item_data') {
frappe.model.set_value(d.doctype, d.name, k, v);
item_row[k] = v;
}
}
}
frappe.model.round_floats_in(item_row, ["price_list_rate", "discount_percentage"]);
// if pricing rule set as blank from an existing value, apply price_list
if(!me.frm.doc.ignore_pricing_rule && existing_pricing_rule && !d.pricing_rules) {
me.apply_price_list(frappe.get_doc(d.doctype, d.name));
@@ -1597,9 +1599,10 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
}
}
me.frm.refresh_field('items');
me.apply_rule_on_other_items(items_rule_dict);
if(!price_list_rate_changed) me.calculate_taxes_and_totals();
me.calculate_taxes_and_totals();
},
apply_rule_on_other_items: function(args) {

View File

@@ -83,6 +83,13 @@ $.extend(erpnext.queries, {
};
},
dispatch_address_query: function(doc) {
return {
query: 'frappe.contacts.doctype.address.address.address_query',
filters: { link_doctype: 'Company', link_name: doc.company || '' }
};
},
supplier_filter: function(doc) {
if(!doc.supplier) {
frappe.throw(__("Please set {0}", [__(frappe.meta.get_label(doc.doctype, "supplier", doc.name))]));

View File

@@ -430,12 +430,9 @@ erpnext.utils.select_alternate_items = function(opts) {
qty = row.qty;
}
row[item_field] = d.alternate_item;
frm.script_manager.trigger(item_field, row.doctype, row.name)
.then(() => {
frappe.model.set_value(row.doctype, row.name, 'qty', qty);
frappe.model.set_value(row.doctype, row.name,
opts.original_item_field, d.item_code);
});
frappe.model.set_value(row.doctype, row.name, 'qty', qty);
frappe.model.set_value(row.doctype, row.name, opts.original_item_field, d.item_code);
frm.trigger(item_field, row.doctype, row.name);
});
refresh_field(opts.child_docname);

View File

@@ -284,8 +284,10 @@ def get_custom_fields():
inter_state_gst_field = [
dict(fieldname='is_inter_state', label='Is Inter State',
fieldtype='Check', insert_after='disabled', print_hide=1),
dict(fieldname='is_reverse_charge', label='Is Reverse Charge', fieldtype='Check',
insert_after='is_inter_state', print_hide=1),
dict(fieldname='tax_category_column_break', fieldtype='Column Break',
insert_after='is_inter_state'),
insert_after='is_reverse_charge'),
dict(fieldname='gst_state', label='Source State', fieldtype='Select',
options='\n'.join(states), insert_after='company')
]

View File

@@ -69,7 +69,8 @@ def validate_pan_for_india(doc, method):
frappe.throw(_("Invalid PAN No. The input you've entered doesn't match the format of PAN."))
def validate_tax_category(doc, method):
if doc.get('gst_state') and frappe.db.get_value('Tax Category', {'gst_state': doc.gst_state, 'is_inter_state': doc.is_inter_state}):
if doc.get('gst_state') and frappe.db.get_value('Tax Category', {'gst_state': doc.gst_state, 'is_inter_state': doc.is_inter_state,
'is_reverse_charge': doc.is_reverse_charge}):
if doc.is_inter_state:
frappe.throw(_("Inter State tax category for GST State {0} already exists").format(doc.gst_state))
else:
@@ -216,7 +217,7 @@ def get_regional_address_details(party_details, doctype, company):
if tax_template_by_category:
party_details['taxes_and_charges'] = tax_template_by_category
return
return party_details
if not party_details.place_of_supply: return party_details
if not party_details.company_gstin: return party_details
@@ -266,7 +267,7 @@ def get_tax_template_based_on_category(master_doctype, company, party_details):
def get_tax_template(master_doctype, company, is_inter_state, state_code):
tax_categories = frappe.get_all('Tax Category', fields = ['name', 'is_inter_state', 'gst_state'],
filters = {'is_inter_state': is_inter_state})
filters = {'is_inter_state': is_inter_state, 'is_reverse_charge': 0})
default_tax = ''

View File

@@ -2,8 +2,6 @@
# License: GNU General Public License v3. See license.txt
import unittest
import frappe
from frappe.test_runner import make_test_records
from frappe.utils import flt
@@ -11,7 +9,7 @@ from frappe.utils import flt
from erpnext.accounts.party import get_due_date
from erpnext.exceptions import PartyDisabled, PartyFrozen
from erpnext.selling.doctype.customer.customer import get_credit_limit, get_customer_outstanding
from erpnext.tests.utils import create_test_contact_and_address
from erpnext.tests.utils import ERPNextTestCase, create_test_contact_and_address
test_ignore = ["Price List"]
test_dependencies = ['Payment Term', 'Payment Terms Template']
@@ -20,7 +18,7 @@ test_records = frappe.get_test_records('Customer')
from six import iteritems
class TestCustomer(unittest.TestCase):
class TestCustomer(ERPNextTestCase):
def setUp(self):
if not frappe.get_value('Item', '_Test Item'):
make_test_records('Item')

View File

@@ -6,6 +6,7 @@ import unittest
import frappe
from erpnext.controllers.queries import item_query
from erpnext.tests.utils import ERPNextTestCase
test_dependencies = ['Item', 'Customer', 'Supplier']
@@ -17,7 +18,7 @@ def create_party_specific_item(**args):
psi.based_on_value = args.get('based_on_value')
psi.insert()
class TestPartySpecificItem(unittest.TestCase):
class TestPartySpecificItem(ERPNextTestCase):
def setUp(self):
self.customer = frappe.get_last_doc("Customer")
self.supplier = frappe.get_last_doc("Supplier")

View File

@@ -1,15 +1,15 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import unittest
import frappe
from frappe.utils import add_days, add_months, flt, getdate, nowdate
from erpnext.tests.utils import ERPNextTestCase
test_dependencies = ["Product Bundle"]
class TestQuotation(unittest.TestCase):
class TestQuotation(ERPNextTestCase):
def test_make_quotation_without_terms(self):
quotation = make_quotation(do_not_save=1)
self.assertFalse(quotation.get('payment_schedule'))

View File

@@ -2,7 +2,6 @@
# License: GNU General Public License v3. See license.txt
import json
import unittest
import frappe
import frappe.permissions
@@ -22,12 +21,14 @@ from erpnext.selling.doctype.sales_order.sales_order import (
)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestCase
class TestSalesOrder(unittest.TestCase):
class TestSalesOrder(ERPNextTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.unlink_setting = int(frappe.db.get_value("Accounts Settings", "Accounts Settings",
"unlink_advance_payment_on_cancelation_of_order"))
@@ -36,6 +37,7 @@ class TestSalesOrder(unittest.TestCase):
# reset config to previous state
frappe.db.set_value("Accounts Settings", "Accounts Settings",
"unlink_advance_payment_on_cancelation_of_order", cls.unlink_setting)
super().tearDownClass()
def tearDown(self):
frappe.set_user("Administrator")

View File

@@ -2,8 +2,6 @@
# For license information, please see license.txt
import unittest
from frappe.utils import add_months, nowdate
from erpnext.selling.doctype.sales_order.sales_order import make_material_request
@@ -11,9 +9,10 @@ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_orde
from erpnext.selling.report.pending_so_items_for_purchase_request.pending_so_items_for_purchase_request import (
execute,
)
from erpnext.tests.utils import ERPNextTestCase
class TestPendingSOItemsForPurchaseRequest(unittest.TestCase):
class TestPendingSOItemsForPurchaseRequest(ERPNextTestCase):
def test_result_for_partial_material_request(self):
so = make_sales_order()
mr=make_material_request(so.name)

View File

@@ -2,15 +2,14 @@
# For license information, please see license.txt
import unittest
import frappe
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.selling.report.sales_analytics.sales_analytics import execute
from erpnext.tests.utils import ERPNextTestCase
class TestAnalytics(unittest.TestCase):
class TestAnalytics(ERPNextTestCase):
def test_sales_analytics(self):
frappe.db.sql("delete from `tabSales Order` where company='_Test Company 2'")

View File

@@ -41,6 +41,7 @@ erpnext.selling.SellingController = erpnext.TransactionController.extend({
me.frm.set_query('contact_person', erpnext.queries.contact_query);
me.frm.set_query('customer_address', erpnext.queries.address_query);
me.frm.set_query('shipping_address_name', erpnext.queries.address_query);
me.frm.set_query('dispatch_address_name', erpnext.queries.dispatch_address_query);
if(this.frm.fields_dict.selling_price_list) {

View File

@@ -36,7 +36,7 @@
"abbr": "_TC3",
"company_name": "_Test Company 3",
"is_group": 1,
"country": "India",
"country": "Pakistan",
"default_currency": "INR",
"doctype": "Company",
"domain": "Manufacturing",
@@ -49,7 +49,7 @@
"company_name": "_Test Company 4",
"parent_company": "_Test Company 3",
"is_group": 1,
"country": "India",
"country": "Pakistan",
"default_currency": "INR",
"doctype": "Company",
"domain": "Manufacturing",
@@ -61,7 +61,7 @@
"abbr": "_TC5",
"company_name": "_Test Company 5",
"parent_company": "_Test Company 4",
"country": "India",
"country": "Pakistan",
"default_currency": "INR",
"doctype": "Company",
"domain": "Manufacturing",

View File

@@ -1178,11 +1178,13 @@
{
"title": "Reverse Charge In-State",
"is_inter_state": 0,
"is_reverse_charge": 1,
"gst_state": ""
},
{
"title": "Reverse Charge Out-State",
"is_inter_state": 1,
"is_reverse_charge": 1,
"gst_state": ""
},
{

View File

@@ -33,10 +33,10 @@ class Bin(Document):
in open work orders'''
self.reserved_qty_for_production = frappe.db.sql('''
SELECT
CASE WHEN ifnull(skip_transfer, 0) = 0 THEN
SUM(item.required_qty - item.transferred_qty)
SUM(CASE WHEN ifnull(skip_transfer, 0) = 0 THEN
item.required_qty - item.transferred_qty
ELSE
SUM(item.required_qty - item.consumed_qty)
item.required_qty - item.consumed_qty END)
END
FROM `tabWork Order` pro, `tabWork Order Item` item
WHERE
@@ -103,8 +103,8 @@ def update_stock(bin_name, args, allow_negative_stock=False, via_landed_cost_vou
"""WARNING: This function is deprecated. Inline this function instead of using it."""
from erpnext.stock.stock_ledger import repost_current_voucher
update_qty(bin_name, args)
repost_current_voucher(args, allow_negative_stock, via_landed_cost_voucher)
update_qty(bin_name, args)
def get_bin_details(bin_name):
return frappe.db.get_value('Bin', bin_name, ['actual_qty', 'ordered_qty',
@@ -112,13 +112,23 @@ def get_bin_details(bin_name):
'reserved_qty_for_sub_contract'], as_dict=1)
def update_qty(bin_name, args):
bin_details = get_bin_details(bin_name)
from erpnext.controllers.stock_controller import future_sle_exists
# update the stock values (for current quantities)
if args.get("voucher_type")=="Stock Reconciliation":
actual_qty = args.get('qty_after_transaction')
else:
actual_qty = bin_details.actual_qty + flt(args.get("actual_qty"))
bin_details = get_bin_details(bin_name)
# actual qty is already updated by processing current voucher
actual_qty = bin_details.actual_qty
# actual qty is not up to date in case of backdated transaction
if future_sle_exists(args):
actual_qty = frappe.db.get_value("Stock Ledger Entry",
filters={
"item_code": args.get("item_code"),
"warehouse": args.get("warehouse"),
"is_cancelled": 0
},
fieldname="qty_after_transaction",
order_by="posting_date desc, posting_time desc, creation desc",
) or 0.0
ordered_qty = flt(bin_details.ordered_qty) + flt(args.get("ordered_qty"))
reserved_qty = flt(bin_details.reserved_qty) + flt(args.get("reserved_qty"))

View File

@@ -359,8 +359,7 @@
"fieldname": "valuation_method",
"fieldtype": "Select",
"label": "Valuation Method",
"options": "\nFIFO\nMoving Average",
"set_only_once": 1
"options": "\nFIFO\nMoving Average"
},
{
"depends_on": "is_stock_item",
@@ -956,7 +955,7 @@
"image_field": "image",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-12-03 08:32:03.869294",
"modified": "2021-12-14 04:13:16.857534",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",

View File

@@ -1,451 +1,140 @@
{
"allow_copy": 0,
"allow_import": 0,
"allow_rename": 0,
"autoname": "hash",
"beta": 0,
"creation": "2013-04-08 13:10:16",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "Document",
"editable_grid": 1,
"engine": "InnoDB",
"actions": [],
"autoname": "hash",
"creation": "2013-04-08 13:10:16",
"doctype": "DocType",
"document_type": "Document",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"item_code",
"column_break_2",
"item_name",
"batch_no",
"desc_section",
"description",
"quantity_section",
"qty",
"net_weight",
"column_break_10",
"stock_uom",
"weight_uom",
"page_break",
"dn_detail"
],
"fields": [
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "item_code",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 1,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Item Code",
"length": 0,
"no_copy": 0,
"options": "Item",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "100px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"unique": 0,
"fieldname": "item_code",
"fieldtype": "Link",
"in_global_search": 1,
"in_list_view": 1,
"label": "Item Code",
"options": "Item",
"print_width": "100px",
"reqd": 1,
"width": "100px"
},
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "column_break_2",
"fieldtype": "Column Break",
"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,
"length": 0,
"no_copy": 0,
"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,
"unique": 0
},
"fieldname": "column_break_2",
"fieldtype": "Column Break"
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "item_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": "Item Name",
"length": 0,
"no_copy": 0,
"options": "item_code.item_name",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "200px",
"read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0,
"fetch_from": "item_code.item_name",
"fieldname": "item_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Item Name",
"print_width": "200px",
"read_only": 1,
"width": "200px"
},
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "batch_no",
"fieldtype": "Link",
"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": "Batch No",
"length": 0,
"no_copy": 0,
"options": "Batch",
"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,
"unique": 0
},
"fieldname": "batch_no",
"fieldtype": "Link",
"label": "Batch No",
"options": "Batch"
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 1,
"columns": 0,
"fieldname": "desc_section",
"fieldtype": "Section Break",
"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": "Description",
"length": 0,
"no_copy": 0,
"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,
"unique": 0
},
"collapsible": 1,
"fieldname": "desc_section",
"fieldtype": "Section Break",
"label": "Description"
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "description",
"fieldtype": "Text Editor",
"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": "Description",
"length": 0,
"no_copy": 0,
"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,
"unique": 0
},
"fieldname": "description",
"fieldtype": "Text Editor",
"label": "Description"
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "quantity_section",
"fieldtype": "Section Break",
"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": "Quantity",
"length": 0,
"no_copy": 0,
"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,
"unique": 0
},
"fieldname": "quantity_section",
"fieldtype": "Section Break",
"label": "Quantity"
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "qty",
"fieldtype": "Float",
"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": "Quantity",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "100px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"unique": 0,
"fieldname": "qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Quantity",
"print_width": "100px",
"reqd": 1,
"width": "100px"
},
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "net_weight",
"fieldtype": "Float",
"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": "Net Weight",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "100px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0,
"fieldname": "net_weight",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Net Weight",
"print_width": "100px",
"width": "100px"
},
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "column_break_10",
"fieldtype": "Column Break",
"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,
"length": 0,
"no_copy": 0,
"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,
"unique": 0
},
"fieldname": "column_break_10",
"fieldtype": "Column Break"
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "stock_uom",
"fieldtype": "Link",
"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": "UOM",
"length": 0,
"no_copy": 0,
"options": "UOM",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "100px",
"read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0,
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "UOM",
"options": "UOM",
"print_width": "100px",
"read_only": 1,
"width": "100px"
},
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "weight_uom",
"fieldtype": "Link",
"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": "Weight UOM",
"length": 0,
"no_copy": 0,
"options": "UOM",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "100px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0,
"fieldname": "weight_uom",
"fieldtype": "Link",
"label": "Weight UOM",
"options": "UOM",
"print_width": "100px",
"width": "100px"
},
},
{
"allow_on_submit": 1,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "page_break",
"fieldtype": "Check",
"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": "Page Break",
"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,
"unique": 0
},
"allow_on_submit": 1,
"default": "0",
"fieldname": "page_break",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Page Break"
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "dn_detail",
"fieldtype": "Data",
"hidden": 1,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "DN Detail",
"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,
"unique": 0
"fieldname": "dn_detail",
"fieldtype": "Data",
"hidden": 1,
"in_list_view": 1,
"label": "DN Detail"
}
],
"hide_heading": 0,
"hide_toolbar": 0,
"idx": 1,
"image_view": 0,
"in_create": 0,
"is_submittable": 0,
"issingle": 0,
"istable": 1,
"max_attachments": 0,
"modified": "2018-06-01 07:21:58.220980",
"modified_by": "Administrator",
"module": "Stock",
"name": "Packing Slip Item",
"owner": "Administrator",
"permissions": [],
"quick_entry": 0,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"track_changes": 1,
"track_seen": 0
],
"idx": 1,
"istable": 1,
"links": [],
"modified": "2021-12-14 01:22:00.715935",
"modified_by": "Administrator",
"module": "Stock",
"name": "Packing Slip Item",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -46,7 +46,7 @@ class RepostItemValuation(Document):
self.db_set('status', self.status)
def on_submit(self):
if not frappe.flags.in_test or self.flags.dont_run_in_test:
if not frappe.flags.in_test or self.flags.dont_run_in_test or frappe.flags.dont_execute_stock_reposts:
return
frappe.enqueue(repost, timeout=1800, queue='long',
@@ -97,7 +97,8 @@ def repost(doc):
return
doc.set_status('In Progress')
frappe.db.commit()
if not frappe.flags.in_test:
frappe.db.commit()
repost_sl_entries(doc)
repost_gl_entries(doc)

View File

@@ -39,9 +39,9 @@ def create_test_delivery_note():
"description": 'Test delivery note for shipment',
"qty": 5,
"uom": 'Nos',
"warehouse": 'Stores - SC',
"warehouse": 'Stores - _TC',
"rate": item.standard_rate,
"cost_center": 'Main - SC'
"cost_center": 'Main - _TC'
}
)
delivery_note.insert()
@@ -127,13 +127,7 @@ def get_shipment_company_address(company_name):
return create_shipment_address(address_title, company_name, 80331)
def get_shipment_company():
company_name = 'Shipment Company'
abbr = 'SC'
companies = frappe.get_all("Company", fields=["name"], filters = {"company_name": company_name})
if len(companies):
return companies[0]
else:
return create_shipment_company(company_name, abbr)
return frappe.get_doc("Company", "_Test Company")
def get_shipment_item(company_name):
item_name = 'Testing Shipment item'
@@ -182,17 +176,6 @@ def create_customer_contact(fname, lname):
customer.insert()
return customer
def create_shipment_company(company_name, abbr):
company = frappe.new_doc("Company")
company.company_name = company_name
company.abbr = abbr
company.default_currency = 'EUR'
company.country = 'Germany'
company.enable_perpetual_inventory = 0
company.insert()
return company
def create_shipment_customer(customer_name):
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
@@ -211,12 +194,12 @@ def create_material_receipt(item, company):
stock.posting_date = posting_date.strftime("%Y-%m-%d")
stock.append('items',
{
"t_warehouse": 'Stores - SC',
"t_warehouse": 'Stores - _TC',
"item_code": item.name,
"qty": 5,
"uom": 'Nos',
"basic_rate": item.standard_rate,
"cost_center": 'Main - SC'
"cost_center": 'Main - _TC'
}
)
stock.insert()
@@ -233,7 +216,7 @@ def create_shipment_item(item_name, company_name):
item.append('item_defaults',
{
"company": company_name,
"default_warehouse": 'Stores - SC'
"default_warehouse": 'Stores - _TC'
}
)
item.insert()

View File

@@ -36,10 +36,16 @@ from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle, get
from erpnext.stock.utils import get_bin, get_incoming_rate
class IncorrectValuationRateError(frappe.ValidationError): pass
class DuplicateEntryForWorkOrderError(frappe.ValidationError): pass
class OperationsNotCompleteError(frappe.ValidationError): pass
class MaxSampleAlreadyRetainedError(frappe.ValidationError): pass
class FinishedGoodError(frappe.ValidationError):
pass
class IncorrectValuationRateError(frappe.ValidationError):
pass
class DuplicateEntryForWorkOrderError(frappe.ValidationError):
pass
class OperationsNotCompleteError(frappe.ValidationError):
pass
class MaxSampleAlreadyRetainedError(frappe.ValidationError):
pass
from erpnext.controllers.stock_controller import StockController
@@ -702,6 +708,11 @@ class StockEntry(StockController):
finished_item = self.get_finished_item()
if not finished_item and self.purpose == "Manufacture":
# In case of independent Manufacture entry, don't auto set
# user must decide and set
return
for d in self.items:
if d.t_warehouse and not d.s_warehouse:
if self.purpose=="Repack" or d.item_code == finished_item:
@@ -722,38 +733,64 @@ class StockEntry(StockController):
return finished_item
def validate_finished_goods(self):
"""validation: finished good quantity should be same as manufacturing quantity"""
if not self.work_order: return
"""
1. Check if FG exists
2. Check if Multiple FG Items are present
3. Check FG Item and Qty against WO if present
"""
production_item, wo_qty, finished_items = None, 0, []
production_item, wo_qty = frappe.db.get_value("Work Order",
self.work_order, ["production_item", "qty"])
wo_details = frappe.db.get_value(
"Work Order", self.work_order, ["production_item", "qty"]
)
if wo_details:
production_item, wo_qty = wo_details
finished_items = []
for d in self.get('items'):
if d.is_finished_item:
if not self.work_order:
finished_items.append(d.item_code)
continue # Independent Manufacture Entry, no WO to match against
if d.item_code != production_item:
frappe.throw(_("Finished Item {0} does not match with Work Order {1}")
.format(d.item_code, self.work_order))
.format(d.item_code, self.work_order)
)
elif flt(d.transfer_qty) > flt(self.fg_completed_qty):
frappe.throw(_("Quantity in row {0} ({1}) must be same as manufactured quantity {2}"). \
format(d.idx, d.transfer_qty, self.fg_completed_qty))
frappe.throw(_("Quantity in row {0} ({1}) must be same as manufactured quantity {2}")
.format(d.idx, d.transfer_qty, self.fg_completed_qty)
)
finished_items.append(d.item_code)
if len(set(finished_items)) > 1:
frappe.throw(_("Multiple items cannot be marked as finished item"))
frappe.throw(
msg=_("Multiple items cannot be marked as finished item"),
title=_("Note"),
exc=FinishedGoodError
)
if self.purpose == "Manufacture":
if not finished_items:
frappe.throw(_('Finished Good has not set in the stock entry {0}')
.format(self.name))
frappe.throw(
msg=_("There must be atleast 1 Finished Good in this Stock Entry").format(self.name),
title=_("Missing Finished Good"),
exc=FinishedGoodError
)
allowance_percentage = flt(frappe.db.get_single_value("Manufacturing Settings",
"overproduction_percentage_for_work_order"))
allowance_percentage = flt(
frappe.db.get_single_value(
"Manufacturing Settings","overproduction_percentage_for_work_order"
)
)
allowed_qty = wo_qty + ((allowance_percentage/100) * wo_qty)
allowed_qty = wo_qty + (allowance_percentage/100 * wo_qty)
if self.fg_completed_qty > allowed_qty:
frappe.throw(_("For quantity {0} should not be greater than work order quantity {1}")
.format(flt(self.fg_completed_qty), wo_qty))
# No work order could mean independent Manufacture entry, if so skip validation
if self.work_order and self.fg_completed_qty > allowed_qty:
frappe.throw(
_("For quantity {0} should not be greater than work order quantity {1}")
.format(flt(self.fg_completed_qty), wo_qty)
)
def update_stock_ledger(self):
sl_entries = []

View File

@@ -16,7 +16,10 @@ from erpnext.stock.doctype.item.test_item import (
set_item_variant_settings,
)
from erpnext.stock.doctype.serial_no.serial_no import * # noqa
from erpnext.stock.doctype.stock_entry.stock_entry import move_sample_to_retention_warehouse
from erpnext.stock.doctype.stock_entry.stock_entry import (
FinishedGoodError,
move_sample_to_retention_warehouse,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import StockFreezeError
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
@@ -1007,6 +1010,38 @@ class TestStockEntry(ERPNextTestCase):
posting_date='2021-09-02', # backdated consumption of 2nd batch
purpose='Material Issue')
def test_independent_manufacture_entry(self):
"Test FG items and incoming rate calculation in Maniufacture Entry without WO or BOM linked."
se = frappe.get_doc(
doctype="Stock Entry",
purpose="Manufacture",
stock_entry_type="Manufacture",
company="_Test Company",
items=[
frappe._dict(item_code="_Test Item", qty=1, basic_rate=200, s_warehouse="_Test Warehouse - _TC"),
frappe._dict(item_code="_Test FG Item", qty=4, t_warehouse="_Test Warehouse 1 - _TC")
]
)
# SE must have atleast one FG
self.assertRaises(FinishedGoodError, se.save)
se.items[0].is_finished_item = 1
se.items[1].is_finished_item = 1
# SE cannot have multiple FGs
self.assertRaises(FinishedGoodError, se.save)
se.items[0].is_finished_item = 0
se.save()
# Check if FG cost is calculated based on RM total cost
# RM total cost = 200, FG rate = 200/4(FG qty) = 50
self.assertEqual(se.items[1].basic_rate, 50)
self.assertEqual(se.value_difference, 0.0)
self.assertEqual(se.total_incoming_value, se.total_outgoing_value)
# teardown
se.delete()
def make_serialized_item(**args):
args = frappe._dict(args)
se = frappe.copy_doc(test_records[0])

View File

@@ -24,11 +24,15 @@ from erpnext.tests.utils import ERPNextTestCase, change_settings
class TestStockReconciliation(ERPNextTestCase):
@classmethod
def setUpClass(self):
def setUpClass(cls):
super().setUpClass()
create_batch_or_serial_no_items()
frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1)
def tearDown(self):
frappe.flags.dont_execute_stock_reposts = None
def test_reco_for_fifo(self):
self._test_reco_sle_gle("FIFO")
@@ -392,6 +396,41 @@ class TestStockReconciliation(ERPNextTestCase):
repost_exists = bool(frappe.db.exists("Repost Item Valuation", {"voucher_no": sr.name}))
self.assertFalse(repost_exists, msg="Negative stock validation not working on reco cancellation")
def test_intermediate_sr_bin_update(self):
"""Bin should show correct qty even for backdated entries.
-------------------------------------------
| creation | Var | Doc | Qty | balance qty
-------------------------------------------
| 1 | SR | Reco | 10 | 10 (posting date: today+10)
| 3 | SR2 | Reco | 11 | 11 (posting date: today+11)
| 2 | DN | DN | 5 | 6 <-- assert in BIN (posting date: today+12)
"""
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
# repost will make this test useless, qty should update in realtime without reposts
frappe.flags.dont_execute_stock_reposts = True
frappe.db.rollback()
item_code = "Backdated-Reco-Cancellation-Item"
warehouse = "_Test Warehouse - _TC"
create_item(item_code)
create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=10, rate=100,
posting_date=add_days(nowdate(), 10))
create_delivery_note(item_code=item_code, warehouse=warehouse, qty=5, rate=120,
posting_date=add_days(nowdate(), 12))
old_bin_qty = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty")
create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=11, rate=100,
posting_date=add_days(nowdate(), 11))
new_bin_qty = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty")
self.assertEqual(old_bin_qty + 1, new_bin_qty)
frappe.db.rollback()
def test_valid_batch(self):
create_batch_item_with_batch("Testing Batch Item 1", "001")
create_batch_item_with_batch("Testing Batch Item 2", "002")

View File

@@ -1098,7 +1098,7 @@ def apply_price_list(args, as_doc=False):
}
def apply_price_list_on_item(args):
item_doc = frappe.get_doc("Item", args.item_code)
item_doc = frappe.db.get_value("Item", args.item_code, ['name', 'variant_of'], as_dict=1)
item_details = get_price_list_rate(args, item_doc)
item_details.update(get_pricing_rule_for_item(args, item_details.price_list_rate))

View File

@@ -74,7 +74,7 @@ def get_stock_ledger_entries(report_filters):
fields = ['name', 'voucher_type', 'voucher_no', 'item_code', 'serial_no as serial_nos', 'actual_qty',
'posting_date', 'posting_time', 'company', 'warehouse', '(stock_value_difference / actual_qty) as valuation_rate']
filters = {'serial_no': ("is", "set")}
filters = {'serial_no': ("is", "set"), "is_cancelled": 0}
if report_filters.get('item_code'):
filters['item_code'] = report_filters.get('item_code')

View File

@@ -48,6 +48,7 @@ def get_item_info(filters):
conditions = [get_item_group_condition(filters.get("item_group"))]
if filters.get("brand"):
conditions.append("item.brand=%(brand)s")
conditions.append("is_stock_item = 1")
return frappe.db.sql("""select name, item_name, description, brand, item_group,
safety_stock, lead_time_days from `tabItem` item where {}"""

View File

@@ -3,27 +3,37 @@
from operator import itemgetter
from typing import Dict, List, Tuple, Union
import frappe
from frappe import _
from frappe.utils import cint, date_diff, flt
from six import iteritems
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
Filters = frappe._dict
def execute(filters=None):
columns = get_columns(filters)
item_details = get_fifo_queue(filters)
def execute(filters: Filters = None) -> Tuple:
to_date = filters["to_date"]
_func = itemgetter(1)
columns = get_columns(filters)
item_details = FIFOSlots(filters).generate()
data = format_report_data(filters, item_details, to_date)
chart_data = get_chart_data(data, filters)
return columns, data, None, chart_data
def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> List[Dict]:
"Returns ordered, formatted data with ranges."
_func = itemgetter(1)
data = []
for item, item_dict in iteritems(item_details):
for item, item_dict in item_details.items():
earliest_age, latest_age = 0, 0
details = item_dict["details"]
fifo_queue = sorted(filter(_func, item_dict["fifo_queue"]), key=_func)
details = item_dict["details"]
if not fifo_queue: continue
@@ -32,23 +42,22 @@ def execute(filters=None):
latest_age = date_diff(to_date, fifo_queue[-1][1])
range1, range2, range3, above_range3 = get_range_age(filters, fifo_queue, to_date, item_dict)
row = [details.name, details.item_name,
details.description, details.item_group, details.brand]
row = [details.name, details.item_name, details.description,
details.item_group, details.brand]
if filters.get("show_warehouse_wise_stock"):
row.append(details.warehouse)
row.extend([item_dict.get("total_qty"), average_age,
range1, range2, range3, above_range3,
earliest_age, latest_age, details.stock_uom])
earliest_age, latest_age,
details.stock_uom])
data.append(row)
chart_data = get_chart_data(data, filters)
return data
return columns, data, None, chart_data
def get_average_age(fifo_queue, to_date):
def get_average_age(fifo_queue: List, to_date: str) -> float:
batch_age = age_qty = total_qty = 0.0
for batch in fifo_queue:
batch_age = date_diff(to_date, batch[1])
@@ -62,7 +71,7 @@ def get_average_age(fifo_queue, to_date):
return flt(age_qty / total_qty, 2) if total_qty else 0.0
def get_range_age(filters, fifo_queue, to_date, item_dict):
def get_range_age(filters: Filters, fifo_queue: List, to_date: str, item_dict: Dict) -> Tuple:
range1 = range2 = range3 = above_range3 = 0.0
for item in fifo_queue:
@@ -80,7 +89,7 @@ def get_range_age(filters, fifo_queue, to_date, item_dict):
return range1, range2, range3, above_range3
def get_columns(filters):
def get_columns(filters: Filters) -> List[Dict]:
range_columns = []
setup_ageing_columns(filters, range_columns)
columns = [
@@ -165,106 +174,7 @@ def get_columns(filters):
return columns
def get_fifo_queue(filters, sle=None):
item_details = {}
transferred_item_details = {}
serial_no_batch_purchase_details = {}
if sle == None:
sle = get_stock_ledger_entries(filters)
for d in sle:
key = (d.name, d.warehouse) if filters.get('show_warehouse_wise_stock') else d.name
item_details.setdefault(key, {"details": d, "fifo_queue": []})
fifo_queue = item_details[key]["fifo_queue"]
transferred_item_key = (d.voucher_no, d.name, d.warehouse)
transferred_item_details.setdefault(transferred_item_key, [])
if d.voucher_type == "Stock Reconciliation":
d.actual_qty = flt(d.qty_after_transaction) - flt(item_details[key].get("qty_after_transaction", 0))
serial_no_list = get_serial_nos(d.serial_no) if d.serial_no else []
if d.actual_qty > 0:
if transferred_item_details.get(transferred_item_key):
batch = transferred_item_details[transferred_item_key][0]
fifo_queue.append(batch)
transferred_item_details[transferred_item_key].pop(0)
else:
if serial_no_list:
for serial_no in serial_no_list:
if serial_no_batch_purchase_details.get(serial_no):
fifo_queue.append([serial_no, serial_no_batch_purchase_details.get(serial_no)])
else:
serial_no_batch_purchase_details.setdefault(serial_no, d.posting_date)
fifo_queue.append([serial_no, d.posting_date])
else:
fifo_queue.append([d.actual_qty, d.posting_date])
else:
if serial_no_list:
fifo_queue[:] = [serial_no for serial_no in fifo_queue if serial_no[0] not in serial_no_list]
else:
qty_to_pop = abs(d.actual_qty)
while qty_to_pop:
batch = fifo_queue[0] if fifo_queue else [0, None]
if 0 < flt(batch[0]) <= qty_to_pop:
# if batch qty > 0
# not enough or exactly same qty in current batch, clear batch
qty_to_pop -= flt(batch[0])
transferred_item_details[transferred_item_key].append(fifo_queue.pop(0))
else:
# all from current batch
batch[0] = flt(batch[0]) - qty_to_pop
transferred_item_details[transferred_item_key].append([qty_to_pop, batch[1]])
qty_to_pop = 0
item_details[key]["qty_after_transaction"] = d.qty_after_transaction
if "total_qty" not in item_details[key]:
item_details[key]["total_qty"] = d.actual_qty
else:
item_details[key]["total_qty"] += d.actual_qty
item_details[key]["has_serial_no"] = d.has_serial_no
return item_details
def get_stock_ledger_entries(filters):
return frappe.db.sql("""select
item.name, item.item_name, item_group, brand, description, item.stock_uom, item.has_serial_no,
actual_qty, posting_date, voucher_type, voucher_no, serial_no, batch_no, qty_after_transaction, warehouse
from `tabStock Ledger Entry` sle,
(select name, item_name, description, stock_uom, brand, item_group, has_serial_no
from `tabItem` {item_conditions}) item
where item_code = item.name and
company = %(company)s and
posting_date <= %(to_date)s and
is_cancelled != 1
{sle_conditions}
order by posting_date, posting_time, sle.creation, actual_qty""" #nosec
.format(item_conditions=get_item_conditions(filters),
sle_conditions=get_sle_conditions(filters)), filters, as_dict=True)
def get_item_conditions(filters):
conditions = []
if filters.get("item_code"):
conditions.append("item_code=%(item_code)s")
if filters.get("brand"):
conditions.append("brand=%(brand)s")
return "where {}".format(" and ".join(conditions)) if conditions else ""
def get_sle_conditions(filters):
conditions = []
if filters.get("warehouse"):
lft, rgt = frappe.db.get_value('Warehouse', filters.get("warehouse"), ['lft', 'rgt'])
conditions.append("""warehouse in (select wh.name from `tabWarehouse` wh
where wh.lft >= {0} and rgt <= {1})""".format(lft, rgt))
return "and {}".format(" and ".join(conditions)) if conditions else ""
def get_chart_data(data, filters):
def get_chart_data(data: List, filters: Filters) -> Dict:
if not data:
return []
@@ -295,17 +205,201 @@ def get_chart_data(data, filters):
"type" : "bar"
}
def setup_ageing_columns(filters, range_columns):
for i, label in enumerate(["0-{range1}".format(range1=filters["range1"]),
"{range1}-{range2}".format(range1=cint(filters["range1"])+ 1, range2=filters["range2"]),
"{range2}-{range3}".format(range2=cint(filters["range2"])+ 1, range3=filters["range3"]),
"{range3}-{above}".format(range3=cint(filters["range3"])+ 1, above=_("Above"))]):
add_column(range_columns, label="Age ("+ label +")", fieldname='range' + str(i+1))
def setup_ageing_columns(filters: Filters, range_columns: List):
ranges = [
f"0 - {filters['range1']}",
f"{cint(filters['range1']) + 1} - {cint(filters['range2'])}",
f"{cint(filters['range2']) + 1} - {cint(filters['range3'])}",
f"{cint(filters['range3']) + 1} - {_('Above')}"
]
for i, label in enumerate(ranges):
fieldname = 'range' + str(i+1)
add_column(range_columns, label=f"Age ({label})",fieldname=fieldname)
def add_column(range_columns, label, fieldname, fieldtype='Float', width=140):
def add_column(range_columns: List, label:str, fieldname: str, fieldtype: str = 'Float', width: int = 140):
range_columns.append(dict(
label=label,
fieldname=fieldname,
fieldtype=fieldtype,
width=width
))
class FIFOSlots:
"Returns FIFO computed slots of inwarded stock as per date."
def __init__(self, filters: Dict = None , sle: List = None):
self.item_details = {}
self.transferred_item_details = {}
self.serial_no_batch_purchase_details = {}
self.filters = filters
self.sle = sle
def generate(self) -> Dict:
"""
Returns dict of the foll.g structure:
Key = Item A / (Item A, Warehouse A)
Key: {
'details' -> Dict: ** item details **,
'fifo_queue' -> List: ** list of lists containing entries/slots for existing stock,
consumed/updated and maintained via FIFO. **
}
"""
if self.sle is None:
self.sle = self.__get_stock_ledger_entries()
for d in self.sle:
key, fifo_queue, transferred_item_key = self.__init_key_stores(d)
if d.voucher_type == "Stock Reconciliation":
prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0)
d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty)
serial_nos = get_serial_nos(d.serial_no) if d.serial_no else []
if d.actual_qty > 0:
self.__compute_incoming_stock(d, fifo_queue, transferred_item_key, serial_nos)
else:
self.__compute_outgoing_stock(d, fifo_queue, transferred_item_key, serial_nos)
self.__update_balances(d, key)
return self.item_details
def __init_key_stores(self, row: Dict) -> Tuple:
"Initialise keys and FIFO Queue."
key = (row.name, row.warehouse) if self.filters.get('show_warehouse_wise_stock') else row.name
self.item_details.setdefault(key, {"details": row, "fifo_queue": []})
fifo_queue = self.item_details[key]["fifo_queue"]
transferred_item_key = (row.voucher_no, row.name, row.warehouse)
self.transferred_item_details.setdefault(transferred_item_key, [])
return key, fifo_queue, transferred_item_key
def __compute_incoming_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List):
"Update FIFO Queue on inward stock."
if self.transferred_item_details.get(transfer_key):
# inward/outward from same voucher, item & warehouse
slot = self.transferred_item_details[transfer_key].pop(0)
fifo_queue.append(slot)
else:
if not serial_nos:
if fifo_queue and flt(fifo_queue[0][0]) < 0:
# neutralize negative stock by adding positive stock
fifo_queue[0][0] += flt(row.actual_qty)
fifo_queue[0][1] = row.posting_date
else:
fifo_queue.append([flt(row.actual_qty), row.posting_date])
return
for serial_no in serial_nos:
if self.serial_no_batch_purchase_details.get(serial_no):
fifo_queue.append([serial_no, self.serial_no_batch_purchase_details.get(serial_no)])
else:
self.serial_no_batch_purchase_details.setdefault(serial_no, row.posting_date)
fifo_queue.append([serial_no, row.posting_date])
def __compute_outgoing_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List):
"Update FIFO Queue on outward stock."
if serial_nos:
fifo_queue[:] = [serial_no for serial_no in fifo_queue if serial_no[0] not in serial_nos]
return
qty_to_pop = abs(row.actual_qty)
while qty_to_pop:
slot = fifo_queue[0] if fifo_queue else [0, None]
if 0 < flt(slot[0]) <= qty_to_pop:
# qty to pop >= slot qty
# if +ve and not enough or exactly same balance in current slot, consume whole slot
qty_to_pop -= flt(slot[0])
self.transferred_item_details[transfer_key].append(fifo_queue.pop(0))
elif not fifo_queue:
# negative stock, no balance but qty yet to consume
fifo_queue.append([-(qty_to_pop), row.posting_date])
self.transferred_item_details[transfer_key].append([row.actual_qty, row.posting_date])
qty_to_pop = 0
else:
# qty to pop < slot qty, ample balance
# consume actual_qty from first slot
slot[0] = flt(slot[0]) - qty_to_pop
self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1]])
qty_to_pop = 0
def __update_balances(self, row: Dict, key: Union[Tuple, str]):
self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction
if "total_qty" not in self.item_details[key]:
self.item_details[key]["total_qty"] = row.actual_qty
else:
self.item_details[key]["total_qty"] += row.actual_qty
self.item_details[key]["has_serial_no"] = row.has_serial_no
def __get_stock_ledger_entries(self) -> List[Dict]:
sle = frappe.qb.DocType("Stock Ledger Entry")
item = self.__get_item_query() # used as derived table in sle query
sle_query = (
frappe.qb.from_(sle).from_(item)
.select(
item.name, item.item_name, item.item_group,
item.brand, item.description,
item.stock_uom, item.has_serial_no,
sle.actual_qty, sle.posting_date,
sle.voucher_type, sle.voucher_no,
sle.serial_no, sle.batch_no,
sle.qty_after_transaction, sle.warehouse
).where(
(sle.item_code == item.name)
& (sle.company == self.filters.get("company"))
& (sle.posting_date <= self.filters.get("to_date"))
& (sle.is_cancelled != 1)
)
)
if self.filters.get("warehouse"):
sle_query = self.__get_warehouse_conditions(sle, sle_query)
sle_query = sle_query.orderby(
sle.posting_date, sle.posting_time, sle.creation, sle.actual_qty
)
return sle_query.run(as_dict=True)
def __get_item_query(self) -> str:
item_table = frappe.qb.DocType("Item")
item = frappe.qb.from_("Item").select(
"name", "item_name", "description", "stock_uom",
"brand", "item_group", "has_serial_no"
)
if self.filters.get("item_code"):
item = item.where(item_table.item_code == self.filters.get("item_code"))
if self.filters.get("brand"):
item = item.where(item_table.brand == self.filters.get("brand"))
return item
def __get_warehouse_conditions(self, sle, sle_query) -> str:
warehouse = frappe.qb.DocType("Warehouse")
lft, rgt = frappe.db.get_value(
"Warehouse",
self.filters.get("warehouse"),
['lft', 'rgt']
)
warehouse_results = (
frappe.qb.from_(warehouse)
.select("name").where(
(warehouse.lft >= lft)
& (warehouse.rgt <= rgt)
).run()
)
warehouse_results = [x[0] for x in warehouse_results]
return sle_query.where(sle.warehouse.isin(warehouse_results))

View File

@@ -0,0 +1,73 @@
### Concept of FIFO Slots
Since we need to know age-wise remaining stock, we maintain all the inward entries as slots. So each time stock comes in, a slot is added for the same.
Eg. For Item A:
----------------------
Date | Qty | Queue
----------------------
1st | +50 | [[50, 1-12-2021]]
2nd | +20 | [[50, 1-12-2021], [20, 2-12-2021]]
----------------------
Now the queue can tell us the total stock and also how old the stock is.
Here, the balance qty is 70.
50 qty is (today-the 1st) days old
20 qty is (today-the 2nd) days old
### Calculation of FIFO Slots
#### Case 1: Outward from sufficient balance qty
----------------------
Date | Qty | Queue
----------------------
1st | +50 | [[50, 1-12-2021]]
2nd | -20 | [[30, 1-12-2021]]
2nd | +20 | [[30, 1-12-2021], [20, 2-12-2021]]
Here after the first entry, while issuing 20 qty:
- **since 20 is lesser than the balance**, **qty_to_pop (20)** is simply consumed from first slot (FIFO consumption)
- Any inward entry after as usual will get its own slot added to the queue
#### Case 2: Outward from sufficient cumulative (slots) balance qty
----------------------
Date | Qty | Queue
----------------------
1st | +50 | [[50, 1-12-2021]]
2nd | +20 | [[50, 1-12-2021], [20, 2-12-2021]]
2nd | -60 | [[10, 2-12-2021]]
- Consumption happens slot wise. First slot 1 is consumed
- Since **qty_to_pop (60) is greater than slot 1 qty (50)**, the entire slot is consumed and popped
- Now the queue is [[20, 2-12-2021]], and **qty_to_pop=10** (remaining qty to pop)
- It then goes ahead to the next slot and consumes 10 from it
- Now the queue is [[10, 2-12-2021]]
#### Case 3: Outward from insufficient balance qty
> This case is possible only if **Allow Negative Stock** was enabled at some point/is enabled.
----------------------
Date | Qty | Queue
----------------------
1st | +50 | [[50, 1-12-2021]]
2nd | -60 | [[-10, 1-12-2021]]
- Since **qty_to_pop (60)** is more than the balance in slot 1, the entire slot is consumed and popped
- Now the queue is **empty**, and **qty_to_pop=10** (remaining qty to pop)
- Since we still have more to consume, we append the balance since 60 is issued from 50 i.e. -10.
- We register this negative value, since the stock issue has caused the balance to become negative
Now when stock is inwarded:
- Instead of adding a slot we check if there are any negative balances.
- If yes, we keep adding positive stock to it until we make the balance positive.
- Once the balance is positive, the next inward entry will add a new slot in the queue
Eg:
----------------------
Date | Qty | Queue
----------------------
1st | +50 | [[50, 1-12-2021]]
2nd | -60 | [[-10, 1-12-2021]]
3rd | +5 | [[-5, 3-12-2021]]
4th | +10 | [[5, 4-12-2021]]
4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]]

View File

@@ -0,0 +1,126 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots
from erpnext.tests.utils import ERPNextTestCase
class TestStockAgeing(ERPNextTestCase):
def setUp(self) -> None:
self.filters = frappe._dict(
company="_Test Company",
to_date="2021-12-10"
)
def test_normal_inward_outward_queue(self):
"Reference: Case 1 in stock_ageing_fifo_logic.md"
sle = [
frappe._dict(
name="Flask Item",
actual_qty=30, qty_after_transaction=30,
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=20, qty_after_transaction=50,
posting_date="2021-12-02", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-10), qty_after_transaction=40,
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
)
]
slots = FIFOSlots(self.filters, sle).generate()
self.assertTrue(slots["Flask Item"]["fifo_queue"])
result = slots["Flask Item"]
queue = result["fifo_queue"]
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
self.assertEqual(queue[0][0], 20.0)
def test_insufficient_balance(self):
"Reference: Case 3 in stock_ageing_fifo_logic.md"
sle = [
frappe._dict(
name="Flask Item",
actual_qty=(-30), qty_after_transaction=(-30),
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=20, qty_after_transaction=(-10),
posting_date="2021-12-02", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=20, qty_after_transaction=10,
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=10, qty_after_transaction=20,
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="004",
has_serial_no=False, serial_no=None
)
]
slots = FIFOSlots(self.filters, sle).generate()
result = slots["Flask Item"]
queue = result["fifo_queue"]
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
self.assertEqual(queue[0][0], 10.0)
self.assertEqual(queue[1][0], 10.0)
def test_stock_reconciliation(self):
sle = [
frappe._dict(
name="Flask Item",
actual_qty=30, qty_after_transaction=30,
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=0, qty_after_transaction=50,
posting_date="2021-12-02", voucher_type="Stock Reconciliation",
voucher_no="002",
has_serial_no=False, serial_no=None
),
frappe._dict(
name="Flask Item",
actual_qty=(-10), qty_after_transaction=40,
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
)
]
slots = FIFOSlots(self.filters, sle).generate()
result = slots["Flask Item"]
queue = result["fifo_queue"]
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
self.assertEqual(queue[0][0], 20.0)
self.assertEqual(queue[1][0], 20.0)

View File

@@ -10,7 +10,7 @@ from frappe.utils import cint, date_diff, flt, getdate
from six import iteritems
import erpnext
from erpnext.stock.report.stock_ageing.stock_ageing import get_average_age, get_fifo_queue
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age
from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition
from erpnext.stock.utils import add_additional_uom_columns, is_reposting_item_valuation_in_progress
@@ -34,7 +34,7 @@ def execute(filters=None):
if filters.get('show_stock_ageing_data'):
filters['show_warehouse_wise_stock'] = True
item_wise_fifo_queue = get_fifo_queue(filters, sle)
item_wise_fifo_queue = FIFOSlots(filters, sle).generate()
# if no stock ledger entry found return
if not sle:
@@ -168,7 +168,7 @@ def get_stock_ledger_entries(filters, items):
sle.company, sle.voucher_type, sle.qty_after_transaction, sle.stock_value_difference,
sle.item_code as name, sle.voucher_no, sle.stock_value, sle.batch_no
from
`tabStock Ledger Entry` sle force index (posting_sort_index)
`tabStock Ledger Entry` sle
where sle.docstatus < 2 %s %s
and is_cancelled = 0
order by sle.posting_date, sle.posting_time, sle.creation, sle.actual_qty""" % #nosec

View File

@@ -8,7 +8,8 @@ const DIFFERNCE_FIELD_NAMES = [
"fifo_value_diff",
"fifo_valuation_diff",
"valuation_diff",
"fifo_difference_diff"
"fifo_difference_diff",
"diff_value_diff"
];
frappe.query_reports["Stock Ledger Invariant Check"] = {

View File

@@ -50,6 +50,7 @@ def get_stock_ledger_entries(filters):
def add_invariant_check_fields(sles):
balance_qty = 0.0
balance_stock_value = 0.0
for idx, sle in enumerate(sles):
queue = json.loads(sle.stock_queue)
@@ -60,6 +61,7 @@ def add_invariant_check_fields(sles):
fifo_value += qty * rate
balance_qty += sle.actual_qty
balance_stock_value += sle.stock_value_difference
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no:
balance_qty = sle.qty_after_transaction
@@ -70,6 +72,7 @@ def add_invariant_check_fields(sles):
sle.stock_value / sle.qty_after_transaction if sle.qty_after_transaction else None
)
sle.expected_qty_after_transaction = balance_qty
sle.stock_value_from_diff = balance_stock_value
# set difference fields
sle.difference_in_qty = sle.qty_after_transaction - sle.expected_qty_after_transaction
@@ -81,6 +84,7 @@ def add_invariant_check_fields(sles):
sle.valuation_diff = (
sle.valuation_rate - sle.balance_value_by_qty if sle.balance_value_by_qty else None
)
sle.diff_value_diff = sle.stock_value_from_diff - sle.stock_value
if idx > 0:
sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value
@@ -191,12 +195,21 @@ def get_columns():
"fieldtype": "Float",
"label": "D - E",
},
{
"fieldname": "stock_value_difference",
"fieldtype": "Float",
"label": "(F) Stock Value Difference",
},
{
"fieldname": "stock_value_from_diff",
"fieldtype": "Float",
"label": "Balance Stock Value using (F)",
},
{
"fieldname": "diff_value_diff",
"fieldtype": "Float",
"label": "K - D",
},
{
"fieldname": "fifo_stock_diff",
"fieldtype": "Float",

View File

@@ -10,7 +10,7 @@ from frappe import _
from frappe.utils import flt
from six import iteritems
from erpnext.stock.report.stock_ageing.stock_ageing import get_average_age, get_fifo_queue
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age
from erpnext.stock.report.stock_balance.stock_balance import (
get_item_details,
get_item_warehouse_map,
@@ -34,7 +34,7 @@ def execute(filters=None):
item_map = get_item_details(items, sle, filters)
iwb_map = get_item_warehouse_map(filters, sle)
warehouse_list = get_warehouse_list(filters)
item_ageing = get_fifo_queue(filters)
item_ageing = FIFOSlots(filters).generate()
data = []
item_balance = {}
item_value = {}

View File

@@ -65,8 +65,8 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
is_stock_item = frappe.get_cached_value('Item', args.get("item_code"), 'is_stock_item')
if is_stock_item:
bin_name = get_or_make_bin(args.get("item_code"), args.get("warehouse"))
update_bin_qty(bin_name, args)
repost_current_voucher(args, allow_negative_stock, via_landed_cost_voucher)
update_bin_qty(bin_name, args)
else:
frappe.msgprint(_("Item {0} ignored since it is not a stock item").format(args.get("item_code")))

View File

@@ -87,8 +87,8 @@ def get_stock_balance(item_code, warehouse, posting_date=None, posting_time=None
from erpnext.stock.stock_ledger import get_previous_sle
if not posting_date: posting_date = nowdate()
if not posting_time: posting_time = nowtime()
if posting_date is None: posting_date = nowdate()
if posting_time is None: posting_time = nowtime()
args = {
"item_code": item_code,

View File

@@ -10,13 +10,8 @@ test_records = frappe.get_test_records('Company')
class TestInit(unittest.TestCase):
def test_encode_company_abbr(self):
company = frappe.new_doc("Company")
company.company_name = "New from Existing Company For Test"
company.abbr = "NFECT"
company.default_currency = "INR"
company.save()
abbr = company.abbr
abbr = "NFECT"
names = [
"Warehouse Name", "ERPNext Foundation India", "Gold - Member - {a}".format(a=abbr),
@@ -34,7 +29,7 @@ class TestInit(unittest.TestCase):
]
for i in range(len(names)):
enc_name = encode_company_abbr(names[i], company.name)
enc_name = encode_company_abbr(names[i], abbr=abbr)
self.assertTrue(
enc_name == expected_names[i],
"{enc} is not same as {exp}".format(enc=enc_name, exp=expected_names[i])

View File

@@ -2,6 +2,7 @@
# License: GNU General Public License v3. See license.txt
import copy
import signal
import unittest
from contextlib import contextmanager
from typing import Any, Dict, NewType, Optional
@@ -135,3 +136,23 @@ def execute_script_report(
report_execute_fn(filter_with_optional_param)
return report_data
def timeout(seconds=30, error_message="Test timed out."):
""" Timeout decorator to ensure a test doesn't run for too long.
adapted from https://stackoverflow.com/a/2282656"""
def decorator(func):
def _handle_timeout(signum, frame):
raise Exception(error_message)
def wrapper(*args, **kwargs):
signal.signal(signal.SIGALRM, _handle_timeout)
signal.alarm(seconds)
try:
result = func(*args, **kwargs)
finally:
signal.alarm(0)
return result
return wrapper
return decorator

View File

@@ -69,6 +69,8 @@ def qty_from_all_warehouses(batch_info):
return qty
def get_price(item_code, price_list, customer_group, company, qty=1):
from erpnext.e_commerce.shopping_cart.cart import get_party
template_item_code = frappe.db.get_value("Item", item_code, "variant_of")
if price_list:
@@ -80,7 +82,8 @@ def get_price(item_code, price_list, customer_group, company, qty=1):
filters={"price_list": price_list, "item_code": template_item_code})
if price:
pricing_rule = get_pricing_rule_for_item(frappe._dict({
party = get_party()
pricing_rule_dict = frappe._dict({
"item_code": item_code,
"qty": qty,
"stock_qty": qty,
@@ -91,7 +94,12 @@ def get_price(item_code, price_list, customer_group, company, qty=1):
"conversion_rate": 1,
"for_shopping_cart": True,
"currency": frappe.db.get_value("Price List", price_list, "currency")
}))
})
if party and party.doctype == "Customer":
pricing_rule_dict.update({"customer": party.name})
pricing_rule = get_pricing_rule_for_item(pricing_rule_dict)
price_obj = price[0]
if pricing_rule: