mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-06 19:23:06 +00:00
Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6b2fb2f96 | ||
|
|
a6b7142c18 | ||
|
|
6796617921 | ||
|
|
c65d768020 | ||
|
|
7200c22890 | ||
|
|
aa94c3ff22 | ||
|
|
87c6ad4f85 | ||
|
|
35b4ada3e2 | ||
|
|
ad511b80c0 | ||
|
|
ad55c7c372 | ||
|
|
689a3f50ae | ||
|
|
96bd97dd6d | ||
|
|
897722c35f | ||
|
|
54cbc91166 | ||
|
|
ecf9aa146c | ||
|
|
42e2fd5fc9 | ||
|
|
10664b7b95 | ||
|
|
1980307048 | ||
|
|
13eeddd1f6 | ||
|
|
dc08b615f1 | ||
|
|
ce94f4fd11 | ||
|
|
e314d0cfc5 | ||
|
|
741216d3eb | ||
|
|
94e15ae9ef | ||
|
|
4a6af25d11 | ||
|
|
c7fbc133e6 | ||
|
|
e5aa45cf0d | ||
|
|
3e3689d938 | ||
|
|
d0fc3f029f | ||
|
|
a9cfa22199 | ||
|
|
1238aeb30a | ||
|
|
1c5220b86f | ||
|
|
779f1b6104 | ||
|
|
c2063c4707 | ||
|
|
e429e608c2 | ||
|
|
75d00ef173 | ||
|
|
94fd15e550 | ||
|
|
03532624b8 | ||
|
|
338feb31e1 | ||
|
|
2a805e090c | ||
|
|
2a12ae1afe | ||
|
|
715ca39abc | ||
|
|
cad14ac3e6 | ||
|
|
2a52ea6850 | ||
|
|
a2d924c48f | ||
|
|
3ad39c987b | ||
|
|
5c392d6123 | ||
|
|
9e7b03173d | ||
|
|
bfdf1e43f9 | ||
|
|
ad6e3a45d2 | ||
|
|
4669ff295f | ||
|
|
264433b23d | ||
|
|
41bf2f32fd | ||
|
|
a6d4bc5c86 | ||
|
|
93dcba40ec | ||
|
|
067c23f20e | ||
|
|
16fbf8299f | ||
|
|
7ce7e3d5e5 | ||
|
|
60fdc6bc1a | ||
|
|
b972b7c307 | ||
|
|
4927d346c8 | ||
|
|
8f164cff1d | ||
|
|
31c251d956 | ||
|
|
bc81992a40 | ||
|
|
66267cf99a | ||
|
|
9d211990c3 | ||
|
|
76078a7fb9 | ||
|
|
cba4c9f0ee | ||
|
|
46d5395148 | ||
|
|
b8b2141e20 | ||
|
|
4436585aa0 | ||
|
|
937eb87932 | ||
|
|
6a21d28030 | ||
|
|
4f89f3a856 | ||
|
|
8b241b45e2 | ||
|
|
b517f26085 | ||
|
|
f28b948e1b | ||
|
|
a797ab3482 | ||
|
|
d31a051c74 | ||
|
|
aad270914a | ||
|
|
af3e7f53ac | ||
|
|
238f1685f1 | ||
|
|
418a7fb301 | ||
|
|
304474d2f7 | ||
|
|
59e9f5192c | ||
|
|
914576040e | ||
|
|
ff442cd8e7 | ||
|
|
25739ae217 | ||
|
|
425e6c52f4 | ||
|
|
97d2152a36 | ||
|
|
034e159ee4 | ||
|
|
fff023bf7b | ||
|
|
429e02e6f9 | ||
|
|
eb96f0429f | ||
|
|
960be3e081 | ||
|
|
2a91c7229a | ||
|
|
6517ed72b4 | ||
|
|
c125d1489c | ||
|
|
be1f1e8781 | ||
|
|
259f499e25 | ||
|
|
fc05c38b9b | ||
|
|
da8d25d80a | ||
|
|
6981599103 | ||
|
|
5557e982bf | ||
|
|
08466218d8 | ||
|
|
1c90c3bbc2 | ||
|
|
aa79247c39 | ||
|
|
04e28f9556 | ||
|
|
519e409c1d | ||
|
|
3b748abfe5 | ||
|
|
57d1f27e84 | ||
|
|
5bd8132630 | ||
|
|
d666871d86 | ||
|
|
d81b6ab5dc | ||
|
|
bf27f2d869 | ||
|
|
cf337824e7 | ||
|
|
a4bdbec8f8 | ||
|
|
0b41df5ac8 | ||
|
|
08f4437902 | ||
|
|
5ad80b8fb9 | ||
|
|
67d67616ca | ||
|
|
1983204112 | ||
|
|
dbacfd13b8 | ||
|
|
3b3e33d354 | ||
|
|
49b4830785 | ||
|
|
5e1880f09e | ||
|
|
470bf628c7 | ||
|
|
c705a93776 | ||
|
|
9ef783cd48 | ||
|
|
8512eb4493 | ||
|
|
9309aec209 | ||
|
|
d02314935d | ||
|
|
f5d83599cc | ||
|
|
6e6ef83d60 | ||
|
|
651af67b26 | ||
|
|
47c6bc4b91 | ||
|
|
f037ee6501 | ||
|
|
2b2eb2fa27 | ||
|
|
d1d4480187 | ||
|
|
48ed07816d | ||
|
|
d43862624a | ||
|
|
dc4b9cc4bc | ||
|
|
6d3cd7d38a | ||
|
|
b969662b6c |
@@ -4,7 +4,7 @@ import inspect
|
||||
import frappe
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "15.108.0"
|
||||
__version__ = "15.110.0"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
{
|
||||
"custom_fields": [
|
||||
{
|
||||
"_assign": null,
|
||||
"_comments": null,
|
||||
"_liked_by": null,
|
||||
"_user_tags": null,
|
||||
"allow_in_quick_entry": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"collapsible_depends_on": null,
|
||||
"columns": 0,
|
||||
"creation": "2018-12-28 22:29:21.828090",
|
||||
"default": null,
|
||||
"depends_on": null,
|
||||
"description": null,
|
||||
"docstatus": 0,
|
||||
"dt": "Address",
|
||||
"fetch_from": null,
|
||||
"fetch_if_empty": 0,
|
||||
"fieldname": "tax_category",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 0,
|
||||
"hide_border": 0,
|
||||
"hide_days": 0,
|
||||
"hide_seconds": 0,
|
||||
"idx": 15,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_preview": 0,
|
||||
"in_standard_filter": 0,
|
||||
"insert_after": "fax",
|
||||
"label": "Tax Category",
|
||||
"length": 0,
|
||||
"mandatory_depends_on": null,
|
||||
"modified": "2018-12-28 22:29:21.828090",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Address-tax_category",
|
||||
"no_copy": 0,
|
||||
"options": "Tax Category",
|
||||
"owner": "Administrator",
|
||||
"parent": null,
|
||||
"parentfield": null,
|
||||
"parenttype": null,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"print_width": null,
|
||||
"read_only": 0,
|
||||
"read_only_depends_on": null,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0,
|
||||
"width": null
|
||||
},
|
||||
{
|
||||
"_assign": null,
|
||||
"_comments": null,
|
||||
"_liked_by": null,
|
||||
"_user_tags": null,
|
||||
"allow_in_quick_entry": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"collapsible_depends_on": null,
|
||||
"columns": 0,
|
||||
"creation": "2020-10-14 17:41:40.878179",
|
||||
"default": "0",
|
||||
"depends_on": null,
|
||||
"description": null,
|
||||
"docstatus": 0,
|
||||
"dt": "Address",
|
||||
"fetch_from": null,
|
||||
"fetch_if_empty": 0,
|
||||
"fieldname": "is_your_company_address",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 0,
|
||||
"hide_border": 0,
|
||||
"hide_days": 0,
|
||||
"hide_seconds": 0,
|
||||
"idx": 20,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_preview": 0,
|
||||
"in_standard_filter": 0,
|
||||
"insert_after": "linked_with",
|
||||
"label": "Is Your Company Address",
|
||||
"length": 0,
|
||||
"mandatory_depends_on": null,
|
||||
"modified": "2020-10-14 17:41:40.878179",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Address-is_your_company_address",
|
||||
"no_copy": 0,
|
||||
"options": null,
|
||||
"owner": "Administrator",
|
||||
"parent": null,
|
||||
"parentfield": null,
|
||||
"parenttype": null,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"print_width": null,
|
||||
"read_only": 0,
|
||||
"read_only_depends_on": null,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0,
|
||||
"width": null
|
||||
}
|
||||
],
|
||||
"custom_perms": [],
|
||||
"doctype": "Address",
|
||||
"property_setters": [],
|
||||
"sync_on_migrate": 1
|
||||
}
|
||||
@@ -517,6 +517,7 @@ def get_account_autoname(account_number, account_name, company):
|
||||
def update_account_number(name, account_name, account_number=None, from_descendant=False):
|
||||
_ensure_idle_system()
|
||||
account = frappe.get_cached_doc("Account", name)
|
||||
account.check_permission("write")
|
||||
if not account:
|
||||
return
|
||||
|
||||
|
||||
@@ -30,16 +30,6 @@ frappe.ui.form.on("Accounts Settings", {
|
||||
add_taxes_from_item_tax_template(frm) {
|
||||
toggle_tax_settings(frm, "add_taxes_from_item_tax_template");
|
||||
},
|
||||
|
||||
drop_ar_procedures: function (frm) {
|
||||
frm.call({
|
||||
doc: frm.doc,
|
||||
method: "drop_ar_sql_procedures",
|
||||
callback: function (r) {
|
||||
frappe.show_alert(__("Procedures dropped"), 5);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function toggle_tax_settings(frm, field_name) {
|
||||
|
||||
@@ -95,7 +95,6 @@
|
||||
"receivable_payable_fetch_method",
|
||||
"default_ageing_range",
|
||||
"column_break_ntmi",
|
||||
"drop_ar_procedures",
|
||||
"legacy_section",
|
||||
"ignore_is_opening_check_for_reporting",
|
||||
"payment_request_settings",
|
||||
@@ -561,7 +560,7 @@
|
||||
"fieldname": "receivable_payable_fetch_method",
|
||||
"fieldtype": "Select",
|
||||
"label": "Data Fetch Method",
|
||||
"options": "Buffered Cursor\nUnBuffered Cursor\nRaw SQL"
|
||||
"options": "Buffered Cursor\nUnBuffered Cursor"
|
||||
},
|
||||
{
|
||||
"fieldname": "accounts_receivable_payable_tuning_section",
|
||||
@@ -623,13 +622,6 @@
|
||||
"fieldname": "column_break_ntmi",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.receivable_payable_fetch_method == \"Raw SQL\"",
|
||||
"description": "Drops existing SQL Procedures and Function setup by Accounts Receivable report",
|
||||
"fieldname": "drop_ar_procedures",
|
||||
"fieldtype": "Button",
|
||||
"label": "Drop Procedures"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "fetch_valuation_rate_for_internal_transaction",
|
||||
@@ -671,7 +663,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-06 14:49:11.467716",
|
||||
"modified": "2026-05-18 12:16:33.679345",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
@@ -701,4 +693,4 @@
|
||||
"sort_order": "ASC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ class AccountsSettings(Document):
|
||||
merge_similar_account_heads: DF.Check
|
||||
over_billing_allowance: DF.Currency
|
||||
post_change_gl_entries: DF.Check
|
||||
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor", "Raw SQL"]
|
||||
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
|
||||
receivable_payable_remarks_length: DF.Int
|
||||
reconciliation_queue_size: DF.Int
|
||||
role_allowed_to_over_bill: DF.Link | None
|
||||
@@ -170,11 +170,3 @@ class AccountsSettings(Document):
|
||||
),
|
||||
title=_("Auto Tax Settings Error"),
|
||||
)
|
||||
|
||||
@frappe.whitelist()
|
||||
def drop_ar_sql_procedures(self):
|
||||
from erpnext.accounts.report.accounts_receivable.accounts_receivable import InitSQLProceduresForAR
|
||||
|
||||
frappe.db.sql(f"drop function if exists {InitSQLProceduresForAR.genkey_function_name}")
|
||||
frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.init_procedure_name}")
|
||||
frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.allocate_procedure_name}")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"autoname": "format:Bank Statement Import on {creation}",
|
||||
"beta": 1,
|
||||
"creation": "2019-08-04 14:16:08.318714",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -211,10 +210,11 @@
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"links": [],
|
||||
"modified": "2024-06-25 17:32:07.658250",
|
||||
"modified": "2026-05-30 20:51:10.353723",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Statement Import",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -230,7 +230,9 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
"actions": [],
|
||||
"allow_events_in_timeline": 1,
|
||||
"autoname": "naming_series:",
|
||||
"beta": 1,
|
||||
"creation": "2019-07-05 16:34:31.013238",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
@@ -400,7 +399,7 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-11-26 13:46:07.760867",
|
||||
"modified": "2026-05-30 20:40:30.851842",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Dunning",
|
||||
@@ -449,9 +448,10 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "ASC",
|
||||
"states": [],
|
||||
"title_field": "customer_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"beta": 1,
|
||||
"creation": "2019-12-04 04:59:08.003664",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -107,7 +106,7 @@
|
||||
"link_fieldname": "dunning_type"
|
||||
}
|
||||
],
|
||||
"modified": "2021-11-13 00:25:35.659283",
|
||||
"modified": "2026-05-30 20:40:09.952533",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Dunning Type",
|
||||
@@ -151,7 +150,9 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -378,15 +378,17 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
|
||||
|
||||
accounts_add(doc, cdt, cdn) {
|
||||
var row = frappe.get_doc(cdt, cdn);
|
||||
row.exchange_rate = 1;
|
||||
$.each(doc.accounts, function (i, d) {
|
||||
if (d.account && d.party && d.party_type) {
|
||||
row.account = d.account;
|
||||
row.party = d.party;
|
||||
row.party_type = d.party_type;
|
||||
row.exchange_rate = d.exchange_rate;
|
||||
}
|
||||
});
|
||||
if (!row.exchange_rate) row.exchange_rate = 1;
|
||||
if (!row.account) {
|
||||
$.each(doc.accounts, function (i, d) {
|
||||
if (d.account && d.party && d.party_type) {
|
||||
row.account = d.account;
|
||||
row.party = d.party;
|
||||
row.party_type = d.party_type;
|
||||
row.exchange_rate = d.exchange_rate;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// set difference
|
||||
if (doc.difference) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_copy": 1,
|
||||
"beta": 1,
|
||||
"creation": "2017-08-29 02:22:54.947711",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -64,10 +64,10 @@
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
@@ -82,7 +82,8 @@
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"issingle": 1,
|
||||
"modified": "2022-01-04 15:25:06.053187",
|
||||
"links": [],
|
||||
"modified": "2026-05-30 20:43:36.282738",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Opening Invoice Creation Tool",
|
||||
@@ -99,7 +100,9 @@
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -31,6 +31,7 @@ class OpeningInvoiceCreationTool(Document):
|
||||
create_missing_party: DF.Check
|
||||
invoice_type: DF.Literal["Sales", "Purchase"]
|
||||
invoices: DF.Table[OpeningInvoiceCreationToolItem]
|
||||
project: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
def onload(self):
|
||||
|
||||
@@ -725,31 +725,12 @@ frappe.ui.form.on("Payment Entry", {
|
||||
if (!frm.doc.paid_from_account_currency || !frm.doc.company) return;
|
||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||
|
||||
if (frm.doc.paid_from_account_currency == company_currency) {
|
||||
frm.set_value("source_exchange_rate", 1);
|
||||
} else if (frm.doc.paid_from) {
|
||||
if (["Internal Transfer", "Pay"].includes(frm.doc.payment_type)) {
|
||||
let company_currency = frappe.get_doc(":Company", frm.doc.company)?.default_currency;
|
||||
frappe.call({
|
||||
method: "erpnext.setup.utils.get_exchange_rate",
|
||||
args: {
|
||||
from_currency: frm.doc.paid_from_account_currency,
|
||||
to_currency: company_currency,
|
||||
transaction_date: frm.doc.posting_date,
|
||||
},
|
||||
callback: function (r, rt) {
|
||||
frm.set_value("source_exchange_rate", r.message);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
frm.events.set_current_exchange_rate(
|
||||
frm,
|
||||
"source_exchange_rate",
|
||||
frm.doc.paid_from_account_currency,
|
||||
company_currency
|
||||
);
|
||||
}
|
||||
}
|
||||
frm.events.set_current_exchange_rate(
|
||||
frm,
|
||||
"source_exchange_rate",
|
||||
frm.doc.paid_from_account_currency,
|
||||
company_currency
|
||||
);
|
||||
},
|
||||
|
||||
paid_to_account_currency: function (frm) {
|
||||
@@ -781,49 +762,24 @@ frappe.ui.form.on("Payment Entry", {
|
||||
|
||||
posting_date: function (frm) {
|
||||
frm.events.paid_from_account_currency(frm);
|
||||
frm.events.paid_to_account_currency(frm);
|
||||
},
|
||||
|
||||
source_exchange_rate: function (frm) {
|
||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||
if (frm.doc.paid_amount) {
|
||||
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
|
||||
// target exchange rate should always be same as source if both account currencies is same
|
||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate);
|
||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
} else if (company_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("received_amount", frm.doc.base_paid_amount);
|
||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
}
|
||||
|
||||
// set_unallocated_amount is called by below method,
|
||||
// no need trigger separately
|
||||
frm.events.set_total_allocated_amount(frm);
|
||||
}
|
||||
|
||||
// Make read only if Accounts Settings doesn't allow stale rates
|
||||
frm.set_df_property("source_exchange_rate", "read_only", erpnext.stale_rate_allowed() ? 0 : 1);
|
||||
},
|
||||
|
||||
target_exchange_rate: function (frm) {
|
||||
frm.set_paid_amount_based_on_received_amount = true;
|
||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||
|
||||
if (frm.doc.received_amount) {
|
||||
frm.set_value(
|
||||
"base_received_amount",
|
||||
flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate)
|
||||
);
|
||||
if (frm.doc.base_received_amount && frm.doc.source_exchange_rate) {
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
|
||||
if (
|
||||
!frm.doc.source_exchange_rate &&
|
||||
frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency
|
||||
) {
|
||||
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
} else if (company_currency == frm.doc.paid_from_account_currency) {
|
||||
frm.set_value("paid_amount", frm.doc.base_received_amount);
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
// target exchange rate should always be same as source if both account currencies is same
|
||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate);
|
||||
} else {
|
||||
frm.set_value(
|
||||
"paid_amount",
|
||||
flt(frm.doc.base_paid_amount) / flt(frm.doc.source_exchange_rate)
|
||||
);
|
||||
}
|
||||
|
||||
// set_unallocated_amount is called by below method,
|
||||
@@ -832,6 +788,32 @@ frappe.ui.form.on("Payment Entry", {
|
||||
}
|
||||
frm.set_paid_amount_based_on_received_amount = false;
|
||||
|
||||
// Make read only if Accounts Settings doesn't allow stale rates
|
||||
frm.set_df_property("source_exchange_rate", "read_only", erpnext.stale_rate_allowed() ? 0 : 1);
|
||||
},
|
||||
|
||||
target_exchange_rate: function (frm) {
|
||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||
|
||||
if (frm.doc.base_paid_amount && frm.doc.target_exchange_rate) {
|
||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
if (
|
||||
!frm.doc.source_exchange_rate &&
|
||||
frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency
|
||||
) {
|
||||
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
|
||||
} else {
|
||||
frm.set_value(
|
||||
"received_amount",
|
||||
flt(frm.doc.base_received_amount) / flt(frm.doc.target_exchange_rate)
|
||||
);
|
||||
}
|
||||
|
||||
// set_unallocated_amount is called by below method,
|
||||
// no need trigger separately
|
||||
frm.events.set_total_allocated_amount(frm);
|
||||
}
|
||||
|
||||
// Make read only if Accounts Settings doesn't allow stale rates
|
||||
frm.set_df_property("target_exchange_rate", "read_only", erpnext.stale_rate_allowed() ? 0 : 1);
|
||||
},
|
||||
@@ -840,11 +822,14 @@ frappe.ui.form.on("Payment Entry", {
|
||||
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
|
||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||
if (!frm.doc.received_amount) {
|
||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("received_amount", frm.doc.paid_amount);
|
||||
} else if (company_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
if (company_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("received_amount", frm.doc.base_paid_amount);
|
||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
} else if (frm.doc.target_exchange_rate) {
|
||||
frm.set_value(
|
||||
"received_amount",
|
||||
flt(frm.doc.base_paid_amount) / flt(frm.doc.target_exchange_rate)
|
||||
);
|
||||
}
|
||||
}
|
||||
frm.trigger("reset_received_amount");
|
||||
@@ -861,15 +846,14 @@ frappe.ui.form.on("Payment Entry", {
|
||||
);
|
||||
|
||||
if (!frm.doc.paid_amount) {
|
||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("paid_amount", frm.doc.received_amount);
|
||||
if (frm.doc.target_exchange_rate) {
|
||||
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
|
||||
}
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
} else if (company_currency == frm.doc.paid_from_account_currency) {
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
if (company_currency == frm.doc.paid_from_account_currency) {
|
||||
frm.set_value("paid_amount", frm.doc.base_received_amount);
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
} else if (frm.doc.source_exchange_rate) {
|
||||
frm.set_value(
|
||||
"paid_amount",
|
||||
flt(frm.doc.base_received_amount) / flt(frm.doc.source_exchange_rate)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1789,6 +1773,35 @@ frappe.ui.form.on("Payment Entry", {
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
before_cancel: function (frm) {
|
||||
return new Promise((resolve, reject) => {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_linked_bank_transactions",
|
||||
args: { payment_entry: frm.doc.name },
|
||||
callback: function (r) {
|
||||
const linked = r.message || [];
|
||||
if (!linked.length) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const bt_links = linked
|
||||
.map((name) => frappe.utils.get_form_link("Bank Transaction", name, true))
|
||||
.join(", ");
|
||||
frappe.confirm(
|
||||
__(
|
||||
"This Payment Entry is reconciled with {0}. Cancelling will automatically unreconcile it. Do you want to proceed?",
|
||||
[bt_links]
|
||||
),
|
||||
() => resolve(),
|
||||
() => reject(),
|
||||
__("Yes"),
|
||||
__("No")
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Payment Entry Reference", {
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "doc.received_amount",
|
||||
"depends_on": "eval:doc.received_amount;",
|
||||
"fieldname": "base_received_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Received Amount (Company Currency)",
|
||||
@@ -800,7 +800,7 @@
|
||||
"table_fieldname": "payment_entries"
|
||||
}
|
||||
],
|
||||
"modified": "2025-05-15 18:01:04.013025",
|
||||
"modified": "2026-05-15 13:31:01.166010",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Entry",
|
||||
|
||||
@@ -3599,3 +3599,16 @@ def make_payment_order(source_name, target_doc=None):
|
||||
@erpnext.allow_regional
|
||||
def add_regional_gl_entries(gl_entries, doc):
|
||||
return
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_linked_bank_transactions(payment_entry: str) -> list:
|
||||
frappe.has_permission("Payment Entry", ptype="read", doc=payment_entry, throw=True)
|
||||
return frappe.get_all(
|
||||
"Bank Transaction Payments",
|
||||
filters={
|
||||
"payment_document": "Payment Entry",
|
||||
"payment_entry": payment_entry,
|
||||
},
|
||||
pluck="parent",
|
||||
)
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.query_builder.functions import Count, Sum
|
||||
from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
from frappe.utils import nowdate
|
||||
from frappe.utils import add_days, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
@@ -94,6 +95,7 @@ class TestPaymentLedgerEntry(FrappeTestCase):
|
||||
posting_date = nowdate()
|
||||
|
||||
sinv = create_sales_invoice(
|
||||
posting_date=posting_date,
|
||||
qty=qty,
|
||||
rate=rate,
|
||||
company=self.company,
|
||||
@@ -535,3 +537,82 @@ class TestPaymentLedgerEntry(FrappeTestCase):
|
||||
# with references removed, deletion should be possible
|
||||
so.delete()
|
||||
self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, so.doctype, so.name)
|
||||
|
||||
@change_settings(
|
||||
"Accounts Settings",
|
||||
{"enable_immutable_ledger": 1},
|
||||
)
|
||||
def test_reverse_entries_on_cancel_for_immutable_ledger(self):
|
||||
invoice_posting_date = add_days(nowdate(), -5)
|
||||
gle = qb.DocType("GL Entry")
|
||||
ple = qb.DocType("Payment Ledger Entry")
|
||||
|
||||
si = self.create_sales_invoice(qty=1, rate=100, posting_date=invoice_posting_date)
|
||||
|
||||
gles_before = (
|
||||
qb.from_(gle)
|
||||
.select(
|
||||
Count(gle.name),
|
||||
)
|
||||
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
|
||||
.run()[0][0]
|
||||
)
|
||||
ples_before = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
Count(ple.name),
|
||||
)
|
||||
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked.eq(0)))
|
||||
.run()[0][0]
|
||||
)
|
||||
|
||||
si.cancel()
|
||||
|
||||
gles_after = (
|
||||
qb.from_(gle)
|
||||
.select(Count(gle.account))
|
||||
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
|
||||
.run()[0][0]
|
||||
)
|
||||
self.assertEqual(gles_after, gles_before * 2)
|
||||
|
||||
ples_after = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
Count(ple.name),
|
||||
)
|
||||
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked.eq(0)))
|
||||
.run()[0][0]
|
||||
)
|
||||
self.assertEqual(ples_after, ples_before * 2)
|
||||
|
||||
# assert debit/credit are reversed
|
||||
gl_entries = (
|
||||
qb.from_(gle)
|
||||
.select(gle.account, Sum(gle.debit).as_("total_debit"), Sum(gle.credit).as_("total_credit"))
|
||||
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
|
||||
.groupby(gle.account)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
for gl in gl_entries:
|
||||
with self.subTest(gl=gl):
|
||||
self.assertEqual(gl.total_debit, gl.total_credit)
|
||||
|
||||
# assert amounts are reversed
|
||||
pl_entries = (
|
||||
qb.from_(ple)
|
||||
.select(ple.account, Sum(ple.amount).as_("total_amount"))
|
||||
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked == 0))
|
||||
.groupby(ple.account)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
for pl in pl_entries:
|
||||
with self.subTest(pl=pl):
|
||||
self.assertEqual(pl.total_amount, 0)
|
||||
|
||||
self.assertFalse(
|
||||
frappe.db.exists(
|
||||
"Payment Ledger Entry",
|
||||
{"voucher_type": si.doctype, "voucher_no": si.name, "delinked": 1},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import change_settings
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.general_ledger import make_reverse_gl_entries
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
|
||||
|
||||
@@ -351,6 +353,51 @@ class TestPeriodClosingVoucher(unittest.TestCase):
|
||||
|
||||
return pcv
|
||||
|
||||
@change_settings(
|
||||
"Accounts Settings",
|
||||
{"enable_immutable_ledger": 1},
|
||||
)
|
||||
def test_immutable_ledger_reverse_entry_uses_passed_posting_date_after_pcv(self):
|
||||
frappe.db.sql("delete from `tabGL Entry` where company='Test PCV Company'")
|
||||
frappe.db.sql("delete from `tabPeriod Closing Voucher` where company='Test PCV Company'")
|
||||
|
||||
company = create_company()
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
|
||||
jv = make_journal_entry(
|
||||
posting_date="2021-03-15",
|
||||
amount=400,
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center,
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
jv.company = company
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
self.make_period_closing_voucher(posting_date="2021-03-31")
|
||||
|
||||
# Passed posting_date is after PCV end date, so cancellation should not fail.
|
||||
make_reverse_gl_entries(
|
||||
voucher_type="Journal Entry",
|
||||
voucher_no=jv.name,
|
||||
posting_date="2022-01-01",
|
||||
)
|
||||
|
||||
totals_after_cancel = frappe.db.sql(
|
||||
"""
|
||||
select sum(debit) as total_debit, sum(credit) as total_credit
|
||||
from `tabGL Entry`
|
||||
where voucher_type=%s and voucher_no=%s and is_cancelled=0
|
||||
""",
|
||||
("Journal Entry", jv.name),
|
||||
as_dict=True,
|
||||
)[0]
|
||||
|
||||
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)
|
||||
|
||||
|
||||
def create_company():
|
||||
company = frappe.get_doc(
|
||||
|
||||
@@ -202,15 +202,14 @@ class POSProfile(Document):
|
||||
def set_defaults(self, include_current_pos=True):
|
||||
frappe.defaults.clear_default("is_pos")
|
||||
|
||||
if not include_current_pos:
|
||||
condition = " where pfu.name != '%s' and pfu.default = 1 " % self.name.replace("'", "'")
|
||||
else:
|
||||
condition = " where pfu.default = 1 "
|
||||
pfu = frappe.qb.DocType("POS Profile User")
|
||||
|
||||
pos_view_users = frappe.db.sql_list(
|
||||
f"""select pfu.user
|
||||
from `tabPOS Profile User` as pfu {condition}"""
|
||||
)
|
||||
query = frappe.qb.from_(pfu).select(pfu.user).where(pfu.default == 1)
|
||||
|
||||
if not include_current_pos:
|
||||
query = query.where(pfu.name != self.name)
|
||||
|
||||
pos_view_users = query.run(as_list=1, pluck=True)
|
||||
|
||||
for user in pos_view_users:
|
||||
if user:
|
||||
|
||||
@@ -151,13 +151,13 @@
|
||||
"label": "Default Advance Account",
|
||||
"mandatory_depends_on": "doc.party_type",
|
||||
"options": "Account",
|
||||
"reqd": 1
|
||||
"reqd": 0
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-08 08:22:14.798085",
|
||||
"modified": "2026-05-16 11:43:12.758685",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Payment Reconciliation",
|
||||
|
||||
@@ -23,7 +23,7 @@ class ProcessPaymentReconciliation(Document):
|
||||
bank_cash_account: DF.Link | None
|
||||
company: DF.Link
|
||||
cost_center: DF.Link | None
|
||||
default_advance_account: DF.Link
|
||||
default_advance_account: DF.Link | None
|
||||
error_log: DF.LongText | None
|
||||
from_invoice_date: DF.Date | None
|
||||
from_payment_date: DF.Date | None
|
||||
@@ -215,10 +215,7 @@ def trigger_reconciliation_for_queued_docs():
|
||||
fields = ["company", "party_type", "party", "receivable_payable_account", "default_advance_account"]
|
||||
|
||||
def get_filters_as_tuple(fields, doc):
|
||||
filters = ()
|
||||
for x in fields:
|
||||
filters += tuple(doc.get(x))
|
||||
return filters
|
||||
return tuple(doc.get(x) or "" for x in fields)
|
||||
|
||||
for x in all_queued:
|
||||
doc = frappe.get_doc("Process Payment Reconciliation", x)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "format:Process-PCV-{###}",
|
||||
"creation": "2025-09-25 15:44:03.534699",
|
||||
"doctype": "DocType",
|
||||
@@ -7,11 +8,13 @@
|
||||
"field_order": [
|
||||
"parent_pcv",
|
||||
"status",
|
||||
"amended_from",
|
||||
"section_normal_balances",
|
||||
"p_l_closing_balance",
|
||||
"normal_balances",
|
||||
"bs_closing_balance",
|
||||
"z_opening_balances",
|
||||
"amended_from"
|
||||
"normal_balances",
|
||||
"section_opening_balances",
|
||||
"z_opening_balances"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -64,17 +67,27 @@
|
||||
"fieldname": "bs_closing_balance",
|
||||
"fieldtype": "JSON",
|
||||
"label": "Balance Sheet Closing Balance"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_normal_balances",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Normal Balances"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_opening_balances",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Opening Balances"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-05 11:40:24.996403",
|
||||
"modified": "2026-06-01 12:16:37.374412",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Period Closing Voucher",
|
||||
"naming_rule": "Expression",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -69,8 +69,8 @@ class ProcessPeriodClosingVoucher(Document):
|
||||
pcv = frappe.get_doc("Period Closing Voucher", self.parent_pcv)
|
||||
if pcv.is_first_period_closing_voucher():
|
||||
gl = qb.DocType("GL Entry")
|
||||
min = qb.from_(gl).select(Min(gl.posting_date)).where(gl.company.eq(pcv.company)).run()[0][0]
|
||||
max = qb.from_(gl).select(Max(gl.posting_date)).where(gl.company.eq(pcv.company)).run()[0][0]
|
||||
min = qb.from_(gl).select(Min(gl.posting_date)).run()[0][0]
|
||||
max = qb.from_(gl).select(Max(gl.posting_date)).run()[0][0]
|
||||
|
||||
dates = self.get_dates(get_datetime(min), get_datetime(max))
|
||||
for x in dates:
|
||||
@@ -90,12 +90,16 @@ class ProcessPeriodClosingVoucher(Document):
|
||||
def start_pcv_processing(docname: str):
|
||||
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
if normal_balances := frappe.db.get_all(
|
||||
"Process Period Closing Voucher Detail",
|
||||
filters={"parent": docname, "status": "Queued"},
|
||||
fields=["processing_date", "report_type", "parentfield"],
|
||||
order_by="parentfield, idx, processing_date",
|
||||
limit=4,
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
if normal_balances := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(4)
|
||||
.for_update(skip_locked=True)
|
||||
.run(as_dict=True)
|
||||
):
|
||||
if not is_scheduler_inactive():
|
||||
for x in normal_balances:
|
||||
@@ -130,9 +134,10 @@ def pause_pcv_processing(docname: str):
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
# If a date is stuck in 'Running' state, this will allow it to procced.
|
||||
if queued_dates := frappe.db.get_all(
|
||||
"Process Period Closing Voucher Detail",
|
||||
filters={"parent": docname, "status": "Queued"},
|
||||
filters={"parent": docname, "status": ["in", ["Queued", "Running"]]},
|
||||
pluck="name",
|
||||
):
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
@@ -166,6 +171,9 @@ def resume_pcv_processing(docname: str):
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
qb.update(ppcvd).set(ppcvd.status, "Queued").where(ppcvd.name.isin(paused_dates)).run()
|
||||
start_pcv_processing(docname)
|
||||
else:
|
||||
# If a parent doc is stuck in 'Running' state, will allow it to proceed.
|
||||
schedule_next_date(docname)
|
||||
|
||||
|
||||
def update_default_dimensions(dimension_fields, gl_entry, dimension_values):
|
||||
@@ -235,12 +243,15 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
|
||||
|
||||
@frappe.whitelist()
|
||||
def schedule_next_date(docname: str):
|
||||
if to_process := frappe.db.get_all(
|
||||
"Process Period Closing Voucher Detail",
|
||||
filters={"parent": docname, "status": "Queued"},
|
||||
fields=["processing_date", "report_type", "parentfield"],
|
||||
order_by="parentfield, idx, processing_date",
|
||||
limit=1,
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
if to_process := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(1)
|
||||
.for_update(skip_locked=True)
|
||||
.run(as_dict=True)
|
||||
):
|
||||
if not is_scheduler_inactive():
|
||||
frappe.db.set_value(
|
||||
@@ -278,7 +289,21 @@ def schedule_next_date(docname: str):
|
||||
)
|
||||
# Ensure both normal and opening balances are processed for all dates
|
||||
if total_no_of_dates == completed:
|
||||
summarize_and_post_ledger_entries(docname)
|
||||
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
|
||||
is_job_running,
|
||||
)
|
||||
|
||||
job_name = f"summarize_{docname}"
|
||||
if not is_job_running(job_name):
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries",
|
||||
queue="long",
|
||||
timeout="3600",
|
||||
is_async=True,
|
||||
job_name=job_name,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
)
|
||||
|
||||
|
||||
def make_dict_json_compliant(dimension_wise_balance) -> dict:
|
||||
@@ -534,6 +559,9 @@ def process_individual_date(docname: str, date, report_type, parentfield):
|
||||
|
||||
if parentfield == "z_opening_balances":
|
||||
query = query.where(gle.is_opening.eq("Yes"))
|
||||
else:
|
||||
# Keep balances aligned with legacy PCV logic (non-opening transactions only)
|
||||
query = query.where(gle.is_opening.eq("No"))
|
||||
|
||||
query = query.groupby(gle.account)
|
||||
for dim in dimensions:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
// render
|
||||
frappe.listview_settings["Process Period Closing Voucher"] = {
|
||||
add_fields: ["status"],
|
||||
get_indicator: function (doc) {
|
||||
const status_colors = {
|
||||
Queued: "blue",
|
||||
Running: "orange",
|
||||
Paused: "gray",
|
||||
Completed: "green",
|
||||
Cancelled: "red",
|
||||
};
|
||||
return [__(doc.status), status_colors[doc.status], "status,=," + doc.status];
|
||||
},
|
||||
};
|
||||
@@ -8,10 +8,13 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.contacts.doctype.address.address import get_default_address
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import IfNull
|
||||
from frappe.utils import cstr
|
||||
from frappe.utils.nestedset import get_root_of
|
||||
|
||||
from erpnext.setup.doctype.customer_group.customer_group import get_parent_customer_groups
|
||||
from erpnext.setup.doctype.supplier_group.supplier_group import get_parent_supplier_groups
|
||||
|
||||
|
||||
class IncorrectCustomerGroup(frappe.ValidationError):
|
||||
@@ -174,38 +177,44 @@ def get_party_details(party, party_type, args=None):
|
||||
def get_tax_template(posting_date, args):
|
||||
"""Get matching tax rule"""
|
||||
args = frappe._dict(args)
|
||||
conditions = []
|
||||
|
||||
TaxRule = DocType("Tax Rule")
|
||||
query = frappe.qb.from_(TaxRule).select("*")
|
||||
|
||||
if posting_date:
|
||||
conditions.append(
|
||||
f"""(from_date is null or from_date <= '{posting_date}')
|
||||
and (to_date is null or to_date >= '{posting_date}')"""
|
||||
query = query.where(
|
||||
(TaxRule.from_date.isnull() | (TaxRule.from_date <= posting_date))
|
||||
& (TaxRule.to_date.isnull() | (TaxRule.to_date >= posting_date))
|
||||
)
|
||||
else:
|
||||
conditions.append("(from_date is null) and (to_date is null)")
|
||||
query = query.where(TaxRule.from_date.isnull() & TaxRule.to_date.isnull())
|
||||
|
||||
conditions.append(
|
||||
"ifnull(tax_category, '') = {}".format(frappe.db.escape(cstr(args.get("tax_category")), False))
|
||||
)
|
||||
if "tax_category" in args.keys():
|
||||
del args["tax_category"]
|
||||
def get_group_ancestors(doctype, get_parents, value):
|
||||
if not value:
|
||||
value = get_root_of(doctype)
|
||||
return [""] + [d.name for d in get_parents(value)]
|
||||
|
||||
group_fields = {
|
||||
"customer_group": ("Customer Group", get_parent_customer_groups),
|
||||
"supplier_group": ("Supplier Group", get_parent_supplier_groups),
|
||||
}
|
||||
|
||||
args.setdefault("tax_category", "")
|
||||
|
||||
for key, value in args.items():
|
||||
if key == "use_for_shopping_cart":
|
||||
conditions.append(f"use_for_shopping_cart = {1 if value else 0}")
|
||||
elif key == "customer_group":
|
||||
if not value:
|
||||
value = get_root_of("Customer Group")
|
||||
customer_group_condition = get_customer_group_condition(value)
|
||||
conditions.append(f"ifnull({key}, '') in ('', {customer_group_condition})")
|
||||
query = query.where(TaxRule.use_for_shopping_cart == value)
|
||||
elif key == "tax_category":
|
||||
query = query.where(IfNull(TaxRule.tax_category, "") == (value or ""))
|
||||
elif key in group_fields:
|
||||
doctype, get_parents = group_fields[key]
|
||||
query = query.where(
|
||||
IfNull(TaxRule[key], "").isin(get_group_ancestors(doctype, get_parents, value))
|
||||
)
|
||||
else:
|
||||
conditions.append(f"ifnull({key}, '') in ('', {frappe.db.escape(cstr(value))})")
|
||||
query = query.where(IfNull(TaxRule[key], "").isin(["", value or ""]))
|
||||
|
||||
tax_rule = frappe.db.sql(
|
||||
"""select * from `tabTax Rule`
|
||||
where {}""".format(" and ".join(conditions)),
|
||||
as_dict=True,
|
||||
)
|
||||
tax_rule = query.run(as_dict=True)
|
||||
|
||||
if not tax_rule:
|
||||
return None
|
||||
@@ -234,11 +243,3 @@ def get_tax_template(posting_date, args):
|
||||
return None
|
||||
|
||||
return tax_template
|
||||
|
||||
|
||||
def get_customer_group_condition(customer_group):
|
||||
condition = ""
|
||||
customer_groups = ["%s" % (frappe.db.escape(d.name)) for d in get_parent_customer_groups(customer_group)]
|
||||
if customer_groups:
|
||||
condition = ",".join(["%s"] * len(customer_groups)) % (tuple(customer_groups))
|
||||
return condition
|
||||
|
||||
@@ -72,6 +72,117 @@ class TestTaxRule(unittest.TestCase):
|
||||
"_Test Sales Taxes and Charges Template - _TC",
|
||||
)
|
||||
|
||||
def test_for_parent_supplier_group(self):
|
||||
purchase_template = "_Test Purchase Taxes and Charges Template - _TC"
|
||||
if not frappe.db.exists("Purchase Taxes and Charges Template", purchase_template):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Purchase Taxes and Charges Template",
|
||||
"title": "_Test Purchase Taxes and Charges Template",
|
||||
"company": "_Test Company",
|
||||
"taxes": [
|
||||
{
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"charge_type": "On Net Total",
|
||||
"description": "VAT",
|
||||
"doctype": "Purchase Taxes and Charges",
|
||||
"cost_center": "Main - _TC",
|
||||
"rate": 6,
|
||||
}
|
||||
],
|
||||
}
|
||||
).insert()
|
||||
|
||||
make_tax_rule(
|
||||
supplier_group="All Supplier Groups",
|
||||
tax_type="Purchase",
|
||||
purchase_tax_template=purchase_template,
|
||||
priority=1,
|
||||
use_for_shopping_cart=0,
|
||||
from_date="2015-01-01",
|
||||
save=1,
|
||||
)
|
||||
|
||||
# "_Test Supplier Group" has "All Supplier Groups" as its parent — should match hierarchically
|
||||
self.assertEqual(
|
||||
get_tax_template(
|
||||
"2015-01-01",
|
||||
{
|
||||
"supplier_group": "_Test Supplier Group",
|
||||
"tax_type": "Purchase",
|
||||
"use_for_shopping_cart": 0,
|
||||
},
|
||||
),
|
||||
purchase_template,
|
||||
)
|
||||
|
||||
def test_use_for_shopping_cart_filter(self):
|
||||
city = "Test Cart City"
|
||||
# higher priority ensures this rule wins when use_for_shopping_cart is not filtered
|
||||
make_tax_rule(
|
||||
customer="_Test Customer",
|
||||
billing_city=city,
|
||||
sales_tax_template="_Test Sales Taxes and Charges Template - _TC",
|
||||
use_for_shopping_cart=0,
|
||||
priority=2,
|
||||
save=1,
|
||||
)
|
||||
make_tax_rule(
|
||||
customer="_Test Customer",
|
||||
billing_city=city,
|
||||
sales_tax_template="_Test Sales Taxes and Charges Template 1 - _TC",
|
||||
use_for_shopping_cart=1,
|
||||
priority=1,
|
||||
save=1,
|
||||
)
|
||||
|
||||
# Cart request (use_for_shopping_cart=1) filters to cart rules only
|
||||
self.assertEqual(
|
||||
get_tax_template(
|
||||
"2015-01-01",
|
||||
{"customer": "_Test Customer", "billing_city": city, "use_for_shopping_cart": 1},
|
||||
),
|
||||
"_Test Sales Taxes and Charges Template 1 - _TC",
|
||||
)
|
||||
|
||||
# Non-cart request omits use_for_shopping_cart — no filter is applied, both rules
|
||||
# are candidates; non-cart rule wins by higher priority
|
||||
self.assertEqual(
|
||||
get_tax_template(
|
||||
"2015-01-01",
|
||||
{"customer": "_Test Customer", "billing_city": city},
|
||||
),
|
||||
"_Test Sales Taxes and Charges Template - _TC",
|
||||
)
|
||||
|
||||
def test_use_for_shopping_cart_default(self):
|
||||
city = "Test Default Cart City"
|
||||
# use_for_shopping_cart not set — Check field defaults to 0
|
||||
make_tax_rule(
|
||||
customer="_Test Customer",
|
||||
billing_city=city,
|
||||
sales_tax_template="_Test Sales Taxes and Charges Template - _TC",
|
||||
use_for_shopping_cart=0, # Default is set to 1.
|
||||
save=1,
|
||||
)
|
||||
|
||||
# Non-cart request (no use_for_shopping_cart in args) matches the rule
|
||||
self.assertEqual(
|
||||
get_tax_template(
|
||||
"2015-01-01",
|
||||
{"customer": "_Test Customer", "billing_city": city},
|
||||
),
|
||||
"_Test Sales Taxes and Charges Template - _TC",
|
||||
)
|
||||
|
||||
# Cart request (use_for_shopping_cart=1) does not match — rule has default 0
|
||||
self.assertIsNone(
|
||||
get_tax_template(
|
||||
"2015-01-01",
|
||||
{"customer": "_Test Customer", "billing_city": city, "use_for_shopping_cart": 1},
|
||||
)
|
||||
)
|
||||
|
||||
def test_conflict_with_overlapping_dates(self):
|
||||
tax_rule1 = make_tax_rule(
|
||||
customer="_Test Customer",
|
||||
|
||||
@@ -700,7 +700,12 @@ def make_reverse_gl_entries(
|
||||
check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
|
||||
|
||||
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
|
||||
validate_against_pcv(is_opening, gl_entries[0]["posting_date"], gl_entries[0]["company"])
|
||||
|
||||
# For reverse entries, use the posting_date parameter if provided and valid
|
||||
# Otherwise fall back to original posting_date
|
||||
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
|
||||
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
|
||||
|
||||
if partial_cancel:
|
||||
# Partial cancel is only used by `Advance` in separate account feature.
|
||||
# Only cancel GL entries for unlinked reference using `voucher_detail_no`
|
||||
|
||||
@@ -669,7 +669,7 @@ def validate_due_date_with_template(posting_date, due_date, bill_date, template_
|
||||
if not default_due_date:
|
||||
return
|
||||
|
||||
if default_due_date != posting_date and getdate(due_date) > getdate(default_due_date):
|
||||
if getdate(default_due_date) != getdate(posting_date) and getdate(due_date) > getdate(default_due_date):
|
||||
if frappe.db.get_single_value("Accounts Settings", "credit_controller") in frappe.get_roles():
|
||||
party_type = "supplier" if doctype == "Purchase Invoice" else "customer"
|
||||
|
||||
@@ -743,7 +743,7 @@ def set_taxes(
|
||||
args.update({"tax_type": "Purchase"})
|
||||
|
||||
if use_for_shopping_cart:
|
||||
args.update({"use_for_shopping_cart": use_for_shopping_cart})
|
||||
args.update({"use_for_shopping_cart": cint(use_for_shopping_cart)})
|
||||
|
||||
return get_tax_template(posting_date, args)
|
||||
|
||||
|
||||
@@ -129,8 +129,6 @@ class ReceivablePayableReport:
|
||||
self.fetch_ple_in_buffered_cursor()
|
||||
elif self.ple_fetch_method == "UnBuffered Cursor":
|
||||
self.fetch_ple_in_unbuffered_cursor()
|
||||
elif self.ple_fetch_method == "Raw SQL":
|
||||
self.fetch_ple_in_sql_procedures()
|
||||
|
||||
# Build delivery note map against all sales invoices
|
||||
self.build_delivery_note_map()
|
||||
@@ -321,81 +319,6 @@ class ReceivablePayableReport:
|
||||
row.paid -= amount
|
||||
row.paid_in_account_currency -= amount_in_account_currency
|
||||
|
||||
def fetch_ple_in_sql_procedures(self):
|
||||
self.proc = InitSQLProceduresForAR()
|
||||
|
||||
build_balance = f"""
|
||||
begin not atomic
|
||||
declare done boolean default false;
|
||||
declare rec1 row type of `{self.proc._row_def_table_name}`;
|
||||
declare ple cursor for {self.ple_query.get_sql()};
|
||||
declare continue handler for not found set done = true;
|
||||
|
||||
open ple;
|
||||
fetch ple into rec1;
|
||||
while not done do
|
||||
call {self.proc.init_procedure_name}(rec1);
|
||||
fetch ple into rec1;
|
||||
end while;
|
||||
close ple;
|
||||
|
||||
set done = false;
|
||||
open ple;
|
||||
fetch ple into rec1;
|
||||
while not done do
|
||||
call {self.proc.allocate_procedure_name}(rec1);
|
||||
fetch ple into rec1;
|
||||
end while;
|
||||
close ple;
|
||||
end;
|
||||
"""
|
||||
frappe.db.sql(build_balance)
|
||||
|
||||
balances = frappe.db.sql(
|
||||
f"""select
|
||||
name,
|
||||
voucher_type,
|
||||
voucher_no,
|
||||
party,
|
||||
party_account `account`,
|
||||
posting_date,
|
||||
account_currency,
|
||||
cost_center,
|
||||
project,
|
||||
sum(invoiced) `invoiced`,
|
||||
sum(paid) `paid`,
|
||||
sum(credit_note) `credit_note`,
|
||||
sum(invoiced) - sum(paid) - sum(credit_note) `outstanding`,
|
||||
sum(invoiced_in_account_currency) `invoiced_in_account_currency`,
|
||||
sum(paid_in_account_currency) `paid_in_account_currency`,
|
||||
sum(credit_note_in_account_currency) `credit_note_in_account_currency`,
|
||||
sum(invoiced_in_account_currency) - sum(paid_in_account_currency) - sum(credit_note_in_account_currency) `outstanding_in_account_currency`
|
||||
from `{self.proc._voucher_balance_name}` group by name order by posting_date;""",
|
||||
as_dict=True,
|
||||
)
|
||||
for x in balances:
|
||||
if self.filters.get("ignore_accounts"):
|
||||
key = (x.voucher_type, x.voucher_no, x.party)
|
||||
else:
|
||||
key = (x.account, x.voucher_type, x.voucher_no, x.party)
|
||||
|
||||
_d = self.build_voucher_dict(x)
|
||||
for field in [
|
||||
"invoiced",
|
||||
"paid",
|
||||
"credit_note",
|
||||
"outstanding",
|
||||
"invoiced_in_account_currency",
|
||||
"paid_in_account_currency",
|
||||
"credit_note_in_account_currency",
|
||||
"outstanding_in_account_currency",
|
||||
"cost_center",
|
||||
"project",
|
||||
]:
|
||||
_d[field] = x.get(field)
|
||||
|
||||
self.voucher_balance[key] = _d
|
||||
|
||||
def update_sub_total_row(self, row, party):
|
||||
total_row = self.total_row_map.get(party)
|
||||
|
||||
@@ -1390,136 +1313,3 @@ def get_party_group_with_children(party, party_groups):
|
||||
frappe.throw(_("{0}: {1} does not exist").format(group_dtype, d))
|
||||
|
||||
return list(set(all_party_groups))
|
||||
|
||||
|
||||
class InitSQLProceduresForAR:
|
||||
"""
|
||||
Initialize SQL Procedures, Functions and Temporary tables to build Receivable / Payable report
|
||||
"""
|
||||
|
||||
_varchar_type = get_definition("Data")
|
||||
_currency_type = get_definition("Currency")
|
||||
# Temporary Tables
|
||||
_voucher_balance_name = "_ar_voucher_balance"
|
||||
_voucher_balance_definition = f"""
|
||||
create temporary table `{_voucher_balance_name}`(
|
||||
name {_varchar_type},
|
||||
voucher_type {_varchar_type},
|
||||
voucher_no {_varchar_type},
|
||||
party {_varchar_type},
|
||||
party_account {_varchar_type},
|
||||
posting_date date,
|
||||
account_currency {_varchar_type},
|
||||
cost_center {_varchar_type},
|
||||
project {_varchar_type},
|
||||
invoiced {_currency_type},
|
||||
paid {_currency_type},
|
||||
credit_note {_currency_type},
|
||||
invoiced_in_account_currency {_currency_type},
|
||||
paid_in_account_currency {_currency_type},
|
||||
credit_note_in_account_currency {_currency_type}) engine=memory;
|
||||
"""
|
||||
|
||||
_row_def_table_name = "_ar_ple_row"
|
||||
_row_def_table_definition = f"""
|
||||
create temporary table `{_row_def_table_name}`(
|
||||
name {_varchar_type},
|
||||
account {_varchar_type},
|
||||
voucher_type {_varchar_type},
|
||||
voucher_no {_varchar_type},
|
||||
against_voucher_type {_varchar_type},
|
||||
against_voucher_no {_varchar_type},
|
||||
party_type {_varchar_type},
|
||||
cost_center {_varchar_type},
|
||||
project {_varchar_type},
|
||||
party {_varchar_type},
|
||||
posting_date date,
|
||||
due_date date,
|
||||
account_currency {_varchar_type},
|
||||
amount {_currency_type},
|
||||
amount_in_account_currency {_currency_type}) engine=memory;
|
||||
"""
|
||||
|
||||
# Function
|
||||
genkey_function_name = "ar_genkey"
|
||||
genkey_function_sql = f"""
|
||||
create function `{genkey_function_name}`(rec row type of `{_row_def_table_name}`, allocate bool) returns char(40)
|
||||
begin
|
||||
if allocate then
|
||||
return sha1(concat_ws(',', rec.account, rec.against_voucher_type, rec.against_voucher_no, rec.party));
|
||||
else
|
||||
return sha1(concat_ws(',', rec.account, rec.voucher_type, rec.voucher_no, rec.party));
|
||||
end if;
|
||||
end
|
||||
"""
|
||||
|
||||
# Procedures
|
||||
init_procedure_name = "ar_init_tmp_table"
|
||||
init_procedure_sql = f"""
|
||||
create procedure ar_init_tmp_table(in ple row type of `{_row_def_table_name}`)
|
||||
begin
|
||||
if not exists (select name from `{_voucher_balance_name}` where name = `{genkey_function_name}`(ple, false))
|
||||
then
|
||||
insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, false), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, ple.project, 0, 0, 0, 0, 0, 0);
|
||||
end if;
|
||||
end;
|
||||
"""
|
||||
|
||||
allocate_procedure_name = "ar_allocate_to_tmp_table"
|
||||
allocate_procedure_sql = f"""
|
||||
create procedure ar_allocate_to_tmp_table(in ple row type of `{_row_def_table_name}`)
|
||||
begin
|
||||
declare invoiced {_currency_type} default 0;
|
||||
declare invoiced_in_account_currency {_currency_type} default 0;
|
||||
declare paid {_currency_type} default 0;
|
||||
declare paid_in_account_currency {_currency_type} default 0;
|
||||
declare credit_note {_currency_type} default 0;
|
||||
declare credit_note_in_account_currency {_currency_type} default 0;
|
||||
|
||||
|
||||
if ple.amount > 0 then
|
||||
if (ple.voucher_type in ("Journal Entry", "Payment Entry") and (ple.voucher_no != ple.against_voucher_no)) then
|
||||
set paid = -1 * ple.amount;
|
||||
set paid_in_account_currency = -1 * ple.amount_in_account_currency;
|
||||
else
|
||||
set invoiced = ple.amount;
|
||||
set invoiced_in_account_currency = ple.amount_in_account_currency;
|
||||
end if;
|
||||
else
|
||||
|
||||
if ple.voucher_type in ("Sales Invoice", "Purchase Invoice") then
|
||||
if (ple.voucher_no = ple.against_voucher_no) then
|
||||
set paid = -1 * ple.amount;
|
||||
set paid_in_account_currency = -1 * ple.amount_in_account_currency;
|
||||
else
|
||||
set credit_note = -1 * ple.amount;
|
||||
set credit_note_in_account_currency = -1 * ple.amount_in_account_currency;
|
||||
end if;
|
||||
else
|
||||
set paid = -1 * ple.amount;
|
||||
set paid_in_account_currency = -1 * ple.amount_in_account_currency;
|
||||
end if;
|
||||
|
||||
end if;
|
||||
|
||||
insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, true), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', '', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0);
|
||||
end;
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
existing_procedures = frappe.db.get_routines()
|
||||
|
||||
if self.genkey_function_name not in existing_procedures:
|
||||
frappe.db.sql(self.genkey_function_sql)
|
||||
|
||||
if self.init_procedure_name not in existing_procedures:
|
||||
frappe.db.sql(self.init_procedure_sql)
|
||||
|
||||
if self.allocate_procedure_name not in existing_procedures:
|
||||
frappe.db.sql(self.allocate_procedure_sql)
|
||||
|
||||
frappe.db.sql(f"drop table if exists `{self._voucher_balance_name}`")
|
||||
frappe.db.sql(self._voucher_balance_definition)
|
||||
|
||||
frappe.db.sql(f"drop table if exists `{self._row_def_table_name}`")
|
||||
frappe.db.sql(self._row_def_table_definition)
|
||||
|
||||
@@ -176,10 +176,16 @@ frappe.query_reports["General Ledger"] = {
|
||||
fieldtype: "Check",
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "disable_opening_balance_calculation",
|
||||
label: __("Disable Opening Balance Calculation"),
|
||||
fieldtype: "Check",
|
||||
},
|
||||
{
|
||||
fieldname: "show_opening_entries",
|
||||
label: __("Show Opening Entries"),
|
||||
fieldtype: "Check",
|
||||
depends_on: "eval: !doc.disable_opening_balance_calculation",
|
||||
},
|
||||
{
|
||||
fieldname: "include_default_book_entries",
|
||||
|
||||
@@ -279,7 +279,15 @@ def get_conditions(filters):
|
||||
if filters.get("party"):
|
||||
conditions.append("party in %(party)s")
|
||||
|
||||
if not (
|
||||
if filters.get("disable_opening_balance_calculation"):
|
||||
if not ignore_is_opening:
|
||||
conditions.append("(posting_date >=%(from_date)s or is_opening = 'Yes')")
|
||||
else:
|
||||
conditions.append("posting_date >=%(from_date)s")
|
||||
|
||||
# opening balance calculation is done only if filtered on account/party
|
||||
# so from_date filter is not applied
|
||||
elif not (
|
||||
filters.get("account")
|
||||
or filters.get("party")
|
||||
or filters.get("categorize_by") in ["Categorize by Account", "Categorize by Party"]
|
||||
@@ -398,7 +406,13 @@ def get_data_with_opening_closing(filters, account_details, accounting_dimension
|
||||
# Opening for filtered account
|
||||
data.append(totals.opening)
|
||||
|
||||
if filters.get("categorize_by") != "Categorize by Voucher (Consolidated)":
|
||||
if not filters.get("categorize_by"):
|
||||
all_entries = []
|
||||
for acc_dict in gle_map.values():
|
||||
all_entries.extend(acc_dict.entries)
|
||||
data += all_entries
|
||||
|
||||
elif filters.get("categorize_by") != "Categorize by Voucher (Consolidated)":
|
||||
for _acc, acc_dict in gle_map.items():
|
||||
# acc
|
||||
if acc_dict.entries:
|
||||
@@ -528,7 +542,11 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map, tot
|
||||
group_by_value = gle.get(group_by)
|
||||
gle.voucher_type = gle.voucher_type
|
||||
|
||||
if gle.posting_date < from_date or (cstr(gle.is_opening) == "Yes" and not show_opening_entries):
|
||||
if gle.posting_date < from_date or (
|
||||
cstr(gle.is_opening) == "Yes"
|
||||
and not show_opening_entries
|
||||
and not filters.disable_opening_balance_calculation
|
||||
):
|
||||
if not group_by_voucher_consolidated:
|
||||
update_value_in_dict(gle_map[group_by_value].totals, "opening", gle, True)
|
||||
update_value_in_dict(gle_map[group_by_value].totals, "closing", gle, True)
|
||||
|
||||
@@ -786,19 +786,11 @@ class GrossProfitGenerator:
|
||||
return self.calculate_buying_amount_from_sle(
|
||||
row, my_sle, parenttype, parent, row.item_row, item_code
|
||||
)
|
||||
elif self.delivery_notes.get((row.parent, row.item_code), None):
|
||||
# check if Invoice has delivery notes
|
||||
dn = self.delivery_notes.get((row.parent, row.item_code))
|
||||
parenttype, parent, item_row, dn_warehouse = (
|
||||
"Delivery Note",
|
||||
dn["delivery_note"],
|
||||
dn["item_row"],
|
||||
dn["warehouse"],
|
||||
)
|
||||
my_sle = self.get_stock_ledger_entries(item_code, dn_warehouse)
|
||||
return self.calculate_buying_amount_from_sle(
|
||||
row, my_sle, parenttype, parent, item_row, item_code
|
||||
)
|
||||
elif row.item_row and self.delivery_notes.get(row.item_row):
|
||||
dn = self.delivery_notes[row.item_row]
|
||||
if flt(dn.total_qty):
|
||||
return flt(row.qty) * flt(dn.total_incoming_value) / flt(dn.total_qty)
|
||||
return flt(row.qty) * self.get_average_buying_rate(row, item_code)
|
||||
elif row.sales_order and row.so_detail:
|
||||
incoming_amount = self.get_buying_amount_from_so_dn(row.sales_order, row.so_detail, item_code)
|
||||
if incoming_amount:
|
||||
@@ -1049,25 +1041,29 @@ class GrossProfitGenerator:
|
||||
def get_delivery_notes(self):
|
||||
self.delivery_notes = frappe._dict({})
|
||||
if self.si_list:
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
invoices = [x.parent for x in self.si_list]
|
||||
dni = qb.DocType("Delivery Note Item")
|
||||
delivery_notes = (
|
||||
qb.from_(dni)
|
||||
.select(
|
||||
dni.against_sales_invoice.as_("sales_invoice"),
|
||||
dni.item_code,
|
||||
dni.warehouse,
|
||||
dni.parent.as_("delivery_note"),
|
||||
dni.name.as_("item_row"),
|
||||
dni.si_detail,
|
||||
Sum(dni.stock_qty * dni.incoming_rate).as_("total_incoming_value"),
|
||||
Sum(dni.stock_qty).as_("total_qty"),
|
||||
)
|
||||
.where((dni.docstatus == 1) & (dni.against_sales_invoice.isin(invoices)))
|
||||
.groupby(dni.against_sales_invoice, dni.item_code)
|
||||
.orderby(dni.creation, order=Order.desc)
|
||||
.where(
|
||||
(dni.docstatus == 1)
|
||||
& (dni.against_sales_invoice.isin(invoices))
|
||||
& (dni.si_detail.isnotnull())
|
||||
& (dni.si_detail != "")
|
||||
)
|
||||
.groupby(dni.si_detail)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
for entry in delivery_notes:
|
||||
self.delivery_notes[(entry.sales_invoice, entry.item_code)] = entry
|
||||
self.delivery_notes[entry.si_detail] = entry
|
||||
|
||||
def group_items_by_invoice(self):
|
||||
"""
|
||||
|
||||
@@ -1934,8 +1934,9 @@ def create_payment_ledger_entry(
|
||||
ple = frappe.get_doc(entry)
|
||||
|
||||
if cancel:
|
||||
delink_original_entry(ple, partial_cancel=partial_cancel)
|
||||
if is_immutable_ledger_enabled():
|
||||
if not is_immutable_ledger_enabled():
|
||||
delink_original_entry(ple, partial_cancel=partial_cancel)
|
||||
else:
|
||||
ple.delinked = 0
|
||||
ple.posting_date = frappe.form_dict.get("posting_date") or getdate()
|
||||
|
||||
@@ -2027,6 +2028,7 @@ def delink_original_entry(pl_entry, partial_cancel=False):
|
||||
qb.update(ple)
|
||||
.set(ple.modified, now())
|
||||
.set(ple.modified_by, frappe.session.user)
|
||||
.set(ple.delinked, True)
|
||||
.where(
|
||||
(ple.company == pl_entry.company)
|
||||
& (ple.account_type == pl_entry.account_type)
|
||||
@@ -2043,9 +2045,6 @@ def delink_original_entry(pl_entry, partial_cancel=False):
|
||||
if partial_cancel:
|
||||
query = query.where(ple.voucher_detail_no == pl_entry.voucher_detail_no)
|
||||
|
||||
if not is_immutable_ledger_enabled():
|
||||
query = query.set(ple.delinked, True)
|
||||
|
||||
query.run()
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.contacts.doctype.contact.contact import get_full_name
|
||||
from frappe.core.doctype.communication.email import make
|
||||
from frappe.desk.form.load import get_attachments
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
@@ -272,12 +273,20 @@ class RequestforQuotation(BuyingController):
|
||||
supplier_doc.save()
|
||||
|
||||
def create_user(self, rfq_supplier, link):
|
||||
contact_name = None
|
||||
if rfq_supplier.contact:
|
||||
name_fields = frappe.get_value(
|
||||
"Contact", rfq_supplier.contact, ["first_name", "middle_name", "last_name"]
|
||||
)
|
||||
if name_fields:
|
||||
contact_name = get_full_name(*name_fields)
|
||||
|
||||
user = frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"send_welcome_email": 0,
|
||||
"email": rfq_supplier.email_id,
|
||||
"first_name": rfq_supplier.supplier_name or rfq_supplier.supplier,
|
||||
"first_name": contact_name or rfq_supplier.supplier_name or rfq_supplier.supplier,
|
||||
"user_type": "Website User",
|
||||
"redirect_url": link,
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
|
||||
from erpnext.stock.get_item_details import (
|
||||
_get_item_tax_template,
|
||||
_get_item_tax_template_from_item_group,
|
||||
get_bin_details,
|
||||
get_conversion_factor,
|
||||
get_item_details,
|
||||
get_item_tax_map,
|
||||
@@ -3704,6 +3705,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child
|
||||
child_item.warehouse = get_item_warehouse(item, p_doc, overwrite_warehouse=True)
|
||||
conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor"))
|
||||
child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor
|
||||
child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company")))
|
||||
|
||||
if child_doctype in ["Purchase Order Item", "Supplier Quotation Item"]:
|
||||
# Initialized value will update in parent validation
|
||||
|
||||
@@ -364,17 +364,7 @@ class BuyingController(SubcontractingController):
|
||||
get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0
|
||||
)
|
||||
|
||||
net_rate = (
|
||||
flt(
|
||||
(item.base_net_amount / item.received_qty) * item.qty,
|
||||
item.precision("base_net_amount"),
|
||||
)
|
||||
if item.received_qty
|
||||
and frappe.get_single_value(
|
||||
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
|
||||
)
|
||||
else item.base_net_amount
|
||||
)
|
||||
net_rate = item.base_net_amount
|
||||
if item.sales_incoming_rate: # for internal transfer
|
||||
net_rate = item.qty * item.sales_incoming_rate
|
||||
|
||||
|
||||
@@ -209,7 +209,9 @@ def create_variant(item, args, use_template_image=False):
|
||||
variant_attributes = []
|
||||
|
||||
for d in template.attributes:
|
||||
variant_attributes.append({"attribute": d.attribute, "attribute_value": args.get(d.attribute)})
|
||||
attribute_value = args.get(_(d.attribute)) or args.get(d.attribute)
|
||||
if attribute_value:
|
||||
variant_attributes.append({"attribute": d.attribute, "attribute_value": attribute_value})
|
||||
|
||||
variant.set("attributes", variant_attributes)
|
||||
copy_attributes_to_variant(template, variant)
|
||||
@@ -228,6 +230,12 @@ def enqueue_multiple_variant_creation(item, args, use_template_image=False):
|
||||
# There can be innumerable attribute combinations, enqueue
|
||||
if isinstance(args, str):
|
||||
variants = json.loads(args)
|
||||
else:
|
||||
variants = args
|
||||
variants = {key: values for key, values in variants.items() if values}
|
||||
if not variants:
|
||||
frappe.throw(_("Please select at least one attribute value"))
|
||||
|
||||
total_variants = 1
|
||||
for key in variants:
|
||||
total_variants *= len(variants[key])
|
||||
@@ -251,6 +259,7 @@ def create_multiple_variants(item, args, use_template_image=False):
|
||||
count = 0
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
args = {key: values for key, values in args.items() if values}
|
||||
|
||||
template_item = frappe.get_doc("Item", item)
|
||||
args_set = generate_keyed_value_combinations(args)
|
||||
@@ -285,6 +294,9 @@ def generate_keyed_value_combinations(args):
|
||||
|
||||
"""
|
||||
# Return empty list if empty
|
||||
if not args:
|
||||
return []
|
||||
args = {key: values for key, values in args.items() if values}
|
||||
if not args:
|
||||
return []
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from pypika import Order
|
||||
import erpnext
|
||||
from erpnext.accounts.utils import build_qb_match_conditions
|
||||
from erpnext.stock.get_item_details import _get_item_tax_template
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
|
||||
|
||||
# searches for active employees
|
||||
@@ -369,10 +370,14 @@ def get_delivery_notes_to_be_billed(
|
||||
.where((DeliveryNote.docstatus == 1) & (DeliveryNote.is_return == 0) & (DeliveryNote.per_billed > 0))
|
||||
)
|
||||
|
||||
query = frappe.qb.get_query(
|
||||
"Delivery Note",
|
||||
fields=fields,
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(DeliveryNote)
|
||||
.select(*[DeliveryNote[f] for f in fields])
|
||||
.where(
|
||||
query.where(
|
||||
(DeliveryNote.docstatus == 1)
|
||||
& (DeliveryNote.status.notin(["Stopped", "Closed"]))
|
||||
& (DeliveryNote[searchfield].like(f"%{txt}%"))
|
||||
@@ -386,12 +391,11 @@ def get_delivery_notes_to_be_billed(
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderby(DeliveryNote[searchfield], order=Order.asc)
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
)
|
||||
if filters and isinstance(filters, dict):
|
||||
for key, value in filters.items():
|
||||
query = query.where(DeliveryNote[key] == value)
|
||||
|
||||
query = query.orderby(DeliveryNote[searchfield], order=Order.asc).limit(page_len).offset(start)
|
||||
return query.run(as_dict=as_dict)
|
||||
|
||||
|
||||
@@ -476,6 +480,13 @@ def get_batches_from_stock_ledger_entries(searchfields, txt, filters, start=0, p
|
||||
.limit(page_len)
|
||||
)
|
||||
|
||||
if not filters.get("is_inward"):
|
||||
if filters.get("posting_date") and filters.get("posting_time"):
|
||||
query = query.where(
|
||||
stock_ledger_entry.posting_datetime
|
||||
<= get_combine_datetime(filters.get("posting_date"), filters.get("posting_time"))
|
||||
)
|
||||
|
||||
if not filters.get("include_expired_batches"):
|
||||
query = query.where((batch_table.expiry_date >= expiry_date) | (batch_table.expiry_date.isnull()))
|
||||
|
||||
@@ -529,6 +540,13 @@ def get_batches_from_serial_and_batch_bundle(searchfields, txt, filters, start=0
|
||||
.limit(page_len)
|
||||
)
|
||||
|
||||
if not filters.get("is_inward"):
|
||||
if filters.get("posting_date") and filters.get("posting_time"):
|
||||
bundle_query = bundle_query.where(
|
||||
stock_ledger_entry.posting_datetime
|
||||
<= get_combine_datetime(filters.get("posting_date"), filters.get("posting_time"))
|
||||
)
|
||||
|
||||
if not filters.get("include_expired_batches"):
|
||||
bundle_query = bundle_query.where(
|
||||
(batch_table.expiry_date >= expiry_date) | (batch_table.expiry_date.isnull())
|
||||
|
||||
@@ -240,10 +240,10 @@ class StatusUpdater(Document):
|
||||
|
||||
# get unique transactions to update
|
||||
for d in self.get_all_children():
|
||||
if hasattr(d, "qty") and d.qty < 0 and not self.get("is_return"):
|
||||
if hasattr(d, "qty") and flt(d.qty) < 0 and not self.get("is_return"):
|
||||
frappe.throw(_("For an item {0}, quantity must be positive number").format(d.item_code))
|
||||
|
||||
if hasattr(d, "qty") and d.qty > 0 and self.get("is_return"):
|
||||
if hasattr(d, "qty") and flt(d.qty) > 0 and self.get("is_return"):
|
||||
frappe.throw(_("For an item {0}, quantity must be negative number").format(d.item_code))
|
||||
|
||||
if not frappe.db.get_single_value("Selling Settings", "allow_negative_rates_for_items"):
|
||||
|
||||
@@ -1680,7 +1680,7 @@ def repost_required_for_queue(doc: StockController) -> bool:
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def check_item_quality_inspection(doctype, items):
|
||||
def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str | list[dict]):
|
||||
if isinstance(items, str):
|
||||
items = json.loads(items)
|
||||
|
||||
@@ -1692,13 +1692,30 @@ def check_item_quality_inspection(doctype, items):
|
||||
"Delivery Note": "inspection_required_before_delivery",
|
||||
}
|
||||
|
||||
items_to_remove = []
|
||||
for item in items:
|
||||
if not frappe.db.get_value("Item", item.get("item_code"), inspection_fieldname_map.get(doctype)):
|
||||
items_to_remove.append(item)
|
||||
items = [item for item in items if item not in items_to_remove]
|
||||
inspection_fieldname = inspection_fieldname_map.get(doctype)
|
||||
if inspection_fieldname is None:
|
||||
return []
|
||||
|
||||
return items
|
||||
allow_after_transaction = cint(docstatus) == 1 and frappe.get_single_value(
|
||||
"Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery"
|
||||
)
|
||||
|
||||
if allow_after_transaction:
|
||||
return items
|
||||
|
||||
item_codes = list({item.get("item_code") for item in items})
|
||||
|
||||
Item = frappe.qb.DocType("Item")
|
||||
results = (
|
||||
frappe.qb.from_(Item)
|
||||
.select(Item.name)
|
||||
.where((Item.name.isin(item_codes)) & (Item[inspection_fieldname] == 1))
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
inspection_required_items = {row.name for row in results}
|
||||
|
||||
return [item for item in items if item.get("item_code") in inspection_required_items]
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -183,10 +183,6 @@ class calculate_taxes_and_totals:
|
||||
return
|
||||
|
||||
if not self.discount_amount_applied:
|
||||
bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value(
|
||||
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
|
||||
)
|
||||
|
||||
do_not_round_fields = ["valuation_rate", "incoming_rate"]
|
||||
|
||||
for item in self.doc.items:
|
||||
@@ -244,13 +240,7 @@ class calculate_taxes_and_totals:
|
||||
elif not item.qty and self.doc.get("is_debit_note"):
|
||||
item.amount = flt(item.rate, item.precision("amount"))
|
||||
else:
|
||||
qty = (
|
||||
(item.qty + item.rejected_qty)
|
||||
if bill_for_rejected_quantity_in_purchase_invoice
|
||||
and self.doc.doctype == "Purchase Receipt"
|
||||
else item.qty
|
||||
)
|
||||
item.amount = flt(item.rate * qty, item.precision("amount"))
|
||||
item.amount = flt(item.rate * item.qty, item.precision("amount"))
|
||||
|
||||
item.net_amount = item.amount
|
||||
|
||||
@@ -382,16 +372,9 @@ class calculate_taxes_and_totals:
|
||||
self.doc.total
|
||||
) = self.doc.base_total = self.doc.net_total = self.doc.base_net_total = 0.0
|
||||
|
||||
bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value(
|
||||
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
|
||||
)
|
||||
for item in self._items:
|
||||
self.doc.total += item.amount
|
||||
self.doc.total_qty += (
|
||||
(item.qty + item.rejected_qty)
|
||||
if bill_for_rejected_quantity_in_purchase_invoice and self.doc.doctype == "Purchase Receipt"
|
||||
else item.qty
|
||||
)
|
||||
self.doc.total_qty += item.qty
|
||||
self.doc.base_total += item.base_amount
|
||||
self.doc.net_total += item.net_amount
|
||||
self.doc.base_net_total += item.base_net_amount
|
||||
|
||||
@@ -3,7 +3,11 @@ import unittest
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.controllers.item_variant import copy_attributes_to_variant, make_variant_item_code
|
||||
from erpnext.controllers.item_variant import (
|
||||
copy_attributes_to_variant,
|
||||
generate_keyed_value_combinations,
|
||||
make_variant_item_code,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import set_item_variant_settings
|
||||
from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
|
||||
create_quality_inspection_parameter,
|
||||
@@ -17,6 +21,19 @@ class TestItemVariant(unittest.TestCase):
|
||||
variant = make_item_variant()
|
||||
self.assertEqual(variant.get("quality_inspection_template"), "_Test QC Template")
|
||||
|
||||
def test_generate_keyed_value_combinations_ignores_empty_attributes(self):
|
||||
combinations = generate_keyed_value_combinations(
|
||||
{"Test Colour": ["Red", "Blue"], "Test Size": ["Small", "Large"], "Test Fit": []}
|
||||
)
|
||||
|
||||
self.assertEqual(len(combinations), 4)
|
||||
self.assertNotIn("Test Fit", combinations[0])
|
||||
|
||||
single_attribute_combinations = generate_keyed_value_combinations(
|
||||
{"Test Colour": ["Red", "Blue"], "Test Size": []}
|
||||
)
|
||||
self.assertEqual(single_attribute_combinations, [{"Test Colour": "Red"}, {"Test Colour": "Blue"}])
|
||||
|
||||
|
||||
def create_variant_with_tables(item, args):
|
||||
if isinstance(args, str):
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"custom_fields": [
|
||||
{
|
||||
"_assign": null,
|
||||
"_comments": null,
|
||||
"_liked_by": null,
|
||||
"_user_tags": null,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"collapsible_depends_on": null,
|
||||
"columns": 0,
|
||||
"creation": "2019-12-02 11:00:03.432994",
|
||||
"default": null,
|
||||
"depends_on": null,
|
||||
"description": null,
|
||||
"docstatus": 0,
|
||||
"dt": "Contact",
|
||||
"fetch_from": null,
|
||||
"fetch_if_empty": 0,
|
||||
"fieldname": "is_billing_contact",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 0,
|
||||
"idx": 27,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"insert_after": "is_primary_contact",
|
||||
"label": "Is Billing Contact",
|
||||
"length": 0,
|
||||
"modified": "2019-12-02 11:00:03.432994",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Contact-is_billing_contact",
|
||||
"no_copy": 0,
|
||||
"options": null,
|
||||
"owner": "Administrator",
|
||||
"parent": null,
|
||||
"parentfield": null,
|
||||
"parenttype": null,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"print_width": null,
|
||||
"read_only": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0,
|
||||
"width": null
|
||||
}
|
||||
],
|
||||
"custom_perms": [],
|
||||
"doctype": "Contact",
|
||||
"property_setters": [],
|
||||
"sync_on_migrate": 1
|
||||
}
|
||||
@@ -438,7 +438,7 @@ frappe.ui.form.on("BOM", {
|
||||
},
|
||||
|
||||
routing(frm) {
|
||||
if (frm.doc.routing) {
|
||||
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations) {
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: "get_routing",
|
||||
|
||||
@@ -1367,18 +1367,71 @@ def add_non_stock_items_cost(stock_entry, work_order, expense_account):
|
||||
|
||||
|
||||
def add_operations_cost(stock_entry, work_order=None, expense_account=None):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry import get_operating_cost_per_unit
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry import (
|
||||
get_consumed_operating_cost,
|
||||
get_operating_cost_per_unit,
|
||||
)
|
||||
|
||||
operating_cost_per_unit = get_operating_cost_per_unit(work_order, stock_entry.bom_no)
|
||||
|
||||
if operating_cost_per_unit:
|
||||
stock_entry.append(
|
||||
"additional_costs",
|
||||
{
|
||||
def append_operating_cost(amount, operation=None, qty=None):
|
||||
if amount:
|
||||
row = {
|
||||
"expense_account": expense_account,
|
||||
"description": _("Operating Cost as per Work Order / BOM"),
|
||||
"amount": operating_cost_per_unit * flt(stock_entry.fg_completed_qty),
|
||||
},
|
||||
"amount": flt(
|
||||
amount,
|
||||
frappe.get_precision("Landed Cost Taxes and Charges", "amount"),
|
||||
),
|
||||
"has_operating_cost": 1,
|
||||
}
|
||||
if operation:
|
||||
row["operation_id"] = operation.name
|
||||
if qty is not None:
|
||||
row["qty"] = qty
|
||||
stock_entry.append(
|
||||
"additional_costs",
|
||||
row,
|
||||
)
|
||||
|
||||
if (
|
||||
work_order
|
||||
and stock_entry.bom_no
|
||||
and frappe.db.get_single_value("Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies")
|
||||
and work_order.get("use_multi_level_bom")
|
||||
):
|
||||
operating_cost_per_unit = get_operating_cost_per_unit(work_order, stock_entry.bom_no)
|
||||
append_operating_cost(
|
||||
operating_cost_per_unit * flt(stock_entry.fg_completed_qty),
|
||||
qty=flt(stock_entry.fg_completed_qty),
|
||||
)
|
||||
elif work_order and work_order.get("operations"):
|
||||
for operation in work_order.get("operations"):
|
||||
qty = flt(stock_entry.fg_completed_qty)
|
||||
amount = 0
|
||||
|
||||
if flt(operation.completed_qty):
|
||||
consumed_cost = get_consumed_operating_cost(
|
||||
work_order.name, stock_entry.bom_no, operation.name
|
||||
)
|
||||
remaining_cost = flt(
|
||||
flt(operation.actual_operating_cost) - flt(consumed_cost.get("consumed_cost")),
|
||||
operation.precision("actual_operating_cost"),
|
||||
)
|
||||
remaining_qty = flt(operation.completed_qty) - flt(consumed_cost.get("consumed_qty"))
|
||||
|
||||
if remaining_cost <= 0 or remaining_qty <= 0:
|
||||
continue
|
||||
|
||||
qty = min(remaining_qty, flt(stock_entry.fg_completed_qty))
|
||||
amount = remaining_cost / remaining_qty * qty
|
||||
elif work_order.qty:
|
||||
amount = flt(operation.planned_operating_cost) / flt(work_order.qty) * qty
|
||||
|
||||
append_operating_cost(amount, operation=operation, qty=qty)
|
||||
else:
|
||||
operating_cost_per_unit = get_operating_cost_per_unit(work_order, stock_entry.bom_no)
|
||||
append_operating_cost(
|
||||
operating_cost_per_unit * flt(stock_entry.fg_completed_qty),
|
||||
qty=flt(stock_entry.fg_completed_qty),
|
||||
)
|
||||
|
||||
if work_order and work_order.additional_operating_cost and work_order.qty:
|
||||
|
||||
@@ -126,11 +126,13 @@
|
||||
"label": "Image"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fetch_from": "operation.batch_size",
|
||||
"fetch_if_empty": 1,
|
||||
"fieldname": "batch_size",
|
||||
"fieldtype": "Int",
|
||||
"label": "Batch Size"
|
||||
"fieldtype": "Float",
|
||||
"label": "Batch Size",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.parenttype == \"Routing\" || !parent.routing",
|
||||
@@ -196,13 +198,14 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-17 15:33:28.495850",
|
||||
"modified": "2026-05-27 12:09:44.797434",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "BOM Operation",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class BOMOperation(Document):
|
||||
base_cost_per_unit: DF.Float
|
||||
base_hour_rate: DF.Currency
|
||||
base_operating_cost: DF.Currency
|
||||
batch_size: DF.Int
|
||||
batch_size: DF.Float
|
||||
cost_per_unit: DF.Float
|
||||
description: DF.TextEditor | None
|
||||
fixed_time: DF.Check
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2018-07-09 17:23:29.518745",
|
||||
"doctype": "DocType",
|
||||
@@ -135,6 +136,7 @@
|
||||
"fieldname": "wip_warehouse",
|
||||
"fieldtype": "Link",
|
||||
"label": "WIP Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"options": "Warehouse",
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -511,7 +513,7 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-04 15:47:54.514290",
|
||||
"modified": "2026-05-12 12:17:17.750857",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Job Card",
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Literal
|
||||
import frappe
|
||||
from frappe.test_runner import make_test_records
|
||||
from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
from frappe.utils import random_string
|
||||
from frappe.utils import flt, random_string
|
||||
from frappe.utils.data import add_to_date, now, today
|
||||
|
||||
from erpnext.manufacturing.doctype.job_card.job_card import (
|
||||
@@ -697,6 +697,403 @@ class TestJobCard(FrappeTestCase):
|
||||
self.assertEqual(wo_doc.process_loss_qty, 2)
|
||||
self.assertEqual(wo_doc.status, "Completed")
|
||||
|
||||
def test_op_cost_calculation(self):
|
||||
from erpnext.manufacturing.doctype.routing.test_routing import (
|
||||
create_routing,
|
||||
setup_bom,
|
||||
setup_operations,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import make_job_card
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_stock_entry_for_wo,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
suffix = random_string(5)
|
||||
workstation = make_workstation(
|
||||
workstation_name=f"Test Workstation Z {suffix}", hour_rate_rent=240, hour_rate_labour=0
|
||||
)
|
||||
workstation.update(
|
||||
{
|
||||
"hour_rate_rent": 240,
|
||||
"hour_rate_labour": 0,
|
||||
"hour_rate_electricity": 0,
|
||||
"hour_rate_consumable": 0,
|
||||
}
|
||||
)
|
||||
workstation.save()
|
||||
operations = [
|
||||
{
|
||||
"operation": f"Test Operation A1 {suffix}",
|
||||
"workstation": workstation.name,
|
||||
"time_in_mins": 30,
|
||||
},
|
||||
]
|
||||
|
||||
warehouse = create_warehouse(f"Test Warehouse 123 for Job Card {suffix}")
|
||||
setup_operations(operations)
|
||||
|
||||
item_code = f"Test Job Card Process Qty Item {suffix}"
|
||||
for item in [item_code, item_code + "RM 1", item_code + "RM 2"]:
|
||||
if not frappe.db.exists("Item", item):
|
||||
make_item(
|
||||
item,
|
||||
{
|
||||
"item_name": item,
|
||||
"stock_uom": "Nos",
|
||||
"is_stock_item": 1,
|
||||
},
|
||||
)
|
||||
|
||||
routing_doc = create_routing(routing_name="Testing Route", operations=operations)
|
||||
bom_doc = setup_bom(
|
||||
item_code=item_code,
|
||||
routing=routing_doc.name,
|
||||
raw_materials=[item_code + "RM 1", item_code + "RM 2"],
|
||||
source_warehouse=warehouse,
|
||||
)
|
||||
|
||||
for row in bom_doc.items:
|
||||
make_stock_entry(
|
||||
item_code=row.item_code,
|
||||
target=row.source_warehouse,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
wo_doc = make_wo_order_test_record(
|
||||
production_item=item_code,
|
||||
bom_no=bom_doc.name,
|
||||
qty=10,
|
||||
skip_transfer=1,
|
||||
wip_warehouse=warehouse,
|
||||
source_warehouse=warehouse,
|
||||
)
|
||||
|
||||
first_job_card = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": wo_doc.name, "sequence_id": 1},
|
||||
fields=["name"],
|
||||
order_by="sequence_id",
|
||||
limit=1,
|
||||
)[0].name
|
||||
|
||||
jc = frappe.get_doc("Job Card", first_job_card)
|
||||
from_time = "2025-01-01 09:00:00"
|
||||
for _ in jc.scheduled_time_logs:
|
||||
jc.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": from_time,
|
||||
"to_time": add_to_date(from_time, minutes=1),
|
||||
"completed_qty": 4,
|
||||
},
|
||||
)
|
||||
jc.for_quantity = 4
|
||||
jc.save()
|
||||
jc.submit()
|
||||
|
||||
s1 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 4))
|
||||
s1.submit()
|
||||
|
||||
wo_doc.reload()
|
||||
precision = s1.additional_costs[0].precision("amount")
|
||||
self.assertEqual(
|
||||
flt(s1.additional_costs[0].amount, precision),
|
||||
flt(wo_doc.operations[0].actual_operating_cost, precision),
|
||||
)
|
||||
|
||||
make_job_card(
|
||||
wo_doc.name,
|
||||
[
|
||||
{
|
||||
"name": wo_doc.operations[0].name,
|
||||
"operation": operations[0]["operation"],
|
||||
"workstation": wo_doc.operations[0].workstation,
|
||||
"qty": 6,
|
||||
"pending_qty": 6,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
|
||||
from_time = "2025-01-01 10:00:00"
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": from_time,
|
||||
"to_time": add_to_date(from_time, minutes=2),
|
||||
"completed_qty": 6,
|
||||
},
|
||||
)
|
||||
job_card.for_quantity = 6
|
||||
job_card.save()
|
||||
job_card.submit()
|
||||
|
||||
s2 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 6))
|
||||
wo_doc.reload()
|
||||
precision = s2.additional_costs[0].precision("amount")
|
||||
self.assertEqual(
|
||||
flt(s2.additional_costs[0].amount, precision),
|
||||
flt(wo_doc.operations[0].actual_operating_cost - s1.additional_costs[0].amount, precision),
|
||||
)
|
||||
|
||||
@change_settings("Manufacturing Settings", {"overproduction_percentage_for_work_order": 100})
|
||||
def test_operating_cost_with_overproduction(self):
|
||||
from erpnext.manufacturing.doctype.routing.test_routing import (
|
||||
create_routing,
|
||||
setup_bom,
|
||||
setup_operations,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import make_job_card
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_stock_entry_for_wo,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
suffix = random_string(5)
|
||||
workstation = make_workstation(
|
||||
workstation_name=f"Test Workstation for Overproduction {suffix}",
|
||||
hour_rate_rent=10,
|
||||
hour_rate_labour=10,
|
||||
)
|
||||
workstation.update(
|
||||
{
|
||||
"hour_rate_rent": 10,
|
||||
"hour_rate_labour": 10,
|
||||
"hour_rate_electricity": 0,
|
||||
"hour_rate_consumable": 0,
|
||||
}
|
||||
)
|
||||
workstation.save()
|
||||
operations = [
|
||||
{"operation": f"Test Operation 1 {suffix}", "workstation": workstation.name, "time_in_mins": 30},
|
||||
{"operation": f"Test Operation 2 {suffix}", "workstation": workstation.name, "time_in_mins": 30},
|
||||
]
|
||||
warehouse = create_warehouse(f"Test Warehouse for Overproduction {suffix}")
|
||||
setup_operations(operations)
|
||||
|
||||
fg = make_item(f"Test FG for Overproduction {suffix}", {"stock_uom": "Nos", "is_stock_item": 1})
|
||||
rm = make_item(f"Test RM for Overproduction {suffix}", {"stock_uom": "Nos", "is_stock_item": 1})
|
||||
|
||||
routing_doc = create_routing(routing_name=f"Testing Route {suffix}", operations=operations)
|
||||
bom_doc = setup_bom(
|
||||
item_code=fg.name,
|
||||
routing=routing_doc.name,
|
||||
raw_materials=[rm.name],
|
||||
source_warehouse=warehouse,
|
||||
)
|
||||
|
||||
for row in bom_doc.items:
|
||||
make_stock_entry(
|
||||
item_code=row.item_code,
|
||||
target=row.source_warehouse,
|
||||
qty=100,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
wo_doc = make_wo_order_test_record(
|
||||
production_item=fg.name,
|
||||
bom_no=bom_doc.name,
|
||||
qty=10,
|
||||
skip_transfer=1,
|
||||
source_warehouse=warehouse,
|
||||
)
|
||||
|
||||
first_operation = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": wo_doc.name, "sequence_id": 1},
|
||||
fields=["name"],
|
||||
order_by="sequence_id",
|
||||
limit=1,
|
||||
)[0].name
|
||||
|
||||
jc = frappe.get_doc("Job Card", first_operation)
|
||||
from_time = "2025-01-02 09:00:00"
|
||||
for _ in jc.scheduled_time_logs:
|
||||
jc.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": from_time,
|
||||
"to_time": add_to_date(from_time, days=1),
|
||||
"completed_qty": 4,
|
||||
},
|
||||
)
|
||||
jc.for_quantity = 4
|
||||
jc.save()
|
||||
jc.submit()
|
||||
|
||||
second_operation = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": wo_doc.name, "sequence_id": 2},
|
||||
fields=["name"],
|
||||
order_by="sequence_id",
|
||||
limit=1,
|
||||
)[0].name
|
||||
|
||||
jc = frappe.get_doc("Job Card", second_operation)
|
||||
from_time = "2025-01-05 09:00:00"
|
||||
for _ in jc.scheduled_time_logs:
|
||||
jc.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": from_time,
|
||||
"to_time": add_to_date(from_time, days=2),
|
||||
"completed_qty": 4,
|
||||
},
|
||||
)
|
||||
jc.for_quantity = 4
|
||||
jc.save()
|
||||
jc.submit()
|
||||
|
||||
s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 6)) # overproduction
|
||||
s.submit()
|
||||
|
||||
def assert_operating_costs(stock_entry, qty, previous_entries):
|
||||
wo_doc.reload()
|
||||
for idx, operation in enumerate(wo_doc.operations):
|
||||
consumed_cost = sum(
|
||||
entry.additional_costs[idx].amount for entry in previous_entries if entry.docstatus == 1
|
||||
)
|
||||
consumed_qty = sum(
|
||||
entry.additional_costs[idx].qty for entry in previous_entries if entry.docstatus == 1
|
||||
)
|
||||
remaining_cost = operation.actual_operating_cost - consumed_cost
|
||||
remaining_qty = operation.completed_qty - consumed_qty
|
||||
precision = stock_entry.additional_costs[idx].precision("amount")
|
||||
expected_cost = flt(remaining_cost / remaining_qty * min(remaining_qty, qty), precision)
|
||||
|
||||
self.assertEqual(flt(stock_entry.additional_costs[idx].amount, precision), expected_cost)
|
||||
|
||||
assert_operating_costs(s, 6, [])
|
||||
|
||||
make_job_card(
|
||||
wo_doc.name,
|
||||
[
|
||||
{
|
||||
"name": wo_doc.operations[0].name,
|
||||
"operation": operations[0]["operation"],
|
||||
"workstation": wo_doc.operations[0].workstation,
|
||||
"qty": 2,
|
||||
"pending_qty": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
|
||||
from_time = "2025-01-09 09:00:00"
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": from_time,
|
||||
"to_time": add_to_date(from_time, days=1),
|
||||
"completed_qty": 2,
|
||||
},
|
||||
)
|
||||
job_card.for_quantity = 2
|
||||
job_card.save()
|
||||
job_card.submit()
|
||||
|
||||
make_job_card(
|
||||
wo_doc.name,
|
||||
[
|
||||
{
|
||||
"name": wo_doc.operations[1].name,
|
||||
"operation": operations[1]["operation"],
|
||||
"workstation": wo_doc.operations[1].workstation,
|
||||
"qty": 2,
|
||||
"pending_qty": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
|
||||
from_time = "2025-01-12 09:00:00"
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": from_time,
|
||||
"to_time": add_to_date(from_time, days=2),
|
||||
"completed_qty": 2,
|
||||
},
|
||||
)
|
||||
job_card.for_quantity = 2
|
||||
job_card.save()
|
||||
job_card.submit()
|
||||
|
||||
s2 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 1))
|
||||
s2.submit()
|
||||
|
||||
assert_operating_costs(s2, 1, [s])
|
||||
|
||||
make_job_card(
|
||||
wo_doc.name,
|
||||
[
|
||||
{
|
||||
"name": wo_doc.operations[0].name,
|
||||
"operation": operations[0]["operation"],
|
||||
"workstation": wo_doc.operations[0].workstation,
|
||||
"qty": 2,
|
||||
"pending_qty": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
|
||||
from_time = "2025-01-16 09:00:00"
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": from_time,
|
||||
"to_time": add_to_date(from_time, days=1),
|
||||
"completed_qty": 2,
|
||||
},
|
||||
)
|
||||
job_card.for_quantity = 2
|
||||
job_card.save()
|
||||
job_card.submit()
|
||||
|
||||
make_job_card(
|
||||
wo_doc.name,
|
||||
[
|
||||
{
|
||||
"name": wo_doc.operations[1].name,
|
||||
"operation": operations[1]["operation"],
|
||||
"workstation": wo_doc.operations[1].workstation,
|
||||
"qty": 2,
|
||||
"pending_qty": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
|
||||
from_time = "2025-01-19 09:00:00"
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": from_time,
|
||||
"to_time": add_to_date(from_time, days=2),
|
||||
"completed_qty": 2,
|
||||
},
|
||||
)
|
||||
job_card.for_quantity = 2
|
||||
job_card.save()
|
||||
job_card.submit()
|
||||
|
||||
s3 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 2))
|
||||
s3.submit()
|
||||
|
||||
assert_operating_costs(s3, 2, [s, s2])
|
||||
|
||||
s2.cancel()
|
||||
|
||||
s4 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 3))
|
||||
s4.submit()
|
||||
|
||||
assert_operating_costs(s4, 3, [s, s3])
|
||||
|
||||
|
||||
def create_bom_with_multiple_operations():
|
||||
"Create a BOM with multiple operations and Material Transfer against Job Card"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"creation": "2018-07-09 17:20:44.737289",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -33,6 +34,7 @@
|
||||
"ignore_user_permissions": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Source Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
@@ -105,7 +107,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-04-22 18:50:00.003444",
|
||||
"modified": "2026-05-12 12:22:18.506904",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Job Card Item",
|
||||
@@ -115,4 +117,4 @@
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,21 +12,10 @@ frappe.ui.form.on("Work Order", {
|
||||
frm.ignore_doctypes_on_cancel_all = ["Serial and Batch Bundle"];
|
||||
|
||||
// Set query for warehouses
|
||||
frm.set_query("wip_warehouse", function () {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("source_warehouse", function () {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
frm.events.set_company_filters(frm, "wip_warehouse");
|
||||
frm.events.set_company_filters(frm, "source_warehouse");
|
||||
frm.events.set_company_filters(frm, "fg_warehouse");
|
||||
frm.events.set_company_filters(frm, "scrap_warehouse");
|
||||
|
||||
frm.set_query("source_warehouse", "required_items", function () {
|
||||
return {
|
||||
@@ -44,24 +33,6 @@ frappe.ui.form.on("Work Order", {
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("fg_warehouse", function () {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("scrap_warehouse", function () {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Set query for BOM
|
||||
frm.set_query("bom_no", function () {
|
||||
if (frm.doc.production_item) {
|
||||
@@ -118,6 +89,16 @@ frappe.ui.form.on("Work Order", {
|
||||
});
|
||||
},
|
||||
|
||||
set_company_filters(frm, fieldname) {
|
||||
frm.set_query(fieldname, () => {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
onload: function (frm) {
|
||||
if (!frm.doc.status) frm.doc.status = "Draft";
|
||||
|
||||
@@ -315,7 +296,7 @@ frappe.ui.form.on("Work Order", {
|
||||
{
|
||||
fieldtype: "Data",
|
||||
fieldname: "name",
|
||||
label: __("Operation Id"),
|
||||
label: __("Operation ID"),
|
||||
},
|
||||
{
|
||||
fieldtype: "Float",
|
||||
@@ -385,6 +366,7 @@ frappe.ui.form.on("Work Order", {
|
||||
|
||||
if (pending_qty) {
|
||||
dialog.fields_dict.operations.df.data.push({
|
||||
__checked: 1,
|
||||
name: data.name,
|
||||
operation: data.operation,
|
||||
workstation: data.workstation,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_import": 1,
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2025-04-09 12:09:40.634472",
|
||||
@@ -249,6 +250,7 @@
|
||||
"fieldname": "wip_warehouse",
|
||||
"fieldtype": "Link",
|
||||
"label": "Work-in-Progress Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"mandatory_depends_on": "eval:!doc.skip_transfer || doc.from_wip_warehouse",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
@@ -257,6 +259,7 @@
|
||||
"fieldname": "fg_warehouse",
|
||||
"fieldtype": "Link",
|
||||
"label": "Target Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"options": "Warehouse",
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -269,6 +272,7 @@
|
||||
"fieldname": "scrap_warehouse",
|
||||
"fieldtype": "Link",
|
||||
"label": "Scrap Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
@@ -498,6 +502,7 @@
|
||||
"fieldname": "source_warehouse",
|
||||
"fieldtype": "Link",
|
||||
"label": "Source Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
@@ -602,7 +607,7 @@
|
||||
"image_field": "image",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-21 00:55:45.916224",
|
||||
"modified": "2026-05-19 12:20:38.102403",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order",
|
||||
|
||||
@@ -158,7 +158,7 @@ class WorkOrder(Document):
|
||||
self.calculate_operating_cost()
|
||||
self.validate_qty()
|
||||
self.validate_transfer_against()
|
||||
self.validate_operation_time()
|
||||
self.validate_operations()
|
||||
self.status = self.get_status()
|
||||
self.validate_workstation_type()
|
||||
self.reset_use_multi_level_bom()
|
||||
@@ -1120,9 +1120,12 @@ class WorkOrder(Document):
|
||||
title=_("Missing value"),
|
||||
)
|
||||
|
||||
def validate_operation_time(self):
|
||||
def validate_operations(self):
|
||||
for d in self.operations:
|
||||
if not d.time_in_mins > 0:
|
||||
if not d.batch_size or d.batch_size <= 0:
|
||||
d.batch_size = 1
|
||||
|
||||
if d.time_in_mins <= 0:
|
||||
frappe.throw(_("Operation Time must be greater than 0 for Operation {0}").format(d.operation))
|
||||
|
||||
def update_required_items(self):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"creation": "2016-04-18 07:38:26.314642",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -46,6 +47,7 @@
|
||||
"ignore_user_permissions": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Source Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
@@ -151,7 +153,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-12-02 11:16:05.081613",
|
||||
"modified": "2026-05-12 12:05:16.687866",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order Item",
|
||||
|
||||
@@ -185,10 +185,11 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "batch_size",
|
||||
"fieldtype": "Float",
|
||||
"label": "Batch Size",
|
||||
"read_only": 1
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "sequence_id",
|
||||
@@ -225,14 +226,15 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-05-15 15:10:06.885440",
|
||||
"modified": "2026-05-27 12:56:37.240431",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order Operation",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -433,3 +433,5 @@ erpnext.patches.v15_0.replace_http_with_https_in_sales_partner
|
||||
erpnext.patches.v16_0.add_portal_redirects
|
||||
erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po
|
||||
erpnext.patches.v16_0.depends_on_inv_dimensions
|
||||
erpnext.patches.v16_0.clear_procedures_from_receivable_report
|
||||
erpnext.patches.v16_0.migrate_address_contact_custom_fields
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
frappe.db.sql("drop function if exists ar_genkey")
|
||||
frappe.db.sql("drop procedure if exists ar_init_tmp_table")
|
||||
frappe.db.sql("drop procedure if exists ar_allocate_to_tmp_table")
|
||||
|
||||
if frappe.db.get_single_value("Accounts Settings", "receivable_payable_fetch_method") == "Raw SQL":
|
||||
frappe.db.set_single_value(
|
||||
"Accounts Settings", "receivable_payable_fetch_method", "UnBuffered Cursor"
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.setup.install import create_address_and_contact_custom_fields
|
||||
|
||||
|
||||
def execute():
|
||||
"""Replace fixture-based custom fields on Address and Contact with programmatic ones."""
|
||||
for custom_field in (
|
||||
"Address-tax_category",
|
||||
"Address-is_your_company_address",
|
||||
"Contact-is_billing_contact",
|
||||
):
|
||||
if frappe.db.exists("Custom Field", custom_field):
|
||||
frappe.delete_doc("Custom Field", custom_field, ignore_missing=True, force=True)
|
||||
|
||||
create_address_and_contact_custom_fields()
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"actions": [],
|
||||
"beta": 1,
|
||||
"creation": "2016-04-22 05:27:52.109319",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
@@ -87,7 +86,7 @@
|
||||
],
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2022-12-19 21:10:29.127277",
|
||||
"modified": "2026-05-30 20:51:04.415019",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Portal",
|
||||
"name": "Homepage",
|
||||
@@ -114,6 +113,7 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
|
||||
@@ -176,6 +176,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_global_search": 1,
|
||||
"label": "Customer",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "customer",
|
||||
"oldfieldtype": "Link",
|
||||
"options": "Customer",
|
||||
@@ -190,6 +191,7 @@
|
||||
"fieldname": "sales_order",
|
||||
"fieldtype": "Link",
|
||||
"label": "Sales Order",
|
||||
"no_copy": 1,
|
||||
"options": "Sales Order"
|
||||
},
|
||||
{
|
||||
@@ -462,7 +464,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"max_attachments": 4,
|
||||
"modified": "2025-08-21 17:57:58.314809",
|
||||
"modified": "2026-05-22 16:45:50.762759",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Projects",
|
||||
"name": "Project",
|
||||
|
||||
@@ -176,14 +176,9 @@ erpnext.buying = {
|
||||
callback: (r) => {
|
||||
if (!r.message) return;
|
||||
|
||||
if (!this.frm.doc.billing_address) {
|
||||
this.frm.set_value("billing_address", r.message.primary_address || "");
|
||||
}
|
||||
this.frm.set_value("billing_address", r.message.primary_address || "");
|
||||
|
||||
if (
|
||||
frappe.meta.has_field(this.frm.doc.doctype, "shipping_address") &&
|
||||
!this.frm.doc.shipping_address
|
||||
) {
|
||||
if (frappe.meta.has_field(this.frm.doc.doctype, "shipping_address")) {
|
||||
this.frm.set_value("shipping_address", r.message.shipping_address || "");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2506,11 +2506,29 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
method: "erpnext.controllers.stock_controller.check_item_quality_inspection",
|
||||
args: {
|
||||
doctype: this.frm.doc.doctype,
|
||||
items: this.frm.doc.items
|
||||
docstatus: this.frm.doc.docstatus,
|
||||
items: this.frm.doc.items,
|
||||
},
|
||||
freeze: true,
|
||||
callback: function (r) {
|
||||
r.message.forEach(item => {
|
||||
if (r.message.length == 0) {
|
||||
let type = inspection_type === "Incoming" ? "Purchase" : "Delivery";
|
||||
let fieldname =
|
||||
inspection_type === "Incoming"
|
||||
? "Inspection Required before Purchase"
|
||||
: "Inspection Required before Delivery";
|
||||
|
||||
frappe.msgprint({
|
||||
title: __("Quality Inspection Not Configured"),
|
||||
message: __(`Enable <b>{0}</b> on the Item master to proceed with {1} inspection.`, [
|
||||
fieldname,
|
||||
type,
|
||||
]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
r.message.forEach((item) => {
|
||||
if (me.has_inspection_required(item)) {
|
||||
let dialog_items = dialog.fields_dict.items;
|
||||
dialog_items.df.data.push({
|
||||
|
||||
@@ -472,6 +472,8 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
warehouse:
|
||||
this.item.s_warehouse || this.item.t_warehouse || this.item.warehouse,
|
||||
is_inward: is_inward,
|
||||
posting_date: this.frm.doc.posting_date,
|
||||
posting_time: this.frm.doc.posting_time,
|
||||
include_expired_batches: include_expired_batches,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import erpnext
|
||||
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.regional.report.uae_vat_201.uae_vat_201 import (
|
||||
execute,
|
||||
get_exempt_total,
|
||||
get_standard_rated_expenses_tax,
|
||||
get_standard_rated_expenses_total,
|
||||
@@ -39,6 +40,13 @@ class TestUaeVat201(TestCase):
|
||||
make_item("_Test UAE VAT Zero Rated Item", properties={"is_zero_rated": 1, "is_exempt": 0})
|
||||
make_item("_Test UAE VAT Exempt Item", properties={"is_zero_rated": 0, "is_exempt": 1})
|
||||
|
||||
def test_validate_company_region(self):
|
||||
self.assertRaises(
|
||||
frappe.exceptions.ValidationError,
|
||||
execute,
|
||||
{"company": "_Test Company"},
|
||||
)
|
||||
|
||||
def test_uae_vat_201_report(self):
|
||||
make_sales_invoices()
|
||||
create_purchase_invoices()
|
||||
|
||||
@@ -10,6 +10,13 @@ frappe.query_reports["UAE VAT 201"] = {
|
||||
options: "Company",
|
||||
reqd: 1,
|
||||
default: frappe.defaults.get_user_default("Company"),
|
||||
get_query: function () {
|
||||
return {
|
||||
filters: {
|
||||
country: "United Arab Emirates",
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "from_date",
|
||||
|
||||
@@ -5,13 +5,25 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
from erpnext import get_region
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
validate_company_region(filters)
|
||||
columns = get_columns()
|
||||
data, emirates, amounts_by_emirate = get_data(filters)
|
||||
return columns, data
|
||||
|
||||
|
||||
def validate_company_region(filters):
|
||||
if filters.get("company") and get_region(filters.get("company")) != "United Arab Emirates":
|
||||
frappe.throw(
|
||||
_(
|
||||
"The company {0} is not in United Arab Emirates. UAE VAT 201 report is only available for companies in United Arab Emirates."
|
||||
).format(frappe.bold(filters.get("company")))
|
||||
)
|
||||
|
||||
|
||||
def get_columns():
|
||||
"""Creates a list of dictionaries that are used to generate column headers of the data table."""
|
||||
return [
|
||||
|
||||
@@ -308,7 +308,6 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:(doc.quotation_to=='Customer' && doc.party_name)",
|
||||
"fieldname": "col_break98",
|
||||
"fieldtype": "Column Break",
|
||||
"width": "50%"
|
||||
@@ -1108,7 +1107,7 @@
|
||||
"idx": 82,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-31 17:23:48.875382",
|
||||
"modified": "2026-05-30 17:40:02.667637",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Quotation",
|
||||
|
||||
@@ -385,7 +385,7 @@ class SalesOrder(SellingController):
|
||||
and not cint(d.delivered_by_supplier)
|
||||
):
|
||||
frappe.throw(
|
||||
_("Delivery warehouse required for stock item {0}").format(d.item_code), WarehouseRequired
|
||||
_("Source warehouse required for stock item {0}").format(d.item_code), WarehouseRequired
|
||||
)
|
||||
|
||||
def validate_with_previous_doc(self):
|
||||
|
||||
@@ -377,42 +377,80 @@ def get_past_order_list(search_term, status, limit=20):
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_customer_info(fieldname, customer, value=""):
|
||||
customer_doc = frappe.get_doc("Customer", customer)
|
||||
customer_doc.check_permission("write")
|
||||
|
||||
if fieldname == "loyalty_program":
|
||||
frappe.db.set_value("Customer", customer, "loyalty_program", value)
|
||||
customer_doc.loyalty_program = value
|
||||
else:
|
||||
contact = customer_doc.get("customer_primary_contact")
|
||||
if not contact:
|
||||
Contact = DocType("Contact")
|
||||
DynamicLink = DocType("Dynamic Link")
|
||||
|
||||
contact = frappe.get_cached_value("Customer", customer, "customer_primary_contact")
|
||||
if not contact:
|
||||
contact = frappe.db.sql(
|
||||
"""
|
||||
SELECT parent FROM `tabDynamic Link`
|
||||
WHERE
|
||||
parenttype = 'Contact' AND
|
||||
parentfield = 'links' AND
|
||||
link_doctype = 'Customer' AND
|
||||
link_name = %s
|
||||
""",
|
||||
(customer),
|
||||
as_dict=1,
|
||||
)
|
||||
contact = contact[0].get("parent") if contact else None
|
||||
# Inner join with Contact DocType, to priorities records that have is_primary_contact set.
|
||||
query = (
|
||||
frappe.qb.from_(DynamicLink)
|
||||
.join(Contact)
|
||||
.on(DynamicLink.parent == Contact.name)
|
||||
.select(DynamicLink.parent)
|
||||
.where(
|
||||
(DynamicLink.link_name == customer)
|
||||
& (DynamicLink.parentfield == "links")
|
||||
& (DynamicLink.parenttype == "Contact")
|
||||
& (DynamicLink.link_doctype == "Customer")
|
||||
)
|
||||
.orderby(Contact.is_primary_contact, order=Order.desc)
|
||||
)
|
||||
|
||||
if not contact:
|
||||
new_contact = frappe.new_doc("Contact")
|
||||
new_contact.is_primary_contact = 1
|
||||
new_contact.first_name = customer
|
||||
new_contact.set("links", [{"link_doctype": "Customer", "link_name": customer}])
|
||||
new_contact.save()
|
||||
contact = new_contact.name
|
||||
frappe.db.set_value("Customer", customer, "customer_primary_contact", contact)
|
||||
contacts = query.run(pluck=DynamicLink.parent)
|
||||
|
||||
contact_doc = frappe.get_doc("Contact", contact)
|
||||
if fieldname == "email_id":
|
||||
contact_doc.set("email_ids", [{"email_id": value, "is_primary": 1}])
|
||||
frappe.db.set_value("Customer", customer, "email_id", value)
|
||||
elif fieldname == "mobile_no":
|
||||
contact_doc.set("phone_nos", [{"phone": value, "is_primary_mobile_no": 1}])
|
||||
frappe.db.set_value("Customer", customer, "mobile_no", value)
|
||||
contact_doc.save()
|
||||
contact = contacts[0] if contacts else None
|
||||
|
||||
if not contact:
|
||||
new_contact = frappe.new_doc("Contact")
|
||||
new_contact.is_primary_contact = 1
|
||||
new_contact.first_name = customer
|
||||
new_contact.set("links", [{"link_doctype": "Customer", "link_name": customer}])
|
||||
new_contact.save()
|
||||
contact = new_contact.name
|
||||
|
||||
def set_primary_phone_no_email(field, value):
|
||||
# Create new record instead deleting existing email or phone_no and setting the new row as primary.
|
||||
field_mapper = {
|
||||
"email_ids": {"field": "email_id", "primary": "is_primary"},
|
||||
"phone_nos": {"field": "phone", "primary": "is_primary_mobile_no"},
|
||||
}
|
||||
|
||||
value_already_exists = False
|
||||
for d in contact_doc.get(field):
|
||||
if d.get(field_mapper[field].get("field")) == value and not value_already_exists:
|
||||
d.set(field_mapper[field]["primary"], 1)
|
||||
value_already_exists = True
|
||||
continue
|
||||
d.set(field_mapper[field]["primary"], 0)
|
||||
|
||||
if not value_already_exists:
|
||||
contact_doc.append(
|
||||
field, {field_mapper[field]["field"]: value, field_mapper[field]["primary"]: 1}
|
||||
)
|
||||
|
||||
contact_doc = frappe.get_doc("Contact", contact)
|
||||
# setting is_primary_contact = 1 on Contact to refetch the same contact incase it's removed from Customer records.
|
||||
contact_doc.set("is_primary_contact", 1)
|
||||
if fieldname == "email_id":
|
||||
set_primary_phone_no_email("email_ids", value)
|
||||
elif fieldname == "mobile_no":
|
||||
set_primary_phone_no_email("phone_nos", value)
|
||||
# Saving contact_doc to set mobile_no and email.
|
||||
contact_doc.save()
|
||||
|
||||
# Auto-fetches from Contact DocType, no need to set values separately.
|
||||
customer_doc.customer_primary_contact = contact
|
||||
|
||||
# using save method instead db.set_value which bypasses the validation for loyalty program
|
||||
# and auto sets the mobile_no and email field on customer records.
|
||||
customer_doc.save()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -174,8 +174,8 @@ erpnext.PointOfSale.Controller = class {
|
||||
set_opening_entry_status() {
|
||||
this.page.set_title_sub(
|
||||
`<span class="indicator orange">
|
||||
<a class="text-muted" href="#Form/POS%20Opening%20Entry/${this.pos_opening}">
|
||||
Opened at ${frappe.datetime.str_to_user(this.pos_opening_time)}
|
||||
<a class="text-muted" href="#Form/POS%20Opening%20Entry/${encodeURIComponent(this.pos_opening)}">
|
||||
Opened at ${frappe.utils.escape_html(frappe.datetime.str_to_user(this.pos_opening_time))}
|
||||
</a>
|
||||
</span>`
|
||||
);
|
||||
|
||||
@@ -178,7 +178,7 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
me.$totals_section.find(".edit-cart-btn").click();
|
||||
}
|
||||
|
||||
const item_row_name = unescape($cart_item.attr("data-row-name"));
|
||||
const item_row_name = $cart_item.attr("data-row-name");
|
||||
me.events.cart_item_clicked({ name: item_row_name });
|
||||
this.numpad_value = "";
|
||||
});
|
||||
@@ -453,10 +453,10 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
<div class="customer-display">
|
||||
${this.get_customer_image()}
|
||||
<div class="customer-name-desc">
|
||||
<div class="customer-name">${customer_name}</div>
|
||||
<div class="customer-name">${frappe.utils.escape_html(customer_name)}</div>
|
||||
${get_customer_description()}
|
||||
</div>
|
||||
<div class="reset-customer-btn" data-customer="${escape(customer)}">
|
||||
<div class="reset-customer-btn" data-customer="${frappe.utils.escape_html(customer)}">
|
||||
<svg width="32" height="32" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M4.93764 4.93759L7.00003 6.99998M9.06243 9.06238L7.00003 6.99998M7.00003 6.99998L4.93764 9.06238L9.06243 4.93759" stroke="#8D99A6"/>
|
||||
</svg>
|
||||
@@ -473,11 +473,13 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
if (!email_id && !mobile_no) {
|
||||
return `<div class="customer-desc">${__("Click to add email / phone")}</div>`;
|
||||
} else if (email_id && !mobile_no) {
|
||||
return `<div class="customer-desc">${email_id}</div>`;
|
||||
return `<div class="customer-desc">${frappe.utils.escape_html(email_id)}</div>`;
|
||||
} else if (mobile_no && !email_id) {
|
||||
return `<div class="customer-desc">${mobile_no}</div>`;
|
||||
return `<div class="customer-desc">${frappe.utils.escape_html(mobile_no)}</div>`;
|
||||
} else {
|
||||
return `<div class="customer-desc">${email_id} - ${mobile_no}</div>`;
|
||||
return `<div class="customer-desc">${frappe.utils.escape_html(
|
||||
email_id
|
||||
)} - ${frappe.utils.escape_html(mobile_no)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,9 +487,13 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
get_customer_image() {
|
||||
const { customer, image } = this.customer_info || {};
|
||||
if (image) {
|
||||
return `<div class="customer-image"><img src="${image}" alt="${image}""></div>`;
|
||||
return `<div class="customer-image"><img src="${frappe.utils.escape_html(
|
||||
image
|
||||
)}" alt="${frappe.utils.escape_html(image)}"></div>`;
|
||||
} else {
|
||||
return `<div class="customer-image customer-abbr">${frappe.get_abbr(customer)}</div>`;
|
||||
return `<div class="customer-image customer-abbr">${frappe.utils.escape_html(
|
||||
frappe.get_abbr(customer)
|
||||
)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,10 +555,10 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
if (t.tax_amount_after_discount_amount == 0.0) return;
|
||||
// if tax rate is 0, don't print it.
|
||||
const description = /[0-9]+/.test(t.description)
|
||||
? t.description
|
||||
? frappe.utils.escape_html(t.description)
|
||||
: t.rate != 0
|
||||
? `${t.description} @ ${t.rate}%`
|
||||
: t.description;
|
||||
? `${frappe.utils.escape_html(t.description)} @ ${t.rate}%`
|
||||
: frappe.utils.escape_html(t.description);
|
||||
return `<div class="tax-row">
|
||||
<div class="tax-label">${description}</div>
|
||||
<div class="tax-value">${format_currency(t.tax_amount_after_discount_amount, currency)}</div>
|
||||
@@ -566,8 +572,9 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
}
|
||||
|
||||
get_cart_item({ name }) {
|
||||
const item_selector = `.cart-item-wrapper[data-row-name="${escape(name)}"]`;
|
||||
return this.$cart_items_wrapper.find(item_selector);
|
||||
return this.$cart_items_wrapper.find(".cart-item-wrapper").filter(function () {
|
||||
return $(this).attr("data-row-name") === name;
|
||||
});
|
||||
}
|
||||
|
||||
get_item_from_frm(item) {
|
||||
@@ -597,7 +604,9 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
|
||||
if (!$item_to_update.length) {
|
||||
this.$cart_items_wrapper.append(
|
||||
`<div class="cart-item-wrapper" data-row-name="${escape(item_data.name)}"></div>
|
||||
`<div class="cart-item-wrapper" data-row-name="${frappe.utils.escape_html(
|
||||
item_data.name
|
||||
)}"></div>
|
||||
<div class="seperator"></div>`
|
||||
);
|
||||
$item_to_update = this.get_cart_item(item_data);
|
||||
@@ -607,7 +616,7 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
`${get_item_image_html()}
|
||||
<div class="item-name-desc">
|
||||
<div class="item-name">
|
||||
${item_data.item_name}
|
||||
${frappe.utils.escape_html(item_data.item_name)}
|
||||
</div>
|
||||
${get_description_html()}
|
||||
</div>
|
||||
@@ -636,7 +645,7 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
if (item_data.rate && item_data.amount && item_data.rate !== item_data.amount) {
|
||||
return `
|
||||
<div class="item-qty-rate">
|
||||
<div class="item-qty"><span>${item_data.qty || 0} ${item_data.uom}</span></div>
|
||||
<div class="item-qty"><span>${item_data.qty || 0} ${frappe.utils.escape_html(item_data.uom)}</span></div>
|
||||
<div class="item-rate-amount">
|
||||
<div class="item-rate">${format_currency(item_data.amount, currency)}</div>
|
||||
<div class="item-amount">${format_currency(item_data.rate, currency)}</div>
|
||||
@@ -645,7 +654,7 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
} else {
|
||||
return `
|
||||
<div class="item-qty-rate">
|
||||
<div class="item-qty"><span>${item_data.qty || 0} ${item_data.uom}</span></div>
|
||||
<div class="item-qty"><span>${item_data.qty || 0} ${frappe.utils.escape_html(item_data.uom)}</span></div>
|
||||
<div class="item-rate-amount">
|
||||
<div class="item-rate">${format_currency(item_data.rate, currency)}</div>
|
||||
</div>
|
||||
@@ -666,7 +675,7 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
}
|
||||
}
|
||||
item_data.description = frappe.ellipsis(item_data.description, 45);
|
||||
return `<div class="item-desc">${item_data.description}</div>`;
|
||||
return `<div class="item-desc">${frappe.utils.escape_html(item_data.description)}</div>`;
|
||||
}
|
||||
return ``;
|
||||
}
|
||||
@@ -678,22 +687,26 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
<div class="item-image">
|
||||
<img
|
||||
onerror="cur_pos.cart.handle_broken_image(this)"
|
||||
src="${image}" alt="${frappe.get_abbr(item_name)}"">
|
||||
src="${frappe.utils.escape_html(image)}" alt="${frappe.utils.escape_html(frappe.get_abbr(item_name))}">
|
||||
</div>`;
|
||||
} else {
|
||||
return `<div class="item-image item-abbr">${frappe.get_abbr(item_name)}</div>`;
|
||||
return `<div class="item-image item-abbr">${frappe.utils.escape_html(
|
||||
frappe.get_abbr(item_name)
|
||||
)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle_broken_image($img) {
|
||||
const item_abbr = $($img).attr("alt");
|
||||
$($img).parent().replaceWith(`<div class="item-image item-abbr">${item_abbr}</div>`);
|
||||
$($img)
|
||||
.parent()
|
||||
.replaceWith(`<div class="item-image item-abbr">${frappe.utils.escape_html(item_abbr)}</div>`);
|
||||
}
|
||||
|
||||
update_selector_value_in_cart_item(selector, value, item) {
|
||||
const $item_to_update = this.get_cart_item(item);
|
||||
$item_to_update.attr(`data-${selector}`, escape(value));
|
||||
$item_to_update.attr(`data-${selector}`, value);
|
||||
}
|
||||
|
||||
toggle_checkout_btn(show_checkout) {
|
||||
@@ -892,8 +905,8 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
<div class="customer-display">
|
||||
${this.get_customer_image()}
|
||||
<div class="customer-name-desc">
|
||||
<div class="customer-name">${customer_name}</div>
|
||||
<div class="customer-desc">${customer}</div>
|
||||
<div class="customer-name">${frappe.utils.escape_html(customer_name)}</div>
|
||||
<div class="customer-desc">${frappe.utils.escape_html(customer)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="customer-fields-container">
|
||||
@@ -980,6 +993,7 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
customer: current_customer,
|
||||
value: this.value,
|
||||
},
|
||||
freeze: true,
|
||||
callback: (r) => {
|
||||
if (!r.exc) {
|
||||
me.customer_info[this.df.fieldname] = this.value;
|
||||
@@ -1029,9 +1043,11 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
};
|
||||
|
||||
transaction_container.append(
|
||||
`<div class="invoice-wrapper" data-invoice-name="${escape(invoice.name)}">
|
||||
`<div class="invoice-wrapper" data-invoice-name="${frappe.utils.escape_html(
|
||||
invoice.name
|
||||
)}">
|
||||
<div class="invoice-name-date">
|
||||
<div class="invoice-name">${invoice.name}</div>
|
||||
<div class="invoice-name">${frappe.utils.escape_html(invoice.name)}</div>
|
||||
<div class="invoice-date">${posting_datetime}</div>
|
||||
</div>
|
||||
<div class="invoice-total-status">
|
||||
@@ -1039,7 +1055,7 @@ erpnext.PointOfSale.ItemCart = class {
|
||||
${format_currency(invoice.grand_total, invoice.currency, frappe.sys_defaults.currency_precision) || 0}
|
||||
</div>
|
||||
<div class="invoice-status">
|
||||
<span class="indicator-pill whitespace-nowrap ${indicator_color[invoice.status]}">
|
||||
<span class="indicator-pill whitespace-nowrap ${indicator_color[invoice.status] || ""}">
|
||||
<span>${__(invoice.status)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -128,25 +128,27 @@ erpnext.PointOfSale.ItemDetails = class {
|
||||
return ``;
|
||||
}
|
||||
|
||||
this.$item_name.html(item_name);
|
||||
this.$item_name.html(frappe.utils.escape_html(item_name));
|
||||
this.$item_description.html(get_description_html());
|
||||
this.$item_price.html(format_currency(price_list_rate, this.currency));
|
||||
if (!this.hide_images && image) {
|
||||
this.$item_image.html(
|
||||
`<img
|
||||
onerror="cur_pos.item_details.handle_broken_image(this)"
|
||||
class="h-full" src="${image}"
|
||||
alt="${frappe.get_abbr(item_name)}"
|
||||
class="h-full" src="${frappe.utils.escape_html(image)}"
|
||||
alt="${frappe.utils.escape_html(frappe.get_abbr(item_name))}"
|
||||
style="object-fit: cover;">`
|
||||
);
|
||||
} else {
|
||||
this.$item_image.html(`<div class="item-abbr">${frappe.get_abbr(item_name)}</div>`);
|
||||
this.$item_image.html(
|
||||
`<div class="item-abbr">${frappe.utils.escape_html(frappe.get_abbr(item_name))}</div>`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handle_broken_image($img) {
|
||||
const item_abbr = $($img).attr("alt");
|
||||
$($img).replaceWith(`<div class="item-abbr">${item_abbr}</div>`);
|
||||
$($img).replaceWith(`<div class="item-abbr">${frappe.utils.escape_html(item_abbr)}</div>`);
|
||||
}
|
||||
|
||||
render_discount_dom(item) {
|
||||
|
||||
@@ -107,39 +107,45 @@ erpnext.PointOfSale.ItemSelector = class {
|
||||
<div class="flex items-center justify-center border-b-grey text-6xl text-grey-100" style="height:8rem; min-height:8rem">
|
||||
<img
|
||||
onerror="cur_pos.item_selector.handle_broken_image(this)"
|
||||
class="h-full item-img" src="${item_image}"
|
||||
alt="${frappe.get_abbr(item.item_name)}"
|
||||
class="h-full item-img" src="${frappe.utils.escape_html(item_image)}"
|
||||
alt="${frappe.utils.escape_html(frappe.get_abbr(item.item_name))}"
|
||||
>
|
||||
</div>`;
|
||||
} else {
|
||||
return `<div class="item-qty-pill">
|
||||
<span class="indicator-pill whitespace-nowrap ${indicator_color}">${qty_to_display}</span>
|
||||
</div>
|
||||
<div class="item-display abbr">${frappe.get_abbr(item.item_name)}</div>`;
|
||||
<div class="item-display abbr">${frappe.utils.escape_html(frappe.get_abbr(item.item_name))}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `<div class="item-wrapper"
|
||||
data-item-code="${escape(item.item_code)}" data-serial-no="${escape(serial_no)}"
|
||||
data-batch-no="${escape(batch_no)}" data-uom="${escape(uom)}"
|
||||
data-rate="${escape(price_list_rate || 0)}"
|
||||
data-stock-uom="${escape(item.stock_uom)}"
|
||||
title="${item.item_name}">
|
||||
data-item-code="${frappe.utils.escape_html(item.item_code)}" data-serial-no="${frappe.utils.escape_html(
|
||||
serial_no
|
||||
)}"
|
||||
data-batch-no="${frappe.utils.escape_html(batch_no)}" data-uom="${frappe.utils.escape_html(uom)}"
|
||||
data-rate="${frappe.utils.escape_html(price_list_rate || 0)}"
|
||||
data-stock-uom="${frappe.utils.escape_html(item.stock_uom)}"
|
||||
title="${frappe.utils.escape_html(item.item_name)}">
|
||||
|
||||
${get_item_image_html()}
|
||||
|
||||
<div class="item-detail">
|
||||
<div class="item-name">
|
||||
${frappe.ellipsis(item.item_name, 18)}
|
||||
${frappe.utils.escape_html(frappe.ellipsis(item.item_name, 18))}
|
||||
</div>
|
||||
<div class="item-rate">${format_currency(price_list_rate, item.currency, precision) || 0} / ${uom}</div>
|
||||
<div class="item-rate">${
|
||||
format_currency(price_list_rate, item.currency, precision) || 0
|
||||
} / ${frappe.utils.escape_html(uom)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
handle_broken_image($img) {
|
||||
const item_abbr = $($img).attr("alt");
|
||||
$($img).parent().replaceWith(`<div class="item-display abbr">${item_abbr}</div>`);
|
||||
$($img)
|
||||
.parent()
|
||||
.replaceWith(`<div class="item-display abbr">${frappe.utils.escape_html(item_abbr)}</div>`);
|
||||
}
|
||||
|
||||
make_search_bar() {
|
||||
@@ -252,14 +258,13 @@ erpnext.PointOfSale.ItemSelector = class {
|
||||
|
||||
this.$component.on("click", ".item-wrapper", function () {
|
||||
const $item = $(this);
|
||||
const item_code = unescape($item.attr("data-item-code"));
|
||||
let batch_no = unescape($item.attr("data-batch-no"));
|
||||
let serial_no = unescape($item.attr("data-serial-no"));
|
||||
let uom = unescape($item.attr("data-uom"));
|
||||
let rate = unescape($item.attr("data-rate"));
|
||||
let stock_uom = unescape($item.attr("data-stock-uom"));
|
||||
const item_code = $item.attr("data-item-code");
|
||||
let batch_no = $item.attr("data-batch-no");
|
||||
let serial_no = $item.attr("data-serial-no");
|
||||
let uom = $item.attr("data-uom");
|
||||
let rate = $item.attr("data-rate");
|
||||
let stock_uom = $item.attr("data-stock-uom");
|
||||
|
||||
// escape(undefined) returns "undefined" then unescape returns "undefined"
|
||||
batch_no = batch_no === "undefined" ? undefined : batch_no;
|
||||
serial_no = serial_no === "undefined" ? undefined : serial_no;
|
||||
uom = uom === "undefined" ? undefined : uom;
|
||||
|
||||
@@ -38,7 +38,7 @@ erpnext.PointOfSale.PastOrderList = class {
|
||||
});
|
||||
const me = this;
|
||||
this.$invoices_container.on("click", ".invoice-wrapper", function () {
|
||||
const invoice_name = unescape($(this).attr("data-invoice-name"));
|
||||
const invoice_name = $(this).attr("data-invoice-name");
|
||||
|
||||
me.events.open_invoice_data(invoice_name);
|
||||
});
|
||||
@@ -99,14 +99,14 @@ erpnext.PointOfSale.PastOrderList = class {
|
||||
const posting_datetime = frappe.datetime.str_to_user(
|
||||
invoice.posting_date + " " + invoice.posting_time
|
||||
);
|
||||
return `<div class="invoice-wrapper" data-invoice-name="${escape(invoice.name)}">
|
||||
return `<div class="invoice-wrapper" data-invoice-name="${frappe.utils.escape_html(invoice.name)}">
|
||||
<div class="invoice-name-date">
|
||||
<div class="invoice-name">${invoice.name}</div>
|
||||
<div class="invoice-name">${frappe.utils.escape_html(invoice.name)}</div>
|
||||
<div class="invoice-date">
|
||||
<svg class="mr-2" width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
${frappe.ellipsis(invoice.customer_name, 20)}
|
||||
${frappe.utils.escape_html(frappe.ellipsis(invoice.customer_name, 20))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="invoice-total-status">
|
||||
|
||||
@@ -81,23 +81,27 @@ erpnext.PointOfSale.PastOrderSummary = class {
|
||||
|
||||
return `<div class="left-section">
|
||||
<div class="customer-section">
|
||||
<div class="customer-name">${doc.customer_name}</div>
|
||||
${is_customer_naming_by_customer_name ? `<div class="customer-code">${doc.customer}</div>` : ""}
|
||||
<div class="customer-email">${this.customer_email}</div>
|
||||
<div class="customer-name">${frappe.utils.escape_html(doc.customer_name)}</div>
|
||||
${
|
||||
is_customer_naming_by_customer_name
|
||||
? `<div class="customer-code">${frappe.utils.escape_html(doc.customer)}</div>`
|
||||
: ""
|
||||
}
|
||||
<div class="customer-email">${frappe.utils.escape_html(this.customer_email)}</div>
|
||||
</div>
|
||||
<div class="cashier">${__("Sold by")}: ${doc.owner}</div>
|
||||
<div class="cashier">${__("Sold by")}: ${frappe.utils.escape_html(doc.owner)}</div>
|
||||
</div>
|
||||
<div class="right-section">
|
||||
<div class="paid-amount">${format_currency(doc.paid_amount, doc.currency)}</div>
|
||||
<div class="invoice-name">${doc.name}</div>
|
||||
<div class="invoice-name">${frappe.utils.escape_html(doc.name)}</div>
|
||||
<span class="indicator-pill whitespace-nowrap ${indicator_color}"><span>${__(doc.status)}</span></span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
get_item_html(doc, item_data) {
|
||||
return `<div class="item-row-wrapper">
|
||||
<div class="item-name">${item_data.item_name}</div>
|
||||
<div class="item-qty">${item_data.qty || 0} ${item_data.uom}</div>
|
||||
<div class="item-name">${frappe.utils.escape_html(item_data.item_name)}</div>
|
||||
<div class="item-qty">${item_data.qty || 0} ${frappe.utils.escape_html(item_data.uom)}</div>
|
||||
<div class="item-rate-disc">${get_rate_discount_html()}</div>
|
||||
</div>`;
|
||||
|
||||
@@ -139,10 +143,10 @@ erpnext.PointOfSale.PastOrderSummary = class {
|
||||
.map((t) => {
|
||||
// if tax rate is 0, don't print it.
|
||||
const description = /[0-9]+/.test(t.description)
|
||||
? t.description
|
||||
? frappe.utils.escape_html(t.description)
|
||||
: t.rate != 0
|
||||
? `${t.description} @ ${t.rate}%`
|
||||
: t.description;
|
||||
? `${frappe.utils.escape_html(t.description)} @ ${t.rate}%`
|
||||
: frappe.utils.escape_html(t.description);
|
||||
return `
|
||||
<div class="tax-row">
|
||||
<div class="tax-label">${description}</div>
|
||||
|
||||
@@ -408,8 +408,10 @@ erpnext.PointOfSale.Payment = class {
|
||||
|
||||
return `
|
||||
<div class="payment-mode-wrapper">
|
||||
<div class="mode-of-payment" data-mode="${mode}" data-payment-type="${payment_type}">
|
||||
${p.mode_of_payment}
|
||||
<div class="mode-of-payment" data-mode="${mode}" data-payment-type="${frappe.utils.escape_html(
|
||||
payment_type
|
||||
)}">
|
||||
${frappe.utils.escape_html(p.mode_of_payment)}
|
||||
<div class="${mode}-amount pay-amount">${amount}</div>
|
||||
<div class="${mode} mode-of-payment-control"></div>
|
||||
</div>
|
||||
@@ -544,7 +546,7 @@ erpnext.PointOfSale.Payment = class {
|
||||
<div class="mode-of-payment loyalty-card" data-mode="loyalty-amount" data-payment-type="loyalty-amount">
|
||||
Redeem Loyalty Points
|
||||
<div class="loyalty-amount-amount pay-amount">${amount}</div>
|
||||
<div class="loyalty-amount-name">${loyalty_program}</div>
|
||||
<div class="loyalty-amount-name">${frappe.utils.escape_html(loyalty_program)}</div>
|
||||
<div class="loyalty-amount mode-of-payment-control"></div>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
@@ -138,12 +138,30 @@ class Analytics:
|
||||
self.get_sales_transactions_based_on_project()
|
||||
self.get_rows()
|
||||
|
||||
def _get_permitted_parent_names(self):
|
||||
return frappe.get_list(
|
||||
self.filters.doc_type,
|
||||
fields=["name"],
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"company": ["in", self.filters.company],
|
||||
self.date_field: ("between", [self.filters.from_date, self.filters.to_date]),
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
def get_sales_transactions_based_on_order_type(self):
|
||||
if self.filters["value_quantity"] == "Value":
|
||||
value_field = "base_net_total"
|
||||
else:
|
||||
value_field = "total_qty"
|
||||
|
||||
permitted_names = self._get_permitted_parent_names()
|
||||
if not permitted_names:
|
||||
self.entries = []
|
||||
self.get_teams()
|
||||
return
|
||||
|
||||
doctype = DocType(self.filters.doc_type)
|
||||
|
||||
self.entries = (
|
||||
@@ -153,12 +171,7 @@ class Analytics:
|
||||
doctype[self.date_field],
|
||||
doctype[value_field].as_("value_field"),
|
||||
)
|
||||
.where(
|
||||
(doctype.docstatus == 1)
|
||||
& (doctype.company.isin(self.filters.company))
|
||||
& (doctype[self.date_field].between(self.filters.from_date, self.filters.to_date))
|
||||
& (IfNull(doctype.order_type, "") != "")
|
||||
)
|
||||
.where((doctype.name.isin(permitted_names)) & (IfNull(doctype.order_type, "") != ""))
|
||||
.orderby(doctype.order_type)
|
||||
).run(as_dict=True)
|
||||
|
||||
@@ -186,8 +199,10 @@ class Analytics:
|
||||
if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]:
|
||||
filters.update({"is_opening": "No"})
|
||||
|
||||
self.entries = frappe.get_all(
|
||||
self.filters.doc_type, fields=[entity, entity_name, value_field, self.date_field], filters=filters
|
||||
self.entries = frappe.get_list(
|
||||
self.filters.doc_type,
|
||||
fields=[entity, entity_name, value_field, self.date_field],
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
self.entity_names = {}
|
||||
@@ -200,6 +215,12 @@ class Analytics:
|
||||
else:
|
||||
value_field = "stock_qty"
|
||||
|
||||
permitted_names = self._get_permitted_parent_names()
|
||||
if not permitted_names:
|
||||
self.entries = []
|
||||
self.entity_names = {}
|
||||
return
|
||||
|
||||
doctype = DocType(self.filters.doc_type)
|
||||
doctype_item = DocType(f"{self.filters.doc_type} Item")
|
||||
|
||||
@@ -214,11 +235,7 @@ class Analytics:
|
||||
doctype_item[value_field].as_("value_field"),
|
||||
doctype[self.date_field],
|
||||
)
|
||||
.where(
|
||||
(doctype_item.docstatus == 1)
|
||||
& (doctype.company.isin(self.filters.company))
|
||||
& (doctype[self.date_field].between(self.filters.from_date, self.filters.to_date))
|
||||
)
|
||||
.where((doctype_item.docstatus == 1) & (doctype.name.isin(permitted_names)))
|
||||
).run(as_dict=True)
|
||||
|
||||
self.entity_names = {}
|
||||
@@ -248,7 +265,7 @@ class Analytics:
|
||||
if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]:
|
||||
filters.update({"is_opening": "No"})
|
||||
|
||||
self.entries = frappe.get_all(
|
||||
self.entries = frappe.get_list(
|
||||
self.filters.doc_type,
|
||||
fields=[entity_field, value_field, self.date_field],
|
||||
filters=filters,
|
||||
@@ -261,6 +278,12 @@ class Analytics:
|
||||
else:
|
||||
value_field = "qty"
|
||||
|
||||
permitted_names = self._get_permitted_parent_names()
|
||||
if not permitted_names:
|
||||
self.entries = []
|
||||
self.get_groups()
|
||||
return
|
||||
|
||||
doctype = DocType(self.filters.doc_type)
|
||||
doctype_item = DocType(f"{self.filters.doc_type} Item")
|
||||
|
||||
@@ -273,11 +296,7 @@ class Analytics:
|
||||
doctype_item[value_field].as_("value_field"),
|
||||
doctype[self.date_field],
|
||||
)
|
||||
.where(
|
||||
(doctype_item.docstatus == 1)
|
||||
& (doctype.company.isin(self.filters.company))
|
||||
& (doctype[self.date_field].between(self.filters.from_date, self.filters.to_date))
|
||||
)
|
||||
.where((doctype_item.docstatus == 1) & (doctype.name.isin(permitted_names)))
|
||||
).run(as_dict=True)
|
||||
|
||||
self.get_groups()
|
||||
@@ -300,8 +319,10 @@ class Analytics:
|
||||
if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]:
|
||||
filters.update({"is_opening": "No"})
|
||||
|
||||
self.entries = frappe.get_all(
|
||||
self.filters.doc_type, fields=[entity, value_field, self.date_field], filters=filters
|
||||
self.entries = frappe.get_list(
|
||||
self.filters.doc_type,
|
||||
fields=[entity, value_field, self.date_field],
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
def get_rows(self):
|
||||
|
||||
@@ -77,13 +77,11 @@ class CustomerGroup(NestedSet):
|
||||
|
||||
def get_parent_customer_groups(customer_group):
|
||||
lft, rgt = frappe.db.get_value("Customer Group", customer_group, ["lft", "rgt"])
|
||||
|
||||
return frappe.db.sql(
|
||||
"""select name from `tabCustomer Group`
|
||||
where lft <= %s and rgt >= %s
|
||||
order by lft asc""",
|
||||
(lft, rgt),
|
||||
as_dict=True,
|
||||
return frappe.get_all(
|
||||
"Customer Group",
|
||||
filters=[["lft", "<=", lft], ["rgt", ">=", rgt]],
|
||||
fields=["name"],
|
||||
order_by="lft asc",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -70,3 +70,13 @@ class SupplierGroup(NestedSet):
|
||||
def on_trash(self):
|
||||
NestedSet.validate_if_child_exists(self)
|
||||
frappe.utils.nestedset.update_nsm(self)
|
||||
|
||||
|
||||
def get_parent_supplier_groups(supplier_group):
|
||||
lft, rgt = frappe.db.get_value("Supplier Group", supplier_group, ["lft", "rgt"])
|
||||
return frappe.get_all(
|
||||
"Supplier Group",
|
||||
filters=[["lft", "<=", lft], ["rgt", ">=", rgt]],
|
||||
fields=["name"],
|
||||
order_by="lft asc",
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ def after_install():
|
||||
|
||||
set_single_defaults()
|
||||
create_print_setting_custom_fields()
|
||||
create_address_and_contact_custom_fields()
|
||||
create_custom_company_links()
|
||||
add_all_roles_to("Administrator")
|
||||
create_default_success_action()
|
||||
@@ -132,6 +133,37 @@ def create_print_setting_custom_fields():
|
||||
)
|
||||
|
||||
|
||||
def create_address_and_contact_custom_fields():
|
||||
create_custom_fields(
|
||||
{
|
||||
"Address": [
|
||||
{
|
||||
"label": _("Tax Category"),
|
||||
"fieldname": "tax_category",
|
||||
"fieldtype": "Link",
|
||||
"options": "Tax Category",
|
||||
"insert_after": "fax",
|
||||
},
|
||||
{
|
||||
"label": _("Is Your Company Address"),
|
||||
"fieldname": "is_your_company_address",
|
||||
"fieldtype": "Check",
|
||||
"default": "0",
|
||||
"insert_after": "linked_with",
|
||||
},
|
||||
],
|
||||
"Contact": [
|
||||
{
|
||||
"label": _("Is Billing Contact"),
|
||||
"fieldname": "is_billing_contact",
|
||||
"fieldtype": "Check",
|
||||
"insert_after": "is_primary_contact",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def create_custom_company_links():
|
||||
"""Add link fields to Company in Email Account and Communication.
|
||||
|
||||
|
||||
@@ -4115,9 +4115,14 @@
|
||||
},
|
||||
|
||||
"Japan": {
|
||||
"Japan Tax": {
|
||||
"account_name": "CT",
|
||||
"tax_rate": 5.00
|
||||
"Japan Tax 10%": {
|
||||
"account_name": "CT 10%",
|
||||
"tax_rate": 10.00,
|
||||
"default": 1
|
||||
},
|
||||
"Japan Tax 8%": {
|
||||
"account_name": "CT 8%",
|
||||
"tax_rate": 8.00
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ def get_all_customers(date_range, company, field, limit=None):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_all_items(date_range, company, field, limit=None):
|
||||
def get_all_items(date_range: str, company: str, field: str, limit: int | None = None):
|
||||
if field in ("available_stock_qty", "available_stock_value"):
|
||||
select_field = "sum(actual_qty)" if field == "available_stock_qty" else "sum(stock_value)"
|
||||
results = frappe.db.get_all(
|
||||
@@ -103,21 +103,21 @@ def get_all_items(date_range, company, field, limit=None):
|
||||
else:
|
||||
if field == "total_sales_amount":
|
||||
select_field = "base_net_amount"
|
||||
select_doctype = "Sales Order"
|
||||
select_doctype = "Sales Invoice"
|
||||
elif field == "total_purchase_amount":
|
||||
select_field = "base_net_amount"
|
||||
select_doctype = "Purchase Order"
|
||||
select_doctype = "Purchase Invoice"
|
||||
elif field == "total_qty_sold":
|
||||
select_field = "stock_qty"
|
||||
select_doctype = "Sales Order"
|
||||
select_doctype = "Sales Invoice"
|
||||
elif field == "total_qty_purchased":
|
||||
select_field = "stock_qty"
|
||||
select_doctype = "Purchase Order"
|
||||
select_doctype = "Purchase Invoice"
|
||||
|
||||
filters = [["docstatus", "=", "1"], ["company", "=", company]]
|
||||
from_date, to_date = parse_date_range(date_range)
|
||||
if from_date and to_date:
|
||||
filters.append(["transaction_date", "between", [from_date, to_date]])
|
||||
filters.append(["posting_date", "between", [from_date, to_date]])
|
||||
|
||||
child_doctype = f"{select_doctype} Item"
|
||||
return frappe.get_list(
|
||||
|
||||
@@ -30,11 +30,20 @@ class DeprecatedSerialNoValuation:
|
||||
def get_incoming_value_for_serial_nos(self, serial_nos):
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
|
||||
do_not_fetch_rate = frappe.db.get_single_value(
|
||||
"Stock Reposting Settings", "do_not_fetch_incoming_rate_from_serial_no"
|
||||
)
|
||||
|
||||
# get rate from serial nos within same company
|
||||
incoming_values = 0.0
|
||||
for serial_no in serial_nos:
|
||||
sn_details = frappe.db.get_value("Serial No", serial_no, ["purchase_rate", "company"], as_dict=1)
|
||||
if sn_details and sn_details.purchase_rate and sn_details.company == self.sle.company:
|
||||
if (
|
||||
sn_details
|
||||
and sn_details.purchase_rate
|
||||
and sn_details.company == self.sle.company
|
||||
and (not frappe.flags.through_repost_item_valuation or not do_not_fetch_rate)
|
||||
):
|
||||
self.serial_no_incoming_rate[serial_no] += flt(sn_details.purchase_rate)
|
||||
incoming_values += self.serial_no_incoming_rate[serial_no]
|
||||
continue
|
||||
|
||||
@@ -263,8 +263,9 @@ def update_qty(bin_name, args):
|
||||
# actual qty is already updated by processing current voucher
|
||||
actual_qty = bin_details.actual_qty or 0.0
|
||||
|
||||
# actual qty is not up to date in case of backdated transaction
|
||||
if future_sle_exists(args):
|
||||
# actual qty is not up to date in case of backdated transactions
|
||||
# or when cancellations are the most recent SLE
|
||||
if future_sle_exists(args) or args.get("is_cancelled"):
|
||||
actual_qty = get_actual_qty(args.get("item_code"), args.get("warehouse"))
|
||||
|
||||
ordered_qty = flt(bin_details.ordered_qty) + flt(args.get("ordered_qty"))
|
||||
|
||||
@@ -226,13 +226,6 @@ frappe.ui.form.on("Item", {
|
||||
});
|
||||
frm.set_df_property("is_fixed_asset", "read_only", frm.doc.__onload?.asset_exists ? 1 : 0);
|
||||
frm.toggle_reqd("customer", frm.doc.is_customer_provided_item ? 1 : 0);
|
||||
frm.set_query("item_group", () => {
|
||||
return {
|
||||
filters: {
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
validate: function (frm) {
|
||||
@@ -411,12 +404,6 @@ $.extend(erpnext.item, {
|
||||
};
|
||||
};
|
||||
|
||||
frm.fields_dict["item_group"].get_query = function (doc, cdt, cdn) {
|
||||
return {
|
||||
filters: [["Item Group", "docstatus", "!=", 2]],
|
||||
};
|
||||
};
|
||||
|
||||
frm.fields_dict["item_defaults"].grid.get_field("deferred_revenue_account").get_query = function (
|
||||
doc,
|
||||
cdt,
|
||||
@@ -594,11 +581,10 @@ $.extend(erpnext.item, {
|
||||
default: 0,
|
||||
onchange: function () {
|
||||
let selected_attributes = get_selected_attributes();
|
||||
let lengths = [];
|
||||
Object.keys(selected_attributes).map((key) => {
|
||||
lengths.push(selected_attributes[key].length);
|
||||
let lengths = Object.keys(selected_attributes).map((key) => {
|
||||
return selected_attributes[key].length;
|
||||
});
|
||||
if (lengths.includes(0)) {
|
||||
if (!lengths.length) {
|
||||
me.multiple_variant_dialog.get_primary_btn().html(__("Create Variants"));
|
||||
me.multiple_variant_dialog.disable_primary_action();
|
||||
} else {
|
||||
@@ -635,7 +621,7 @@ $.extend(erpnext.item, {
|
||||
fieldtype: "HTML",
|
||||
fieldname: "help",
|
||||
options: `<label class="control-label">
|
||||
${__("Select at least one value from each of the attributes.")}
|
||||
${__("Select at least one attribute value.")}
|
||||
</label>`,
|
||||
},
|
||||
]
|
||||
@@ -693,6 +679,9 @@ $.extend(erpnext.item, {
|
||||
selected_attributes[attribute_name].push($(opt).attr("data-fieldname"));
|
||||
}
|
||||
});
|
||||
if (!selected_attributes[attribute_name].length) {
|
||||
delete selected_attributes[attribute_name];
|
||||
}
|
||||
});
|
||||
|
||||
return selected_attributes;
|
||||
|
||||
@@ -855,8 +855,13 @@ class Item(Document):
|
||||
if disabled:
|
||||
frappe.throw(_("Attribute {0} is disabled.").format(frappe.bold(d.attribute)))
|
||||
|
||||
if not numeric_values and not frappe.db.exists(
|
||||
"Item Attribute Value", {"parent": d.attribute, "attribute_value": d.attribute_value}
|
||||
if (
|
||||
not numeric_values
|
||||
and d.attribute_value
|
||||
and not frappe.db.exists(
|
||||
"Item Attribute Value",
|
||||
{"parent": d.attribute, "attribute_value": d.attribute_value},
|
||||
)
|
||||
):
|
||||
frappe.throw(
|
||||
_("Attribute Value {0} is not valid for the selected attribute {1}.").format(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"creation": "2014-07-11 11:51:00.453717",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -12,7 +13,10 @@
|
||||
"col_break3",
|
||||
"amount",
|
||||
"base_amount",
|
||||
"has_corrective_cost"
|
||||
"has_corrective_cost",
|
||||
"has_operating_cost",
|
||||
"operation_id",
|
||||
"qty"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -70,12 +74,36 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "Has Corrective Cost",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "has_operating_cost",
|
||||
"fieldtype": "Check",
|
||||
"label": "Has Operating Cost",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "operation_id",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Operation ID",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "qty",
|
||||
"fieldtype": "Float",
|
||||
"hidden": 1,
|
||||
"label": "Qty",
|
||||
"no_copy": 1,
|
||||
"non_negative": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-20 12:22:03.455762",
|
||||
"modified": "2026-05-19 12:21:07.953801",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Landed Cost Taxes and Charges",
|
||||
@@ -83,4 +111,4 @@
|
||||
"permissions": [],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,12 @@ class LandedCostTaxesandCharges(Document):
|
||||
exchange_rate: DF.Float
|
||||
expense_account: DF.Link | None
|
||||
has_corrective_cost: DF.Check
|
||||
has_operating_cost: DF.Check
|
||||
operation_id: DF.Data | None
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
qty: DF.Float
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
@@ -510,14 +510,7 @@ class PurchaseReceipt(BuyingController):
|
||||
else flt(item.net_amount, item.precision("net_amount"))
|
||||
)
|
||||
|
||||
outgoing_amount = (
|
||||
flt((item.base_net_amount / item.received_qty) * item.qty, item.precision("base_net_amount"))
|
||||
if item.received_qty
|
||||
and frappe.get_single_value(
|
||||
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
|
||||
)
|
||||
else item.base_net_amount
|
||||
)
|
||||
outgoing_amount = item.base_net_amount
|
||||
if self.is_internal_transfer() and item.valuation_rate:
|
||||
outgoing_amount = abs(get_stock_value_difference(self.name, item.name, item.from_warehouse))
|
||||
credit_amount = outgoing_amount
|
||||
@@ -1257,7 +1250,7 @@ def get_billed_qty_amount_against_purchase_receipt(pr_doc):
|
||||
.on(parent_table.name == table.parent)
|
||||
.select(
|
||||
table.pr_detail,
|
||||
fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"),
|
||||
fn.Sum(table.base_net_amount).as_("amount"),
|
||||
fn.Sum(table.qty).as_("qty"),
|
||||
)
|
||||
.where((table.pr_detail.isin(pr_names)) & (table.docstatus == 1))
|
||||
@@ -1303,7 +1296,7 @@ def get_billed_qty_amount_against_purchase_order(pr_doc):
|
||||
.select(
|
||||
table.po_detail,
|
||||
fn.Sum(table.qty).as_("qty"),
|
||||
fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"),
|
||||
fn.Sum(table.base_net_amount).as_("amount"),
|
||||
)
|
||||
.where((table.po_detail.isin(po_names)) & (table.docstatus == 1) & (table.pr_detail.isnull()))
|
||||
.groupby(table.po_detail)
|
||||
|
||||
@@ -4539,7 +4539,7 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
|
||||
self.assertEqual(srbnb_cost, 1500)
|
||||
|
||||
def test_valuation_rate_for_rejected_materials_without_accepted_materials(self):
|
||||
def test_valuation_rate_for_rejected_materials_withoout_accepted_materials(self):
|
||||
item = make_item("Test Item with Rej Material Valuation WO Accepted", {"is_stock_item": 1})
|
||||
company = "_Test Company with perpetual inventory"
|
||||
|
||||
@@ -5106,33 +5106,6 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
self.assertEqual(row.warehouse, "_Test Warehouse 1 - _TC")
|
||||
self.assertEqual(row.incoming_rate, 100)
|
||||
|
||||
def test_bill_for_rejected_quantity_in_purchase_invoice(self):
|
||||
item_code = make_item("Test Rejected Qty", {"is_stock_item": 1}).name
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 0)
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item_code,
|
||||
qty=10,
|
||||
rejected_qty=2,
|
||||
rate=10,
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
)
|
||||
|
||||
self.assertEqual(pr.total_qty, 10)
|
||||
self.assertEqual(pr.total, 100)
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 1)
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item_code,
|
||||
qty=10,
|
||||
rejected_qty=2,
|
||||
rate=10,
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
)
|
||||
|
||||
self.assertEqual(pr.total_qty, 12)
|
||||
self.assertEqual(pr.total, 120)
|
||||
|
||||
def test_different_exchange_rate_in_pr_and_pi(self):
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
|
||||
@@ -5197,6 +5170,157 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
|
||||
)
|
||||
|
||||
@change_settings(
|
||||
"Buying Settings", {"set_landed_cost_based_on_purchase_invoice_rate": 1, "maintain_same_rate": 0}
|
||||
)
|
||||
def test_srbnb_with_inclusive_tax_and_rate_change_in_pi(self):
|
||||
"""
|
||||
When 'Set Landed Cost Based on PI Rate' is enabled and PI has an inclusive tax:
|
||||
- PR: qty=2, rate=1000 INR → base_net_amount=2000
|
||||
- PI: rate changed to 2000, 5% tax included in basic rate
|
||||
→ PI base_net_amount = 2 * 2000 / 1.05 ≈ 3809.52
|
||||
|
||||
The system must use PI's base_net_amount (not amount=4000) so that
|
||||
SRBNB credit on PR = 3809.52, not 4000.
|
||||
"""
|
||||
company = "_Test Company with perpetual inventory"
|
||||
warehouse = "Stores - TCP1"
|
||||
cost_center = "Main - TCP1"
|
||||
|
||||
item_code = make_item(
|
||||
"Test Item for SRBNB Inclusive Tax Rate Change",
|
||||
{"is_stock_item": 1},
|
||||
).name
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item_code,
|
||||
qty=2,
|
||||
rate=1000,
|
||||
company=company,
|
||||
warehouse=warehouse,
|
||||
cost_center=cost_center,
|
||||
)
|
||||
|
||||
pi = make_purchase_invoice(pr.name)
|
||||
pi.items[0].rate = 2000
|
||||
pi.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account VAT - TCP1",
|
||||
"category": "Total",
|
||||
"add_deduct_tax": "Add",
|
||||
"included_in_print_rate": 1,
|
||||
"rate": 5,
|
||||
"description": "Test Inclusive Tax",
|
||||
"cost_center": cost_center,
|
||||
},
|
||||
)
|
||||
pi.save()
|
||||
pi.submit()
|
||||
|
||||
pr.reload()
|
||||
|
||||
# PI base_net_amount = qty * (rate / (1 + tax_rate/100)) = 2 * (2000 / 1.05)
|
||||
pi_base_net_amount = flt(2 * 2000 / 1.05, 2)
|
||||
pr_base_net_amount = flt(pr.items[0].amount, 2) # 2 * 1000 = 2000
|
||||
expected_diff = flt(pi_base_net_amount - pr_base_net_amount, 2)
|
||||
|
||||
self.assertAlmostEqual(pr.items[0].amount_difference_with_purchase_invoice, expected_diff, places=2)
|
||||
|
||||
# Total SRBNB credit = PR base_net_amount + amount_difference = PI base_net_amount
|
||||
srbnb_account = "Stock Received But Not Billed - TCP1"
|
||||
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True)
|
||||
srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account)
|
||||
self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2)
|
||||
|
||||
@change_settings(
|
||||
"Buying Settings", {"set_landed_cost_based_on_purchase_invoice_rate": 1, "maintain_same_rate": 0}
|
||||
)
|
||||
def test_srbnb_with_inclusive_tax_and_exchange_rate_change_in_pi(self):
|
||||
"""
|
||||
When 'Set Landed Cost Based on PI Rate' is enabled, PI has an inclusive tax, and only
|
||||
the exchange rate changes on the PI (rate stays the same):
|
||||
- PR: qty=2, rate=100 USD, conversion_rate=70 → base_net_amount=14000 INR
|
||||
- PI: same rate=100 USD, conversion_rate changed to 90, 5% tax included in basic rate
|
||||
→ PI base_net_amount = 2 * (100 / 1.05) * 90 ≈ 17142.86 INR
|
||||
|
||||
The system must use PI's base_net_amount (not amount = 2*100*90 = 18000) so that
|
||||
SRBNB credit on PR = 17142.86, not 18000.
|
||||
"""
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
|
||||
company = "_Test Company with perpetual inventory"
|
||||
warehouse = "Stores - TCP1"
|
||||
cost_center = "Main - TCP1"
|
||||
|
||||
party_account = create_account(
|
||||
account_name="USD Payable For SRBNB Exchange Rate Test",
|
||||
parent_account="Accounts Payable - TCP1",
|
||||
account_type="Payable",
|
||||
company=company,
|
||||
account_currency="USD",
|
||||
)
|
||||
|
||||
supplier = create_supplier(
|
||||
supplier_name="_Test USD Supplier for SRBNB Exchange Rate",
|
||||
default_currency="USD",
|
||||
party_account=party_account,
|
||||
).name
|
||||
|
||||
item_code = make_item(
|
||||
"Test Item for SRBNB Inclusive Tax Exchange Rate Change",
|
||||
{"is_stock_item": 1},
|
||||
).name
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item_code,
|
||||
qty=2,
|
||||
rate=100,
|
||||
currency="USD",
|
||||
conversion_rate=70,
|
||||
company=company,
|
||||
warehouse=warehouse,
|
||||
supplier=supplier,
|
||||
)
|
||||
|
||||
pi = make_purchase_invoice(pr.name)
|
||||
pi.conversion_rate = 90
|
||||
pi.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account VAT - TCP1",
|
||||
"category": "Total",
|
||||
"add_deduct_tax": "Add",
|
||||
"included_in_print_rate": 1,
|
||||
"rate": 5,
|
||||
"description": "Test Inclusive Tax",
|
||||
"cost_center": cost_center,
|
||||
},
|
||||
)
|
||||
pi.save()
|
||||
pi.submit()
|
||||
|
||||
pr.reload()
|
||||
|
||||
# PI base_net_amount = qty * (rate / (1 + tax_rate/100)) * new_conversion_rate
|
||||
# = 2 * (100 / 1.05) * 90 ≈ 17142.86 INR
|
||||
# PR base_net_amount = qty * rate * pr_conversion_rate = 2 * 100 * 70 = 14000 INR
|
||||
tax_amount_pr = (200 - flt(200 / 1.05, 2)) * 90
|
||||
|
||||
pi_base_net_amount = flt(2 * 100 * 90) - flt(tax_amount_pr)
|
||||
pr_base_net_amount = flt(2 * 100 * 70)
|
||||
expected_diff = flt(pi_base_net_amount - pr_base_net_amount)
|
||||
|
||||
self.assertAlmostEqual(pr.items[0].amount_difference_with_purchase_invoice, expected_diff, places=2)
|
||||
|
||||
# Total SRBNB credit = PR base_net_amount + amount_difference = PI base_net_amount
|
||||
srbnb_account = "Stock Received But Not Billed - TCP1"
|
||||
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True)
|
||||
srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account)
|
||||
self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2)
|
||||
|
||||
|
||||
def prepare_data_for_internal_transfer():
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
|
||||
|
||||
@@ -2194,25 +2194,6 @@ def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, wareh
|
||||
return doc
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_serial_or_batch(bundle_id, serial_no=None, batch_no=None):
|
||||
if batch_no and not serial_no:
|
||||
if qty := frappe.db.get_value(
|
||||
"Serial and Batch Entry", {"parent": bundle_id, "batch_no": batch_no}, "qty"
|
||||
):
|
||||
frappe.db.set_value(
|
||||
"Serial and Batch Entry", {"parent": bundle_id, "batch_no": batch_no}, "qty", qty + 1
|
||||
)
|
||||
return
|
||||
|
||||
doc = frappe.get_cached_doc("Serial and Batch Bundle", bundle_id)
|
||||
if not serial_no and not batch_no:
|
||||
return
|
||||
|
||||
doc.append("entries", {"serial_no": serial_no, "batch_no": batch_no, "qty": 1})
|
||||
doc.save(ignore_permissions=True)
|
||||
|
||||
|
||||
def get_serial_and_batch_ledger(**kwargs):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
|
||||
@@ -774,7 +774,7 @@ class StockEntry(StockController):
|
||||
else:
|
||||
frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx))
|
||||
|
||||
if self.purpose == "Manufacture":
|
||||
if self.purpose in ["Manufacture", "Repack"]:
|
||||
if d.is_finished_item or d.is_scrap_item:
|
||||
d.s_warehouse = None
|
||||
if not d.t_warehouse:
|
||||
@@ -1174,13 +1174,21 @@ class StockEntry(StockController):
|
||||
)
|
||||
|
||||
def get_basic_rate_for_repacked_items(self, finished_item_qty, outgoing_items_cost):
|
||||
finished_items = [d.item_code for d in self.get("items") if d.is_finished_item]
|
||||
finished_items = [
|
||||
d.item_code for d in self.get("items") if d.is_finished_item and not d.set_basic_rate_manually
|
||||
]
|
||||
if len(finished_items) == 1:
|
||||
return flt(outgoing_items_cost / finished_item_qty)
|
||||
else:
|
||||
unique_finished_items = set(finished_items)
|
||||
if len(unique_finished_items) == 1:
|
||||
total_fg_qty = sum([flt(d.transfer_qty) for d in self.items if d.is_finished_item])
|
||||
if unique_finished_items:
|
||||
total_fg_qty = sum(
|
||||
[
|
||||
flt(d.transfer_qty)
|
||||
for d in self.items
|
||||
if d.is_finished_item and not d.set_basic_rate_manually
|
||||
]
|
||||
)
|
||||
return flt(outgoing_items_cost / total_fg_qty)
|
||||
|
||||
def get_basic_rate_for_manufactured_item(self, finished_item_qty, outgoing_items_cost=0) -> float:
|
||||
@@ -3124,12 +3132,12 @@ class StockEntry(StockController):
|
||||
args = {
|
||||
"source_dt": "Stock Entry Detail",
|
||||
"target_field": "transferred_qty",
|
||||
"target_ref_field": "qty",
|
||||
"target_ref_field": "transfer_qty",
|
||||
"target_dt": "Stock Entry Detail",
|
||||
"join_field": "ste_detail",
|
||||
"target_parent_dt": "Stock Entry",
|
||||
"target_parent_field": "per_transferred",
|
||||
"source_field": "qty",
|
||||
"source_field": "transfer_qty",
|
||||
"percent_join_field": "against_stock_entry",
|
||||
}
|
||||
|
||||
@@ -3362,6 +3370,33 @@ def get_work_order_details(work_order, company):
|
||||
}
|
||||
|
||||
|
||||
def get_consumed_operating_cost(work_order, bom_no, operation_id=None):
|
||||
table = frappe.qb.DocType("Stock Entry")
|
||||
child_table = frappe.qb.DocType("Landed Cost Taxes and Charges")
|
||||
query = (
|
||||
frappe.qb.from_(child_table)
|
||||
.join(table)
|
||||
.on(child_table.parent == table.name)
|
||||
.select(
|
||||
Sum(child_table.amount).as_("consumed_cost"),
|
||||
Sum(child_table.qty).as_("consumed_qty"),
|
||||
)
|
||||
.where(
|
||||
(table.docstatus == 1)
|
||||
& (table.work_order == work_order)
|
||||
& (table.purpose == "Manufacture")
|
||||
& (table.bom_no == bom_no)
|
||||
& (child_table.has_operating_cost == 1)
|
||||
)
|
||||
)
|
||||
|
||||
if operation_id:
|
||||
query = query.where(child_table.operation_id == operation_id)
|
||||
|
||||
data = query.run(as_dict=True)
|
||||
return data[0] if data else frappe._dict()
|
||||
|
||||
|
||||
def get_operating_cost_per_unit(work_order=None, bom_no=None):
|
||||
operating_cost_per_unit = 0
|
||||
if work_order:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_copy": 1,
|
||||
"autoname": "MAT-SLE-.YYYY.-.#####",
|
||||
"creation": "2013-01-29 19:25:42",
|
||||
@@ -204,7 +205,7 @@
|
||||
{
|
||||
"fieldname": "valuation_rate",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Valuation Rate",
|
||||
"label": "Average Rate",
|
||||
"oldfieldname": "valuation_rate",
|
||||
"oldfieldtype": "Currency",
|
||||
"options": "Company:company:default_currency",
|
||||
@@ -362,11 +363,11 @@
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-04 09:59:15.546556",
|
||||
"modified": "2026-05-26 19:07:43.537450",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Ledger Entry",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -82,6 +82,7 @@ class StockReconciliation(StockController):
|
||||
self.set_total_qty_and_amount()
|
||||
self.validate_putaway_capacity()
|
||||
self.validate_inventory_dimension()
|
||||
self.validate_uom_is_integer("stock_uom", "qty")
|
||||
|
||||
if self._action == "submit":
|
||||
self.validate_reserved_stock()
|
||||
@@ -566,15 +567,18 @@ class StockReconciliation(StockController):
|
||||
|
||||
def calculate_difference_amount(self, item, item_dict):
|
||||
qty_precision = item.precision("qty")
|
||||
val_precision = item.precision("valuation_rate")
|
||||
amount_precision = item.precision("amount")
|
||||
|
||||
new_qty = flt(item.qty, qty_precision)
|
||||
new_valuation_rate = flt(item.valuation_rate or item_dict.get("rate"), val_precision)
|
||||
new_valuation_rate = flt(item.valuation_rate or item_dict.get("rate"))
|
||||
|
||||
current_qty = flt(item_dict.get("qty"), qty_precision)
|
||||
current_valuation_rate = flt(item_dict.get("rate"), val_precision)
|
||||
current_valuation_rate = flt(item_dict.get("rate"))
|
||||
|
||||
self.difference_amount += (new_qty * new_valuation_rate) - (current_qty * current_valuation_rate)
|
||||
new_amount = flt(new_qty * new_valuation_rate, amount_precision)
|
||||
current_amount = flt(current_qty * current_valuation_rate, amount_precision)
|
||||
|
||||
self.difference_amount += new_amount - current_amount
|
||||
|
||||
def validate_data(self):
|
||||
def _get_msg(row_num, msg):
|
||||
@@ -874,7 +878,7 @@ class StockReconciliation(StockController):
|
||||
"company": self.company,
|
||||
"stock_uom": frappe.db.get_value("Item", row.item_code, "stock_uom"),
|
||||
"is_cancelled": 1 if self.docstatus == 2 else 0,
|
||||
"valuation_rate": flt(row.valuation_rate, row.precision("valuation_rate")),
|
||||
"valuation_rate": flt(row.valuation_rate),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1034,86 +1038,6 @@ class StockReconciliation(StockController):
|
||||
else:
|
||||
self._cancel()
|
||||
|
||||
def recalculate_current_qty(self, voucher_detail_no, sle_creation, add_new_sle=False):
|
||||
from erpnext.stock.stock_ledger import get_valuation_rate
|
||||
|
||||
for row in self.items:
|
||||
if voucher_detail_no != row.name:
|
||||
continue
|
||||
|
||||
if row.current_qty < 0:
|
||||
return
|
||||
|
||||
val_rate = 0.0
|
||||
current_qty = 0.0
|
||||
if row.current_serial_and_batch_bundle:
|
||||
current_qty = self.get_current_qty_for_serial_or_batch(row, sle_creation)
|
||||
elif row.serial_no:
|
||||
item_dict = get_stock_balance_for(
|
||||
row.item_code,
|
||||
row.warehouse,
|
||||
self.posting_date,
|
||||
self.posting_time,
|
||||
row=row,
|
||||
company=self.company,
|
||||
)
|
||||
|
||||
current_qty = item_dict.get("qty")
|
||||
row.current_serial_no = item_dict.get("serial_nos")
|
||||
row.current_valuation_rate = item_dict.get("rate")
|
||||
val_rate = item_dict.get("rate")
|
||||
elif row.batch_no:
|
||||
current_qty = get_batch_qty_for_stock_reco(
|
||||
row.item_code,
|
||||
row.warehouse,
|
||||
row.batch_no,
|
||||
self.posting_date,
|
||||
self.posting_time,
|
||||
self.name,
|
||||
sle_creation,
|
||||
)
|
||||
|
||||
precesion = row.precision("current_qty")
|
||||
if flt(current_qty, precesion) != flt(row.current_qty, precesion):
|
||||
if not row.serial_no:
|
||||
val_rate = get_incoming_rate(
|
||||
frappe._dict(
|
||||
{
|
||||
"item_code": row.item_code,
|
||||
"warehouse": row.warehouse,
|
||||
"qty": current_qty * -1,
|
||||
"serial_and_batch_bundle": row.current_serial_and_batch_bundle,
|
||||
"batch_no": row.batch_no,
|
||||
"voucher_type": self.doctype,
|
||||
"voucher_no": self.name,
|
||||
"company": self.company,
|
||||
"posting_date": self.posting_date,
|
||||
"posting_time": self.posting_time,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
row.current_valuation_rate = val_rate
|
||||
row.current_qty = current_qty
|
||||
row.db_set(
|
||||
{
|
||||
"current_qty": row.current_qty,
|
||||
"current_valuation_rate": row.current_valuation_rate,
|
||||
"current_amount": flt(row.current_qty * row.current_valuation_rate),
|
||||
}
|
||||
)
|
||||
|
||||
if add_new_sle and not frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_detail_no": row.name, "actual_qty": ("<", 0), "is_cancelled": 0},
|
||||
"name",
|
||||
):
|
||||
if not row.current_serial_and_batch_bundle:
|
||||
self.set_current_serial_and_batch_bundle(voucher_detail_no, save=True)
|
||||
row.reload()
|
||||
|
||||
self.add_missing_stock_ledger_entry(row, voucher_detail_no, sle_creation)
|
||||
|
||||
def add_missing_stock_ledger_entry(self, row, voucher_detail_no, sle_creation):
|
||||
if row.current_qty == 0:
|
||||
return
|
||||
|
||||
@@ -1039,7 +1039,7 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
|
||||
|
||||
sr.reload()
|
||||
self.assertTrue(sr.items[0].serial_and_batch_bundle)
|
||||
self.assertTrue(sr.items[0].current_serial_and_batch_bundle)
|
||||
self.assertFalse(sr.items[0].current_serial_and_batch_bundle)
|
||||
|
||||
def test_not_reconcile_all_batch(self):
|
||||
from erpnext.stock.doctype.batch.batch import get_batch_qty
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_rename": 1,
|
||||
"beta": 1,
|
||||
"creation": "2021-10-01 10:56:30.814787",
|
||||
@@ -13,6 +14,7 @@
|
||||
"end_time",
|
||||
"limits_dont_apply_on",
|
||||
"item_based_reposting",
|
||||
"do_not_fetch_incoming_rate_from_serial_no",
|
||||
"errors_notification_section",
|
||||
"notify_reposting_error_to_role"
|
||||
],
|
||||
@@ -65,12 +67,19 @@
|
||||
"fieldname": "errors_notification_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Errors Notification"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "For legacy serial nos, do not fetch incoming rate from serial no and calculate it based on the inward transaction",
|
||||
"fieldname": "do_not_fetch_incoming_rate_from_serial_no",
|
||||
"fieldtype": "Check",
|
||||
"label": "Do not fetch incoming rate from Serial No"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-08 11:27:46.659056",
|
||||
"modified": "2026-05-15 12:59:34.392491",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Reposting Settings",
|
||||
|
||||
@@ -16,6 +16,7 @@ class StockRepostingSettings(Document):
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
do_not_fetch_incoming_rate_from_serial_no: DF.Check
|
||||
end_time: DF.Time | None
|
||||
item_based_reposting: DF.Check
|
||||
limit_reposting_timeslot: DF.Check
|
||||
|
||||
@@ -294,9 +294,8 @@ def clean_all_descriptions():
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_enable_stock_uom_editing():
|
||||
return frappe.get_cached_value(
|
||||
return frappe.get_single_value(
|
||||
"Stock Settings",
|
||||
None,
|
||||
["allow_to_edit_stock_uom_qty_for_sales", "allow_to_edit_stock_uom_qty_for_purchase"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
@@ -1044,7 +1044,7 @@ def insert_item_price(args):
|
||||
)
|
||||
item_price.insert()
|
||||
frappe.msgprint(
|
||||
_("Item Price Added for {0} in Price List {1}").format(
|
||||
_("Item Price added for {0} in Price List - {1}").format(
|
||||
get_link_to_form("Item", args.item_code), args.price_list
|
||||
),
|
||||
alert=True,
|
||||
@@ -1070,7 +1070,9 @@ def insert_item_price(args):
|
||||
)
|
||||
item_price.insert()
|
||||
frappe.msgprint(
|
||||
_("Item Price added for {0} in Price List {1}").format(args.item_code, args.price_list),
|
||||
_("Item Price added for {0} in Price List - {1}").format(
|
||||
get_link_to_form("Item", args.item_code), args.price_list
|
||||
),
|
||||
alert=True,
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,11 @@
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import (
|
||||
FIFOSlots,
|
||||
format_report_data,
|
||||
get_average_age,
|
||||
)
|
||||
|
||||
|
||||
class TestStockAgeing(FrappeTestCase):
|
||||
@@ -868,6 +872,560 @@ class TestStockAgeing(FrappeTestCase):
|
||||
range_valuations = range_values[1::2]
|
||||
self.assertEqual(range_valuations, [15, 7.5, 20, 5])
|
||||
|
||||
def test_batch_item_report_formatting_preserves_mixed_fifo_slots(self):
|
||||
item_details = {
|
||||
"Batch Mixed Item": {
|
||||
"details": frappe._dict(
|
||||
name="Batch Mixed Item",
|
||||
item_name="Batch Mixed Item",
|
||||
description="Batch Mixed Item",
|
||||
item_group=None,
|
||||
brand=None,
|
||||
has_batch_no=True,
|
||||
stock_uom="Nos",
|
||||
),
|
||||
"fifo_queue": [
|
||||
["SA-BATCH-MIXED-SLOT", 1, 5.0, "2021-12-01", 50.0],
|
||||
[3.0, "2021-12-02", 30.0],
|
||||
],
|
||||
"has_serial_no": False,
|
||||
"total_qty": 8.0,
|
||||
}
|
||||
}
|
||||
|
||||
report_data = format_report_data(self.filters, item_details, self.filters["to_date"])
|
||||
|
||||
self.assertEqual(report_data[0][7:15], [8.0, 80.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
|
||||
|
||||
def test_average_age_accepts_batchwise_valuation_slots(self):
|
||||
fifo_queue = [["SA-BATCH-SLOT", 1, 5.0, "2021-12-01", 50.0]]
|
||||
|
||||
self.assertEqual(get_average_age(fifo_queue, self.filters["to_date"]), 9.0)
|
||||
|
||||
def test_serial_transfer_replay_preserves_serial_slots(self):
|
||||
fifo_slots = FIFOSlots(self.filters, [])
|
||||
transfer_key = ("001", "Serial Item", "WH 1")
|
||||
fifo_slots.transferred_item_details[transfer_key] = [[2, "2021-12-01", 20]]
|
||||
|
||||
row = frappe._dict(
|
||||
name="Serial Item",
|
||||
actual_qty=2,
|
||||
stock_value_difference=20,
|
||||
posting_date="2021-12-05",
|
||||
has_serial_no=True,
|
||||
)
|
||||
fifo_queue = []
|
||||
|
||||
fifo_slots._compute_incoming_stock(row, fifo_queue, transfer_key, ["SN-A", "SN-B"], [])
|
||||
|
||||
self.assertEqual(fifo_queue, [["SN-A", "2021-12-01", 10.0], ["SN-B", "2021-12-01", 10.0]])
|
||||
self.assertFalse(fifo_slots.transferred_item_details[transfer_key])
|
||||
|
||||
def test_batch_transfer_replay_removes_zeroed_negative_slot(self):
|
||||
fifo_slots = FIFOSlots(self.filters, [])
|
||||
fifo_queue = [["SA-ZERO-BATCH", 1, -4, "2021-12-01", -40]]
|
||||
|
||||
fifo_slots._add_transfer_slot_to_fifo_queue(fifo_queue, ["SA-ZERO-BATCH", 1, 4, "2021-12-02", 40])
|
||||
|
||||
self.assertEqual(fifo_queue, [])
|
||||
|
||||
def test_batchwise_valuation(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batchwise Valuation",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
|
||||
def make_batch(batch_id, use_batchwise_valuation):
|
||||
if not frappe.db.exists("Batch", batch_id):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_id,
|
||||
"item": item_code,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.db.set_value("Batch", batch_id, "use_batchwise_valuation", use_batchwise_valuation)
|
||||
|
||||
batchwise_above_90 = "SA-BATCHWISE-ABOVE-90"
|
||||
non_batchwise_above_90 = "SA-NON-BATCHWISE-ABOVE-90"
|
||||
batchwise_61_90 = "SA-BATCHWISE-61-90"
|
||||
non_batchwise_61_90 = "SA-NON-BATCHWISE-61-90"
|
||||
batchwise_31_60 = "SA-BATCHWISE-31-60"
|
||||
non_batchwise_31_60 = "SA-NON-BATCHWISE-31-60"
|
||||
batchwise_0_30 = "SA-BATCHWISE-0-30"
|
||||
non_batchwise_0_30 = "SA-NON-BATCHWISE-0-30"
|
||||
|
||||
for batch_id, use_batchwise_valuation in {
|
||||
batchwise_above_90: 1,
|
||||
non_batchwise_above_90: 0,
|
||||
batchwise_61_90: 1,
|
||||
non_batchwise_61_90: 0,
|
||||
batchwise_31_60: 1,
|
||||
non_batchwise_31_60: 0,
|
||||
batchwise_0_30: 1,
|
||||
non_batchwise_0_30: 0,
|
||||
}.items():
|
||||
make_batch(batch_id, use_batchwise_valuation)
|
||||
|
||||
qty_after_transaction = 0
|
||||
|
||||
def make_sle(posting_date, voucher_no, batch_no, actual_qty, stock_value_difference):
|
||||
nonlocal qty_after_transaction
|
||||
|
||||
qty_after_transaction += actual_qty
|
||||
return frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=actual_qty,
|
||||
qty_after_transaction=qty_after_transaction,
|
||||
stock_value_difference=stock_value_difference,
|
||||
warehouse="WH 1",
|
||||
posting_date=posting_date,
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no=voucher_no,
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
valuation_rate=10,
|
||||
)
|
||||
|
||||
sle = [
|
||||
make_sle("2021-08-01", "001", batchwise_above_90, 50, 500),
|
||||
make_sle("2021-08-10", "002", non_batchwise_above_90, 60, 600),
|
||||
make_sle("2021-08-20", "003", batchwise_above_90, -10, -100),
|
||||
make_sle("2021-09-01", "004", non_batchwise_above_90, -15, -150),
|
||||
make_sle("2021-09-20", "005", batchwise_61_90, 40, 400),
|
||||
make_sle("2021-09-25", "006", non_batchwise_61_90, 50, 500),
|
||||
make_sle("2021-09-30", "007", batchwise_61_90, -5, -50),
|
||||
make_sle("2021-10-05", "008", non_batchwise_above_90, -20, -200),
|
||||
make_sle("2021-10-20", "009", batchwise_31_60, 30, 300),
|
||||
make_sle("2021-10-25", "010", non_batchwise_31_60, 40, 400),
|
||||
make_sle("2021-10-30", "011", batchwise_31_60, -8, -80),
|
||||
make_sle("2021-11-05", "012", non_batchwise_above_90, -25, -250),
|
||||
make_sle("2021-11-20", "013", batchwise_0_30, 20, 200),
|
||||
make_sle("2021-11-25", "014", non_batchwise_0_30, 30, 300),
|
||||
make_sle("2021-11-30", "015", batchwise_0_30, -6, -60),
|
||||
make_sle("2021-12-01", "016", non_batchwise_61_90, -10, -100),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
item_result = slots[item_code]
|
||||
|
||||
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
|
||||
self.assertEqual(item_result["total_qty"], 221.0)
|
||||
self.assertEqual(
|
||||
item_result["fifo_queue"],
|
||||
[
|
||||
[batchwise_above_90, 1, 40.0, "2021-08-01", 400.0],
|
||||
[batchwise_61_90, 1, 35.0, "2021-09-20", 350.0],
|
||||
[non_batchwise_61_90, 0, 40.0, "2021-09-25", 400.0],
|
||||
[batchwise_31_60, 1, 22.0, "2021-10-20", 220.0],
|
||||
[non_batchwise_31_60, 0, 40, "2021-10-25", 400],
|
||||
[batchwise_0_30, 1, 14.0, "2021-11-20", 140.0],
|
||||
[non_batchwise_0_30, 0, 30, "2021-11-25", 300],
|
||||
],
|
||||
)
|
||||
|
||||
report_data = format_report_data(self.filters, slots, self.filters["to_date"])
|
||||
range_values = report_data[0][7:15]
|
||||
self.assertEqual(range_values, [44.0, 440.0, 62.0, 620.0, 75.0, 750.0, 40.0, 400.0])
|
||||
|
||||
def test_batchwise_valuation_same_voucher_transfer(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batchwise Transfer",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
|
||||
def make_batch(batch_id):
|
||||
if not frappe.db.exists("Batch", batch_id):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_id,
|
||||
"item": item_code,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.db.set_value("Batch", batch_id, "use_batchwise_valuation", 1)
|
||||
|
||||
source_batch = "SA-BATCHWISE-TRANSFER-SOURCE"
|
||||
target_batch = "SA-BATCHWISE-TRANSFER-TARGET"
|
||||
make_batch(source_batch)
|
||||
make_batch(target_batch)
|
||||
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=20,
|
||||
qty_after_transaction=20,
|
||||
stock_value_difference=200,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-09-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=source_batch,
|
||||
valuation_rate=10,
|
||||
),
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=-15,
|
||||
qty_after_transaction=5,
|
||||
stock_value_difference=-150,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-10-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=source_batch,
|
||||
valuation_rate=10,
|
||||
),
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=10,
|
||||
qty_after_transaction=15,
|
||||
stock_value_difference=100,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-10-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=target_batch,
|
||||
valuation_rate=10,
|
||||
),
|
||||
]
|
||||
|
||||
fifo_slots = FIFOSlots(self.filters, sle)
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots[item_code]
|
||||
|
||||
self.assertEqual(item_result["total_qty"], 15.0)
|
||||
self.assertEqual(
|
||||
item_result["fifo_queue"],
|
||||
[
|
||||
[source_batch, 1, 5.0, "2021-09-01", 50.0],
|
||||
[target_batch, 1, 10.0, "2021-09-01", 100.0],
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
fifo_slots.transferred_item_details[("002", item_code, "WH 1")],
|
||||
[[5.0, "2021-09-01", 50.0]],
|
||||
)
|
||||
|
||||
def test_batchwise_valuation_negative_stock_same_voucher(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batchwise Negative Stock",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
|
||||
batch_no = "SA-BATCHWISE-NEGATIVE-STOCK"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_no,
|
||||
"item": item_code,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=-10,
|
||||
qty_after_transaction=-10,
|
||||
stock_value_difference=-100,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
valuation_rate=10,
|
||||
)
|
||||
]
|
||||
|
||||
fifo_slots = FIFOSlots(self.filters, sle)
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots[item_code]
|
||||
|
||||
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -10, "2021-12-01", -100]])
|
||||
self.assertEqual(
|
||||
fifo_slots.transferred_item_details[("001", item_code, "WH 1")], [[10, "2021-12-01", 100]]
|
||||
)
|
||||
|
||||
sle.append(
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=6,
|
||||
qty_after_transaction=-4,
|
||||
stock_value_difference=60,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
valuation_rate=10,
|
||||
)
|
||||
)
|
||||
|
||||
fifo_slots = FIFOSlots(self.filters, sle)
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots[item_code]
|
||||
|
||||
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-12-01", -40.0]])
|
||||
self.assertEqual(
|
||||
fifo_slots.transferred_item_details[("001", item_code, "WH 1")],
|
||||
[[4.0, "2021-12-01", 40.0]],
|
||||
)
|
||||
|
||||
def test_batchwise_valuation_neutralizes_non_head_negative_batch(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batchwise Negative Non Head",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
|
||||
buffer_batch = "SA-BATCHWISE-NEGATIVE-BUFFER"
|
||||
negative_batch = "SA-BATCHWISE-NEGATIVE-NON-HEAD"
|
||||
for batch_no in [buffer_batch, negative_batch]:
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_no,
|
||||
"item": item_code,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=5,
|
||||
qty_after_transaction=5,
|
||||
stock_value_difference=50,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-11-30",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=buffer_batch,
|
||||
valuation_rate=10,
|
||||
),
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=-10,
|
||||
qty_after_transaction=-5,
|
||||
stock_value_difference=-100,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=negative_batch,
|
||||
valuation_rate=10,
|
||||
),
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=6,
|
||||
qty_after_transaction=1,
|
||||
stock_value_difference=60,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=negative_batch,
|
||||
valuation_rate=10,
|
||||
),
|
||||
]
|
||||
|
||||
fifo_slots = FIFOSlots(self.filters, sle)
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots[item_code]
|
||||
|
||||
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
|
||||
self.assertEqual(
|
||||
item_result["fifo_queue"],
|
||||
[
|
||||
[buffer_batch, 1, 5, "2021-11-30", 50],
|
||||
[negative_batch, 1, -4.0, "2021-12-01", -40.0],
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
fifo_slots.transferred_item_details[("002", item_code, "WH 1")],
|
||||
[[4.0, "2021-12-01", 40.0]],
|
||||
)
|
||||
|
||||
def test_batchwise_valuation_negative_stock_later_voucher(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batchwise Negative Later Voucher",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
|
||||
batch_no = "SA-BATCHWISE-NEGATIVE-LATER-VOUCHER"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_no,
|
||||
"item": item_code,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=-10,
|
||||
qty_after_transaction=-10,
|
||||
stock_value_difference=-100,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-11-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
valuation_rate=10,
|
||||
),
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=6,
|
||||
qty_after_transaction=-4,
|
||||
stock_value_difference=60,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-11-10",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
valuation_rate=10,
|
||||
),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
item_result = slots[item_code]
|
||||
|
||||
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
|
||||
self.assertEqual(item_result["total_qty"], -4.0)
|
||||
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-11-10", -40.0]])
|
||||
|
||||
def test_batchwise_valuation_stock_reconciliation_with_bundle(self):
|
||||
from frappe.utils import add_days, getdate, nowdate
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
get_batch_from_bundle,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
|
||||
suffix = frappe.generate_hash(length=8).upper()
|
||||
item_code = make_item(
|
||||
f"Test Stock Ageing Batch Reco {suffix}",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": f"SA-RECO-{suffix}-.###",
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
base_date = nowdate()
|
||||
|
||||
opening_reco = create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=12,
|
||||
rate=10,
|
||||
posting_date=add_days(base_date, -2),
|
||||
posting_time="10:00:00",
|
||||
)
|
||||
batch_no = get_batch_from_bundle(opening_reco.items[0].serial_and_batch_bundle)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=5,
|
||||
rate=10,
|
||||
batch_no=batch_no,
|
||||
posting_date=add_days(base_date, -1),
|
||||
posting_time="10:00:00",
|
||||
)
|
||||
|
||||
filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
to_date=base_date,
|
||||
ranges=["30", "60", "90"],
|
||||
item_code=item_code,
|
||||
)
|
||||
slots = FIFOSlots(filters).generate()
|
||||
item_result = slots[item_code]
|
||||
|
||||
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
|
||||
self.assertEqual(item_result["total_qty"], 5.0)
|
||||
self.assertEqual(
|
||||
item_result["fifo_queue"], [[batch_no.upper(), 1, 5.0, getdate(add_days(base_date, -2)), 50.0]]
|
||||
)
|
||||
|
||||
|
||||
def generate_item_and_item_wh_wise_slots(filters, sle):
|
||||
"Return results with and without 'show_warehouse_wise_stock'"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user