mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-19 12:44:03 +00:00
Compare commits
128 Commits
refactor/j
...
mergify/me
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90570cc6c6 | ||
|
|
8479a8b4d3 | ||
|
|
9a612d0164 | ||
|
|
3a6b32bcf9 | ||
|
|
81dea34dd3 | ||
|
|
be05e01bd7 | ||
|
|
61927b61fe | ||
|
|
f8550838a3 | ||
|
|
9e15e52847 | ||
|
|
a954539b53 | ||
|
|
f8120d1818 | ||
|
|
d5d2e3406b | ||
|
|
a80be19081 | ||
|
|
9ce1b02e6e | ||
|
|
f4d9869d7b | ||
|
|
6b1e339ed4 | ||
|
|
fe13c0709b | ||
|
|
c86aa3e3ad | ||
|
|
60e05bdaa6 | ||
|
|
4f42f52306 | ||
|
|
e85f2c4fbc | ||
|
|
bbc684aa80 | ||
|
|
cb97c3a55a | ||
|
|
cb6fc640ce | ||
|
|
3d44b4d98c | ||
|
|
dd7891e18f | ||
|
|
ea665d1a9b | ||
|
|
6255495cc4 | ||
|
|
8c1a1aafe6 | ||
|
|
0a9aa448c1 | ||
|
|
02f7cba20a | ||
|
|
96d4c48357 | ||
|
|
db2e2105ab | ||
|
|
e2fbc48b9a | ||
|
|
4180e29af4 | ||
|
|
39eb34f333 | ||
|
|
c6f9415e9d | ||
|
|
f768778d81 | ||
|
|
c541bc9239 | ||
|
|
817c5007d9 | ||
|
|
900c71840c | ||
|
|
dfd0c85ba4 | ||
|
|
8caaac96b6 | ||
|
|
9f02c47592 | ||
|
|
7f47c218ce | ||
|
|
ae11b3b848 | ||
|
|
64e177df8b | ||
|
|
413ec60a3e | ||
|
|
6733681e93 | ||
|
|
5104007d12 | ||
|
|
4bc3420b21 | ||
|
|
fc9608d14d | ||
|
|
facb27c3f4 | ||
|
|
68a1fe1480 | ||
|
|
e34a64ecee | ||
|
|
060cd9f320 | ||
|
|
eb6530208b | ||
|
|
98e012095a | ||
|
|
e8bebba915 | ||
|
|
d3c0d9b283 | ||
|
|
1cfb41e1c4 | ||
|
|
0e244dd83a | ||
|
|
4c29d5630d | ||
|
|
4806b82add | ||
|
|
5a80278d1e | ||
|
|
c4e1fe274b | ||
|
|
1a56f3b032 | ||
|
|
08375a9e2f | ||
|
|
fa378e2d7a | ||
|
|
006a65e873 | ||
|
|
e7b135b51e | ||
|
|
dabc94ed06 | ||
|
|
1f4702bde7 | ||
|
|
4708ac4e3d | ||
|
|
996a02180b | ||
|
|
d8a2f53a29 | ||
|
|
13d06e77b4 | ||
|
|
5787951ed1 | ||
|
|
3f6f3abf69 | ||
|
|
055c58364a | ||
|
|
cfa6d286ad | ||
|
|
b9b402f2ec | ||
|
|
b04a9e25ff | ||
|
|
b1c6666d02 | ||
|
|
fe0465f16e | ||
|
|
3ba8f690a4 | ||
|
|
47a9c54b70 | ||
|
|
336307f287 | ||
|
|
79421bcfcc | ||
|
|
e23a7883f3 | ||
|
|
deff5848ed | ||
|
|
b579dbc1e6 | ||
|
|
41da9eb7fc | ||
|
|
1cc98a82ba | ||
|
|
eb7f7f2124 | ||
|
|
fcd312f205 | ||
|
|
3d4b50d37d | ||
|
|
5de87f473e | ||
|
|
31849f6029 | ||
|
|
1f06f2e3a0 | ||
|
|
3038ad8abe | ||
|
|
dc202ac4a2 | ||
|
|
ca07982ee0 | ||
|
|
f269f6a8d8 | ||
|
|
2ca1bdd8a7 | ||
|
|
cf338bb757 | ||
|
|
d37e5cd97d | ||
|
|
526f91f6b5 | ||
|
|
4465ebaeb5 | ||
|
|
88cb132fd1 | ||
|
|
d23677636d | ||
|
|
8e0ba50c4d | ||
|
|
08abf96047 | ||
|
|
813b42d706 | ||
|
|
8ce63dac65 | ||
|
|
8e9680afce | ||
|
|
a09e875109 | ||
|
|
42c61915c4 | ||
|
|
37a6ebd431 | ||
|
|
279c8dea06 | ||
|
|
b1b6ae98ed | ||
|
|
e91bcd6dd6 | ||
|
|
35e55d3e13 | ||
|
|
6f9a8ff101 | ||
|
|
8e627db785 | ||
|
|
b2eb6a69c1 | ||
|
|
d0f1239d2b | ||
|
|
b1de654dfd |
@@ -1,6 +1,7 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.email import sendmail_to_system_managers
|
||||
from frappe.query_builder.functions import IfNull, Sum
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
add_months,
|
||||
@@ -53,20 +54,24 @@ def validate_service_stop_date(doc):
|
||||
|
||||
|
||||
def build_conditions(process_type, account, company):
|
||||
conditions = ""
|
||||
deferred_account = (
|
||||
"item.deferred_revenue_account" if process_type == "Income" else "item.deferred_expense_account"
|
||||
)
|
||||
if process_type == "Income":
|
||||
item = frappe.qb.DocType("Sales Invoice Item")
|
||||
parent = frappe.qb.DocType("Sales Invoice")
|
||||
deferred_account = item.deferred_revenue_account
|
||||
else:
|
||||
item = frappe.qb.DocType("Purchase Invoice Item")
|
||||
parent = frappe.qb.DocType("Purchase Invoice")
|
||||
deferred_account = item.deferred_expense_account
|
||||
|
||||
if account:
|
||||
conditions += f"AND {deferred_account}={frappe.db.escape(account)}"
|
||||
return deferred_account == account
|
||||
elif company:
|
||||
conditions += f"AND p.company = {frappe.db.escape(company)}"
|
||||
return parent.company == company
|
||||
|
||||
return conditions
|
||||
return None
|
||||
|
||||
|
||||
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=""):
|
||||
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=None):
|
||||
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
|
||||
|
||||
if not start_date:
|
||||
@@ -75,17 +80,25 @@ def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_d
|
||||
end_date = add_days(today(), -1)
|
||||
|
||||
# check for the purchase invoice for which GL entries has to be done
|
||||
invoices = frappe.db.sql_list(
|
||||
f"""
|
||||
select distinct item.parent
|
||||
from `tabPurchase Invoice Item` item, `tabPurchase Invoice` p
|
||||
where item.service_start_date<=%s and item.service_end_date>=%s
|
||||
and item.enable_deferred_expense = 1 and item.parent=p.name
|
||||
and item.docstatus = 1 and ifnull(item.amount, 0) > 0
|
||||
{conditions}
|
||||
""",
|
||||
(end_date, start_date),
|
||||
) # nosec
|
||||
item = frappe.qb.DocType("Purchase Invoice Item")
|
||||
parent = frappe.qb.DocType("Purchase Invoice")
|
||||
query = (
|
||||
frappe.qb.from_(item)
|
||||
.inner_join(parent)
|
||||
.on(item.parent == parent.name)
|
||||
.select(item.parent)
|
||||
.distinct()
|
||||
.where(
|
||||
(item.service_start_date <= end_date)
|
||||
& (item.service_end_date >= start_date)
|
||||
& (item.enable_deferred_expense == 1)
|
||||
& (item.docstatus == 1)
|
||||
& (IfNull(item.amount, 0) > 0)
|
||||
)
|
||||
)
|
||||
if conditions is not None:
|
||||
query = query.where(conditions)
|
||||
invoices = query.run(pluck=True)
|
||||
|
||||
# For each invoice, book deferred expense
|
||||
for invoice in invoices:
|
||||
@@ -96,7 +109,7 @@ def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_d
|
||||
send_mail(deferred_process)
|
||||
|
||||
|
||||
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=""):
|
||||
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=None):
|
||||
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
|
||||
|
||||
if not start_date:
|
||||
@@ -105,17 +118,25 @@ def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_da
|
||||
end_date = add_days(today(), -1)
|
||||
|
||||
# check for the sales invoice for which GL entries has to be done
|
||||
invoices = frappe.db.sql_list(
|
||||
f"""
|
||||
select distinct item.parent
|
||||
from `tabSales Invoice Item` item, `tabSales Invoice` p
|
||||
where item.service_start_date<=%s and item.service_end_date>=%s
|
||||
and item.enable_deferred_revenue = 1 and item.parent=p.name
|
||||
and item.docstatus = 1 and ifnull(item.amount, 0) > 0
|
||||
{conditions}
|
||||
""",
|
||||
(end_date, start_date),
|
||||
) # nosec
|
||||
item = frappe.qb.DocType("Sales Invoice Item")
|
||||
parent = frappe.qb.DocType("Sales Invoice")
|
||||
query = (
|
||||
frappe.qb.from_(item)
|
||||
.inner_join(parent)
|
||||
.on(item.parent == parent.name)
|
||||
.select(item.parent)
|
||||
.distinct()
|
||||
.where(
|
||||
(item.service_start_date <= end_date)
|
||||
& (item.service_end_date >= start_date)
|
||||
& (item.enable_deferred_revenue == 1)
|
||||
& (item.docstatus == 1)
|
||||
& (IfNull(item.amount, 0) > 0)
|
||||
)
|
||||
)
|
||||
if conditions is not None:
|
||||
query = query.where(conditions)
|
||||
invoices = query.run(pluck=True)
|
||||
|
||||
for invoice in invoices:
|
||||
doc = frappe.get_doc("Sales Invoice", invoice)
|
||||
@@ -136,26 +157,39 @@ def get_booking_dates(doc, item, posting_date=None, prev_posting_date=None):
|
||||
)
|
||||
|
||||
if not prev_posting_date:
|
||||
prev_gl_entry = frappe.db.sql(
|
||||
"""
|
||||
select name, posting_date from `tabGL Entry` where company=%s and account=%s and
|
||||
voucher_type=%s and voucher_no=%s and voucher_detail_no=%s
|
||||
and is_cancelled = 0
|
||||
order by posting_date desc limit 1
|
||||
""",
|
||||
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
|
||||
as_dict=True,
|
||||
prev_gl_entry = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"company": doc.company,
|
||||
"account": item.get(deferred_account),
|
||||
"voucher_type": doc.doctype,
|
||||
"voucher_no": doc.name,
|
||||
"voucher_detail_no": item.name,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
fields=["name", "posting_date"],
|
||||
order_by="posting_date desc",
|
||||
limit=1,
|
||||
)
|
||||
|
||||
prev_gl_via_je = frappe.db.sql(
|
||||
"""
|
||||
SELECT p.name, p.posting_date FROM `tabJournal Entry` p, `tabJournal Entry Account` c
|
||||
WHERE p.name = c.parent and p.company=%s and c.account=%s
|
||||
and c.reference_type=%s and c.reference_name=%s
|
||||
and c.reference_detail_no=%s and c.docstatus < 2 order by posting_date desc limit 1
|
||||
""",
|
||||
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
|
||||
as_dict=True,
|
||||
je = frappe.qb.DocType("Journal Entry")
|
||||
jea = frappe.qb.DocType("Journal Entry Account")
|
||||
prev_gl_via_je = (
|
||||
frappe.qb.from_(je)
|
||||
.inner_join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.name, je.posting_date)
|
||||
.where(
|
||||
(je.company == doc.company)
|
||||
& (jea.account == item.get(deferred_account))
|
||||
& (jea.reference_type == doc.doctype)
|
||||
& (jea.reference_name == doc.name)
|
||||
& (jea.reference_detail_no == item.name)
|
||||
& (jea.docstatus < 2)
|
||||
)
|
||||
.orderby(je.posting_date, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
if prev_gl_via_je:
|
||||
@@ -277,26 +311,47 @@ def get_already_booked_amount(doc, item):
|
||||
total_credit_debit, total_credit_debit_currency = "credit", "credit_in_account_currency"
|
||||
deferred_account = "deferred_expense_account"
|
||||
|
||||
gl_entries_details = frappe.db.sql(
|
||||
"""
|
||||
select sum({}) as total_credit, sum({}) as total_credit_in_account_currency, voucher_detail_no
|
||||
from `tabGL Entry` where company=%s and account=%s and voucher_type=%s and voucher_no=%s and voucher_detail_no=%s
|
||||
and is_cancelled = 0
|
||||
group by voucher_detail_no
|
||||
""".format(total_credit_debit, total_credit_debit_currency),
|
||||
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
|
||||
as_dict=True,
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
gl_entries_details = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(
|
||||
Sum(gle[total_credit_debit]).as_("total_credit"),
|
||||
Sum(gle[total_credit_debit_currency]).as_("total_credit_in_account_currency"),
|
||||
gle.voucher_detail_no,
|
||||
)
|
||||
.where(
|
||||
(gle.company == doc.company)
|
||||
& (gle.account == item.get(deferred_account))
|
||||
& (gle.voucher_type == doc.doctype)
|
||||
& (gle.voucher_no == doc.name)
|
||||
& (gle.voucher_detail_no == item.name)
|
||||
& (gle.is_cancelled == 0)
|
||||
)
|
||||
.groupby(gle.voucher_detail_no)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
journal_entry_details = frappe.db.sql(
|
||||
"""
|
||||
SELECT sum(c.{}) as total_credit, sum(c.{}) as total_credit_in_account_currency, reference_detail_no
|
||||
FROM `tabJournal Entry` p , `tabJournal Entry Account` c WHERE p.name = c.parent and
|
||||
p.company = %s and c.account=%s and c.reference_type=%s and c.reference_name=%s and c.reference_detail_no=%s
|
||||
and p.docstatus < 2 group by reference_detail_no
|
||||
""".format(total_credit_debit, total_credit_debit_currency),
|
||||
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
|
||||
as_dict=True,
|
||||
je = frappe.qb.DocType("Journal Entry")
|
||||
jea = frappe.qb.DocType("Journal Entry Account")
|
||||
journal_entry_details = (
|
||||
frappe.qb.from_(je)
|
||||
.inner_join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(
|
||||
Sum(jea[total_credit_debit]).as_("total_credit"),
|
||||
Sum(jea[total_credit_debit_currency]).as_("total_credit_in_account_currency"),
|
||||
jea.reference_detail_no,
|
||||
)
|
||||
.where(
|
||||
(je.company == doc.company)
|
||||
& (jea.account == item.get(deferred_account))
|
||||
& (jea.reference_type == doc.doctype)
|
||||
& (jea.reference_name == doc.name)
|
||||
& (jea.reference_detail_no == item.name)
|
||||
& (je.docstatus < 2)
|
||||
)
|
||||
.groupby(jea.reference_detail_no)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
already_booked_amount = gl_entries_details[0].total_credit if gl_entries_details else 0
|
||||
|
||||
@@ -7,7 +7,7 @@ from frappe import _, msgprint
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Coalesce, Sum
|
||||
from frappe.query_builder.functions import Coalesce, Max, Sum
|
||||
from frappe.utils import cint, flt, fmt_money, getdate
|
||||
from pypika import Order
|
||||
|
||||
@@ -195,14 +195,17 @@ def get_payment_entries_for_bank_clearance(
|
||||
.select(
|
||||
ConstantColumn("Journal Entry").as_("payment_document"),
|
||||
journal_entry.name.as_("payment_entry"),
|
||||
journal_entry.cheque_no.as_("cheque_number"),
|
||||
journal_entry.cheque_date,
|
||||
# non-grouped columns are constant per grouped JE name / account (against_account is
|
||||
# arbitrary per group on MySQL) -> Max() keeps the GROUP BY valid on postgres with the
|
||||
# same value MySQL picked.
|
||||
Max(journal_entry.cheque_no).as_("cheque_number"),
|
||||
Max(journal_entry.cheque_date).as_("cheque_date"),
|
||||
Sum(journal_entry_account.debit_in_account_currency).as_("debit"),
|
||||
Sum(journal_entry_account.credit_in_account_currency).as_("credit"),
|
||||
journal_entry.posting_date,
|
||||
journal_entry_account.against_account,
|
||||
journal_entry.clearance_date,
|
||||
journal_entry_account.account_currency,
|
||||
Max(journal_entry.posting_date).as_("posting_date"),
|
||||
Max(journal_entry_account.against_account).as_("against_account"),
|
||||
Max(journal_entry.clearance_date).as_("clearance_date"),
|
||||
Max(journal_entry_account.account_currency).as_("account_currency"),
|
||||
)
|
||||
.where(
|
||||
(journal_entry_account.account == account)
|
||||
@@ -215,12 +218,13 @@ def get_payment_entries_for_bank_clearance(
|
||||
|
||||
if not include_reconciled_entries:
|
||||
journal_entry_query = journal_entry_query.where(
|
||||
(journal_entry.clearance_date.isnull()) | (journal_entry.clearance_date == "0000-00-00")
|
||||
(journal_entry.clearance_date.isnull())
|
||||
| (journal_entry.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
|
||||
)
|
||||
|
||||
journal_entries = (
|
||||
journal_entry_query.groupby(journal_entry_account.account, journal_entry.name)
|
||||
.orderby(journal_entry.posting_date)
|
||||
.orderby(Max(journal_entry.posting_date))
|
||||
.orderby(journal_entry.name, order=Order.desc)
|
||||
).run(as_dict=True)
|
||||
|
||||
@@ -290,7 +294,8 @@ def get_payment_entries_for_bank_clearance(
|
||||
|
||||
if not include_reconciled_entries:
|
||||
payment_entry_query = payment_entry_query.where(
|
||||
(pe.clearance_date.isnull()) | (pe.clearance_date == "0000-00-00")
|
||||
(pe.clearance_date.isnull())
|
||||
| (pe.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
|
||||
)
|
||||
|
||||
payment_entries = (payment_entry_query.orderby(pe.posting_date).orderby(pe.name, order=Order.desc)).run(
|
||||
@@ -327,7 +332,8 @@ def get_payment_entries_for_bank_clearance(
|
||||
|
||||
if not include_reconciled_entries:
|
||||
paid_purchase_invoices_query = paid_purchase_invoices_query.where(
|
||||
(pi.clearance_date.isnull()) | (pi.clearance_date == "0000-00-00")
|
||||
(pi.clearance_date.isnull())
|
||||
| (pi.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
|
||||
)
|
||||
|
||||
paid_purchase_invoices = (
|
||||
@@ -367,7 +373,8 @@ def get_payment_entries_for_bank_clearance(
|
||||
|
||||
if not include_reconciled_entries:
|
||||
pos_sales_invoices_query = pos_sales_invoices_query.where(
|
||||
(si_payment.clearance_date.isnull()) | (si_payment.clearance_date == "0000-00-00")
|
||||
(si_payment.clearance_date.isnull())
|
||||
| (si_payment.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
|
||||
)
|
||||
|
||||
pos_sales_invoices = (
|
||||
|
||||
@@ -9,6 +9,13 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
|
||||
|
||||
frappe.ui.form.on("Bank Guarantee", {
|
||||
setup: function (frm) {
|
||||
frm.set_query("reference_doctype", function () {
|
||||
return {
|
||||
filters: {
|
||||
name: ["in", ["Sales Order", "Purchase Order"]],
|
||||
},
|
||||
};
|
||||
});
|
||||
frm.set_query("bank_account", function () {
|
||||
return {
|
||||
filters: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "ACC-BG-.YYYY.-.#####",
|
||||
"creation": "2016-12-17 10:43:35.731631",
|
||||
"doctype": "DocType",
|
||||
@@ -50,8 +51,7 @@
|
||||
"fieldname": "reference_doctype",
|
||||
"fieldtype": "Link",
|
||||
"label": "Reference Document Type",
|
||||
"options": "DocType",
|
||||
"read_only": 1
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_docname",
|
||||
@@ -60,14 +60,14 @@
|
||||
"options": "reference_doctype"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.bg_type == \"Receiving\"",
|
||||
"depends_on": "eval: doc.reference_doctype == \"Sales Order\"",
|
||||
"fieldname": "customer",
|
||||
"fieldtype": "Link",
|
||||
"label": "Customer",
|
||||
"options": "Customer"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.bg_type == \"Providing\"",
|
||||
"depends_on": "eval: doc.reference_doctype == \"Purchase Order\"",
|
||||
"fieldname": "supplier",
|
||||
"fieldtype": "Link",
|
||||
"label": "Supplier",
|
||||
@@ -218,10 +218,11 @@
|
||||
"grid_page_length": 50,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-29 11:52:33.550847",
|
||||
"modified": "2026-05-25 18:12:10.768835",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Guarantee",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -136,6 +136,9 @@ function set_total_budget_amount(frm) {
|
||||
function toggle_distribution_fields(frm) {
|
||||
const grid = frm.fields_dict.budget_distribution.grid;
|
||||
|
||||
frm.set_df_property("budget_distribution", "cannot_add_rows", true);
|
||||
frm.set_df_property("budget_distribution", "cannot_delete_rows", true);
|
||||
|
||||
["amount", "percent"].forEach((field) => {
|
||||
grid.update_docfield_property(field, "read_only", frm.doc.distribute_equally);
|
||||
});
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder.functions import Coalesce, Sum
|
||||
from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate
|
||||
from frappe.utils.data import get_first_day
|
||||
from pypika.terms import ExistsCriterion
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
@@ -115,23 +117,26 @@ class Budget(Document):
|
||||
if not account:
|
||||
return
|
||||
|
||||
existing_budget = frappe.db.sql(
|
||||
f"""
|
||||
SELECT name, account
|
||||
FROM `tabBudget`
|
||||
WHERE
|
||||
docstatus < 2
|
||||
AND company = %s
|
||||
AND {budget_against_field} = %s
|
||||
AND account = %s
|
||||
AND name != %s
|
||||
AND (
|
||||
(SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
|
||||
AND (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
|
||||
)
|
||||
""",
|
||||
(self.company, budget_against, account, self.name, self.budget_end_date, self.budget_start_date),
|
||||
as_dict=True,
|
||||
budget = frappe.qb.DocType("Budget")
|
||||
fy_from = frappe.qb.DocType("Fiscal Year").as_("fy_from")
|
||||
fy_to = frappe.qb.DocType("Fiscal Year").as_("fy_to")
|
||||
existing_budget = (
|
||||
frappe.qb.from_(budget)
|
||||
.inner_join(fy_from)
|
||||
.on(fy_from.name == budget.from_fiscal_year)
|
||||
.inner_join(fy_to)
|
||||
.on(fy_to.name == budget.to_fiscal_year)
|
||||
.select(budget.name, budget.account)
|
||||
.where(
|
||||
(budget.docstatus < 2)
|
||||
& (budget.company == self.company)
|
||||
& (budget[budget_against_field] == budget_against)
|
||||
& (budget.account == account)
|
||||
& (budget.name != self.name)
|
||||
& (fy_from.year_start_date <= self.budget_end_date)
|
||||
& (fy_to.year_end_date >= self.budget_start_date)
|
||||
)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
if existing_budget:
|
||||
@@ -353,8 +358,8 @@ class Budget(Document):
|
||||
if self.should_regenerate_budget_distribution():
|
||||
return
|
||||
|
||||
total_amount = sum(d.amount for d in self.budget_distribution)
|
||||
total_percent = sum(d.percent for d in self.budget_distribution)
|
||||
total_amount = sum(flt(d.amount) for d in self.budget_distribution)
|
||||
total_percent = sum(flt(d.percent) for d in self.budget_distribution)
|
||||
|
||||
if flt(abs(total_amount - self.budget_amount), 2) > 0.10:
|
||||
frappe.throw(
|
||||
@@ -381,17 +386,24 @@ def validate_expense_against_budget(params, expense_amount=0):
|
||||
posting_fiscal_year = get_fiscal_year(posting_date, company=params.get("company"))[0]
|
||||
year_start_date, year_end_date = get_fiscal_year_date_range(posting_fiscal_year, posting_fiscal_year)
|
||||
|
||||
budget_exists = frappe.db.sql(
|
||||
"""
|
||||
select name
|
||||
from `tabBudget`
|
||||
where company = %s
|
||||
and docstatus = 1
|
||||
and (SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
|
||||
and (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
|
||||
limit 1
|
||||
""",
|
||||
(params.company, year_end_date, year_start_date),
|
||||
budget = frappe.qb.DocType("Budget")
|
||||
fy_from = frappe.qb.DocType("Fiscal Year").as_("fy_from")
|
||||
fy_to = frappe.qb.DocType("Fiscal Year").as_("fy_to")
|
||||
budget_exists = (
|
||||
frappe.qb.from_(budget)
|
||||
.inner_join(fy_from)
|
||||
.on(fy_from.name == budget.from_fiscal_year)
|
||||
.inner_join(fy_to)
|
||||
.on(fy_to.name == budget.to_fiscal_year)
|
||||
.select(budget.name)
|
||||
.where(
|
||||
(budget.company == params.company)
|
||||
& (budget.docstatus == 1)
|
||||
& (fy_from.year_start_date <= year_end_date)
|
||||
& (fy_to.year_end_date >= year_start_date)
|
||||
)
|
||||
.limit(1)
|
||||
.run()
|
||||
)
|
||||
|
||||
if not budget_exists:
|
||||
@@ -434,50 +446,52 @@ def validate_expense_against_budget(params, expense_amount=0):
|
||||
and (frappe.get_cached_value("Account", params.account, "root_type") == "Expense")
|
||||
):
|
||||
doctype = dimension.get("document_type")
|
||||
|
||||
if frappe.get_cached_value("DocType", doctype, "is_tree"):
|
||||
lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"])
|
||||
condition = f"""and exists(select name from `tab{doctype}`
|
||||
where lft<={lft} and rgt>={rgt} and name=b.{budget_against})""" # nosec
|
||||
params.is_tree = True
|
||||
else:
|
||||
condition = f"and b.{budget_against}={frappe.db.escape(params.get(budget_against))}"
|
||||
params.is_tree = False
|
||||
|
||||
params.is_tree = bool(frappe.get_cached_value("DocType", doctype, "is_tree"))
|
||||
params.budget_against_field = budget_against
|
||||
params.budget_against_doctype = doctype
|
||||
|
||||
budget_records = frappe.db.sql(
|
||||
f"""
|
||||
SELECT
|
||||
b = frappe.qb.DocType("Budget")
|
||||
query = (
|
||||
frappe.qb.from_(b)
|
||||
.select(
|
||||
b.name,
|
||||
b.{budget_against} AS budget_against,
|
||||
getattr(b, budget_against).as_("budget_against"),
|
||||
b.budget_amount,
|
||||
b.from_fiscal_year,
|
||||
b.to_fiscal_year,
|
||||
b.budget_start_date,
|
||||
b.budget_end_date,
|
||||
IFNULL(b.applicable_on_material_request, 0) AS for_material_request,
|
||||
IFNULL(b.applicable_on_purchase_order, 0) AS for_purchase_order,
|
||||
IFNULL(b.applicable_on_booking_actual_expenses, 0) AS for_actual_expenses,
|
||||
Coalesce(b.applicable_on_material_request, 0).as_("for_material_request"),
|
||||
Coalesce(b.applicable_on_purchase_order, 0).as_("for_purchase_order"),
|
||||
Coalesce(b.applicable_on_booking_actual_expenses, 0).as_("for_actual_expenses"),
|
||||
b.action_if_annual_budget_exceeded,
|
||||
b.action_if_accumulated_monthly_budget_exceeded,
|
||||
b.action_if_annual_budget_exceeded_on_mr,
|
||||
b.action_if_accumulated_monthly_budget_exceeded_on_mr,
|
||||
b.action_if_annual_budget_exceeded_on_po,
|
||||
b.action_if_accumulated_monthly_budget_exceeded_on_po
|
||||
FROM
|
||||
`tabBudget` b
|
||||
WHERE
|
||||
b.company = %s
|
||||
AND b.docstatus = 1
|
||||
AND %s BETWEEN b.budget_start_date AND b.budget_end_date
|
||||
AND b.account = %s
|
||||
{condition}
|
||||
""",
|
||||
(params.company, params.posting_date, params.account),
|
||||
as_dict=True,
|
||||
) # nosec
|
||||
b.action_if_accumulated_monthly_budget_exceeded_on_po,
|
||||
)
|
||||
.where(b.company == params.company)
|
||||
.where(b.docstatus == 1)
|
||||
.where(b.budget_start_date <= params.posting_date)
|
||||
.where(b.budget_end_date >= params.posting_date)
|
||||
.where(b.account == params.account)
|
||||
)
|
||||
|
||||
if params.is_tree:
|
||||
lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"])
|
||||
dim = frappe.qb.DocType(doctype)
|
||||
query = query.where(
|
||||
ExistsCriterion(
|
||||
frappe.qb.from_(dim)
|
||||
.select(dim.name)
|
||||
.where((dim.lft <= lft) & (dim.rgt >= rgt) & (dim.name == getattr(b, budget_against)))
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = query.where(getattr(b, budget_against) == params.get(budget_against))
|
||||
|
||||
budget_records = query.run(as_dict=True)
|
||||
|
||||
if budget_records:
|
||||
validate_budget_records(params, budget_records, expense_amount)
|
||||
@@ -674,15 +688,27 @@ def get_actions(params, budget):
|
||||
|
||||
def get_requested_amount(params):
|
||||
item_code = params.get("item_code")
|
||||
condition = get_other_condition(params, "Material Request")
|
||||
|
||||
data = frappe.db.sql(
|
||||
""" select ifnull((sum(child.stock_qty - child.ordered_qty) * rate), 0) as amount
|
||||
from `tabMaterial Request Item` child, `tabMaterial Request` parent where parent.name = child.parent and
|
||||
child.item_code = %s and parent.docstatus = 1 and child.stock_qty > child.ordered_qty and {} and
|
||||
parent.material_request_type = 'Purchase' and parent.status != 'Stopped'""".format(condition),
|
||||
item_code,
|
||||
as_list=1,
|
||||
child = frappe.qb.DocType("Material Request Item")
|
||||
parent = frappe.qb.DocType("Material Request")
|
||||
|
||||
data = (
|
||||
frappe.qb.from_(child)
|
||||
.join(parent)
|
||||
.on(parent.name == child.parent)
|
||||
.select(
|
||||
# rate inside the aggregate: Sum(qty * rate) is the correct requested amount and is PG-valid
|
||||
Coalesce(Sum((child.stock_qty - child.ordered_qty) * child.rate), 0).as_("amount")
|
||||
)
|
||||
.where(
|
||||
(child.item_code == item_code)
|
||||
& (parent.docstatus == 1)
|
||||
& (child.stock_qty > child.ordered_qty)
|
||||
& Criterion.all(get_other_condition(params, child, parent, "Material Request"))
|
||||
& (parent.material_request_type == "Purchase")
|
||||
& (parent.status != "Stopped")
|
||||
)
|
||||
.run(as_list=1)
|
||||
)
|
||||
|
||||
return data[0][0] if data else 0
|
||||
@@ -690,37 +716,43 @@ def get_requested_amount(params):
|
||||
|
||||
def get_ordered_amount(params):
|
||||
item_code = params.get("item_code")
|
||||
condition = get_other_condition(params, "Purchase Order")
|
||||
|
||||
data = frappe.db.sql(
|
||||
f""" select ifnull(sum(child.amount - child.billed_amt), 0) as amount
|
||||
from `tabPurchase Order Item` child, `tabPurchase Order` parent where
|
||||
parent.name = child.parent and child.item_code = %s and parent.docstatus = 1 and child.amount > child.billed_amt
|
||||
and parent.status != 'Closed' and {condition}""",
|
||||
item_code,
|
||||
as_list=1,
|
||||
child = frappe.qb.DocType("Purchase Order Item")
|
||||
parent = frappe.qb.DocType("Purchase Order")
|
||||
|
||||
data = (
|
||||
frappe.qb.from_(child)
|
||||
.join(parent)
|
||||
.on(parent.name == child.parent)
|
||||
.select(Coalesce(Sum(child.amount - child.billed_amt), 0).as_("amount"))
|
||||
.where(
|
||||
(child.item_code == item_code)
|
||||
& (parent.docstatus == 1)
|
||||
& (child.amount > child.billed_amt)
|
||||
& (parent.status != "Closed")
|
||||
& Criterion.all(get_other_condition(params, child, parent, "Purchase Order"))
|
||||
)
|
||||
.run(as_list=1)
|
||||
)
|
||||
|
||||
return data[0][0] if data else 0
|
||||
|
||||
|
||||
def get_other_condition(params, for_doc):
|
||||
condition = f"expense_account = {frappe.db.escape(params.expense_account)}"
|
||||
def get_other_condition(params, child, parent, for_doc):
|
||||
conditions = [child.expense_account == params.expense_account]
|
||||
budget_against_field = params.get("budget_against_field")
|
||||
|
||||
if budget_against_field and params.get(budget_against_field):
|
||||
condition += (
|
||||
f" and child.{budget_against_field} = {frappe.db.escape(params.get(budget_against_field))}"
|
||||
)
|
||||
conditions.append(child[budget_against_field] == params.get(budget_against_field))
|
||||
|
||||
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
|
||||
|
||||
start_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date")
|
||||
end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date")
|
||||
|
||||
condition += f" and parent.{date_field} between {frappe.db.escape(str(start_date))} and {frappe.db.escape(str(end_date))}"
|
||||
conditions.append(parent[date_field][str(start_date) : str(end_date)])
|
||||
|
||||
return condition
|
||||
return conditions
|
||||
|
||||
|
||||
def get_actual_expense(params):
|
||||
@@ -728,11 +760,19 @@ def get_actual_expense(params):
|
||||
params.budget_against_doctype = frappe.unscrub(params.budget_against_field)
|
||||
|
||||
budget_against_field = params.get("budget_against_field")
|
||||
condition1 = " and gle.posting_date <= %(month_end_date)s" if params.get("month_end_date") else ""
|
||||
|
||||
date_condition = (
|
||||
f"and gle.posting_date between '{params.budget_start_date}' and '{params.budget_end_date}'"
|
||||
)
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
|
||||
conditions = [
|
||||
gle.is_cancelled == 0,
|
||||
gle.account == params.get("account"),
|
||||
gle.posting_date[str(params.budget_start_date) : str(params.budget_end_date)],
|
||||
gle.company == params.get("company"),
|
||||
gle.docstatus == 1,
|
||||
]
|
||||
|
||||
if params.get("month_end_date"):
|
||||
conditions.append(gle.posting_date <= params.get("month_end_date"))
|
||||
|
||||
if params.is_tree:
|
||||
lft_rgt = frappe.db.get_value(
|
||||
@@ -740,35 +780,27 @@ def get_actual_expense(params):
|
||||
)
|
||||
params.update(lft_rgt)
|
||||
|
||||
condition2 = f"""
|
||||
and exists(
|
||||
select name from `tab{params.budget_against_doctype}`
|
||||
where lft >= %(lft)s and rgt <= %(rgt)s
|
||||
and name = gle.{budget_against_field}
|
||||
tree = frappe.qb.DocType(params.budget_against_doctype)
|
||||
conditions.append(
|
||||
ExistsCriterion(
|
||||
frappe.qb.from_(tree)
|
||||
.select(tree.name)
|
||||
.where(
|
||||
(tree.lft >= params.get("lft"))
|
||||
& (tree.rgt <= params.get("rgt"))
|
||||
& (tree.name == gle[budget_against_field])
|
||||
)
|
||||
)
|
||||
"""
|
||||
)
|
||||
else:
|
||||
condition2 = f"""
|
||||
and gle.{budget_against_field} = %({budget_against_field})s
|
||||
"""
|
||||
conditions.append(gle[budget_against_field] == params.get(budget_against_field))
|
||||
|
||||
amount = flt(
|
||||
frappe.db.sql(
|
||||
f"""
|
||||
select sum(gle.debit) - sum(gle.credit)
|
||||
from `tabGL Entry` gle
|
||||
where
|
||||
is_cancelled = 0
|
||||
and gle.account = %(account)s
|
||||
{condition1}
|
||||
{date_condition}
|
||||
and gle.company = %(company)s
|
||||
and gle.docstatus = 1
|
||||
{condition2}
|
||||
""",
|
||||
params,
|
||||
)[0][0]
|
||||
) # nosec
|
||||
frappe.qb.from_(gle)
|
||||
.select(Sum(gle.debit) - Sum(gle.credit))
|
||||
.where(Criterion.all(conditions))
|
||||
.run()[0][0]
|
||||
)
|
||||
|
||||
return amount
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"in_list_view": 1,
|
||||
"label": "Start Date",
|
||||
"read_only": 1,
|
||||
"reqd": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
@@ -25,26 +26,29 @@
|
||||
"fieldtype": "Date",
|
||||
"in_list_view": 1,
|
||||
"label": "End Date",
|
||||
"read_only": 1
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Amount"
|
||||
"label": "Amount",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "percent",
|
||||
"fieldtype": "Percent",
|
||||
"in_list_view": 1,
|
||||
"label": "Percent"
|
||||
"label": "Percent",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-03 13:18:28.398198",
|
||||
"modified": "2026-06-18 11:23:17.669733",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Budget Distribution",
|
||||
|
||||
@@ -15,12 +15,12 @@ class BudgetDistribution(Document):
|
||||
from frappe.types import DF
|
||||
|
||||
amount: DF.Currency
|
||||
end_date: DF.Date | None
|
||||
end_date: DF.Date
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
percent: DF.Percent
|
||||
start_date: DF.Date | None
|
||||
start_date: DF.Date
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
@@ -75,7 +75,10 @@ def validate_company(company: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def import_coa(file_name: str, company: str):
|
||||
frappe.only_for("Accounts Manager")
|
||||
|
||||
# delete existing data for accounts
|
||||
frappe.has_permission("Company", "write", company, throw=True)
|
||||
unset_existing_data(company)
|
||||
|
||||
# create accounts
|
||||
@@ -453,6 +456,7 @@ def unset_existing_data(company):
|
||||
fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", [])
|
||||
linked = [{"fieldname": name} for name in fieldnames]
|
||||
update_values = {d.get("fieldname"): "" for d in linked}
|
||||
|
||||
frappe.db.set_value("Company", company, update_values, update_values)
|
||||
|
||||
# remove accounts data from various doctypes
|
||||
@@ -464,8 +468,7 @@ def unset_existing_data(company):
|
||||
"Sales Taxes and Charges Template",
|
||||
"Purchase Taxes and Charges Template",
|
||||
]:
|
||||
dt = frappe.qb.DocType(doctype)
|
||||
frappe.qb.from_(dt).where(dt.company == company).delete().run()
|
||||
frappe.get_query(doctype, delete=True, filters={"company": company}, ignore_permissions=False).run()
|
||||
|
||||
|
||||
def set_default_accounts(company):
|
||||
|
||||
@@ -84,10 +84,10 @@ class CostCenter(NestedSet):
|
||||
return frappe.db.get_value("GL Entry", {"cost_center": self.name})
|
||||
|
||||
def check_if_child_exists(self):
|
||||
return frappe.db.sql(
|
||||
"select name from `tabCost Center` where \
|
||||
parent_cost_center = %s and docstatus != 2",
|
||||
self.name,
|
||||
return frappe.get_all(
|
||||
"Cost Center",
|
||||
filters={"parent_cost_center": self.name, "docstatus": ["!=", 2]},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
def if_allocation_exists_against_cost_center(self):
|
||||
|
||||
@@ -72,10 +72,8 @@ class FiscalYear(Document):
|
||||
|
||||
if existing_fiscal_years:
|
||||
for existing in existing_fiscal_years:
|
||||
company_for_existing = frappe.db.sql_list(
|
||||
"""select company from `tabFiscal Year Company`
|
||||
where parent=%s""",
|
||||
existing.name,
|
||||
company_for_existing = frappe.get_all(
|
||||
"Fiscal Year Company", filters={"parent": existing.name}, pluck="company"
|
||||
)
|
||||
|
||||
overlap = False
|
||||
|
||||
@@ -7,6 +7,7 @@ from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.model.naming import set_name_from_naming_options
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import create_batch, flt, fmt_money, now
|
||||
|
||||
import erpnext
|
||||
@@ -331,10 +332,12 @@ def validate_balance_type(account, adv_adj=False):
|
||||
if not adv_adj and account:
|
||||
balance_must_be = frappe.get_cached_value("Account", account, "balance_must_be")
|
||||
if balance_must_be:
|
||||
balance = frappe.db.sql(
|
||||
"""select sum(debit) - sum(credit)
|
||||
from `tabGL Entry` where is_cancelled = 0 and account = %s""",
|
||||
account,
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
balance = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(Sum(gle.debit) - Sum(gle.credit))
|
||||
.where((gle.is_cancelled == 0) & (gle.account == account))
|
||||
.run()
|
||||
)[0][0]
|
||||
|
||||
if (balance_must_be == "Debit" and flt(balance) < 0) or (
|
||||
@@ -348,44 +351,48 @@ def validate_balance_type(account, adv_adj=False):
|
||||
def update_outstanding_amt(
|
||||
account, party_type, party, against_voucher_type, against_voucher, on_cancel=False
|
||||
):
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
|
||||
conditions = (
|
||||
(gle.against_voucher_type == against_voucher_type)
|
||||
& (gle.against_voucher == against_voucher)
|
||||
& (gle.voucher_type != "Invoice Discounting")
|
||||
)
|
||||
if party_type and party:
|
||||
party_condition = " and party_type={} and party={}".format(
|
||||
frappe.db.escape(party_type), frappe.db.escape(party)
|
||||
)
|
||||
else:
|
||||
party_condition = ""
|
||||
conditions &= (gle.party_type == party_type) & (gle.party == party)
|
||||
|
||||
if against_voucher_type == "Sales Invoice":
|
||||
party_account = frappe.get_cached_value(against_voucher_type, against_voucher, "debit_to")
|
||||
account_condition = f"and account in ({frappe.db.escape(account)}, {frappe.db.escape(party_account)})"
|
||||
conditions &= gle.account.isin([account, party_account])
|
||||
else:
|
||||
account_condition = f" and account = {frappe.db.escape(account)}"
|
||||
conditions &= gle.account == account
|
||||
|
||||
# get final outstanding amt
|
||||
bal = flt(
|
||||
frappe.db.sql(
|
||||
f"""
|
||||
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
|
||||
from `tabGL Entry`
|
||||
where against_voucher_type=%s and against_voucher=%s
|
||||
and voucher_type != 'Invoice Discounting'
|
||||
{party_condition} {account_condition}""",
|
||||
(against_voucher_type, against_voucher),
|
||||
)[0][0]
|
||||
frappe.qb.from_(gle)
|
||||
.select(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
|
||||
.where(conditions)
|
||||
.run()[0][0]
|
||||
or 0.0
|
||||
)
|
||||
|
||||
if against_voucher_type == "Purchase Invoice":
|
||||
bal = -bal
|
||||
elif against_voucher_type == "Journal Entry":
|
||||
je_conditions = (
|
||||
(gle.voucher_type == "Journal Entry")
|
||||
& (gle.voucher_no == against_voucher)
|
||||
& (gle.account == account)
|
||||
& (gle.against_voucher.isnull() | (gle.against_voucher == ""))
|
||||
)
|
||||
if party_type and party:
|
||||
je_conditions &= (gle.party_type == party_type) & (gle.party == party)
|
||||
|
||||
against_voucher_amount = flt(
|
||||
frappe.db.sql(
|
||||
f"""
|
||||
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
|
||||
from `tabGL Entry` where voucher_type = 'Journal Entry' and voucher_no = %s
|
||||
and account = %s and (against_voucher is null or against_voucher='') {party_condition}""",
|
||||
(against_voucher, account),
|
||||
)[0][0]
|
||||
frappe.qb.from_(gle)
|
||||
.select(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
|
||||
.where(je_conditions)
|
||||
.run()[0][0]
|
||||
)
|
||||
|
||||
if not against_voucher_amount:
|
||||
@@ -480,10 +487,14 @@ def rename_temporarily_named_docs(doctype):
|
||||
oldname = doc.name
|
||||
set_name_from_naming_options(autoname, doc)
|
||||
newname = doc.name
|
||||
frappe.db.sql(
|
||||
f"UPDATE `tab{doctype}` SET name = %s, to_rename = 0, modified = %s where name = %s",
|
||||
(newname, now(), oldname),
|
||||
)
|
||||
dt = frappe.qb.DocType(doctype)
|
||||
(
|
||||
frappe.qb.update(dt)
|
||||
.set(dt.name, newname)
|
||||
.set(dt.to_rename, 0)
|
||||
.set(dt.modified, now())
|
||||
.where(dt.name == oldname)
|
||||
).run()
|
||||
|
||||
for hook_type in ("on_gle_rename", "on_sle_rename"):
|
||||
for hook in frappe.get_hooks(hook_type):
|
||||
|
||||
@@ -26,12 +26,17 @@ class TestGLEntry(ERPNextTestSuite):
|
||||
jv.flags.ignore_validate = True
|
||||
jv.submit()
|
||||
|
||||
round_off_entry = frappe.db.sql(
|
||||
"""select name from `tabGL Entry`
|
||||
where voucher_type='Journal Entry' and voucher_no = %s
|
||||
and account='_Test Write Off - _TC' and cost_center='_Test Cost Center - _TC'
|
||||
and debit = 0 and credit = '.01'""",
|
||||
jv.name,
|
||||
round_off_entry = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"voucher_type": "Journal Entry",
|
||||
"voucher_no": jv.name,
|
||||
"account": "_Test Write Off - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"debit": 0,
|
||||
"credit": 0.01,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
self.assertTrue(round_off_entry)
|
||||
@@ -55,8 +60,9 @@ class TestGLEntry(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
self.assertTrue(all(entry.to_rename == 1 for entry in gl_entries))
|
||||
old_naming_series_current_value = frappe.db.sql(
|
||||
"SELECT current from tabSeries where name = %s", naming_series
|
||||
series = frappe.qb.DocType("Series")
|
||||
old_naming_series_current_value = (
|
||||
frappe.qb.from_(series).select(series["current"]).where(series.name == naming_series).run()
|
||||
)[0][0]
|
||||
|
||||
rename_gle_sle_docs()
|
||||
@@ -73,8 +79,8 @@ class TestGLEntry(ERPNextTestSuite):
|
||||
all(new.name != old.name for new, old in zip(gl_entries, new_gl_entries, strict=False))
|
||||
)
|
||||
|
||||
new_naming_series_current_value = frappe.db.sql(
|
||||
"SELECT current from tabSeries where name = %s", naming_series
|
||||
new_naming_series_current_value = (
|
||||
frappe.qb.from_(series).select(series["current"]).where(series.name == naming_series).run()
|
||||
)[0][0]
|
||||
self.assertEqual(old_naming_series_current_value + 2, new_naming_series_current_value)
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
|
||||
)
|
||||
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import JournalTaxWithholding
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.services.gl_validator import validate_opening_entry_against_pcv
|
||||
from erpnext.accounts.utils import (
|
||||
cancel_exchange_gain_loss_journal,
|
||||
get_account_currency,
|
||||
@@ -149,6 +150,9 @@ class JournalEntry(AccountsController):
|
||||
if not self.is_opening:
|
||||
self.is_opening = "No"
|
||||
|
||||
if self.is_opening == "Yes":
|
||||
validate_opening_entry_against_pcv(self.company)
|
||||
|
||||
self.clearance_date = None
|
||||
|
||||
self.validate_party()
|
||||
|
||||
@@ -39,28 +39,32 @@ def get_loyalty_point_entries(customer, loyalty_program, company, expiry_date=No
|
||||
if not expiry_date:
|
||||
expiry_date = today()
|
||||
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select name, loyalty_points, expiry_date, loyalty_program_tier, invoice_type, invoice
|
||||
from `tabLoyalty Point Entry`
|
||||
where customer=%s and loyalty_program=%s
|
||||
and expiry_date>=%s and loyalty_points>0 and company=%s
|
||||
order by expiry_date
|
||||
""",
|
||||
(customer, loyalty_program, expiry_date, company),
|
||||
as_dict=1,
|
||||
return frappe.get_all(
|
||||
"Loyalty Point Entry",
|
||||
filters={
|
||||
"customer": customer,
|
||||
"loyalty_program": loyalty_program,
|
||||
"expiry_date": [">=", expiry_date],
|
||||
"loyalty_points": [">", 0],
|
||||
"company": company,
|
||||
},
|
||||
fields=["name", "loyalty_points", "expiry_date", "loyalty_program_tier", "invoice_type", "invoice"],
|
||||
order_by="expiry_date",
|
||||
)
|
||||
|
||||
|
||||
def get_redemption_details(customer, loyalty_program, company):
|
||||
return frappe._dict(
|
||||
frappe.db.sql(
|
||||
"""
|
||||
select redeem_against, sum(loyalty_points)
|
||||
from `tabLoyalty Point Entry`
|
||||
where customer=%s and loyalty_program=%s and loyalty_points<0 and company=%s
|
||||
group by redeem_against
|
||||
""",
|
||||
(customer, loyalty_program, company),
|
||||
frappe.get_all(
|
||||
"Loyalty Point Entry",
|
||||
filters={
|
||||
"customer": customer,
|
||||
"loyalty_program": loyalty_program,
|
||||
"loyalty_points": ["<", 0],
|
||||
"company": company,
|
||||
},
|
||||
fields=["redeem_against", {"SUM": "loyalty_points", "as": "loyalty_points"}],
|
||||
group_by="redeem_against",
|
||||
as_list=True,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1191,9 +1191,9 @@ class PaymentEntry(AccountsController):
|
||||
continue
|
||||
|
||||
if tax.add_deduct_tax == "Add":
|
||||
included_taxes += tax.base_tax_amount
|
||||
included_taxes += flt(tax.base_tax_amount)
|
||||
else:
|
||||
included_taxes -= tax.base_tax_amount
|
||||
included_taxes -= flt(tax.base_tax_amount)
|
||||
|
||||
return included_taxes
|
||||
|
||||
|
||||
@@ -1113,6 +1113,27 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(gl_entries, expected_gl_entries)
|
||||
|
||||
def test_payment_entry_with_inclusive_tax(self):
|
||||
# inclusive tax built server-side: base_tax_amount is None until apply_taxes()
|
||||
payment_entry = create_payment_entry(paid_amount=1180)
|
||||
payment_entry.append(
|
||||
"taxes",
|
||||
{
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"charge_type": "On Paid Amount",
|
||||
"rate": 18,
|
||||
"included_in_paid_amount": 1,
|
||||
"add_deduct_tax": "Add",
|
||||
"description": "Service Tax",
|
||||
},
|
||||
)
|
||||
payment_entry.save()
|
||||
payment_entry.submit()
|
||||
|
||||
# 1180 incl 18% => 1000 base + 180 tax
|
||||
self.assertEqual(flt(payment_entry.total_taxes_and_charges, 2), 180.0)
|
||||
self.assertEqual(flt(payment_entry.unallocated_amount, 2), 1000.0)
|
||||
|
||||
def test_payment_entry_against_onhold_purchase_invoice(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
|
||||
@@ -73,7 +73,10 @@ class PeriodClosingVoucher(AccountsController):
|
||||
if not previous_fiscal_year:
|
||||
return
|
||||
|
||||
previous_fiscal_year_start_date = previous_fiscal_year[0][1]
|
||||
# get_fiscal_year() returns a single (name, start_date, end_date) tuple, so the start date
|
||||
# is [1]; the old [0][1] read the 2nd char of the name ('T'), which MariaDB silently
|
||||
# coerced to NULL but postgres rejects as an invalid date.
|
||||
previous_fiscal_year_start_date = previous_fiscal_year[1]
|
||||
previous_fiscal_year_closed = frappe.db.exists(
|
||||
"Period Closing Voucher",
|
||||
{
|
||||
@@ -287,41 +290,44 @@ class PeriodClosingVoucher(AccountsController):
|
||||
self.accounting_dimension_fields = default_dimensions + get_accounting_dimensions()
|
||||
|
||||
def get_gl_entries_for_current_period(self, report_type, only_opening_entries=False, as_iterator=False):
|
||||
date_condition = ""
|
||||
if only_opening_entries:
|
||||
date_condition = "is_opening = 'Yes'"
|
||||
else:
|
||||
date_condition = f"posting_date BETWEEN '{self.period_start_date}' AND '{self.period_end_date}' and is_opening = 'No'"
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
account = frappe.qb.DocType("Account")
|
||||
|
||||
# nosemgrep
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
name,
|
||||
posting_date,
|
||||
account,
|
||||
account_currency,
|
||||
debit_in_account_currency,
|
||||
credit_in_account_currency,
|
||||
debit,
|
||||
credit,
|
||||
{}
|
||||
FROM `tabGL Entry`
|
||||
WHERE
|
||||
{}
|
||||
AND company = %s
|
||||
AND voucher_type != 'Period Closing Voucher'
|
||||
AND EXISTS(SELECT name FROM `tabAccount` WHERE name = account AND report_type = %s)
|
||||
AND is_cancelled = 0
|
||||
""".format(
|
||||
", ".join(self.accounting_dimension_fields),
|
||||
date_condition,
|
||||
),
|
||||
(self.company, report_type),
|
||||
as_dict=1,
|
||||
as_iterator=as_iterator,
|
||||
fields = [
|
||||
gle.name,
|
||||
gle.posting_date,
|
||||
gle.account,
|
||||
gle.account_currency,
|
||||
gle.debit_in_account_currency,
|
||||
gle.credit_in_account_currency,
|
||||
gle.debit,
|
||||
gle.credit,
|
||||
]
|
||||
fields += [gle[dimension] for dimension in self.accounting_dimension_fields]
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(*fields)
|
||||
.where(
|
||||
(gle.company == self.company)
|
||||
& (gle.voucher_type != "Period Closing Voucher")
|
||||
& (gle.is_cancelled == 0)
|
||||
& gle.account.isin(
|
||||
frappe.qb.from_(account).select(account.name).where(account.report_type == report_type)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if only_opening_entries:
|
||||
query = query.where(gle.is_opening == "Yes")
|
||||
else:
|
||||
query = query.where(
|
||||
gle.posting_date.between(self.period_start_date, self.period_end_date)
|
||||
& (gle.is_opening == "No")
|
||||
)
|
||||
|
||||
return query.run(as_dict=1, as_iterator=as_iterator)
|
||||
|
||||
def set_account_balance_dict(self, gle, acc_bal_dict):
|
||||
key = self.get_key(gle)
|
||||
|
||||
|
||||
@@ -55,15 +55,19 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
("Sales - TPC", 400.0, 0.0),
|
||||
)
|
||||
|
||||
pcv_gle = frappe.db.sql(
|
||||
"""
|
||||
select account, debit, credit from `tabGL Entry` where voucher_no=%s order by account
|
||||
""",
|
||||
(pcv.name),
|
||||
)
|
||||
pcv_gle = [
|
||||
tuple(row)
|
||||
for row in frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": pcv.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
order_by="account",
|
||||
as_list=True,
|
||||
)
|
||||
]
|
||||
pcv.reload()
|
||||
self.assertEqual(pcv.gle_processing_status, "Completed")
|
||||
self.assertEqual(pcv_gle, expected_gle)
|
||||
self.assertEqual(tuple(pcv_gle), expected_gle)
|
||||
|
||||
def test_cost_center_wise_posting(self):
|
||||
surplus_account = create_account()
|
||||
@@ -106,14 +110,16 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
("Sales - TPC", 200.0, 0.0, cost_center2),
|
||||
)
|
||||
|
||||
pcv_gle = frappe.db.sql(
|
||||
"""
|
||||
select account, debit, credit, cost_center
|
||||
from `tabGL Entry` where voucher_no=%s
|
||||
order by account, cost_center
|
||||
""",
|
||||
(pcv.name),
|
||||
)
|
||||
pcv_gle = [
|
||||
tuple(row)
|
||||
for row in frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": pcv.name},
|
||||
fields=["account", "debit", "credit", "cost_center"],
|
||||
order_by="account, cost_center",
|
||||
as_list=True,
|
||||
)
|
||||
]
|
||||
|
||||
self.assertSequenceEqual(pcv_gle, expected_gle)
|
||||
|
||||
@@ -166,16 +172,19 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
("Sales - TPC", 400.0, 0.0, jv.finance_book),
|
||||
)
|
||||
|
||||
pcv_gle = frappe.db.sql(
|
||||
"""
|
||||
select account, debit, credit, finance_book
|
||||
from `tabGL Entry` where voucher_no=%s
|
||||
order by account, finance_book
|
||||
""",
|
||||
(pcv.name),
|
||||
)
|
||||
pcv_gle = [
|
||||
tuple(row)
|
||||
for row in frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": pcv.name},
|
||||
fields=["account", "debit", "credit", "finance_book"],
|
||||
order_by="account, finance_book",
|
||||
as_list=True,
|
||||
)
|
||||
]
|
||||
|
||||
self.assertSequenceEqual(pcv_gle, expected_gle)
|
||||
# compare order-independently: postgres and MariaDB order NULL finance_book differently
|
||||
self.assertSequenceEqual(sorted(pcv_gle, key=str), sorted(expected_gle, key=str))
|
||||
|
||||
def test_gl_entries_restrictions(self):
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
@@ -358,14 +367,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
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,
|
||||
totals_after_cancel = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Journal Entry", "voucher_no": jv.name, "is_cancelled": 0},
|
||||
fields=[{"SUM": "debit", "as": "total_debit"}, {"SUM": "credit", "as": "total_credit"}],
|
||||
)[0]
|
||||
|
||||
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)
|
||||
|
||||
@@ -295,7 +295,7 @@ def get_payments(invoices):
|
||||
.groupby(SalesInvoicePayment.mode_of_payment)
|
||||
.select(
|
||||
SalesInvoicePayment.mode_of_payment,
|
||||
SalesInvoicePayment.account,
|
||||
fn.Max(SalesInvoicePayment.account).as_("account"),
|
||||
fn.Sum(SalesInvoicePayment.amount).as_("amount"),
|
||||
)
|
||||
)
|
||||
@@ -419,7 +419,7 @@ def build_invoice_query(invoice_doctype, user, pos_profile, start, end):
|
||||
InvoiceDocType.account_for_change_amount,
|
||||
InvoiceDocType.is_return,
|
||||
InvoiceDocType.return_against,
|
||||
fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time).as_("timestamp"),
|
||||
fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time).as_("timestamp"),
|
||||
ConstantColumn(invoice_doctype).as_("doctype"),
|
||||
)
|
||||
.where(
|
||||
@@ -428,8 +428,8 @@ def build_invoice_query(invoice_doctype, user, pos_profile, start, end):
|
||||
& (InvoiceDocType.is_pos == 1)
|
||||
& (InvoiceDocType.pos_profile == pos_profile)
|
||||
& (
|
||||
(fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time) >= start)
|
||||
& (fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time) <= end)
|
||||
(fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time) >= start)
|
||||
& (fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time) <= end)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe import _, bold
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import map_child_doc, map_doc
|
||||
from frappe.query_builder.functions import IfNull, Sum
|
||||
from frappe.query_builder.functions import IfNull, Lower, Sum
|
||||
from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate
|
||||
from frappe.utils.nestedset import get_descendants_of
|
||||
|
||||
@@ -505,19 +505,20 @@ class POSInvoice(SalesInvoice):
|
||||
if d.get("serial_no"):
|
||||
serial_nos = get_serial_nos(d.serial_no)
|
||||
for sr in serial_nos:
|
||||
serial_no_exists = frappe.db.sql(
|
||||
"""
|
||||
SELECT name
|
||||
FROM `tabPOS Invoice Item`
|
||||
WHERE
|
||||
parent = %s
|
||||
and (serial_no = %s
|
||||
or serial_no like %s
|
||||
or serial_no like %s
|
||||
or serial_no like %s
|
||||
)
|
||||
""",
|
||||
(self.return_against, sr, sr + "\n%", "%\n" + sr, "%\n" + sr + "\n%"),
|
||||
POI = frappe.qb.DocType("POS Invoice Item")
|
||||
s = sr.lower()
|
||||
serial_no_exists = (
|
||||
frappe.qb.from_(POI)
|
||||
.select(POI.name)
|
||||
.where(POI.parent == self.return_against)
|
||||
.where(
|
||||
(Lower(POI.serial_no) == s)
|
||||
| Lower(POI.serial_no).like(f"{s}\n%")
|
||||
| Lower(POI.serial_no).like(f"%\n{s}")
|
||||
| Lower(POI.serial_no).like(f"%\n{s}\n%")
|
||||
)
|
||||
.limit(1)
|
||||
.run()
|
||||
)
|
||||
|
||||
if not serial_no_exists:
|
||||
@@ -963,15 +964,9 @@ def get_bundle_availability(bundle_item_code, warehouse):
|
||||
|
||||
|
||||
def get_bin_qty(item_code, warehouse):
|
||||
bin_qty = frappe.db.sql(
|
||||
"""select actual_qty from `tabBin`
|
||||
where item_code = %s and warehouse = %s
|
||||
limit 1""",
|
||||
(item_code, warehouse),
|
||||
as_dict=1,
|
||||
)
|
||||
actual_qty = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty")
|
||||
|
||||
return bin_qty[0].actual_qty or 0 if bin_qty else 0
|
||||
return actual_qty or 0
|
||||
|
||||
|
||||
def get_pos_reserved_qty(item_code, warehouse):
|
||||
|
||||
@@ -118,14 +118,21 @@ class POSProfile(Document):
|
||||
|
||||
def validate_default_profile(self):
|
||||
for row in self.applicable_for_users:
|
||||
res = frappe.db.sql(
|
||||
"""select pf.name
|
||||
from
|
||||
`tabPOS Profile User` pfu, `tabPOS Profile` pf
|
||||
where
|
||||
pf.name = pfu.parent and pfu.user = %s and pf.name != %s and pf.company = %s
|
||||
and pfu.default=1 and pf.disabled = 0""",
|
||||
(row.user, self.name, self.company),
|
||||
pfu = frappe.qb.DocType("POS Profile User")
|
||||
pf = frappe.qb.DocType("POS Profile")
|
||||
res = (
|
||||
frappe.qb.from_(pfu)
|
||||
.inner_join(pf)
|
||||
.on(pf.name == pfu.parent)
|
||||
.select(pf.name)
|
||||
.where(
|
||||
(pfu.user == row.user)
|
||||
& (pf.name != self.name)
|
||||
& (pf.company == self.company)
|
||||
& (pfu.default == 1)
|
||||
& (pf.disabled == 0)
|
||||
)
|
||||
.run()
|
||||
)
|
||||
|
||||
if row.default and res:
|
||||
@@ -265,10 +272,11 @@ def get_permitted_nodes(group_type):
|
||||
|
||||
def get_child_nodes(group_type, root):
|
||||
lft, rgt = frappe.db.get_value(group_type, root, ["lft", "rgt"])
|
||||
return frappe.db.sql(
|
||||
f""" Select name, lft, rgt from `tab{group_type}` where
|
||||
lft >= {lft} and rgt <= {rgt} order by lft""",
|
||||
as_dict=1,
|
||||
return frappe.get_all(
|
||||
group_type,
|
||||
filters={"lft": [">=", lft], "rgt": ["<=", rgt]},
|
||||
fields=["name", "lft", "rgt"],
|
||||
order_by="lft",
|
||||
)
|
||||
|
||||
|
||||
@@ -278,40 +286,33 @@ def pos_profile_query(doctype: str, txt: str, searchfield: str, start: int, page
|
||||
user = frappe.session["user"]
|
||||
company = filters.get("company") or frappe.defaults.get_user_default("company")
|
||||
|
||||
args = {
|
||||
"user": user,
|
||||
"start": start,
|
||||
"company": company,
|
||||
"page_len": page_len,
|
||||
"txt": "%%%s%%" % txt,
|
||||
}
|
||||
pf = frappe.qb.DocType("POS Profile")
|
||||
pfu = frappe.qb.DocType("POS Profile User")
|
||||
|
||||
pos_profile = frappe.db.sql(
|
||||
"""select pf.name
|
||||
from
|
||||
`tabPOS Profile` pf, `tabPOS Profile User` pfu
|
||||
where
|
||||
pfu.parent = pf.name and pfu.user = %(user)s and pf.company = %(company)s
|
||||
and (pf.name like %(txt)s)
|
||||
and pf.disabled = 0 limit %(page_len)s offset %(start)s""",
|
||||
args,
|
||||
pos_profile = (
|
||||
frappe.qb.from_(pf)
|
||||
.inner_join(pfu)
|
||||
.on(pfu.parent == pf.name)
|
||||
.select(pf.name)
|
||||
.where((pfu.user == user) & (pf.company == company) & pf.name.like(f"%{txt}%") & (pf.disabled == 0))
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
.run()
|
||||
)
|
||||
|
||||
if not pos_profile:
|
||||
del args["user"]
|
||||
|
||||
pos_profile = frappe.db.sql(
|
||||
"""select pf.name
|
||||
from
|
||||
`tabPOS Profile` pf left join `tabPOS Profile User` pfu
|
||||
on
|
||||
pf.name = pfu.parent
|
||||
where
|
||||
ifnull(pfu.user, '') = ''
|
||||
and pf.company = %(company)s
|
||||
and pf.name like %(txt)s
|
||||
and pf.disabled = 0""",
|
||||
args,
|
||||
pos_profile = (
|
||||
frappe.qb.from_(pf)
|
||||
.left_join(pfu)
|
||||
.on(pf.name == pfu.parent)
|
||||
.select(pf.name)
|
||||
.where(
|
||||
(pfu.user.isnull() | (pfu.user == ""))
|
||||
& (pf.company == company)
|
||||
& pf.name.like(f"%{txt}%")
|
||||
& (pf.disabled == 0)
|
||||
)
|
||||
.run()
|
||||
)
|
||||
|
||||
return pos_profile
|
||||
|
||||
@@ -114,7 +114,7 @@ def _get_pricing_rules(apply_on, args, values):
|
||||
if apply_on_field == "item_code":
|
||||
if args.get("uom", None):
|
||||
item_conditions += (
|
||||
" and ({child_doc}.uom={item_uom} or IFNULL({child_doc}.uom, '')='')".format(
|
||||
" and ({child_doc}.uom={item_uom} or COALESCE({child_doc}.uom, '')='')".format(
|
||||
child_doc=child_doc, item_uom=frappe.db.escape(args.get("uom"))
|
||||
)
|
||||
)
|
||||
@@ -127,7 +127,7 @@ def _get_pricing_rules(apply_on, args, values):
|
||||
elif apply_on_field == "item_group":
|
||||
item_conditions = _get_tree_conditions(args, "Item Group", child_doc, False)
|
||||
if args.get("uom", None):
|
||||
item_conditions += " and ({child_doc}.uom={item_uom} or IFNULL({child_doc}.uom, '')='')".format(
|
||||
item_conditions += " and ({child_doc}.uom={item_uom} or COALESCE({child_doc}.uom, '')='')".format(
|
||||
child_doc=child_doc, item_uom=frappe.db.escape(args.get("uom"))
|
||||
)
|
||||
|
||||
@@ -139,7 +139,7 @@ def _get_pricing_rules(apply_on, args, values):
|
||||
if not args.price_list:
|
||||
args.price_list = None
|
||||
|
||||
conditions += " and ifnull(`tabPricing Rule`.for_price_list, '') in (%(price_list)s, '')"
|
||||
conditions += " and coalesce(`tabPricing Rule`.for_price_list, '') in (%(price_list)s, '')"
|
||||
values["price_list"] = args.get("price_list")
|
||||
|
||||
pricing_rules = (
|
||||
@@ -195,10 +195,8 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
|
||||
except TypeError:
|
||||
frappe.throw(_("Invalid {0}").format(args.get(field)))
|
||||
|
||||
parent_groups = frappe.db.sql_list(
|
||||
"""select name from `tab{}`
|
||||
where lft<={} and rgt>={}""".format(parenttype, "%s", "%s"),
|
||||
(lft, rgt),
|
||||
parent_groups = frappe.get_all(
|
||||
parenttype, filters={"lft": ["<=", lft], "rgt": [">=", rgt]}, pluck="name"
|
||||
)
|
||||
|
||||
if parenttype in ["Customer Group", "Item Group", "Territory"]:
|
||||
@@ -217,14 +215,14 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
|
||||
if parent_groups:
|
||||
if allow_blank:
|
||||
parent_groups.append("")
|
||||
condition = "ifnull({table}.{field}, '') in ({parent_groups})".format(
|
||||
condition = "coalesce({table}.{field}, '') in ({parent_groups})".format(
|
||||
table=table, field=field, parent_groups=", ".join(frappe.db.escape(d) for d in parent_groups)
|
||||
)
|
||||
|
||||
frappe.flags.tree_conditions[key] = condition
|
||||
|
||||
elif allow_blank:
|
||||
condition = f"ifnull({table}.{field}, '') = ''"
|
||||
condition = f"coalesce({table}.{field}, '') = ''"
|
||||
|
||||
return condition
|
||||
|
||||
@@ -232,10 +230,10 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
|
||||
def get_other_conditions(conditions, values, args):
|
||||
for field in ["company", "customer", "supplier", "campaign", "sales_partner"]:
|
||||
if args.get(field):
|
||||
conditions += f" and ifnull(`tabPricing Rule`.{field}, '') in (%({field})s, '')"
|
||||
conditions += f" and coalesce(`tabPricing Rule`.{field}, '') in (%({field})s, '')"
|
||||
values[field] = args.get(field)
|
||||
else:
|
||||
conditions += f" and ifnull(`tabPricing Rule`.{field}, '') = ''"
|
||||
conditions += f" and coalesce(`tabPricing Rule`.{field}, '') = ''"
|
||||
|
||||
for parenttype in ["Customer Group", "Territory", "Supplier Group"]:
|
||||
group_condition = _get_tree_conditions(args, parenttype, "`tabPricing Rule`")
|
||||
@@ -248,8 +246,8 @@ def get_other_conditions(conditions, values, args):
|
||||
or frappe.get_value(args.get("doctype"), args.get("name"), "posting_date", ignore=True)
|
||||
)
|
||||
if date:
|
||||
conditions += """ and %(transaction_date)s between ifnull(`tabPricing Rule`.valid_from, '2000-01-01')
|
||||
and ifnull(`tabPricing Rule`.valid_upto, '2500-12-31')"""
|
||||
conditions += """ and %(transaction_date)s between coalesce(`tabPricing Rule`.valid_from, '2000-01-01')
|
||||
and coalesce(`tabPricing Rule`.valid_upto, '2500-12-31')"""
|
||||
values["transaction_date"] = date
|
||||
|
||||
if args.get("doctype") in [
|
||||
@@ -264,9 +262,9 @@ def get_other_conditions(conditions, values, args):
|
||||
"POS Invoice",
|
||||
"POS Invoice Item",
|
||||
]:
|
||||
conditions += """ and ifnull(`tabPricing Rule`.selling, 0) = 1"""
|
||||
conditions += """ and coalesce(`tabPricing Rule`.selling, 0) = 1"""
|
||||
else:
|
||||
conditions += """ and ifnull(`tabPricing Rule`.buying, 0) = 1"""
|
||||
conditions += """ and coalesce(`tabPricing Rule`.buying, 0) = 1"""
|
||||
|
||||
return conditions
|
||||
|
||||
|
||||
@@ -431,7 +431,9 @@ def reconcile(doc: None | str = None) -> None:
|
||||
# Update reconciled flag
|
||||
allocation_names = [x.name for x in allocations]
|
||||
ppa = qb.DocType("Process Payment Reconciliation Log Allocations")
|
||||
qb.update(ppa).set(ppa.reconciled, True).where(ppa.name.isin(allocation_names)).run()
|
||||
qb.update(ppa).set(ppa.reconciled, 1).where(
|
||||
ppa.name.isin(allocation_names)
|
||||
).run() # smallint, not bool
|
||||
|
||||
# Update reconciled count
|
||||
reconciled_count = frappe.db.count(
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"categorize_by",
|
||||
"cost_center",
|
||||
"territory",
|
||||
"show_opening_entries",
|
||||
"ignore_exchange_rate_revaluation_journals",
|
||||
"ignore_cr_dr_notes",
|
||||
"column_break_14",
|
||||
@@ -414,10 +415,17 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Print Format",
|
||||
"options": "Print Format"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:(doc.report == 'General Ledger');",
|
||||
"fieldname": "show_opening_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Opening Entries"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2025-10-07 12:19:20.719898",
|
||||
"modified": "2026-06-01 15:37:07.660442",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Statement Of Accounts",
|
||||
|
||||
@@ -6,7 +6,6 @@ import copy
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.desk.reportview import get_match_cond
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import add_days, add_months, format_date, getdate, today
|
||||
from frappe.utils.jinja import validate_template
|
||||
@@ -20,6 +19,7 @@ from erpnext.accounts.report.accounts_receivable_summary.accounts_receivable_sum
|
||||
execute as get_ageing,
|
||||
)
|
||||
from erpnext.accounts.report.general_ledger.general_ledger import execute as get_soa
|
||||
from erpnext.utilities.query import get_match_conditions_qb
|
||||
|
||||
|
||||
class ProcessStatementOfAccounts(Document):
|
||||
@@ -75,6 +75,7 @@ class ProcessStatementOfAccounts(Document):
|
||||
sender: DF.Link | None
|
||||
show_future_payments: DF.Check
|
||||
show_net_values_in_party_account: DF.Check
|
||||
show_opening_entries: DF.Check
|
||||
show_remarks: DF.Check
|
||||
start_date: DF.Date | None
|
||||
subject: DF.Data | None
|
||||
@@ -270,7 +271,7 @@ def get_gl_filters(doc, entry, tax_id, presentation_currency):
|
||||
"categorize_by": doc.categorize_by,
|
||||
"currency": doc.currency,
|
||||
"project": [p.project_name for p in doc.project],
|
||||
"show_opening_entries": 0,
|
||||
"show_opening_entries": doc.show_opening_entries,
|
||||
"include_default_book_entries": 0,
|
||||
"tax_id": tax_id if tax_id else None,
|
||||
"show_net_values_in_party_account": doc.show_net_values_in_party_account,
|
||||
@@ -365,15 +366,19 @@ def get_customers_based_on_territory_or_customer_group(customer_collection, coll
|
||||
|
||||
def get_customers_based_on_sales_person(sales_person):
|
||||
lft, rgt = frappe.db.get_value("Sales Person", sales_person, ["lft", "rgt"])
|
||||
records = frappe.db.sql(
|
||||
"""
|
||||
select distinct parent, parenttype
|
||||
from `tabSales Team` steam
|
||||
where parenttype = 'Customer'
|
||||
and exists(select name from `tabSales Person` where lft >= %s and rgt <= %s and name = steam.sales_person)
|
||||
""",
|
||||
(lft, rgt),
|
||||
as_dict=1,
|
||||
steam = frappe.qb.DocType("Sales Team")
|
||||
sp = frappe.qb.DocType("Sales Person")
|
||||
records = (
|
||||
frappe.qb.from_(steam)
|
||||
.select(steam.parent, steam.parenttype)
|
||||
.distinct()
|
||||
.where(
|
||||
(steam.parenttype == "Customer")
|
||||
& steam.sales_person.isin(
|
||||
frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt))
|
||||
)
|
||||
)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
sales_person_records = frappe._dict()
|
||||
for d in records:
|
||||
@@ -468,31 +473,30 @@ def get_customer_emails(customer_name: str, primary_mandatory: str | int, billin
|
||||
|
||||
frappe.has_permission("Customer", "read", customer_name, throw=True)
|
||||
|
||||
billing_email = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
email.email_id
|
||||
FROM
|
||||
`tabContact Email` AS email
|
||||
JOIN
|
||||
`tabDynamic Link` AS link
|
||||
ON
|
||||
email.parent=link.parent
|
||||
JOIN
|
||||
`tabContact` AS contact
|
||||
ON
|
||||
contact.name=link.parent
|
||||
WHERE
|
||||
link.link_doctype='Customer'
|
||||
and link.link_name=%s
|
||||
and contact.is_billing_contact=1
|
||||
{mcond}
|
||||
ORDER BY
|
||||
contact.creation desc
|
||||
""".format(mcond=get_match_cond("Contact")),
|
||||
customer_name,
|
||||
email = frappe.qb.DocType("Contact Email")
|
||||
link = frappe.qb.DocType("Dynamic Link")
|
||||
contact = frappe.qb.DocType("Contact")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(email)
|
||||
.join(link)
|
||||
.on(email.parent == link.parent)
|
||||
.join(contact)
|
||||
.on(contact.name == link.parent)
|
||||
.select(email.email_id)
|
||||
.where(
|
||||
(link.link_doctype == "Customer")
|
||||
& (link.link_name == customer_name)
|
||||
& (contact.is_billing_contact == 1)
|
||||
)
|
||||
.orderby(contact.creation, order=frappe.qb.desc)
|
||||
)
|
||||
|
||||
for condition in get_match_conditions_qb("Contact", table=contact):
|
||||
query = query.where(condition)
|
||||
|
||||
billing_email = query.run()
|
||||
|
||||
if len(billing_email) == 0 or (billing_email[0][0] is None):
|
||||
if billing_and_primary:
|
||||
frappe.throw(_("No billing email found for customer: {0}").format(customer_name))
|
||||
|
||||
@@ -524,16 +524,11 @@ class PurchaseInvoice(BuyingController):
|
||||
def check_prev_docstatus(self):
|
||||
for d in self.get("items"):
|
||||
if d.purchase_order:
|
||||
submitted = frappe.db.sql(
|
||||
"select name from `tabPurchase Order` where docstatus = 1 and name = %s", d.purchase_order
|
||||
)
|
||||
submitted = frappe.db.exists("Purchase Order", {"docstatus": 1, "name": d.purchase_order})
|
||||
if not submitted:
|
||||
frappe.throw(_("Purchase Order {0} is not submitted").format(d.purchase_order))
|
||||
if d.purchase_receipt:
|
||||
submitted = frappe.db.sql(
|
||||
"select name from `tabPurchase Receipt` where docstatus = 1 and name = %s",
|
||||
d.purchase_receipt,
|
||||
)
|
||||
submitted = frappe.db.exists("Purchase Receipt", {"docstatus": 1, "name": d.purchase_receipt})
|
||||
if not submitted:
|
||||
frappe.throw(_("Purchase Receipt {0} is not submitted").format(d.purchase_receipt))
|
||||
|
||||
@@ -801,25 +796,20 @@ class PurchaseInvoice(BuyingController):
|
||||
if cint(frappe.get_single_value("Accounts Settings", "check_supplier_invoice_uniqueness")):
|
||||
fiscal_year = get_fiscal_year(self.posting_date, company=self.company, as_dict=True)
|
||||
|
||||
pi = frappe.db.sql(
|
||||
"""select name from `tabPurchase Invoice`
|
||||
where
|
||||
bill_no = %(bill_no)s
|
||||
and supplier = %(supplier)s
|
||||
and name != %(name)s
|
||||
and docstatus < 2
|
||||
and posting_date between %(year_start_date)s and %(year_end_date)s""",
|
||||
{
|
||||
pi = frappe.get_all(
|
||||
"Purchase Invoice",
|
||||
filters={
|
||||
"bill_no": self.bill_no,
|
||||
"supplier": self.supplier,
|
||||
"name": self.name,
|
||||
"year_start_date": fiscal_year.year_start_date,
|
||||
"year_end_date": fiscal_year.year_end_date,
|
||||
"name": ["!=", self.name],
|
||||
"docstatus": ["<", 2],
|
||||
"posting_date": ["between", [fiscal_year.year_start_date, fiscal_year.year_end_date]],
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
if pi:
|
||||
pi = pi[0][0]
|
||||
pi = pi[0]
|
||||
|
||||
frappe.throw(
|
||||
_("Supplier Invoice No exists in Purchase Invoice {0}").format(
|
||||
|
||||
@@ -55,10 +55,13 @@ class ExpenseAccountService:
|
||||
else:
|
||||
# check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not
|
||||
if item.purchase_receipt:
|
||||
negative_expense_booked_in_pr = frappe.db.sql(
|
||||
"""select name from `tabGL Entry`
|
||||
where voucher_type='Purchase Receipt' and voucher_no=%s and account = %s""",
|
||||
(item.purchase_receipt, stock_not_billed_account),
|
||||
negative_expense_booked_in_pr = frappe.db.exists(
|
||||
"GL Entry",
|
||||
{
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": item.purchase_receipt,
|
||||
"account": stock_not_billed_account,
|
||||
},
|
||||
)
|
||||
|
||||
if negative_expense_booked_in_pr:
|
||||
|
||||
@@ -395,10 +395,14 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
):
|
||||
# Post reverse entry for Stock-Received-But-Not-Billed if booked in Purchase Receipt
|
||||
if item.purchase_receipt and valuation_tax_accounts:
|
||||
negative_expense_booked_in_pr = frappe.db.sql(
|
||||
"""select name from `tabGL Entry`
|
||||
where voucher_type='Purchase Receipt' and voucher_no=%s and account in %s""",
|
||||
(item.purchase_receipt, valuation_tax_accounts),
|
||||
negative_expense_booked_in_pr = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": item.purchase_receipt,
|
||||
"account": ["in", valuation_tax_accounts],
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
(
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"add_deduct_tax",
|
||||
"charge_type",
|
||||
"row_id",
|
||||
"allocate_full_amount_to_stock_items",
|
||||
"included_in_print_rate",
|
||||
"included_in_paid_amount",
|
||||
"col_break1",
|
||||
@@ -78,6 +79,14 @@
|
||||
"oldfieldname": "row_id",
|
||||
"oldfieldtype": "Data"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"depends_on": "eval:doc.charge_type=='Actual' && ['Valuation', 'Valuation and Total'].includes(doc.category)",
|
||||
"description": "If checked, the entire amount (e.g. Freight) is allocated to the valuation of stock & asset items only. If unchecked, the amount is distributed across all items and the portion belonging to non-stock items is not added to valuation.",
|
||||
"fieldname": "allocate_full_amount_to_stock_items",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allocate Full Amount to Stock Items"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If checked, the tax amount will be considered as already included in the Print Rate / Print Amount",
|
||||
|
||||
@@ -200,106 +200,11 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
set_purchase_references(target)
|
||||
|
||||
def update_details(source_doc, target_doc, source_parent):
|
||||
def _validate_address_link(address, link_doctype, link_name):
|
||||
return frappe.db.get_value(
|
||||
"Dynamic Link",
|
||||
{
|
||||
"parent": address,
|
||||
"parenttype": "Address",
|
||||
"link_doctype": link_doctype,
|
||||
"link_name": link_name,
|
||||
},
|
||||
"parent",
|
||||
)
|
||||
|
||||
target_doc.inter_company_invoice_reference = source_doc.name
|
||||
if target_doc.doctype in ["Purchase Invoice", "Purchase Order"]:
|
||||
currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency")
|
||||
target_doc.company = details.get("company")
|
||||
target_doc.supplier = details.get("party")
|
||||
target_doc.is_internal_supplier = 1
|
||||
target_doc.ignore_pricing_rule = 1
|
||||
target_doc.buying_price_list = source_doc.selling_price_list
|
||||
|
||||
# Invert Addresses
|
||||
if source_doc.company_address and _validate_address_link(
|
||||
source_doc.company_address, "Supplier", details.get("party")
|
||||
):
|
||||
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
|
||||
if source_doc.dispatch_address_name and _validate_address_link(
|
||||
source_doc.dispatch_address_name, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc,
|
||||
"dispatch_address",
|
||||
"dispatch_address_display",
|
||||
source_doc.dispatch_address_name,
|
||||
)
|
||||
if source_doc.shipping_address_name and _validate_address_link(
|
||||
source_doc.shipping_address_name, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc,
|
||||
"shipping_address",
|
||||
"shipping_address_display",
|
||||
source_doc.shipping_address_name,
|
||||
)
|
||||
if source_doc.customer_address and _validate_address_link(
|
||||
source_doc.customer_address, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "billing_address", "billing_address_display", source_doc.customer_address
|
||||
)
|
||||
|
||||
if currency:
|
||||
target_doc.currency = currency
|
||||
|
||||
update_taxes(
|
||||
target_doc,
|
||||
party=target_doc.supplier,
|
||||
party_type="Supplier",
|
||||
company=target_doc.company,
|
||||
doctype=target_doc.doctype,
|
||||
party_address=target_doc.supplier_address,
|
||||
company_address=target_doc.shipping_address,
|
||||
)
|
||||
|
||||
_apply_purchase_party_details(target_doc, source_doc, details)
|
||||
else:
|
||||
currency = frappe.db.get_value("Customer", details.get("party"), "default_currency")
|
||||
target_doc.company = details.get("company")
|
||||
target_doc.customer = details.get("party")
|
||||
target_doc.selling_price_list = source_doc.buying_price_list
|
||||
|
||||
if source_doc.supplier_address and _validate_address_link(
|
||||
source_doc.supplier_address, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "company_address", "company_address_display", source_doc.supplier_address
|
||||
)
|
||||
if source_doc.shipping_address and _validate_address_link(
|
||||
source_doc.shipping_address, "Customer", details.get("party")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address
|
||||
)
|
||||
if source_doc.shipping_address and _validate_address_link(
|
||||
source_doc.shipping_address, "Customer", details.get("party")
|
||||
):
|
||||
update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
|
||||
|
||||
if currency:
|
||||
target_doc.currency = currency
|
||||
|
||||
update_taxes(
|
||||
target_doc,
|
||||
party=target_doc.customer,
|
||||
party_type="Customer",
|
||||
company=target_doc.company,
|
||||
doctype=target_doc.doctype,
|
||||
party_address=target_doc.customer_address,
|
||||
company_address=target_doc.company_address,
|
||||
shipping_address_name=target_doc.shipping_address_name,
|
||||
)
|
||||
_apply_sales_party_details(target_doc, source_doc, details)
|
||||
|
||||
def update_item(source, target, source_parent):
|
||||
target.qty = flt(source.qty) - received_items.get(source.name, 0.0)
|
||||
@@ -378,6 +283,97 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
return doclist
|
||||
|
||||
|
||||
def _get_linked_address(address, link_doctype, link_name):
|
||||
return frappe.db.get_value(
|
||||
"Dynamic Link",
|
||||
{
|
||||
"parent": address,
|
||||
"parenttype": "Address",
|
||||
"link_doctype": link_doctype,
|
||||
"link_name": link_name,
|
||||
},
|
||||
"parent",
|
||||
)
|
||||
|
||||
|
||||
def _apply_purchase_party_details(target_doc, source_doc, details):
|
||||
currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency")
|
||||
target_doc.company = details.get("company")
|
||||
target_doc.supplier = details.get("party")
|
||||
target_doc.is_internal_supplier = 1
|
||||
target_doc.ignore_pricing_rule = 1
|
||||
target_doc.buying_price_list = source_doc.selling_price_list
|
||||
|
||||
# Invert Addresses
|
||||
if source_doc.company_address and _get_linked_address(
|
||||
source_doc.company_address, "Supplier", details.get("party")
|
||||
):
|
||||
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
|
||||
if source_doc.dispatch_address_name and _get_linked_address(
|
||||
source_doc.dispatch_address_name, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "dispatch_address", "dispatch_address_display", source_doc.dispatch_address_name
|
||||
)
|
||||
if source_doc.shipping_address_name and _get_linked_address(
|
||||
source_doc.shipping_address_name, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "shipping_address", "shipping_address_display", source_doc.shipping_address_name
|
||||
)
|
||||
if source_doc.customer_address and _get_linked_address(
|
||||
source_doc.customer_address, "Company", details.get("company")
|
||||
):
|
||||
update_address(target_doc, "billing_address", "billing_address_display", source_doc.customer_address)
|
||||
|
||||
if currency:
|
||||
target_doc.currency = currency
|
||||
|
||||
update_taxes(
|
||||
target_doc,
|
||||
party=target_doc.supplier,
|
||||
party_type="Supplier",
|
||||
company=target_doc.company,
|
||||
doctype=target_doc.doctype,
|
||||
party_address=target_doc.supplier_address,
|
||||
company_address=target_doc.shipping_address,
|
||||
)
|
||||
|
||||
|
||||
def _apply_sales_party_details(target_doc, source_doc, details):
|
||||
currency = frappe.db.get_value("Customer", details.get("party"), "default_currency")
|
||||
target_doc.company = details.get("company")
|
||||
target_doc.customer = details.get("party")
|
||||
target_doc.selling_price_list = source_doc.buying_price_list
|
||||
|
||||
if source_doc.supplier_address and _get_linked_address(
|
||||
source_doc.supplier_address, "Company", details.get("company")
|
||||
):
|
||||
update_address(target_doc, "company_address", "company_address_display", source_doc.supplier_address)
|
||||
if source_doc.shipping_address and _get_linked_address(
|
||||
source_doc.shipping_address, "Customer", details.get("party")
|
||||
):
|
||||
update_address(target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address)
|
||||
if source_doc.shipping_address and _get_linked_address(
|
||||
source_doc.shipping_address, "Customer", details.get("party")
|
||||
):
|
||||
update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
|
||||
|
||||
if currency:
|
||||
target_doc.currency = currency
|
||||
|
||||
update_taxes(
|
||||
target_doc,
|
||||
party=target_doc.customer,
|
||||
party_type="Customer",
|
||||
company=target_doc.company,
|
||||
doctype=target_doc.doctype,
|
||||
party_address=target_doc.customer_address,
|
||||
company_address=target_doc.company_address,
|
||||
shipping_address_name=target_doc.shipping_address_name,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
|
||||
reference_field = "inter_company_invoice_reference"
|
||||
|
||||
@@ -93,54 +93,7 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
if enable_discount_accounting:
|
||||
for item in doc.get("items"):
|
||||
if item.get("discount_amount") and item.get("discount_account"):
|
||||
discount_amount = item.discount_amount * item.qty
|
||||
income_account = (
|
||||
item.income_account
|
||||
if (not item.enable_deferred_revenue or doc.is_return)
|
||||
else item.deferred_revenue_account
|
||||
)
|
||||
|
||||
account_currency = get_account_currency(item.discount_account)
|
||||
gl_entries.append(
|
||||
doc.get_gl_dict(
|
||||
{
|
||||
"account": item.discount_account,
|
||||
"against": doc.customer,
|
||||
"debit": flt(
|
||||
discount_amount * doc.get("conversion_rate"),
|
||||
item.precision("discount_amount"),
|
||||
),
|
||||
"debit_in_transaction_currency": flt(
|
||||
discount_amount, item.precision("discount_amount")
|
||||
),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
account_currency = get_account_currency(income_account)
|
||||
gl_entries.append(
|
||||
doc.get_gl_dict(
|
||||
{
|
||||
"account": income_account,
|
||||
"against": doc.customer,
|
||||
"credit": flt(
|
||||
discount_amount * doc.get("conversion_rate"),
|
||||
item.precision("discount_amount"),
|
||||
),
|
||||
"credit_in_transaction_currency": flt(
|
||||
discount_amount, item.precision("discount_amount")
|
||||
),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
self._append_item_discount_gl_entries(item, gl_entries)
|
||||
|
||||
if (
|
||||
(enable_discount_accounting or doc.get("is_cash_or_non_trade_discount"))
|
||||
@@ -159,81 +112,143 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
)
|
||||
)
|
||||
|
||||
def _append_item_discount_gl_entries(self, item, gl_entries) -> None:
|
||||
doc = self.doc
|
||||
discount_amount = item.discount_amount * item.qty
|
||||
income_account = (
|
||||
item.income_account
|
||||
if (not item.enable_deferred_revenue or doc.is_return)
|
||||
else item.deferred_revenue_account
|
||||
)
|
||||
|
||||
account_currency = get_account_currency(item.discount_account)
|
||||
gl_entries.append(
|
||||
doc.get_gl_dict(
|
||||
{
|
||||
"account": item.discount_account,
|
||||
"against": doc.customer,
|
||||
"debit": flt(
|
||||
discount_amount * doc.get("conversion_rate"),
|
||||
item.precision("discount_amount"),
|
||||
),
|
||||
"debit_in_transaction_currency": flt(discount_amount, item.precision("discount_amount")),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
account_currency = get_account_currency(income_account)
|
||||
gl_entries.append(
|
||||
doc.get_gl_dict(
|
||||
{
|
||||
"account": income_account,
|
||||
"against": doc.customer,
|
||||
"credit": flt(
|
||||
discount_amount * doc.get("conversion_rate"),
|
||||
item.precision("discount_amount"),
|
||||
),
|
||||
"credit_in_transaction_currency": flt(discount_amount, item.precision("discount_amount")),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
def stock_delivered_but_not_billed_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
if doc.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(doc.company)):
|
||||
return
|
||||
|
||||
for item in doc.get("items"):
|
||||
if not item.delivery_note and not item.dn_detail:
|
||||
continue
|
||||
booking = self._get_sdbnb_booking_for_item(item)
|
||||
if booking:
|
||||
self._append_sdbnb_gl_entries(item, booking, gl_entries)
|
||||
|
||||
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
|
||||
continue
|
||||
def _get_sdbnb_booking_for_item(self, item) -> dict | None:
|
||||
"""SDBNB account and valuation to reverse for a billed-from-delivery-note item, if any."""
|
||||
if not item.delivery_note and not item.dn_detail:
|
||||
return None
|
||||
|
||||
dn_expense_account = frappe.get_cached_value(
|
||||
"Delivery Note Item", item.dn_detail, "expense_account"
|
||||
)
|
||||
if (
|
||||
not dn_expense_account
|
||||
or frappe.get_cached_value("Account", dn_expense_account, "account_type")
|
||||
!= "Stock Delivered But Not Billed"
|
||||
or not item.expense_account
|
||||
or dn_expense_account == item.expense_account
|
||||
):
|
||||
continue
|
||||
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
|
||||
return None
|
||||
|
||||
delivery_note = item.delivery_note or frappe.get_cached_value(
|
||||
"Delivery Note Item", item.dn_detail, "parent"
|
||||
)
|
||||
if not delivery_note:
|
||||
continue
|
||||
dn_expense_account = frappe.get_cached_value("Delivery Note Item", item.dn_detail, "expense_account")
|
||||
if not self._is_sdbnb_reversal(dn_expense_account, item):
|
||||
return None
|
||||
|
||||
item_g = frappe.get_cached_value(
|
||||
"Stock Ledger Entry",
|
||||
delivery_note = item.delivery_note or frappe.get_cached_value(
|
||||
"Delivery Note Item", item.dn_detail, "parent"
|
||||
)
|
||||
if not delivery_note:
|
||||
return None
|
||||
|
||||
item_g = frappe.get_cached_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_no": delivery_note,
|
||||
"voucher_detail_no": item.dn_detail,
|
||||
"item_code": item.item_code,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
["stock_value_difference", "actual_qty"],
|
||||
as_dict=True,
|
||||
)
|
||||
if not item_g or not flt(item_g.actual_qty):
|
||||
return None
|
||||
|
||||
valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty)
|
||||
return {
|
||||
"dn_expense_account": dn_expense_account,
|
||||
"valuation_amount": valuation_rate * item.stock_qty,
|
||||
}
|
||||
|
||||
def _is_sdbnb_reversal(self, dn_expense_account, item) -> bool:
|
||||
"""True when the DN booked to an SDBNB account distinct from the item's expense account."""
|
||||
return bool(
|
||||
dn_expense_account
|
||||
and frappe.get_cached_value("Account", dn_expense_account, "account_type")
|
||||
== "Stock Delivered But Not Billed"
|
||||
and item.expense_account
|
||||
and dn_expense_account != item.expense_account
|
||||
)
|
||||
|
||||
def _append_sdbnb_gl_entries(self, item, booking, gl_entries) -> None:
|
||||
dn_expense_account = booking["dn_expense_account"]
|
||||
valuation_amount = booking["valuation_amount"]
|
||||
dn_account_currency = get_account_currency(dn_expense_account)
|
||||
item_account_currency = get_account_currency(item.expense_account)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"voucher_no": delivery_note,
|
||||
"voucher_detail_no": item.dn_detail,
|
||||
"item_code": item.item_code,
|
||||
"is_cancelled": 0,
|
||||
"account": dn_expense_account,
|
||||
"against": item.expense_account,
|
||||
"credit": flt(valuation_amount),
|
||||
"credit_in_account_currency": flt(valuation_amount),
|
||||
"cost_center": item.cost_center,
|
||||
},
|
||||
["stock_value_difference", "actual_qty"],
|
||||
as_dict=True,
|
||||
dn_account_currency,
|
||||
item=item,
|
||||
)
|
||||
|
||||
if not item_g or not flt(item_g.actual_qty):
|
||||
continue
|
||||
valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty)
|
||||
valuation_amount = valuation_rate * item.stock_qty
|
||||
dn_account_currency = get_account_currency(dn_expense_account)
|
||||
item_account_currency = get_account_currency(item.expense_account)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": dn_expense_account,
|
||||
"against": item.expense_account,
|
||||
"credit": flt(valuation_amount),
|
||||
"credit_in_account_currency": flt(valuation_amount),
|
||||
"cost_center": item.cost_center,
|
||||
},
|
||||
dn_account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": item.expense_account,
|
||||
"against": dn_expense_account,
|
||||
"debit": flt(valuation_amount),
|
||||
"debit_in_account_currency": flt(valuation_amount),
|
||||
"cost_center": item.cost_center,
|
||||
},
|
||||
item_account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": item.expense_account,
|
||||
"against": dn_expense_account,
|
||||
"debit": flt(valuation_amount),
|
||||
"debit_in_account_currency": flt(valuation_amount),
|
||||
"cost_center": item.cost_center,
|
||||
},
|
||||
item_account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
def make_customer_gl_entry(self, gl_entries):
|
||||
doc = self.doc
|
||||
@@ -250,10 +265,6 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
)
|
||||
|
||||
if grand_total and not doc.is_internal_transfer():
|
||||
against_voucher = doc.name
|
||||
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
|
||||
against_voucher = doc.return_against
|
||||
|
||||
# Did not use base_grand_total to book rounding loss gle
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
@@ -264,11 +275,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
"due_date": doc.due_date,
|
||||
"against": doc.against_income_account,
|
||||
"debit": base_grand_total,
|
||||
"debit_in_account_currency": base_grand_total
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else grand_total,
|
||||
"debit_in_account_currency": self._get_amount_in_account_currency(
|
||||
doc.party_account_currency, base_grand_total, grand_total
|
||||
),
|
||||
"debit_in_transaction_currency": grand_total,
|
||||
"against_voucher": against_voucher,
|
||||
"against_voucher": self._resolve_against_voucher(),
|
||||
"against_voucher_type": doc.doctype,
|
||||
"cost_center": doc.cost_center,
|
||||
"project": doc.project,
|
||||
@@ -296,10 +307,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
"account": tax.account_head,
|
||||
"against": doc.customer,
|
||||
"credit": flt(base_amount, tax.precision("tax_amount_after_discount_amount")),
|
||||
"credit_in_account_currency": (
|
||||
flt(base_amount, tax.precision("base_tax_amount_after_discount_amount"))
|
||||
if account_currency == doc.company_currency
|
||||
else flt(amount, tax.precision("tax_amount_after_discount_amount"))
|
||||
"credit_in_account_currency": self._get_amount_in_account_currency(
|
||||
account_currency,
|
||||
flt(base_amount, tax.precision("base_tax_amount_after_discount_amount")),
|
||||
flt(amount, tax.precision("tax_amount_after_discount_amount")),
|
||||
),
|
||||
"credit_in_transaction_currency": flt(
|
||||
amount, tax.precision("tax_amount_after_discount_amount")
|
||||
@@ -341,53 +352,57 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
)
|
||||
|
||||
for item in doc.get("items"):
|
||||
if (
|
||||
if not (
|
||||
flt(item.base_net_amount, item.precision("base_net_amount"))
|
||||
or item.is_fixed_asset
|
||||
or enable_discount_accounting
|
||||
):
|
||||
# Do not book income for transfer within same company
|
||||
if doc.is_internal_transfer():
|
||||
continue
|
||||
continue
|
||||
|
||||
if item.is_fixed_asset and item.asset:
|
||||
self.get_gl_entries_for_fixed_asset(item, gl_entries)
|
||||
else:
|
||||
income_account = (
|
||||
item.income_account
|
||||
if (not item.enable_deferred_revenue or doc.is_return)
|
||||
else item.deferred_revenue_account
|
||||
)
|
||||
# Do not book income for transfer within same company
|
||||
if doc.is_internal_transfer():
|
||||
continue
|
||||
|
||||
amount, base_amount = tax_service.get_amount_and_base_amount(
|
||||
item, enable_discount_accounting
|
||||
)
|
||||
|
||||
account_currency = get_account_currency(income_account)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": income_account,
|
||||
"against": doc.customer,
|
||||
"credit": flt(base_amount, item.precision("base_net_amount")),
|
||||
"credit_in_account_currency": (
|
||||
flt(base_amount, item.precision("base_net_amount"))
|
||||
if account_currency == doc.company_currency
|
||||
else flt(amount, item.precision("net_amount"))
|
||||
),
|
||||
"credit_in_transaction_currency": flt(amount, item.precision("net_amount")),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
if item.is_fixed_asset and item.asset:
|
||||
self.get_gl_entries_for_fixed_asset(item, gl_entries)
|
||||
else:
|
||||
self._append_item_income_gl_entry(item, gl_entries, tax_service, enable_discount_accounting)
|
||||
|
||||
# expense account gl entries
|
||||
if cint(doc.update_stock) and erpnext.is_perpetual_inventory_enabled(doc.company):
|
||||
gl_entries += super(SalesInvoice, doc).get_gl_entries()
|
||||
|
||||
def _append_item_income_gl_entry(self, item, gl_entries, tax_service, enable_discount_accounting) -> None:
|
||||
doc = self.doc
|
||||
income_account = (
|
||||
item.income_account
|
||||
if (not item.enable_deferred_revenue or doc.is_return)
|
||||
else item.deferred_revenue_account
|
||||
)
|
||||
|
||||
amount, base_amount = tax_service.get_amount_and_base_amount(item, enable_discount_accounting)
|
||||
|
||||
account_currency = get_account_currency(income_account)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": income_account,
|
||||
"against": doc.customer,
|
||||
"credit": flt(base_amount, item.precision("base_net_amount")),
|
||||
"credit_in_account_currency": self._get_amount_in_account_currency(
|
||||
account_currency,
|
||||
flt(base_amount, item.precision("base_net_amount")),
|
||||
flt(amount, item.precision("net_amount")),
|
||||
),
|
||||
"credit_in_transaction_currency": flt(amount, item.precision("net_amount")),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
def get_gl_entries_for_fixed_asset(self, item, gl_entries):
|
||||
doc = self.doc
|
||||
asset = frappe.get_cached_doc("Asset", item.asset)
|
||||
@@ -461,10 +476,6 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
if skip_change_gl_entries and payment_mode.account == doc.account_for_change_amount:
|
||||
payment_mode.base_amount -= flt(doc.change_amount)
|
||||
|
||||
against_voucher = doc.name
|
||||
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
|
||||
against_voucher = doc.return_against
|
||||
|
||||
if payment_mode.base_amount:
|
||||
# POS, make payment entries
|
||||
gl_entries.append(
|
||||
@@ -475,11 +486,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
"party": doc.customer,
|
||||
"against": payment_mode.account,
|
||||
"credit": payment_mode.base_amount,
|
||||
"credit_in_account_currency": payment_mode.base_amount
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else payment_mode.amount,
|
||||
"credit_in_account_currency": self._get_amount_in_account_currency(
|
||||
doc.party_account_currency, payment_mode.base_amount, payment_mode.amount
|
||||
),
|
||||
"credit_in_transaction_currency": payment_mode.amount,
|
||||
"against_voucher": against_voucher,
|
||||
"against_voucher": self._resolve_against_voucher(),
|
||||
"against_voucher_type": doc.doctype,
|
||||
"cost_center": doc.cost_center,
|
||||
},
|
||||
@@ -495,9 +506,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
"account": payment_mode.account,
|
||||
"against": doc.customer,
|
||||
"debit": payment_mode.base_amount,
|
||||
"debit_in_account_currency": payment_mode.base_amount
|
||||
if payment_mode_account_currency == doc.company_currency
|
||||
else payment_mode.amount,
|
||||
"debit_in_account_currency": self._get_amount_in_account_currency(
|
||||
payment_mode_account_currency,
|
||||
payment_mode.base_amount,
|
||||
payment_mode.amount,
|
||||
),
|
||||
"debit_in_transaction_currency": payment_mode.amount,
|
||||
"cost_center": doc.cost_center,
|
||||
},
|
||||
@@ -525,9 +538,9 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
"party": doc.customer,
|
||||
"against": doc.account_for_change_amount,
|
||||
"debit": flt(doc.base_change_amount),
|
||||
"debit_in_account_currency": flt(doc.base_change_amount)
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else flt(doc.change_amount),
|
||||
"debit_in_account_currency": self._get_amount_in_account_currency(
|
||||
doc.party_account_currency, flt(doc.base_change_amount), flt(doc.change_amount)
|
||||
),
|
||||
"debit_in_transaction_currency": flt(doc.change_amount),
|
||||
"against_voucher": doc.return_against
|
||||
if cint(doc.is_return) and doc.return_against
|
||||
@@ -570,10 +583,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
"party": doc.customer,
|
||||
"against": doc.write_off_account,
|
||||
"credit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
|
||||
"credit_in_account_currency": (
|
||||
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount"))
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else flt(doc.write_off_amount, doc.precision("write_off_amount"))
|
||||
"credit_in_account_currency": self._get_amount_in_account_currency(
|
||||
doc.party_account_currency,
|
||||
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
|
||||
flt(doc.write_off_amount, doc.precision("write_off_amount")),
|
||||
),
|
||||
"credit_in_transaction_currency": flt(
|
||||
doc.write_off_amount, doc.precision("write_off_amount")
|
||||
@@ -593,10 +606,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
"account": doc.write_off_account,
|
||||
"against": doc.customer,
|
||||
"debit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
|
||||
"debit_in_account_currency": (
|
||||
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount"))
|
||||
if write_off_account_currency == doc.company_currency
|
||||
else flt(doc.write_off_amount, doc.precision("write_off_amount"))
|
||||
"debit_in_account_currency": self._get_amount_in_account_currency(
|
||||
write_off_account_currency,
|
||||
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
|
||||
flt(doc.write_off_amount, doc.precision("write_off_amount")),
|
||||
),
|
||||
"debit_in_transaction_currency": flt(
|
||||
doc.write_off_amount, doc.precision("write_off_amount")
|
||||
@@ -659,3 +672,14 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
def _get_amount_in_account_currency(self, account_currency, base_amount, transaction_amount):
|
||||
"""Base amount when the account is in company currency, else the transaction amount."""
|
||||
return base_amount if account_currency == self.doc.company_currency else transaction_amount
|
||||
|
||||
def _resolve_against_voucher(self) -> str:
|
||||
"""Settle against the original invoice for returns not kept on their own outstanding."""
|
||||
doc = self.doc
|
||||
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
|
||||
return doc.return_against
|
||||
return doc.name
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"""POS helpers for Sales Invoice."""
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
from frappe import _
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
|
||||
|
||||
@@ -13,106 +13,140 @@ class PartialPaymentValidationError(frappe.ValidationError):
|
||||
|
||||
|
||||
class POSService:
|
||||
def __init__(self, doc):
|
||||
def __init__(self, doc) -> None:
|
||||
self.doc = doc
|
||||
|
||||
def set_pos_fields(self, for_validate: bool = False) -> frappe.Document | None:
|
||||
"""Populate POS-profile fields on the invoice; return the profile or None."""
|
||||
def set_pos_fields(self, for_validate: bool = False) -> frappe.Document | dict | None:
|
||||
"""Populate POS-profile fields on the invoice; return the profile, {} or None."""
|
||||
doc = self.doc
|
||||
if cint(doc.is_pos) != 1:
|
||||
return None
|
||||
|
||||
self._set_default_change_amount_account()
|
||||
|
||||
if not self._ensure_pos_profile():
|
||||
return None
|
||||
|
||||
pos = frappe.get_doc("POS Profile", doc.pos_profile) if doc.pos_profile else {}
|
||||
if pos:
|
||||
self._apply_pos_profile(pos, for_validate)
|
||||
|
||||
return pos
|
||||
|
||||
def _set_default_change_amount_account(self) -> None:
|
||||
doc = self.doc
|
||||
if not doc.account_for_change_amount:
|
||||
doc.account_for_change_amount = frappe.get_cached_value(
|
||||
"Company", doc.company, "default_cash_account"
|
||||
)
|
||||
|
||||
from erpnext.stock.get_item_details import (
|
||||
ItemDetailsCtx,
|
||||
get_pos_profile,
|
||||
get_pos_profile_item_details_,
|
||||
)
|
||||
def _ensure_pos_profile(self) -> bool:
|
||||
"""Auto-pick a POS Profile for the company; return False if none could be found."""
|
||||
doc = self.doc
|
||||
if doc.pos_profile or doc.flags.ignore_pos_profile:
|
||||
return True
|
||||
|
||||
if not doc.pos_profile and not doc.flags.ignore_pos_profile:
|
||||
pos_profile = get_pos_profile(doc.company) or {}
|
||||
if not pos_profile:
|
||||
return None
|
||||
doc.pos_profile = pos_profile.get("name")
|
||||
from erpnext.stock.get_item_details import get_pos_profile
|
||||
|
||||
pos = {}
|
||||
if doc.pos_profile:
|
||||
pos = frappe.get_doc("POS Profile", doc.pos_profile)
|
||||
pos_profile = get_pos_profile(doc.company) or {}
|
||||
if not pos_profile:
|
||||
return False
|
||||
|
||||
if pos:
|
||||
if not for_validate:
|
||||
update_multi_mode_option(doc, pos)
|
||||
doc.tax_category = pos.get("tax_category")
|
||||
doc.pos_profile = pos_profile.get("name")
|
||||
return True
|
||||
|
||||
if not for_validate and not doc.customer:
|
||||
doc.customer = pos.customer
|
||||
def _apply_pos_profile(self, pos, for_validate: bool) -> None:
|
||||
doc = self.doc
|
||||
if not for_validate:
|
||||
self._apply_editable_pos_defaults(pos)
|
||||
|
||||
if not for_validate:
|
||||
doc.ignore_pricing_rule = pos.ignore_pricing_rule
|
||||
if pos.get("account_for_change_amount"):
|
||||
doc.account_for_change_amount = pos.get("account_for_change_amount")
|
||||
|
||||
if pos.get("account_for_change_amount"):
|
||||
doc.account_for_change_amount = pos.get("account_for_change_amount")
|
||||
self._copy_pos_profile_fields(pos, for_validate)
|
||||
|
||||
for fieldname in (
|
||||
"currency",
|
||||
"letter_head",
|
||||
"tc_name",
|
||||
"company",
|
||||
"select_print_heading",
|
||||
"write_off_account",
|
||||
"taxes_and_charges",
|
||||
"write_off_cost_center",
|
||||
"apply_discount_on",
|
||||
"cost_center",
|
||||
):
|
||||
if (not for_validate) or (for_validate and not doc.get(fieldname)):
|
||||
doc.set(fieldname, pos.get(fieldname))
|
||||
if pos.get("company_address"):
|
||||
doc.company_address = pos.get("company_address")
|
||||
|
||||
if pos.get("company_address"):
|
||||
doc.company_address = pos.get("company_address")
|
||||
self._set_selling_price_list(pos)
|
||||
|
||||
if doc.customer:
|
||||
customer_price_list, customer_group = frappe.get_value(
|
||||
"Customer", doc.customer, ["default_price_list", "customer_group"]
|
||||
)
|
||||
customer_group_price_list = frappe.get_value(
|
||||
"Customer Group", customer_group, "default_price_list"
|
||||
)
|
||||
selling_price_list = (
|
||||
customer_price_list or customer_group_price_list or pos.get("selling_price_list")
|
||||
)
|
||||
else:
|
||||
selling_price_list = pos.get("selling_price_list")
|
||||
if not for_validate:
|
||||
self._set_update_stock_from_profile(pos)
|
||||
|
||||
if selling_price_list:
|
||||
doc.set("selling_price_list", selling_price_list)
|
||||
self._apply_pos_item_defaults(pos, for_validate)
|
||||
self._set_terms_and_taxes(pos)
|
||||
|
||||
if not for_validate:
|
||||
dn_flag = any(d.get("dn_detail") for d in doc.get("items"))
|
||||
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
|
||||
def _apply_editable_pos_defaults(self, pos) -> None:
|
||||
"""Profile defaults the user may override; only applied outside validation."""
|
||||
doc = self.doc
|
||||
update_multi_mode_option(doc, pos)
|
||||
doc.tax_category = pos.get("tax_category")
|
||||
if not doc.customer:
|
||||
doc.customer = pos.customer
|
||||
doc.ignore_pricing_rule = pos.ignore_pricing_rule
|
||||
|
||||
for item in doc.get("items"):
|
||||
if item.get("item_code"):
|
||||
profile_details = get_pos_profile_item_details_(
|
||||
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
|
||||
)
|
||||
for fname, val in profile_details.items():
|
||||
if (not for_validate) or (for_validate and not item.get(fname)):
|
||||
item.set(fname, val)
|
||||
def _copy_pos_profile_fields(self, pos, for_validate: bool) -> None:
|
||||
doc = self.doc
|
||||
for fieldname in (
|
||||
"currency",
|
||||
"letter_head",
|
||||
"tc_name",
|
||||
"company",
|
||||
"select_print_heading",
|
||||
"write_off_account",
|
||||
"taxes_and_charges",
|
||||
"write_off_cost_center",
|
||||
"apply_discount_on",
|
||||
"cost_center",
|
||||
):
|
||||
if (not for_validate) or (for_validate and not doc.get(fieldname)):
|
||||
doc.set(fieldname, pos.get(fieldname))
|
||||
|
||||
if doc.tc_name and not doc.terms:
|
||||
doc.terms = frappe.db.get_value("Terms and Conditions", doc.tc_name, "terms")
|
||||
def _set_selling_price_list(self, pos) -> None:
|
||||
doc = self.doc
|
||||
if doc.customer:
|
||||
customer_price_list, customer_group = frappe.get_value(
|
||||
"Customer", doc.customer, ["default_price_list", "customer_group"]
|
||||
)
|
||||
customer_group_price_list = frappe.get_value(
|
||||
"Customer Group", customer_group, "default_price_list"
|
||||
)
|
||||
selling_price_list = (
|
||||
customer_price_list or customer_group_price_list or pos.get("selling_price_list")
|
||||
)
|
||||
else:
|
||||
selling_price_list = pos.get("selling_price_list")
|
||||
|
||||
if doc.taxes_and_charges and not len(doc.get("taxes")):
|
||||
from erpnext.accounts.services.taxes import TaxService
|
||||
if selling_price_list:
|
||||
doc.set("selling_price_list", selling_price_list)
|
||||
|
||||
TaxService(doc).set_taxes()
|
||||
def _set_update_stock_from_profile(self, pos) -> None:
|
||||
doc = self.doc
|
||||
dn_flag = any(d.get("dn_detail") for d in doc.get("items"))
|
||||
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
|
||||
|
||||
return pos
|
||||
def _apply_pos_item_defaults(self, pos, for_validate: bool) -> None:
|
||||
from erpnext.stock.get_item_details import ItemDetailsCtx, get_pos_profile_item_details_
|
||||
|
||||
for item in self.doc.get("items"):
|
||||
if not item.get("item_code"):
|
||||
continue
|
||||
profile_details = get_pos_profile_item_details_(
|
||||
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
|
||||
)
|
||||
for fname, val in profile_details.items():
|
||||
if (not for_validate) or (for_validate and not item.get(fname)):
|
||||
item.set(fname, val)
|
||||
|
||||
def _set_terms_and_taxes(self, pos) -> None:
|
||||
doc = self.doc
|
||||
if doc.tc_name and not doc.terms:
|
||||
doc.terms = frappe.db.get_value("Terms and Conditions", doc.tc_name, "terms")
|
||||
|
||||
if doc.taxes_and_charges and not len(doc.get("taxes")):
|
||||
from erpnext.accounts.services.taxes import TaxService
|
||||
|
||||
TaxService(doc).set_taxes()
|
||||
|
||||
def update_paid_amount(self) -> None:
|
||||
doc = self.doc
|
||||
@@ -144,6 +178,7 @@ class POSService:
|
||||
doc.paid_amount = 0
|
||||
|
||||
def validate_pos_return(self) -> None:
|
||||
"""Ensure POS return payments are not less than the (negative) invoice total."""
|
||||
doc = self.doc
|
||||
if doc.is_consolidated:
|
||||
return
|
||||
@@ -160,6 +195,7 @@ class POSService:
|
||||
frappe.throw(_("At least one mode of payment is required for POS invoice."))
|
||||
|
||||
def validate_pos(self) -> None:
|
||||
"""On a POS return, paid amount plus write-off cannot exceed the grand total."""
|
||||
doc = self.doc
|
||||
if doc.is_return:
|
||||
invoice_total = doc.rounded_total or doc.grand_total
|
||||
@@ -180,6 +216,7 @@ class POSService:
|
||||
self.validate_pos_opening_entry()
|
||||
|
||||
def validate_full_payment(self) -> None:
|
||||
"""Block partial payment on a submitted POS invoice unless the profile allows it."""
|
||||
doc = self.doc
|
||||
allow_partial_payment = frappe.db.get_value("POS Profile", doc.pos_profile, "allow_partial_payment")
|
||||
invoice_total = flt(doc.rounded_total) or flt(doc.grand_total)
|
||||
@@ -196,6 +233,7 @@ class POSService:
|
||||
)
|
||||
|
||||
def validate_pos_opening_entry(self) -> None:
|
||||
"""Require exactly one current, open POS Opening Entry for the profile."""
|
||||
doc = self.doc
|
||||
opening_entries = frappe.get_all(
|
||||
"POS Opening Entry",
|
||||
@@ -281,38 +319,6 @@ class POSService:
|
||||
if entry.amount > 0:
|
||||
frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx))
|
||||
|
||||
def get_warehouse(self) -> str | None:
|
||||
doc = self.doc
|
||||
POSProfile = frappe.qb.DocType("POS Profile")
|
||||
|
||||
user_query = (
|
||||
frappe.qb.from_(POSProfile)
|
||||
.select(POSProfile.name, POSProfile.warehouse)
|
||||
.where(POSProfile.company == doc.company)
|
||||
.where(
|
||||
(POSProfile.user == frappe.session["user"])
|
||||
| ((POSProfile.user.isnull() | (POSProfile.user == "")) & (frappe.session["user"] == ""))
|
||||
)
|
||||
)
|
||||
user_pos_profile = user_query.run()
|
||||
warehouse = user_pos_profile[0][1] if user_pos_profile else None
|
||||
|
||||
if not warehouse:
|
||||
global_query = (
|
||||
frappe.qb.from_(POSProfile)
|
||||
.select(POSProfile.name, POSProfile.warehouse)
|
||||
.where(POSProfile.company == doc.company)
|
||||
.where(POSProfile.user.isnull() | (POSProfile.user == ""))
|
||||
)
|
||||
global_pos_profile = global_query.run()
|
||||
|
||||
if global_pos_profile:
|
||||
warehouse = global_pos_profile[0][1]
|
||||
elif not user_pos_profile:
|
||||
msgprint(_("POS Profile required to make POS Entry"), raise_exception=True)
|
||||
|
||||
return warehouse
|
||||
|
||||
|
||||
def get_bank_cash_account(mode_of_payment: str, company: str) -> dict:
|
||||
account = frappe.db.get_value(
|
||||
@@ -369,61 +375,43 @@ def update_multi_mode_option(doc, pos_profile) -> None:
|
||||
|
||||
|
||||
def get_all_mode_of_payments(doc) -> list:
|
||||
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
|
||||
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(ModeOfPaymentAccount)
|
||||
.join(ModeOfPayment)
|
||||
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
|
||||
.select(
|
||||
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
|
||||
)
|
||||
.where(ModeOfPaymentAccount.company == doc.company)
|
||||
.where(ModeOfPayment.enabled == 1)
|
||||
)
|
||||
|
||||
return query.run(as_dict=1)
|
||||
"""All enabled modes of payment with their default accounts for the doc's company."""
|
||||
query, mopa, mop = _enabled_mode_of_payment_query(doc.company)
|
||||
return query.select(mopa.default_account, mopa.parent, mop.type.as_("type")).run(as_dict=1)
|
||||
|
||||
|
||||
def get_mode_of_payments_info(mode_of_payments: list, company: str) -> dict:
|
||||
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
|
||||
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(ModeOfPaymentAccount)
|
||||
.join(ModeOfPayment)
|
||||
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
|
||||
.select(
|
||||
ModeOfPaymentAccount.default_account,
|
||||
ModeOfPaymentAccount.parent.as_("mop"),
|
||||
ModeOfPayment.type.as_("type"),
|
||||
)
|
||||
.where(ModeOfPaymentAccount.company == company)
|
||||
.where(ModeOfPayment.enabled == 1)
|
||||
.where(ModeOfPayment.name.isin(mode_of_payments))
|
||||
.groupby(ModeOfPayment.name)
|
||||
"""Map each of the named modes of payment to its account info for the company."""
|
||||
query, mopa, mop = _enabled_mode_of_payment_query(company)
|
||||
data = (
|
||||
query.select(mopa.default_account, mopa.parent.as_("mop"), mop.type.as_("type"))
|
||||
.where(mop.name.isin(mode_of_payments))
|
||||
# group by all selected columns so postgres accepts it (one row per mode of payment)
|
||||
.groupby(mopa.default_account, mopa.parent, mop.type)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
data = query.run(as_dict=1)
|
||||
|
||||
return {row.get("mop"): row for row in data}
|
||||
|
||||
|
||||
def get_mode_of_payment_info(mode_of_payment: str, company: str) -> list:
|
||||
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
|
||||
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(ModeOfPayment)
|
||||
.join(ModeOfPaymentAccount)
|
||||
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
|
||||
.select(
|
||||
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
|
||||
)
|
||||
.where(ModeOfPaymentAccount.company == company)
|
||||
.where(ModeOfPayment.enabled == 1)
|
||||
.where(ModeOfPayment.name == mode_of_payment)
|
||||
"""Account info for a single mode of payment in the company."""
|
||||
query, mopa, mop = _enabled_mode_of_payment_query(company)
|
||||
return (
|
||||
query.select(mopa.default_account, mopa.parent, mop.type.as_("type"))
|
||||
.where(mop.name == mode_of_payment)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
return query.run(as_dict=1)
|
||||
|
||||
def _enabled_mode_of_payment_query(company: str):
|
||||
"""Base query joining enabled modes of payment to their accounts for a company."""
|
||||
mopa = frappe.qb.DocType("Mode of Payment Account")
|
||||
mop = frappe.qb.DocType("Mode of Payment")
|
||||
query = (
|
||||
frappe.qb.from_(mopa)
|
||||
.join(mop)
|
||||
.on(mopa.parent == mop.name)
|
||||
.where(mopa.company == company)
|
||||
.where(mop.enabled == 1)
|
||||
)
|
||||
return query, mopa, mop
|
||||
|
||||
@@ -21,45 +21,52 @@ class StatusService:
|
||||
doc.status = "Draft"
|
||||
return
|
||||
|
||||
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
|
||||
total = get_total_in_party_account_currency(doc)
|
||||
|
||||
if not status:
|
||||
if doc.docstatus == 2:
|
||||
status = "Cancelled"
|
||||
elif doc.docstatus == 1:
|
||||
if doc.is_internal_transfer():
|
||||
doc.status = "Internal Transfer"
|
||||
elif is_overdue(doc, total):
|
||||
doc.status = "Overdue"
|
||||
elif 0 < outstanding_amount < total:
|
||||
doc.status = "Partly Paid"
|
||||
elif outstanding_amount > 0 and getdate(doc.due_date) >= getdate():
|
||||
doc.status = "Unpaid"
|
||||
elif doc.is_return == 0 and frappe.db.get_value(
|
||||
"Sales Invoice", {"is_return": 1, "return_against": doc.name, "docstatus": 1}
|
||||
):
|
||||
doc.status = "Credit Note Issued"
|
||||
elif doc.is_return == 1:
|
||||
doc.status = "Return"
|
||||
elif outstanding_amount <= 0:
|
||||
doc.status = "Paid"
|
||||
else:
|
||||
doc.status = "Submitted"
|
||||
|
||||
if (
|
||||
doc.status in ("Unpaid", "Partly Paid", "Overdue")
|
||||
and doc.is_discounted
|
||||
and get_discounting_status(doc.name) == "Disbursed"
|
||||
):
|
||||
doc.status += " and Discounted"
|
||||
|
||||
doc.status = self._get_submitted_status()
|
||||
else:
|
||||
doc.status = "Draft"
|
||||
|
||||
if update:
|
||||
doc.db_set("status", doc.status, update_modified=update_modified)
|
||||
|
||||
def _get_submitted_status(self) -> str:
|
||||
"""Status of a submitted invoice, with the invoice-discounting suffix applied."""
|
||||
doc = self.doc
|
||||
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
|
||||
total = get_total_in_party_account_currency(doc)
|
||||
|
||||
status = self._get_payment_status(outstanding_amount, total)
|
||||
if (
|
||||
status in ("Unpaid", "Partly Paid", "Overdue")
|
||||
and doc.is_discounted
|
||||
and get_discounting_status(doc.name) == "Disbursed"
|
||||
):
|
||||
status += " and Discounted"
|
||||
return status
|
||||
|
||||
def _get_payment_status(self, outstanding_amount: float, total: float) -> str:
|
||||
doc = self.doc
|
||||
if doc.is_internal_transfer():
|
||||
return "Internal Transfer"
|
||||
if is_overdue(doc, total):
|
||||
return "Overdue"
|
||||
if 0 < outstanding_amount < total:
|
||||
return "Partly Paid"
|
||||
if outstanding_amount > 0 and getdate(doc.due_date) >= getdate():
|
||||
return "Unpaid"
|
||||
if doc.is_return == 0 and frappe.db.get_value(
|
||||
"Sales Invoice", {"is_return": 1, "return_against": doc.name, "docstatus": 1}
|
||||
):
|
||||
return "Credit Note Issued"
|
||||
if doc.is_return == 1:
|
||||
return "Return"
|
||||
if outstanding_amount <= 0:
|
||||
return "Paid"
|
||||
return "Submitted"
|
||||
|
||||
def set_indicator(self) -> None:
|
||||
doc = self.doc
|
||||
if doc.outstanding_amount < 0:
|
||||
|
||||
@@ -99,23 +99,24 @@ class TimesheetBillingService:
|
||||
doc.total_billing_hours = sum(flt(ts.billing_hours) for ts in doc.timesheets)
|
||||
|
||||
def _update_time_sheet_detail(self, timesheet, args, sales_invoice: str | None) -> None:
|
||||
doc = self.doc
|
||||
for data in timesheet.time_logs:
|
||||
if (
|
||||
(doc.project and args.timesheet_detail == data.name)
|
||||
or (not doc.project and not data.sales_invoice and args.timesheet_detail == data.name)
|
||||
or (
|
||||
not sales_invoice
|
||||
and data.sales_invoice == doc.name
|
||||
and args.timesheet_detail == data.name
|
||||
)
|
||||
or (
|
||||
doc.is_return
|
||||
and doc.return_against
|
||||
and data.sales_invoice
|
||||
and data.sales_invoice == doc.return_against
|
||||
and not sales_invoice
|
||||
and args.timesheet_detail == data.name
|
||||
)
|
||||
):
|
||||
if args.timesheet_detail == data.name and self._should_set_sales_invoice(data, sales_invoice):
|
||||
data.sales_invoice = sales_invoice
|
||||
|
||||
def _should_set_sales_invoice(self, time_log, sales_invoice: str | None) -> bool:
|
||||
"""Whether this time log's sales-invoice link should be (re)set to sales_invoice."""
|
||||
doc = self.doc
|
||||
if doc.project:
|
||||
return True
|
||||
if not time_log.sales_invoice:
|
||||
return True
|
||||
if not sales_invoice and time_log.sales_invoice == doc.name:
|
||||
# clearing the link on cancellation of this invoice
|
||||
return True
|
||||
# clearing the link on a return raised against the original invoice
|
||||
return bool(
|
||||
doc.is_return
|
||||
and doc.return_against
|
||||
and not sales_invoice
|
||||
and time_log.sales_invoice == doc.return_against
|
||||
)
|
||||
|
||||
@@ -20,6 +20,12 @@ from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
|
||||
unlink_payment_on_cancel_of_invoice,
|
||||
)
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction
|
||||
from erpnext.accounts.doctype.sales_invoice.services.pos import (
|
||||
POSService,
|
||||
get_all_mode_of_payments,
|
||||
get_mode_of_payment_info,
|
||||
get_mode_of_payments_info,
|
||||
)
|
||||
from erpnext.accounts.utils import PaymentEntryUnlinkError
|
||||
from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
|
||||
from erpnext.assets.doctype.asset.test_asset import create_asset
|
||||
@@ -1346,6 +1352,101 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(pos.grand_total, 100.0)
|
||||
self.assertEqual(pos.write_off_amount, 0)
|
||||
|
||||
def test_set_pos_fields_populates_invoice_from_profile(self):
|
||||
terms = frappe.db.exists("Terms and Conditions", "_Test POS Terms")
|
||||
if not terms:
|
||||
terms = (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Terms and Conditions",
|
||||
"title": "_Test POS Terms",
|
||||
"terms": "POS terms and conditions",
|
||||
"selling": 1,
|
||||
}
|
||||
)
|
||||
.insert()
|
||||
.name
|
||||
)
|
||||
|
||||
profile = make_pos_profile()
|
||||
profile.customer = "_Test Customer"
|
||||
profile.tax_category = "_Test Tax Category 1"
|
||||
profile.account_for_change_amount = "Cash - _TC"
|
||||
profile.ignore_pricing_rule = 1
|
||||
profile.update_stock = 1
|
||||
profile.apply_discount_on = "Grand Total"
|
||||
profile.tc_name = terms
|
||||
profile.taxes_and_charges = "_Test Sales Taxes and Charges Template - _TC"
|
||||
profile.save()
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.is_pos = 1
|
||||
si.pos_profile = profile.name
|
||||
si.customer = None
|
||||
si.taxes = []
|
||||
|
||||
POSService(si).set_pos_fields(for_validate=False)
|
||||
|
||||
self.assertEqual(si.customer, "_Test Customer")
|
||||
self.assertEqual(si.tax_category, "_Test Tax Category 1")
|
||||
self.assertEqual(si.ignore_pricing_rule, 1)
|
||||
self.assertEqual(si.account_for_change_amount, "Cash - _TC")
|
||||
self.assertEqual(si.taxes_and_charges, "_Test Sales Taxes and Charges Template - _TC")
|
||||
self.assertEqual(si.apply_discount_on, "Grand Total")
|
||||
self.assertEqual(si.update_stock, 1)
|
||||
self.assertEqual(si.terms, "POS terms and conditions")
|
||||
self.assertTrue(si.get("payments"))
|
||||
self.assertTrue(si.get("taxes"))
|
||||
|
||||
def test_set_pos_fields_for_validate_preserves_existing_values(self):
|
||||
profile = make_pos_profile()
|
||||
profile.tax_category = "_Test Tax Category 1"
|
||||
profile.save()
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.is_pos = 1
|
||||
si.pos_profile = profile.name
|
||||
si.apply_discount_on = "Net Total"
|
||||
existing_customer = si.customer
|
||||
|
||||
POSService(si).set_pos_fields(for_validate=True)
|
||||
|
||||
# for_validate must not overwrite a field the user already set
|
||||
self.assertEqual(si.apply_discount_on, "Net Total")
|
||||
# for_validate skips mode-of-payment fetch and profile-driven customer/tax_category
|
||||
self.assertFalse(si.get("payments"))
|
||||
self.assertEqual(si.customer, existing_customer)
|
||||
self.assertFalse(si.tax_category)
|
||||
|
||||
def test_set_pos_fields_uses_profile_price_list_without_customer(self):
|
||||
profile = make_pos_profile(selling_price_list="_Test Price List")
|
||||
profile.customer = None
|
||||
profile.save()
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.is_pos = 1
|
||||
si.pos_profile = profile.name
|
||||
si.customer = None
|
||||
|
||||
POSService(si).set_pos_fields(for_validate=False)
|
||||
|
||||
self.assertEqual(si.selling_price_list, "_Test Price List")
|
||||
|
||||
def test_pos_service_mode_of_payment_queries(self):
|
||||
make_pos_profile() # ensures a Cash mode-of-payment account for _Test Company
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
|
||||
single = get_mode_of_payment_info("Cash", "_Test Company")
|
||||
self.assertTrue(single)
|
||||
self.assertEqual(single[0].parent, "Cash")
|
||||
|
||||
all_modes = get_all_mode_of_payments(si)
|
||||
self.assertTrue(any(row.parent == "Cash" for row in all_modes))
|
||||
|
||||
grouped = get_mode_of_payments_info(["Cash"], "_Test Company")
|
||||
self.assertIn("Cash", grouped)
|
||||
self.assertEqual(grouped["Cash"].mop, "Cash")
|
||||
|
||||
def test_auto_write_off_amount(self):
|
||||
make_pos_profile(
|
||||
company="_Test Company with perpetual inventory",
|
||||
@@ -1476,6 +1577,75 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
|
||||
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
|
||||
|
||||
def test_stock_delivered_but_not_billed_gl_on_invoice(self):
|
||||
company = "_Test Company with perpetual inventory"
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
make_purchase_receipt(
|
||||
company=company,
|
||||
item_code="_Test FG Item",
|
||||
warehouse="Stores - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
qty=5,
|
||||
rate=100,
|
||||
)
|
||||
|
||||
dn = create_delivery_note(
|
||||
company=company,
|
||||
item_code="_Test FG Item",
|
||||
warehouse="Stores - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
qty=2,
|
||||
rate=300,
|
||||
)
|
||||
# A perpetual-inventory Delivery Note books the cost to the SDBNB account
|
||||
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - TCP1")
|
||||
|
||||
si = make_sales_invoice(dn.name)
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": si.name, "is_cancelled": 0},
|
||||
fields=["account", "debit", "credit"],
|
||||
)
|
||||
sdbnb_credit = sum(
|
||||
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - TCP1"
|
||||
)
|
||||
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - TCP1")
|
||||
|
||||
# Billing reverses SDBNB and recognises the cost in COGS for an equal amount
|
||||
self.assertTrue(sdbnb_credit > 0)
|
||||
self.assertEqual(sdbnb_credit, cogs_debit)
|
||||
|
||||
def test_get_gle_for_change_amount(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.services.gl_composer import SalesInvoiceGLComposer
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.is_pos = 1
|
||||
si.party_account_currency = "INR"
|
||||
|
||||
# no change amount -> no entries
|
||||
si.change_amount = 0
|
||||
self.assertEqual(SalesInvoiceGLComposer(si).get_gle_for_change_amount(), [])
|
||||
|
||||
# change amount without an account -> mandatory error
|
||||
si.change_amount = 10
|
||||
si.base_change_amount = 10
|
||||
si.account_for_change_amount = None
|
||||
self.assertRaises(frappe.ValidationError, SalesInvoiceGLComposer(si).get_gle_for_change_amount)
|
||||
|
||||
# change amount with an account -> debit-to debited, change account credited
|
||||
si.account_for_change_amount = "Cash - _TC"
|
||||
entries = SalesInvoiceGLComposer(si).get_gle_for_change_amount()
|
||||
self.assertEqual(len(entries), 2)
|
||||
debit_entry = next(entry for entry in entries if entry["account"] == si.debit_to)
|
||||
credit_entry = next(entry for entry in entries if entry["account"] == "Cash - _TC")
|
||||
self.assertEqual(debit_entry["party"], si.customer)
|
||||
self.assertEqual(flt(debit_entry["debit"]), 10.0)
|
||||
self.assertEqual(flt(credit_entry["credit"]), 10.0)
|
||||
|
||||
def validate_pos_gl_entry(self, si, pos, cash_amount, validate_without_change_gle=False):
|
||||
if validate_without_change_gle:
|
||||
cash_amount -= pos.change_amount
|
||||
@@ -3710,6 +3880,27 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
|
||||
party_link.delete()
|
||||
|
||||
def test_status_indicator(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.services.status import StatusService
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
cases = [
|
||||
# outstanding, due_date, is_return -> indicator color, title
|
||||
(-50, nowdate(), 0, "gray", "Credit Note Issued"),
|
||||
(100, add_days(nowdate(), 5), 0, "orange", "Unpaid"),
|
||||
(100, add_days(nowdate(), -5), 0, "red", "Overdue"),
|
||||
(0, nowdate(), 1, "gray", "Return"),
|
||||
(0, nowdate(), 0, "green", "Paid"),
|
||||
]
|
||||
for outstanding, due_date, is_return, color, title in cases:
|
||||
with self.subTest(title=title):
|
||||
si.outstanding_amount = outstanding
|
||||
si.due_date = due_date
|
||||
si.is_return = is_return
|
||||
StatusService(si).set_indicator()
|
||||
self.assertEqual(si.indicator_color, color)
|
||||
self.assertEqual(si.indicator_title, title)
|
||||
|
||||
def test_payment_statuses(self):
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
|
||||
|
||||
|
||||
@@ -56,11 +56,14 @@ def valdiate_taxes_and_charges_template(doc):
|
||||
# doc.is_default = 1
|
||||
|
||||
if doc.is_default == 1:
|
||||
frappe.db.sql(
|
||||
f"""update `tab{doc.doctype}` set is_default = 0
|
||||
where is_default = 1 and name != %s and company = %s""",
|
||||
(doc.name, doc.company),
|
||||
)
|
||||
template = frappe.qb.DocType(doc.doctype)
|
||||
(
|
||||
frappe.qb.update(template)
|
||||
.set(template.is_default, 0)
|
||||
.where(
|
||||
(template.is_default == 1) & (template.name != doc.name) & (template.company == doc.company)
|
||||
)
|
||||
).run()
|
||||
|
||||
validate_disabled(doc)
|
||||
|
||||
|
||||
@@ -672,7 +672,7 @@ def make_reverse_gl_entries(
|
||||
)
|
||||
|
||||
if not immutable_ledger_enabled:
|
||||
query = query.set(gle.is_cancelled, True)
|
||||
query = query.set(gle.is_cancelled, 1) # smallint column; postgres rejects boolean true
|
||||
|
||||
query.run()
|
||||
else:
|
||||
@@ -683,12 +683,14 @@ def make_reverse_gl_entries(
|
||||
if not all(gle_names):
|
||||
set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"])
|
||||
else:
|
||||
frappe.db.sql(
|
||||
"""UPDATE `tabGL Entry` SET is_cancelled = 1,
|
||||
modified=%s, modified_by=%s
|
||||
where name in %s and is_cancelled = 0""",
|
||||
(now(), frappe.session.user, tuple(gle_names)),
|
||||
)
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
(
|
||||
frappe.qb.update(gle)
|
||||
.set(gle.is_cancelled, 1)
|
||||
.set(gle.modified, now())
|
||||
.set(gle.modified_by, frappe.session.user)
|
||||
.where(gle.name.isin(gle_names) & (gle.is_cancelled == 0))
|
||||
).run()
|
||||
|
||||
for entry in gl_entries:
|
||||
new_gle = copy.deepcopy(entry)
|
||||
@@ -725,9 +727,11 @@ def set_as_cancel(voucher_type, voucher_no):
|
||||
"""
|
||||
Set is_cancelled=1 in all original gl entries for the voucher
|
||||
"""
|
||||
frappe.db.sql(
|
||||
"""UPDATE `tabGL Entry` SET is_cancelled = 1,
|
||||
modified=%s, modified_by=%s
|
||||
where voucher_type=%s and voucher_no=%s and is_cancelled = 0""",
|
||||
(now(), frappe.session.user, voucher_type, voucher_no),
|
||||
)
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
(
|
||||
frappe.qb.update(gle)
|
||||
.set(gle.is_cancelled, 1)
|
||||
.set(gle.modified, now())
|
||||
.set(gle.modified_by, frappe.session.user)
|
||||
.where((gle.voucher_type == voucher_type) & (gle.voucher_no == voucher_no) & (gle.is_cancelled == 0))
|
||||
).run()
|
||||
|
||||
@@ -900,16 +900,13 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
|
||||
d.company, {"grand_total": d.grand_total, "base_grand_total": d.base_grand_total}
|
||||
)
|
||||
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
company_wise_total_unpaid = frappe._dict(
|
||||
frappe.db.sql(
|
||||
"""
|
||||
select company, sum(debit_in_account_currency) - sum(credit_in_account_currency)
|
||||
from `tabGL Entry`
|
||||
where party_type = %s and party=%s
|
||||
and is_cancelled = 0
|
||||
group by company""",
|
||||
(party_type, party),
|
||||
)
|
||||
frappe.qb.from_(gle)
|
||||
.select(gle.company, Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
|
||||
.where((gle.party_type == party_type) & (gle.party == party) & (gle.is_cancelled == 0))
|
||||
.groupby(gle.company)
|
||||
.run()
|
||||
)
|
||||
|
||||
for d in companies:
|
||||
|
||||
@@ -7,7 +7,7 @@ from collections import OrderedDict
|
||||
import frappe
|
||||
from frappe import _, qb, query_builder, scrub
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder.functions import Date, Max, Substring, Sum
|
||||
from frappe.query_builder.functions import Date, Substring, Sum
|
||||
from frappe.utils import cint, cstr, flt, getdate, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
@@ -691,13 +691,11 @@ class ReceivablePayableReport:
|
||||
.inner_join(jea)
|
||||
.on(jea.parent == je.name)
|
||||
.select(
|
||||
# Sum() below makes this an implicit aggregate (no GROUP BY); the non-aggregated columns
|
||||
# are arbitrary per the single group on MySQL -> Max() keeps it valid on postgres.
|
||||
Max(jea.reference_name).as_("invoice_no"),
|
||||
Max(jea.party).as_("party"),
|
||||
Max(jea.party_type).as_("party_type"),
|
||||
Max(je.posting_date).as_("future_date"),
|
||||
Max(je.cheque_no).as_("future_ref"),
|
||||
jea.reference_name.as_("invoice_no"),
|
||||
jea.party,
|
||||
jea.party_type,
|
||||
je.posting_date.as_("future_date"),
|
||||
je.cheque_no.as_("future_ref"),
|
||||
)
|
||||
.where(
|
||||
(je.docstatus < 2)
|
||||
@@ -727,6 +725,14 @@ class ReceivablePayableReport:
|
||||
future_amount.as_("future_amount"),
|
||||
future_amount_in_base_currency.as_("future_amount_in_base_currency"),
|
||||
)
|
||||
# One row per (future-payment JE, invoice, party): group by the JE name (primary key, so the
|
||||
# JE-level posting_date/cheque_no are deterministic) plus the per-reference dimensions, summing
|
||||
# amounts across JE Account rows that hit the same invoice. Without this GROUP BY the implicit
|
||||
# single-group aggregate collapsed every future JE payment into one row keyed by an arbitrary
|
||||
# invoice, mis-allocating the whole sum.
|
||||
query = query.groupby(
|
||||
je.name, jea.reference_name, jea.party, jea.party_type, je.posting_date, je.cheque_no
|
||||
)
|
||||
# use the aggregate expression in HAVING; postgres can't reference a SELECT alias there
|
||||
query = query.having(future_amount > 0)
|
||||
return query.run(as_dict=True)
|
||||
|
||||
@@ -699,6 +699,61 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
[row.invoiced, row.paid, row.outstanding, row.remaining_balance, row.future_amount],
|
||||
)
|
||||
|
||||
def test_future_payments_from_journal_entry(self):
|
||||
# A single future-dated Journal Entry paying two different invoices must surface as one
|
||||
# future-payment row PER invoice, not collapse the whole sum onto one arbitrary invoice
|
||||
# (regression: the implicit single-group aggregate filed all future JE payments under one key).
|
||||
si_a = self.create_sales_invoice(no_payment_schedule=True)
|
||||
si_b = self.create_sales_invoice(no_payment_schedule=True)
|
||||
|
||||
je = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Journal Entry",
|
||||
"voucher_type": "Journal Entry",
|
||||
"company": self.company,
|
||||
"posting_date": add_days(today(), 1),
|
||||
"accounts": [
|
||||
{
|
||||
"account": self.debit_to,
|
||||
"party_type": "Customer",
|
||||
"party": self.customer,
|
||||
"reference_type": "Sales Invoice",
|
||||
"reference_name": si_a.name,
|
||||
"credit_in_account_currency": 50,
|
||||
"credit": 50,
|
||||
},
|
||||
{
|
||||
"account": self.debit_to,
|
||||
"party_type": "Customer",
|
||||
"party": self.customer,
|
||||
"reference_type": "Sales Invoice",
|
||||
"reference_name": si_b.name,
|
||||
"credit_in_account_currency": 50,
|
||||
"credit": 50,
|
||||
},
|
||||
{"account": self.cash, "debit_in_account_currency": 100, "debit": 100},
|
||||
],
|
||||
}
|
||||
)
|
||||
je.insert().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"show_future_payments": True,
|
||||
}
|
||||
report = execute(filters)[1]
|
||||
rows_a = [row for row in report if row.voucher_no == si_a.name]
|
||||
rows_b = [row for row in report if row.voucher_no == si_b.name]
|
||||
|
||||
# exactly one report row per invoice, each keeping its own future payment; the bug collapsed
|
||||
# both into a single row and allocated the whole 100 to one arbitrary invoice
|
||||
self.assertEqual(len(rows_a), 1)
|
||||
self.assertEqual(len(rows_b), 1)
|
||||
self.assertEqual(rows_a[0].future_amount, 50.0)
|
||||
self.assertEqual(rows_b[0].future_amount, 50.0)
|
||||
|
||||
def test_sales_person(self):
|
||||
sales_person = frappe.get_doc(
|
||||
{"doctype": "Sales Person", "sales_person_name": "John Clark", "enabled": True}
|
||||
|
||||
@@ -84,7 +84,13 @@ def build_budget_map(budget_records, filters):
|
||||
budget_distributions = get_budget_distributions(budget)
|
||||
|
||||
for row in budget_distributions:
|
||||
if not row.start_date or not row.end_date:
|
||||
continue
|
||||
|
||||
months = get_months_in_range(row.start_date, row.end_date)
|
||||
if not months:
|
||||
continue
|
||||
|
||||
monthly_budget = flt(row.amount) / len(months)
|
||||
|
||||
for month_date in months:
|
||||
|
||||
@@ -134,17 +134,17 @@ class TestGeneralLedger(ERPNextTestSuite):
|
||||
revaluation_jv.submit()
|
||||
|
||||
# check the balance of the account
|
||||
balance = frappe.db.sql(
|
||||
"""
|
||||
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
|
||||
from `tabGL Entry`
|
||||
where account = %s
|
||||
group by account
|
||||
""",
|
||||
account.name,
|
||||
balance = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"account": account.name},
|
||||
fields=[
|
||||
{"SUM": "debit_in_account_currency", "as": "debit"},
|
||||
{"SUM": "credit_in_account_currency", "as": "credit"},
|
||||
],
|
||||
group_by="account",
|
||||
)
|
||||
|
||||
self.assertEqual(balance[0][0], 100)
|
||||
self.assertEqual(flt(balance[0].debit) - flt(balance[0].credit), 100)
|
||||
|
||||
# check if general ledger shows correct balance
|
||||
columns, data = execute(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Coalesce, Max, Sum
|
||||
from frappe.utils import cstr
|
||||
|
||||
|
||||
@@ -100,178 +101,275 @@ def get_sales_payment_data(filters, columns):
|
||||
return data
|
||||
|
||||
|
||||
def get_conditions(filters):
|
||||
conditions = "1=1"
|
||||
def apply_conditions(query, a, filters):
|
||||
"""Apply the same filters get_conditions() used to build, as parameterized qb .where() clauses.
|
||||
|
||||
`a` is the field source for the Sales Invoice columns -- either the `tabSales Invoice`
|
||||
DocType or a subquery aliased `a` that selects those columns. This mirrors the previous
|
||||
raw SQL where every predicate was keyed on the `a` alias.
|
||||
"""
|
||||
if filters.get("from_date"):
|
||||
conditions += " and a.posting_date >= %(from_date)s"
|
||||
query = query.where(a.posting_date >= filters.get("from_date"))
|
||||
if filters.get("to_date"):
|
||||
conditions += " and a.posting_date <= %(to_date)s"
|
||||
query = query.where(a.posting_date <= filters.get("to_date"))
|
||||
if filters.get("company"):
|
||||
conditions += " and a.company=%(company)s"
|
||||
query = query.where(a.company == filters.get("company"))
|
||||
if filters.get("customer"):
|
||||
conditions += " and a.customer = %(customer)s"
|
||||
query = query.where(a.customer == filters.get("customer"))
|
||||
if filters.get("owner"):
|
||||
conditions += " and a.owner = %(owner)s"
|
||||
query = query.where(a.owner == filters.get("owner"))
|
||||
if filters.get("is_pos"):
|
||||
conditions += " and a.is_pos = %(is_pos)s"
|
||||
return conditions
|
||||
query = query.where(a.is_pos == filters.get("is_pos"))
|
||||
return query
|
||||
|
||||
|
||||
def get_pos_invoice_data(filters):
|
||||
conditions = get_conditions(filters)
|
||||
result = frappe.db.sql(
|
||||
""
|
||||
"SELECT "
|
||||
'posting_date, owner, sum(net_total) as "net_total", sum(total_taxes) as "total_taxes", '
|
||||
'sum(paid_amount) as "paid_amount", sum(outstanding_amount) as "outstanding_amount", '
|
||||
"mode_of_payment, warehouse, cost_center "
|
||||
"FROM ("
|
||||
"SELECT "
|
||||
'parent, item_code, sum(amount) as "base_total", warehouse, cost_center '
|
||||
"from `tabSales Invoice Item` group by parent"
|
||||
") t1 "
|
||||
"left join "
|
||||
"(select parent, mode_of_payment from `tabSales Invoice Payment` group by parent) t3 "
|
||||
"on (t3.parent = t1.parent) "
|
||||
"JOIN ("
|
||||
"SELECT "
|
||||
'docstatus, company, is_pos, name, posting_date, owner, sum(base_total) as "base_total", '
|
||||
'sum(net_total) as "net_total", sum(total_taxes_and_charges) as "total_taxes", '
|
||||
'sum(base_paid_amount) as "paid_amount", sum(outstanding_amount) as "outstanding_amount" '
|
||||
"FROM `tabSales Invoice` "
|
||||
"GROUP BY name"
|
||||
") a "
|
||||
"ON ("
|
||||
"t1.parent = a.name and t1.base_total = a.base_total) "
|
||||
"WHERE a.docstatus = 1"
|
||||
f" AND {conditions} "
|
||||
"GROUP BY "
|
||||
"owner, posting_date, warehouse",
|
||||
filters,
|
||||
as_dict=1,
|
||||
sii = frappe.qb.DocType("Sales Invoice Item")
|
||||
sip = frappe.qb.DocType("Sales Invoice Payment")
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
|
||||
# t1: one row per invoice with the summed item base_total. warehouse/cost_center are line-level and
|
||||
# not grouped, so they are arbitrary per invoice -- Max() makes that pick deterministic and valid on
|
||||
# Postgres (item_code was selected but never consumed downstream, so it is dropped).
|
||||
t1 = (
|
||||
frappe.qb.from_(sii)
|
||||
.select(
|
||||
sii.parent,
|
||||
Sum(sii.amount).as_("base_total"),
|
||||
Max(sii.warehouse).as_("warehouse"),
|
||||
Max(sii.cost_center).as_("cost_center"),
|
||||
)
|
||||
.groupby(sii.parent)
|
||||
)
|
||||
return result
|
||||
|
||||
# t3: mode_of_payment per invoice (arbitrary across an invoice's payment lines -> Max() to be valid)
|
||||
t3 = (
|
||||
frappe.qb.from_(sip)
|
||||
.select(sip.parent, Max(sip.mode_of_payment).as_("mode_of_payment"))
|
||||
.groupby(sip.parent)
|
||||
)
|
||||
|
||||
# a: invoice-level aggregates. Grouped by the primary key (si.name), so the other plain si columns
|
||||
# (incl. customer, needed by the customer filter) are functionally dependent and valid on Postgres.
|
||||
a = (
|
||||
frappe.qb.from_(si)
|
||||
.select(
|
||||
si.docstatus,
|
||||
si.company,
|
||||
si.customer,
|
||||
si.is_pos,
|
||||
si.name,
|
||||
si.posting_date,
|
||||
si.owner,
|
||||
Sum(si.base_total).as_("base_total"),
|
||||
Sum(si.net_total).as_("net_total"),
|
||||
Sum(si.total_taxes_and_charges).as_("total_taxes"),
|
||||
Sum(si.base_paid_amount).as_("paid_amount"),
|
||||
Sum(si.outstanding_amount).as_("outstanding_amount"),
|
||||
)
|
||||
.groupby(si.name)
|
||||
)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(t1)
|
||||
.left_join(t3)
|
||||
.on(t3.parent == t1.parent)
|
||||
.join(a)
|
||||
.on((t1.parent == a.name) & (t1.base_total == a.base_total))
|
||||
.select(
|
||||
a.posting_date,
|
||||
a.owner,
|
||||
Sum(a.net_total).as_("net_total"),
|
||||
Sum(a.total_taxes).as_("total_taxes"),
|
||||
Sum(a.paid_amount).as_("paid_amount"),
|
||||
Sum(a.outstanding_amount).as_("outstanding_amount"),
|
||||
# mode_of_payment/cost_center are not in the outer GROUP BY -> Max() (deterministic, both engines)
|
||||
Max(t3.mode_of_payment).as_("mode_of_payment"),
|
||||
t1.warehouse,
|
||||
Max(t1.cost_center).as_("cost_center"),
|
||||
)
|
||||
.where(a.docstatus == 1)
|
||||
.groupby(a.owner, a.posting_date, t1.warehouse)
|
||||
)
|
||||
query = apply_conditions(query, a, filters)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_sales_invoice_data(filters):
|
||||
conditions = get_conditions(filters)
|
||||
return frappe.db.sql(
|
||||
f"""
|
||||
select
|
||||
a.posting_date, a.owner,
|
||||
sum(a.net_total) as "net_total",
|
||||
sum(a.total_taxes_and_charges) as "total_taxes",
|
||||
sum(a.base_paid_amount) as "paid_amount",
|
||||
sum(a.outstanding_amount) as "outstanding_amount"
|
||||
from `tabSales Invoice` a
|
||||
where a.docstatus = 1
|
||||
and {conditions}
|
||||
group by
|
||||
a.owner, a.posting_date
|
||||
""",
|
||||
filters,
|
||||
as_dict=1,
|
||||
a = frappe.qb.DocType("Sales Invoice")
|
||||
query = (
|
||||
frappe.qb.from_(a)
|
||||
.select(
|
||||
a.posting_date,
|
||||
a.owner,
|
||||
Sum(a.net_total).as_("net_total"),
|
||||
Sum(a.total_taxes_and_charges).as_("total_taxes"),
|
||||
Sum(a.base_paid_amount).as_("paid_amount"),
|
||||
Sum(a.outstanding_amount).as_("outstanding_amount"),
|
||||
)
|
||||
.where(a.docstatus == 1)
|
||||
.groupby(a.owner, a.posting_date)
|
||||
)
|
||||
query = apply_conditions(query, a, filters)
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_mode_of_payments(filters):
|
||||
mode_of_payments = {}
|
||||
invoice_list = get_invoices(filters)
|
||||
invoice_list_names = ",".join("'" + invoice["name"] + "'" for invoice in invoice_list)
|
||||
invoice_names = [invoice["name"] for invoice in invoice_list]
|
||||
if invoice_list:
|
||||
inv_mop = frappe.db.sql(
|
||||
f"""select a.owner,a.posting_date, ifnull(b.mode_of_payment, '') as mode_of_payment
|
||||
from `tabSales Invoice` a, `tabSales Invoice Payment` b
|
||||
where a.name = b.parent
|
||||
and a.docstatus = 1
|
||||
and a.name in ({invoice_list_names})
|
||||
union
|
||||
select a.owner,a.posting_date, ifnull(b.mode_of_payment, '') as mode_of_payment
|
||||
from `tabSales Invoice` a, `tabPayment Entry` b,`tabPayment Entry Reference` c
|
||||
where a.name = c.reference_name
|
||||
and b.name = c.parent
|
||||
and b.docstatus = 1
|
||||
and a.name in ({invoice_list_names})
|
||||
union
|
||||
select a.owner, a.posting_date,
|
||||
ifnull(a.voucher_type,'') as mode_of_payment
|
||||
from `tabJournal Entry` a, `tabJournal Entry Account` b
|
||||
where a.name = b.parent
|
||||
and a.docstatus = 1
|
||||
and b.reference_type = 'Sales Invoice'
|
||||
and b.reference_name in ({invoice_list_names})
|
||||
""",
|
||||
as_dict=1,
|
||||
# Branch 1: payments recorded directly on the Sales Invoice
|
||||
si1 = frappe.qb.DocType("Sales Invoice")
|
||||
sip = frappe.qb.DocType("Sales Invoice Payment")
|
||||
branch1 = (
|
||||
frappe.qb.from_(si1)
|
||||
.join(sip)
|
||||
.on(si1.name == sip.parent)
|
||||
.select(si1.owner, si1.posting_date, Coalesce(sip.mode_of_payment, "").as_("mode_of_payment"))
|
||||
.where(si1.docstatus == 1)
|
||||
.where(si1.name.isin(invoice_names))
|
||||
)
|
||||
|
||||
# Branch 2: payments via Payment Entry referencing the invoice
|
||||
si2 = frappe.qb.DocType("Sales Invoice")
|
||||
pe = frappe.qb.DocType("Payment Entry")
|
||||
per = frappe.qb.DocType("Payment Entry Reference")
|
||||
branch2 = (
|
||||
frappe.qb.from_(si2)
|
||||
.join(per)
|
||||
.on(si2.name == per.reference_name)
|
||||
.join(pe)
|
||||
.on(pe.name == per.parent)
|
||||
.select(si2.owner, si2.posting_date, Coalesce(pe.mode_of_payment, "").as_("mode_of_payment"))
|
||||
.where(pe.docstatus == 1)
|
||||
.where(si2.name.isin(invoice_names))
|
||||
)
|
||||
|
||||
# Branch 3: payments via Journal Entry referencing the invoice
|
||||
je = frappe.qb.DocType("Journal Entry")
|
||||
jea = frappe.qb.DocType("Journal Entry Account")
|
||||
branch3 = (
|
||||
frappe.qb.from_(je)
|
||||
.join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.owner, je.posting_date, Coalesce(je.voucher_type, "").as_("mode_of_payment"))
|
||||
.where(je.docstatus == 1)
|
||||
.where(jea.reference_type == "Sales Invoice")
|
||||
.where(jea.reference_name.isin(invoice_names))
|
||||
)
|
||||
|
||||
# bare UNION => de-duplicated rows across the three branches
|
||||
inv_mop = (branch1.union(branch2).union(branch3)).run(as_dict=True)
|
||||
for d in inv_mop:
|
||||
mode_of_payments.setdefault(d["owner"] + cstr(d["posting_date"]), []).append(d.mode_of_payment)
|
||||
return mode_of_payments
|
||||
|
||||
|
||||
def get_invoices(filters):
|
||||
conditions = get_conditions(filters)
|
||||
return frappe.db.sql(
|
||||
f"""select a.name
|
||||
from `tabSales Invoice` a
|
||||
where a.docstatus = 1 and {conditions}""",
|
||||
filters,
|
||||
as_dict=1,
|
||||
)
|
||||
a = frappe.qb.DocType("Sales Invoice")
|
||||
query = frappe.qb.from_(a).select(a.name).where(a.docstatus == 1)
|
||||
query = apply_conditions(query, a, filters)
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_mode_of_payment_details(filters):
|
||||
mode_of_payment_details = {}
|
||||
invoice_list = get_invoices(filters)
|
||||
invoice_list_names = ",".join("'" + invoice["name"] + "'" for invoice in invoice_list)
|
||||
invoice_names = [invoice["name"] for invoice in invoice_list]
|
||||
if invoice_list:
|
||||
inv_mop_detail = frappe.db.sql(
|
||||
f"""
|
||||
select t.owner,
|
||||
t.posting_date,
|
||||
t.mode_of_payment,
|
||||
sum(t.paid_amount) as paid_amount
|
||||
from (
|
||||
select a.owner, a.posting_date,
|
||||
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(b.base_amount) as paid_amount
|
||||
from `tabSales Invoice` a, `tabSales Invoice Payment` b
|
||||
where a.name = b.parent
|
||||
and a.docstatus = 1
|
||||
and a.name in ({invoice_list_names})
|
||||
group by a.owner, a.posting_date, mode_of_payment
|
||||
union
|
||||
select a.owner,a.posting_date,
|
||||
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(c.allocated_amount) as paid_amount
|
||||
from `tabSales Invoice` a, `tabPayment Entry` b,`tabPayment Entry Reference` c
|
||||
where a.name = c.reference_name
|
||||
and b.name = c.parent
|
||||
and b.docstatus = 1
|
||||
and a.name in ({invoice_list_names})
|
||||
group by a.owner, a.posting_date, mode_of_payment
|
||||
union
|
||||
select a.owner, a.posting_date,
|
||||
ifnull(a.voucher_type,'') as mode_of_payment, sum(b.credit)
|
||||
from `tabJournal Entry` a, `tabJournal Entry Account` b
|
||||
where a.name = b.parent
|
||||
and a.docstatus = 1
|
||||
and b.reference_type = 'Sales Invoice'
|
||||
and b.reference_name in ({invoice_list_names})
|
||||
group by a.owner, a.posting_date, mode_of_payment
|
||||
) t
|
||||
group by t.owner, t.posting_date, t.mode_of_payment
|
||||
""",
|
||||
as_dict=1,
|
||||
# Branch 1: amounts paid directly on the Sales Invoice
|
||||
si1 = frappe.qb.DocType("Sales Invoice")
|
||||
sip = frappe.qb.DocType("Sales Invoice Payment")
|
||||
mop1 = Coalesce(sip.mode_of_payment, "")
|
||||
branch1 = (
|
||||
frappe.qb.from_(si1)
|
||||
.join(sip)
|
||||
.on(si1.name == sip.parent)
|
||||
.select(
|
||||
si1.owner,
|
||||
si1.posting_date,
|
||||
mop1.as_("mode_of_payment"),
|
||||
Sum(sip.base_amount).as_("paid_amount"),
|
||||
)
|
||||
.where(si1.docstatus == 1)
|
||||
.where(si1.name.isin(invoice_names))
|
||||
.groupby(si1.owner, si1.posting_date, mop1)
|
||||
)
|
||||
|
||||
inv_change_amount = frappe.db.sql(
|
||||
f"""select a.owner, a.posting_date,
|
||||
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(a.base_change_amount) as change_amount
|
||||
from `tabSales Invoice` a, `tabSales Invoice Payment` b
|
||||
where a.name = b.parent
|
||||
and a.name in ({invoice_list_names})
|
||||
and b.type = 'Cash'
|
||||
and a.base_change_amount > 0
|
||||
group by a.owner, a.posting_date, mode_of_payment""",
|
||||
as_dict=1,
|
||||
# Branch 2: amounts allocated via Payment Entry
|
||||
si2 = frappe.qb.DocType("Sales Invoice")
|
||||
pe = frappe.qb.DocType("Payment Entry")
|
||||
per = frappe.qb.DocType("Payment Entry Reference")
|
||||
mop2 = Coalesce(pe.mode_of_payment, "")
|
||||
branch2 = (
|
||||
frappe.qb.from_(si2)
|
||||
.join(per)
|
||||
.on(si2.name == per.reference_name)
|
||||
.join(pe)
|
||||
.on(pe.name == per.parent)
|
||||
.select(
|
||||
si2.owner,
|
||||
si2.posting_date,
|
||||
mop2.as_("mode_of_payment"),
|
||||
Sum(per.allocated_amount).as_("paid_amount"),
|
||||
)
|
||||
.where(pe.docstatus == 1)
|
||||
.where(si2.name.isin(invoice_names))
|
||||
.groupby(si2.owner, si2.posting_date, mop2)
|
||||
)
|
||||
|
||||
# Branch 3: amounts credited via Journal Entry
|
||||
je = frappe.qb.DocType("Journal Entry")
|
||||
jea = frappe.qb.DocType("Journal Entry Account")
|
||||
mop3 = Coalesce(je.voucher_type, "")
|
||||
branch3 = (
|
||||
frappe.qb.from_(je)
|
||||
.join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(
|
||||
je.owner, je.posting_date, mop3.as_("mode_of_payment"), Sum(jea.credit).as_("paid_amount")
|
||||
)
|
||||
.where(je.docstatus == 1)
|
||||
.where(jea.reference_type == "Sales Invoice")
|
||||
.where(jea.reference_name.isin(invoice_names))
|
||||
.groupby(je.owner, je.posting_date, mop3)
|
||||
)
|
||||
|
||||
# bare UNION => de-duplicated rows; wrapped as subquery `t` for the outer re-aggregation
|
||||
t = branch1.union(branch2).union(branch3)
|
||||
inv_mop_detail = (
|
||||
frappe.qb.from_(t)
|
||||
.select(
|
||||
t.owner,
|
||||
t.posting_date,
|
||||
t.mode_of_payment,
|
||||
Sum(t.paid_amount).as_("paid_amount"),
|
||||
)
|
||||
.groupby(t.owner, t.posting_date, t.mode_of_payment)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
# change amount paid back in cash, subtracted from the matching mode-of-payment detail below
|
||||
sic = frappe.qb.DocType("Sales Invoice")
|
||||
sipc = frappe.qb.DocType("Sales Invoice Payment")
|
||||
mopc = Coalesce(sipc.mode_of_payment, "")
|
||||
inv_change_amount = (
|
||||
frappe.qb.from_(sic)
|
||||
.join(sipc)
|
||||
.on(sic.name == sipc.parent)
|
||||
.select(
|
||||
sic.owner,
|
||||
sic.posting_date,
|
||||
mopc.as_("mode_of_payment"),
|
||||
Sum(sic.base_change_amount).as_("change_amount"),
|
||||
)
|
||||
.where(sic.name.isin(invoice_names))
|
||||
.where(sipc.type == "Cash")
|
||||
.where(sic.base_change_amount > 0)
|
||||
.groupby(sic.owner, sic.posting_date, mopc)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
for d in inv_change_amount:
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
from frappe.utils import flt, today
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.report.sales_payment_summary.sales_payment_summary import (
|
||||
get_mode_of_payment_details,
|
||||
get_mode_of_payments,
|
||||
get_pos_invoice_data,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -102,6 +103,33 @@ class TestSalesPaymentSummary(ERPNextTestSuite):
|
||||
|
||||
self.assertGreater(cc_init_amount, cc_final_amount)
|
||||
|
||||
def test_get_pos_invoice_data(self):
|
||||
"""The POS path (is_pos filter -> get_pos_invoice_data) used nested loose-GROUP-BY subqueries
|
||||
that raised on Postgres; it now aggregates deterministically and runs identically on both
|
||||
engines."""
|
||||
si = create_sales_invoice_record()
|
||||
si.is_pos = 1
|
||||
si.append(
|
||||
"payments",
|
||||
{"mode_of_payment": "Cash", "account": "_Test Cash - _TC", "amount": 10000},
|
||||
)
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
filters = frappe._dict(
|
||||
{"is_pos": 1, "company": "_Test Company", "from_date": today(), "to_date": today()}
|
||||
)
|
||||
data = get_pos_invoice_data(filters)
|
||||
|
||||
# the POS invoice's paid amount is aggregated; previously this query raised GroupingError on PG
|
||||
self.assertTrue(data)
|
||||
self.assertTrue(any(flt(row.get("paid_amount")) >= 10000 for row in data))
|
||||
|
||||
# customer filter must work: a.customer was not selected by the invoice subquery before the fix,
|
||||
# so the filter errored on both engines. With the invoice's customer it still returns its payment.
|
||||
filters["customer"] = si.customer
|
||||
self.assertTrue(any(flt(row.get("paid_amount")) >= 10000 for row in get_pos_invoice_data(filters)))
|
||||
|
||||
|
||||
def get_filters():
|
||||
return {"from_date": "1900-01-01", "to_date": today(), "company": "_Test Company"}
|
||||
|
||||
@@ -37,25 +37,22 @@ def validate_disabled_accounts(gl_map):
|
||||
|
||||
|
||||
def validate_accounting_period(gl_map):
|
||||
accounting_periods = frappe.db.sql(
|
||||
""" SELECT
|
||||
ap.name as name, ap.exempted_role as exempted_role
|
||||
FROM
|
||||
`tabAccounting Period` ap, `tabClosed Document` cd
|
||||
WHERE
|
||||
ap.name = cd.parent
|
||||
AND ap.company = %(company)s
|
||||
AND ap.disabled = 0
|
||||
AND cd.closed = 1
|
||||
AND cd.document_type = %(voucher_type)s
|
||||
AND %(date)s between ap.start_date and ap.end_date
|
||||
""",
|
||||
{
|
||||
"date": gl_map[0].posting_date,
|
||||
"company": gl_map[0].company,
|
||||
"voucher_type": gl_map[0].voucher_type,
|
||||
},
|
||||
as_dict=1,
|
||||
ap = frappe.qb.DocType("Accounting Period")
|
||||
cd = frappe.qb.DocType("Closed Document")
|
||||
accounting_periods = (
|
||||
frappe.qb.from_(ap)
|
||||
.inner_join(cd)
|
||||
.on(ap.name == cd.parent)
|
||||
.select(ap.name.as_("name"), ap.exempted_role.as_("exempted_role"))
|
||||
.where(
|
||||
(ap.company == gl_map[0].company)
|
||||
& (ap.disabled == 0)
|
||||
& (cd.closed == 1)
|
||||
& (cd.document_type == gl_map[0].voucher_type)
|
||||
& (ap.start_date <= gl_map[0].posting_date)
|
||||
& (ap.end_date >= gl_map[0].posting_date)
|
||||
)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
if accounting_periods:
|
||||
@@ -81,13 +78,11 @@ def validate_cwip_accounts(gl_map):
|
||||
for ac in frappe.db.get_all("Asset Category", "enable_cwip_accounting")
|
||||
)
|
||||
if cwip_enabled:
|
||||
cwip_accounts = [
|
||||
d[0]
|
||||
for d in frappe.db.sql(
|
||||
"""select name from tabAccount
|
||||
where account_type = 'Capital Work in Progress' and is_group=0"""
|
||||
)
|
||||
]
|
||||
cwip_accounts = frappe.get_all(
|
||||
"Account",
|
||||
filters={"account_type": "Capital Work in Progress", "is_group": 0},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
for entry in gl_map:
|
||||
if entry.account in cwip_accounts:
|
||||
@@ -122,13 +117,24 @@ def check_freezing_date(posting_date, company, adv_adj=False):
|
||||
)
|
||||
|
||||
|
||||
def validate_against_pcv(is_opening, posting_date, company):
|
||||
if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
|
||||
def validate_opening_entry_against_pcv(company):
|
||||
if frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
|
||||
frappe.throw(
|
||||
_("Opening Entry can not be created after Period Closing Voucher is created."),
|
||||
_(
|
||||
"A Period Closing Voucher is already submitted and an Opening Entry can no longer be created. {0} to learn more."
|
||||
).format(
|
||||
'<a href="https://docs.frappe.io/erpnext/period-closing-voucher#14-pcv-and-opening-entries" target="_blank" rel="noopener">'
|
||||
+ _("Read the docs")
|
||||
+ "</a>"
|
||||
),
|
||||
title=_("Invalid Opening Entry"),
|
||||
)
|
||||
|
||||
|
||||
def validate_against_pcv(is_opening, posting_date, company):
|
||||
if is_opening:
|
||||
validate_opening_entry_against_pcv(company)
|
||||
|
||||
last_pcv_date = frappe.db.get_value(
|
||||
"Period Closing Voucher", {"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}]
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ from frappe.desk.reportview import build_match_conditions
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.model.naming import determine_consecutive_week_number
|
||||
from frappe.query_builder import AliasedQuery, Case, Criterion, Field, Table
|
||||
from frappe.query_builder.functions import Count, IfNull, Max, Round, Sum
|
||||
from frappe.query_builder.functions import Count, IfNull, Max, Min, Round, Sum
|
||||
from frappe.query_builder.utils import DocType
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
@@ -411,10 +411,9 @@ def get_count_on(account, fieldname, date):
|
||||
else:
|
||||
dr_or_cr = "debit" if fieldname == "invoiced_amount" else "credit"
|
||||
cr_or_dr = "credit" if fieldname == "invoiced_amount" else "debit"
|
||||
select_fields = (
|
||||
"ifnull(sum(credit-debit),0)"
|
||||
if fieldname == "invoiced_amount"
|
||||
else "ifnull(sum(debit-credit),0)"
|
||||
gl = frappe.qb.DocType("GL Entry")
|
||||
amount_expr = (
|
||||
Sum(gl.credit - gl.debit) if fieldname == "invoiced_amount" else Sum(gl.debit - gl.credit)
|
||||
)
|
||||
|
||||
if (
|
||||
@@ -422,14 +421,21 @@ def get_count_on(account, fieldname, date):
|
||||
or (gle.against_voucher_type in ["Sales Order", "Purchase Order"])
|
||||
or (gle.against_voucher == gle.voucher_no and gle.get(dr_or_cr) > 0)
|
||||
):
|
||||
payment_amount = frappe.db.sql(
|
||||
f"""
|
||||
SELECT {select_fields}
|
||||
FROM `tabGL Entry` gle
|
||||
WHERE docstatus < 2 and posting_date <= %(date)s and against_voucher = %(voucher_no)s
|
||||
and party = %(party)s and name != %(name)s""",
|
||||
{"date": date, "voucher_no": gle.voucher_no, "party": gle.party, "name": gle.name},
|
||||
)[0][0]
|
||||
payment_amount = (
|
||||
(
|
||||
frappe.qb.from_(gl)
|
||||
.select(amount_expr)
|
||||
.where(
|
||||
(gl.docstatus < 2)
|
||||
& (gl.posting_date <= date)
|
||||
& (gl.against_voucher == gle.voucher_no)
|
||||
& (gl.party == gle.party)
|
||||
& (gl.name != gle.name)
|
||||
)
|
||||
.run()[0][0]
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
outstanding_amount = flt(gle.get(dr_or_cr)) - flt(gle.get(cr_or_dr)) - payment_amount
|
||||
currency_precision = get_currency_precision() or 2
|
||||
@@ -1169,26 +1175,27 @@ def get_company_default(company: str, fieldname: str, ignore_validation: bool =
|
||||
|
||||
|
||||
def fix_total_debit_credit():
|
||||
vouchers = frappe.db.sql(
|
||||
"""select voucher_type, voucher_no,
|
||||
sum(debit) - sum(credit) as diff
|
||||
from `tabGL Entry`
|
||||
group by voucher_type, voucher_no
|
||||
having sum(debit) != sum(credit)""",
|
||||
as_dict=1,
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
vouchers = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(gle.voucher_type, gle.voucher_no, (Sum(gle.debit) - Sum(gle.credit)).as_("diff"))
|
||||
.groupby(gle.voucher_type, gle.voucher_no)
|
||||
.having(Sum(gle.debit) != Sum(gle.credit))
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
for d in vouchers:
|
||||
if abs(d.diff) > 0:
|
||||
dr_or_cr = d.voucher_type == "Sales Invoice" and "credit" or "debit"
|
||||
|
||||
frappe.db.sql(
|
||||
"""update `tabGL Entry` set {} = {} + {}
|
||||
where voucher_type = {} and voucher_no = {} and {} > 0 limit 1""".format(
|
||||
dr_or_cr, dr_or_cr, "%s", "%s", "%s", dr_or_cr
|
||||
),
|
||||
(d.diff, d.voucher_type, d.voucher_no),
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
name = frappe.db.get_value(
|
||||
"GL Entry",
|
||||
{"voucher_type": d.voucher_type, "voucher_no": d.voucher_no, dr_or_cr: [">", 0]},
|
||||
"name",
|
||||
)
|
||||
if name:
|
||||
frappe.qb.update(gle).set(gle[dr_or_cr], gle[dr_or_cr] + d.diff).where(gle.name == name).run()
|
||||
|
||||
|
||||
def get_currency_precision():
|
||||
@@ -1230,11 +1237,12 @@ def get_held_invoices(party_type, party):
|
||||
held_invoices = None
|
||||
|
||||
if party_type == "Supplier":
|
||||
held_invoices = frappe.db.sql(
|
||||
"select name from `tabPurchase Invoice` where on_hold = 1 and release_date IS NOT NULL and release_date > CURDATE()",
|
||||
as_dict=1,
|
||||
held_invoices = frappe.get_all(
|
||||
"Purchase Invoice",
|
||||
filters={"on_hold": 1, "release_date": [">", nowdate()]},
|
||||
pluck="name",
|
||||
)
|
||||
held_invoices = set(d["name"] for d in held_invoices)
|
||||
held_invoices = set(held_invoices)
|
||||
|
||||
return held_invoices
|
||||
|
||||
@@ -1742,13 +1750,15 @@ def sort_stock_vouchers_by_posting_date(
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
voucher_nos = [v[1] for v in stock_vouchers]
|
||||
|
||||
# only voucher_type/voucher_no are used downstream; order by Min() of the (per-voucher constant)
|
||||
# posting_datetime so postgres accepts the GROUP BY without selecting non-aggregated columns
|
||||
sles = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(sle.voucher_type, sle.voucher_no, sle.posting_date, sle.posting_time, sle.creation)
|
||||
.select(sle.voucher_type, sle.voucher_no)
|
||||
.where((sle.is_cancelled == 0) & (sle.voucher_no.isin(voucher_nos)))
|
||||
.groupby(sle.voucher_type, sle.voucher_no)
|
||||
.orderby(sle.posting_datetime)
|
||||
.orderby(sle.creation)
|
||||
.orderby(Min(sle.posting_datetime))
|
||||
.orderby(Min(sle.creation))
|
||||
)
|
||||
|
||||
if company:
|
||||
@@ -1769,25 +1779,37 @@ def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, f
|
||||
|
||||
SLE = DocType("Stock Ledger Entry")
|
||||
|
||||
conditions = (SLE.posting_datetime >= posting_datetime) & (SLE.is_cancelled == 0)
|
||||
if for_items:
|
||||
conditions &= SLE.item_code.isin(for_items)
|
||||
if for_warehouses:
|
||||
conditions &= SLE.warehouse.isin(for_warehouses)
|
||||
if company:
|
||||
conditions &= SLE.company == company
|
||||
|
||||
# These SLE rows must stay locked for the duration of the repost so a concurrent stock
|
||||
# transaction can't modify them mid-flight (the original DISTINCT ... FOR UPDATE did this).
|
||||
# MariaDB carries the lock on the grouped query below; postgres rejects FOR UPDATE alongside
|
||||
# GROUP BY, so lock the matching rows in a separate pass first -- the row locks are held until
|
||||
# the surrounding transaction ends, giving the same protection.
|
||||
if frappe.db.db_type == "postgres":
|
||||
frappe.qb.from_(SLE).select(SLE.name).where(conditions).for_update().run()
|
||||
|
||||
# distinct vouchers in chronological order; expressed as GROUP BY + Min() so it's valid on
|
||||
# postgres (SELECT DISTINCT can't ORDER BY non-selected cols, and FOR UPDATE is invalid with both).
|
||||
# posting_datetime is constant per voucher, so the ordering is unchanged vs the DISTINCT form.
|
||||
query = (
|
||||
frappe.qb.from_(SLE)
|
||||
.select(SLE.voucher_type, SLE.voucher_no)
|
||||
.distinct()
|
||||
.where(SLE.posting_datetime >= posting_datetime)
|
||||
.where(SLE.is_cancelled == 0)
|
||||
.orderby(SLE.posting_datetime)
|
||||
.orderby(SLE.creation)
|
||||
.for_update()
|
||||
.where(conditions)
|
||||
.groupby(SLE.voucher_type, SLE.voucher_no)
|
||||
.orderby(Min(SLE.posting_datetime))
|
||||
.orderby(Min(SLE.creation))
|
||||
)
|
||||
|
||||
if for_items:
|
||||
query = query.where(SLE.item_code.isin(for_items))
|
||||
|
||||
if for_warehouses:
|
||||
query = query.where(SLE.warehouse.isin(for_warehouses))
|
||||
|
||||
if company:
|
||||
query = query.where(SLE.company == company)
|
||||
# lock scanned rows on MariaDB; on postgres they were already locked above
|
||||
if frappe.db.db_type != "postgres":
|
||||
query = query.for_update()
|
||||
|
||||
future_stock_vouchers = query.run(as_dict=True)
|
||||
|
||||
@@ -1809,14 +1831,11 @@ def get_voucherwise_gl_entries(future_stock_vouchers, posting_date):
|
||||
|
||||
voucher_nos = [d[1] for d in future_stock_vouchers]
|
||||
|
||||
gles = frappe.db.sql(
|
||||
"""
|
||||
select name, account, credit, debit, cost_center, project, voucher_type, voucher_no
|
||||
from `tabGL Entry`
|
||||
where
|
||||
posting_date >= {} and voucher_no in ({})""".format("%s", ", ".join(["%s"] * len(voucher_nos))),
|
||||
tuple([posting_date, *voucher_nos]),
|
||||
as_dict=1,
|
||||
gles = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"posting_date": [">=", posting_date], "voucher_no": ["in", voucher_nos]},
|
||||
fields=["name", "account", "credit", "debit", "cost_center", "project", "voucher_type", "voucher_no"],
|
||||
limit=0,
|
||||
)
|
||||
|
||||
for d in gles:
|
||||
@@ -2235,7 +2254,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)
|
||||
.set(ple.delinked, 1) # smallint column; postgres rejects boolean true
|
||||
.where(
|
||||
(ple.company == pl_entry.company)
|
||||
& (ple.account_type == pl_entry.account_type)
|
||||
@@ -2350,8 +2369,10 @@ class QueryPaymentLedger:
|
||||
.where(Criterion.all(self.dimensions_filter))
|
||||
.where(Criterion.all(self.voucher_posting_date))
|
||||
.groupby(ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
|
||||
.orderby(ple.invoice_date, ple.voucher_no)
|
||||
.having(qb.Field("amount_in_account_currency") > 0)
|
||||
# order by the select aliases (postgres can't ORDER BY a non-existent ple column)
|
||||
.orderby(qb.Field("invoice_date"), qb.Field("voucher_no"))
|
||||
# postgres HAVING can't reference a select alias; use the aggregate expression
|
||||
.having(Sum(ple.amount_in_account_currency) > 0)
|
||||
.limit(self.limit)
|
||||
.run()
|
||||
)
|
||||
@@ -2365,18 +2386,21 @@ class QueryPaymentLedger:
|
||||
query_voucher_amount = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
ple.account,
|
||||
# columns that are constant per (voucher_type, voucher_no, party_type, party) are
|
||||
# wrapped in Max() so the query is valid on postgres (which, unlike MariaDB, requires
|
||||
# every non-aggregated column to be grouped or aggregated)
|
||||
Max(ple.account).as_("account"),
|
||||
ple.voucher_type,
|
||||
ple.voucher_no,
|
||||
ple.party_type,
|
||||
ple.party,
|
||||
ple.posting_date,
|
||||
ple.due_date,
|
||||
ple.account_currency.as_("currency"),
|
||||
ple.cost_center.as_("cost_center"),
|
||||
Max(ple.posting_date).as_("posting_date"),
|
||||
Max(ple.due_date).as_("due_date"),
|
||||
Max(ple.account_currency).as_("currency"),
|
||||
Max(ple.cost_center).as_("cost_center"),
|
||||
Sum(ple.amount).as_("amount"),
|
||||
Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
|
||||
ple.remarks,
|
||||
Max(ple.remarks).as_("remarks"),
|
||||
)
|
||||
.where(ple.delinked == 0)
|
||||
.where(Criterion.all(filter_on_voucher_no))
|
||||
@@ -2390,14 +2414,15 @@ class QueryPaymentLedger:
|
||||
query_voucher_outstanding = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
ple.account,
|
||||
# Max() on columns constant per group keeps this valid on postgres (see above)
|
||||
Max(ple.account).as_("account"),
|
||||
ple.against_voucher_type.as_("voucher_type"),
|
||||
ple.against_voucher_no.as_("voucher_no"),
|
||||
ple.party_type,
|
||||
ple.party,
|
||||
ple.posting_date,
|
||||
ple.due_date,
|
||||
ple.account_currency.as_("currency"),
|
||||
Max(ple.posting_date).as_("posting_date"),
|
||||
Max(ple.due_date).as_("due_date"),
|
||||
Max(ple.account_currency).as_("currency"),
|
||||
Sum(ple.amount).as_("amount"),
|
||||
Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
|
||||
)
|
||||
@@ -2446,17 +2471,19 @@ class QueryPaymentLedger:
|
||||
|
||||
# build CTE filter
|
||||
# only fetch invoices
|
||||
# The combined CTE query has no GROUP BY, so these are row filters. MariaDB tolerates HAVING
|
||||
# on a select alias here, but postgres does not; express them as WHERE on the source column.
|
||||
if self.get_invoices:
|
||||
self.cte_query_voucher_amount_and_outstanding = (
|
||||
self.cte_query_voucher_amount_and_outstanding.having(
|
||||
qb.Field("outstanding_in_account_currency") > 0
|
||||
self.cte_query_voucher_amount_and_outstanding.where(
|
||||
Table("outstanding").amount_in_account_currency > 0
|
||||
)
|
||||
)
|
||||
# only fetch payments
|
||||
elif self.get_payments:
|
||||
self.cte_query_voucher_amount_and_outstanding = (
|
||||
self.cte_query_voucher_amount_and_outstanding.having(
|
||||
qb.Field("outstanding_in_account_currency") < 0
|
||||
self.cte_query_voucher_amount_and_outstanding.where(
|
||||
Table("outstanding").amount_in_account_currency < 0
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -735,12 +735,16 @@ class Asset(AccountsController):
|
||||
frappe.throw(_("Asset cannot be cancelled, as it is already {0}").format(self.status))
|
||||
|
||||
def cancel_movement_entries(self):
|
||||
movements = frappe.db.sql(
|
||||
"""SELECT asm.name, asm.docstatus
|
||||
FROM `tabAsset Movement` asm, `tabAsset Movement Item` asm_item
|
||||
WHERE asm_item.parent=asm.name and asm_item.asset=%s and asm.docstatus=1""",
|
||||
self.name,
|
||||
as_dict=1,
|
||||
# filter the parent Asset Movement's docstatus (as the original SQL did), not the child row's
|
||||
asm = frappe.qb.DocType("Asset Movement")
|
||||
asm_item = frappe.qb.DocType("Asset Movement Item")
|
||||
movements = (
|
||||
frappe.qb.from_(asm_item)
|
||||
.inner_join(asm)
|
||||
.on(asm_item.parent == asm.name)
|
||||
.select(asm.name)
|
||||
.where((asm_item.asset == self.name) & (asm.docstatus == 1))
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
for movement in movements:
|
||||
@@ -860,15 +864,18 @@ class Asset(AccountsController):
|
||||
cwip_enabled = is_cwip_accounting_enabled(self.asset_category)
|
||||
cwip_account = self.get_cwip_account(cwip_enabled=cwip_enabled)
|
||||
|
||||
query = """SELECT name FROM `tabGL Entry` WHERE voucher_no = %s and account = %s"""
|
||||
if asset_bought_with_invoice:
|
||||
# with invoice purchase either expense or cwip has been booked
|
||||
expense_booked = frappe.db.sql(query, (purchase_document, fixed_asset_account), as_dict=1)
|
||||
expense_booked = frappe.db.exists(
|
||||
"GL Entry", {"voucher_no": purchase_document, "account": fixed_asset_account}
|
||||
)
|
||||
if expense_booked:
|
||||
# if expense is already booked from invoice then do not make gl entries regardless of cwip enabled/disabled
|
||||
return False
|
||||
|
||||
cwip_booked = frappe.db.sql(query, (purchase_document, cwip_account), as_dict=1)
|
||||
cwip_booked = frappe.db.exists(
|
||||
"GL Entry", {"voucher_no": purchase_document, "account": cwip_account}
|
||||
)
|
||||
if cwip_booked:
|
||||
# if cwip is booked from invoice then make gl entries regardless of cwip enabled/disabled
|
||||
return True
|
||||
@@ -878,10 +885,11 @@ class Asset(AccountsController):
|
||||
# if cwip account isn't available do not make gl entries
|
||||
return False
|
||||
|
||||
cwip_booked = frappe.db.sql(query, (purchase_document, cwip_account), as_dict=1)
|
||||
# if cwip is not booked from receipt then do not make gl entries
|
||||
# if cwip is booked from receipt then make gl entries
|
||||
return cwip_booked
|
||||
return bool(
|
||||
frappe.db.exists("GL Entry", {"voucher_no": purchase_document, "account": cwip_account})
|
||||
)
|
||||
|
||||
def get_purchase_document(self):
|
||||
asset_bought_with_invoice = self.purchase_invoice and frappe.db.get_value(
|
||||
@@ -1074,11 +1082,15 @@ def make_post_gl_entry():
|
||||
|
||||
for asset_category in asset_categories:
|
||||
if cint(asset_category.enable_cwip_accounting):
|
||||
assets = frappe.db.sql_list(
|
||||
""" select name from `tabAsset`
|
||||
where asset_category = %s and ifnull(booked_fixed_asset, 0) = 0
|
||||
and available_for_use_date = %s and docstatus = 1""",
|
||||
(asset_category.name, nowdate()),
|
||||
assets = frappe.get_all(
|
||||
"Asset",
|
||||
filters={
|
||||
"asset_category": asset_category.name,
|
||||
"booked_fixed_asset": 0,
|
||||
"available_for_use_date": nowdate(),
|
||||
"docstatus": 1,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
for asset in assets:
|
||||
|
||||
@@ -79,11 +79,14 @@ def assign_tasks(asset_maintenance_name, assign_to_member, maintenance_task, nex
|
||||
"description": maintenance_task,
|
||||
"date": next_due_date,
|
||||
}
|
||||
if not frappe.db.sql(
|
||||
"""select owner from `tabToDo`
|
||||
where reference_type=%(doctype)s and reference_name=%(name)s and status='Open'
|
||||
and owner=%(assign_to)s""",
|
||||
args,
|
||||
if not frappe.db.exists(
|
||||
"ToDo",
|
||||
{
|
||||
"reference_type": args["doctype"],
|
||||
"reference_name": args["name"],
|
||||
"status": "Open",
|
||||
"owner": args["assign_to"],
|
||||
},
|
||||
):
|
||||
# assign_to function expects a list
|
||||
args["assign_to"] = [args["assign_to"]]
|
||||
@@ -187,13 +190,9 @@ def get_team_members(
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_maintenance_log(asset_name: str):
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select maintenance_status, count(asset_name) as count, asset_name
|
||||
from `tabAsset Maintenance Log`
|
||||
where asset_name=%s
|
||||
group by maintenance_status
|
||||
""",
|
||||
(asset_name,),
|
||||
as_dict=1,
|
||||
return frappe.get_all(
|
||||
"Asset Maintenance Log",
|
||||
filters={"asset_name": asset_name},
|
||||
fields=["maintenance_status", {"COUNT": "asset_name", "as": "count"}, "asset_name"],
|
||||
group_by="maintenance_status, asset_name",
|
||||
)
|
||||
|
||||
@@ -18,6 +18,36 @@ class TestAssetMaintenance(ERPNextTestSuite):
|
||||
self.asset_name = frappe.db.get_value("Asset", {"purchase_receipt": self.pr.name}, "name")
|
||||
self.asset_doc = frappe.get_doc("Asset", self.asset_name)
|
||||
|
||||
def test_get_maintenance_log_counts_by_status(self):
|
||||
"""get_maintenance_log uses a v16 dict aggregate field spec
|
||||
({"COUNT": "asset_name", "as": "count"}); confirm it runs and returns correct per-status counts
|
||||
on both engines (the whitelisted endpoint was previously untested)."""
|
||||
from erpnext.assets.doctype.asset_maintenance.asset_maintenance import get_maintenance_log
|
||||
|
||||
self.asset_doc.available_for_use_date = nowdate()
|
||||
self.asset_doc.purchase_date = nowdate()
|
||||
self.asset_doc.save()
|
||||
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Asset Maintenance",
|
||||
"asset_name": self.asset_name,
|
||||
"maintenance_team": "Team Awesome",
|
||||
"company": "_Test Company",
|
||||
"asset_maintenance_tasks": get_maintenance_tasks(),
|
||||
}
|
||||
).insert()
|
||||
|
||||
rows = get_maintenance_log(self.asset_name)
|
||||
# the dict aggregate spec did not crash and returned grouped rows...
|
||||
self.assertTrue(rows)
|
||||
self.assertTrue(all("maintenance_status" in r for r in rows))
|
||||
# ...and the per-status counts sum to the total number of logs for this asset
|
||||
self.assertEqual(
|
||||
sum(r["count"] for r in rows),
|
||||
frappe.db.count("Asset Maintenance Log", {"asset_name": self.asset_name}),
|
||||
)
|
||||
|
||||
def test_create_asset_maintenance_with_log(self):
|
||||
month_end_date = get_last_day(nowdate())
|
||||
|
||||
|
||||
@@ -127,24 +127,20 @@ class AssetMovement(Document):
|
||||
|
||||
def get_latest_location_and_custodian(self, asset):
|
||||
current_location, current_employee = "", ""
|
||||
cond = "1=1"
|
||||
|
||||
# latest entry corresponds to current document's location, employee when transaction date > previous dates
|
||||
# In case of cancellation it corresponds to previous latest document's location, employee
|
||||
args = {"asset": asset, "company": self.company}
|
||||
latest_movement_entry = frappe.db.sql(
|
||||
f"""
|
||||
SELECT asm_item.target_location, asm_item.to_employee
|
||||
FROM `tabAsset Movement Item` asm_item
|
||||
JOIN `tabAsset Movement` asm ON asm_item.parent = asm.name
|
||||
WHERE
|
||||
asm_item.asset = %(asset)s AND
|
||||
asm.company = %(company)s AND
|
||||
asm.docstatus = 1 AND {cond}
|
||||
ORDER BY asm.transaction_date DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
args,
|
||||
asm = frappe.qb.DocType("Asset Movement")
|
||||
asm_item = frappe.qb.DocType("Asset Movement Item")
|
||||
latest_movement_entry = (
|
||||
frappe.qb.from_(asm_item)
|
||||
.inner_join(asm)
|
||||
.on(asm_item.parent == asm.name)
|
||||
.select(asm_item.target_location, asm_item.to_employee)
|
||||
.where((asm_item.asset == asset) & (asm.company == self.company) & (asm.docstatus == 1))
|
||||
.orderby(asm.transaction_date, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
.run()
|
||||
)
|
||||
|
||||
if latest_movement_entry:
|
||||
|
||||
@@ -215,17 +215,12 @@ def get_children(doctype: str, parent: str | None = None, location: str | None =
|
||||
if parent is None or parent == "All Locations":
|
||||
parent = ""
|
||||
|
||||
return frappe.db.sql(
|
||||
f"""
|
||||
select
|
||||
name as value,
|
||||
is_group as expandable
|
||||
from
|
||||
`tabLocation` comp
|
||||
where
|
||||
ifnull(parent_location, "")={frappe.db.escape(parent)}
|
||||
""",
|
||||
as_dict=1,
|
||||
filters = {"parent_location": parent} if parent else {"parent_location": ["is", "not set"]}
|
||||
|
||||
return frappe.get_all(
|
||||
"Location",
|
||||
filters=filters,
|
||||
fields=["name as value", "is_group as expandable"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -395,32 +395,30 @@ def get_group_by_data(
|
||||
|
||||
|
||||
def get_purchase_receipt_supplier_map():
|
||||
pr = frappe.qb.DocType("Purchase Receipt")
|
||||
pri = frappe.qb.DocType("Purchase Receipt Item")
|
||||
return frappe._dict(
|
||||
frappe.db.sql(
|
||||
""" Select
|
||||
pr.name, pr.supplier
|
||||
FROM `tabPurchase Receipt` pr, `tabPurchase Receipt Item` pri
|
||||
WHERE
|
||||
pri.parent = pr.name
|
||||
AND pri.is_fixed_asset=1
|
||||
AND pr.docstatus=1
|
||||
AND pr.is_return=0"""
|
||||
)
|
||||
frappe.qb.from_(pr)
|
||||
.inner_join(pri)
|
||||
.on(pri.parent == pr.name)
|
||||
.select(pr.name, pr.supplier)
|
||||
.distinct()
|
||||
.where((pri.is_fixed_asset == 1) & (pr.docstatus == 1) & (pr.is_return == 0))
|
||||
.run()
|
||||
)
|
||||
|
||||
|
||||
def get_purchase_invoice_supplier_map():
|
||||
pi = frappe.qb.DocType("Purchase Invoice")
|
||||
pii = frappe.qb.DocType("Purchase Invoice Item")
|
||||
return frappe._dict(
|
||||
frappe.db.sql(
|
||||
""" Select
|
||||
pi.name, pi.supplier
|
||||
FROM `tabPurchase Invoice` pi, `tabPurchase Invoice Item` pii
|
||||
WHERE
|
||||
pii.parent = pi.name
|
||||
AND pii.is_fixed_asset=1
|
||||
AND pi.docstatus=1
|
||||
AND pi.is_return=0"""
|
||||
)
|
||||
frappe.qb.from_(pi)
|
||||
.inner_join(pii)
|
||||
.on(pii.parent == pi.name)
|
||||
.select(pi.name, pi.supplier)
|
||||
.distinct()
|
||||
.where((pii.is_fixed_asset == 1) & (pi.docstatus == 1) & (pi.is_return == 0))
|
||||
.run()
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.assets.doctype.asset.test_asset import AssetSetup, create_asset
|
||||
from erpnext.assets.report.fixed_asset_register.fixed_asset_register import execute
|
||||
|
||||
|
||||
class TestFixedAssetRegister(AssetSetup):
|
||||
def test_report_lists_submitted_asset(self):
|
||||
"""Exercises the report's converted queries -- including the depreciation aggregate that groups
|
||||
by asset.name (must be valid on Postgres) -- by asserting a submitted asset is listed."""
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro",
|
||||
purchase_date="2020-01-01",
|
||||
available_for_use_date="2020-06-06",
|
||||
location="Test Location",
|
||||
submit=1,
|
||||
)
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"status": "In Location",
|
||||
"filter_based_on": "Date Range",
|
||||
"from_date": "2020-01-01",
|
||||
"to_date": "2030-12-31",
|
||||
"date_based_on": "Purchase Date",
|
||||
}
|
||||
)
|
||||
data = execute(filters)[1]
|
||||
asset_ids = {row.get("asset_id") for row in data}
|
||||
self.assertIn(asset.name, asset_ids)
|
||||
@@ -43,6 +43,7 @@ def make_supplier_quotation_from_rfq(
|
||||
"name": "request_for_quotation_item",
|
||||
"parent": "request_for_quotation",
|
||||
"project_name": "project",
|
||||
"cost_center": "cost_center",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -110,6 +111,7 @@ def create_rfq_items(sq_doc, supplier, data):
|
||||
"material_request_item",
|
||||
"stock_qty",
|
||||
"uom",
|
||||
"cost_center",
|
||||
]:
|
||||
args[field] = data.get(field)
|
||||
|
||||
@@ -176,6 +178,7 @@ def get_item_from_material_requests_based_on_supplier(
|
||||
["name", "material_request_item"],
|
||||
["parent", "material_request"],
|
||||
["uom", "uom"],
|
||||
["cost_center", "cost_center"],
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ from erpnext.controllers.accounts_controller import InvalidQtyError
|
||||
from erpnext.crm.doctype.opportunity.mapper import make_request_for_quotation as make_rfq
|
||||
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
|
||||
from erpnext.templates.pages.rfq import check_supplier_has_docname_access
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -250,6 +251,41 @@ class TestRequestforQuotation(ERPNextTestSuite):
|
||||
self.assertEqual(sq.items[0].qty, 0)
|
||||
self.assertEqual(sq.items[0].item_code, rfq.items[0].item_code)
|
||||
|
||||
def test_cost_center_flows_from_mr_to_rfq(self):
|
||||
from erpnext.stock.doctype.material_request.mapper import (
|
||||
make_request_for_quotation as mr_make_rfq,
|
||||
)
|
||||
|
||||
mr = make_material_request(cost_center="_Test Cost Center - _TC")
|
||||
rfq = mr_make_rfq(mr.name)
|
||||
|
||||
self.assertEqual(rfq.items[0].cost_center, "_Test Cost Center - _TC")
|
||||
|
||||
def test_cost_center_flows_from_rfq_to_supplier_quotation(self):
|
||||
rfq = make_request_for_quotation(do_not_submit=True)
|
||||
rfq.items[0].cost_center = "_Test Cost Center - _TC"
|
||||
rfq.save()
|
||||
rfq.submit()
|
||||
|
||||
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.get("suppliers")[0].supplier)
|
||||
|
||||
self.assertEqual(sq.items[0].cost_center, "_Test Cost Center - _TC")
|
||||
|
||||
def test_cost_center_flows_end_to_end_mr_rfq_sq(self):
|
||||
from erpnext.stock.doctype.material_request.mapper import (
|
||||
make_request_for_quotation as mr_make_rfq,
|
||||
)
|
||||
|
||||
mr = make_material_request(cost_center="_Test Cost Center - _TC")
|
||||
rfq = mr_make_rfq(mr.name)
|
||||
rfq.append("suppliers", {"supplier": "_Test Supplier", "supplier_name": "_Test Supplier"})
|
||||
rfq.insert()
|
||||
rfq.submit()
|
||||
|
||||
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier="_Test Supplier")
|
||||
|
||||
self.assertEqual(sq.items[0].cost_center, "_Test Cost Center - _TC")
|
||||
|
||||
|
||||
def make_request_for_quotation(**args):
|
||||
"""
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
"col_break4",
|
||||
"material_request",
|
||||
"material_request_item",
|
||||
"section_break_24",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
"dimension_col_break",
|
||||
"project_name",
|
||||
"section_break_23",
|
||||
"page_break"
|
||||
@@ -253,15 +255,26 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "section_break_24",
|
||||
"fieldname": "accounting_dimensions_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Accounting Dimensions"
|
||||
},
|
||||
{
|
||||
"fieldname": "cost_center",
|
||||
"fieldtype": "Link",
|
||||
"label": "Cost Center",
|
||||
"options": "Cost Center",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "dimension_col_break",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-31 19:46:27.884592",
|
||||
"modified": "2026-06-15 00:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Request for Quotation Item",
|
||||
|
||||
@@ -413,39 +413,29 @@ class BuyingController(SubcontractingController):
|
||||
stock_and_asset_items = []
|
||||
stock_and_asset_items = self.get_stock_items() + self.get_asset_items()
|
||||
|
||||
stock_and_asset_items_qty, stock_and_asset_items_amount = 0, 0
|
||||
last_item_idx = 1
|
||||
for d in self.get("items"):
|
||||
if d.item_code:
|
||||
stock_and_asset_items_qty += flt(d.qty)
|
||||
stock_and_asset_items_amount += flt(d.base_net_amount)
|
||||
(
|
||||
tax_accounts,
|
||||
total_valuation_amount,
|
||||
total_actual_tax_amount,
|
||||
total_actual_tax_on_stock_items,
|
||||
) = self.get_tax_details()
|
||||
|
||||
last_item_idx = d.idx
|
||||
# Pre-compute each item's share of the "Actual" valuation charges (keyed by row object).
|
||||
actual_charge_per_item = self.distribute_actual_tax_amount(
|
||||
stock_and_asset_items, total_actual_tax_amount, total_actual_tax_on_stock_items
|
||||
)
|
||||
|
||||
tax_accounts, total_valuation_amount, total_actual_tax_amount = self.get_tax_details()
|
||||
remaining_amount = total_actual_tax_amount
|
||||
last_item_idx = max((d.idx for d in self.get("items")), default=1)
|
||||
|
||||
for i, item in enumerate(self.get("items")):
|
||||
if item.item_code and (item.qty or item.get("rejected_qty")):
|
||||
item_tax_amount, actual_tax_amount = 0.0, 0.0
|
||||
if i == (last_item_idx - 1):
|
||||
# dump any rounding remainder of the On Net Total valuation on the last item
|
||||
item_tax_amount = total_valuation_amount
|
||||
actual_tax_amount = remaining_amount
|
||||
else:
|
||||
# calculate item tax amount
|
||||
item_tax_amount = self.get_item_tax_amount(item, tax_accounts)
|
||||
total_valuation_amount -= item_tax_amount
|
||||
|
||||
if total_actual_tax_amount:
|
||||
actual_tax_amount = self.get_item_actual_tax_amount(
|
||||
item,
|
||||
total_actual_tax_amount,
|
||||
stock_and_asset_items_amount,
|
||||
stock_and_asset_items_qty,
|
||||
)
|
||||
|
||||
remaining_amount -= actual_tax_amount
|
||||
|
||||
# This code is required here to calculate the correct valuation for stock items
|
||||
if item.item_code not in stock_and_asset_items:
|
||||
item.valuation_rate = 0.0
|
||||
@@ -453,7 +443,8 @@ class BuyingController(SubcontractingController):
|
||||
|
||||
# Item tax amount is the total tax amount applied on that item and actual tax type amount
|
||||
item.item_tax_amount = flt(
|
||||
item_tax_amount + actual_tax_amount, self.precision("item_tax_amount", item)
|
||||
item_tax_amount + actual_charge_per_item.get(item.idx, 0.0),
|
||||
self.precision("item_tax_amount", item),
|
||||
)
|
||||
|
||||
self.round_floats_in(item)
|
||||
@@ -494,6 +485,7 @@ class BuyingController(SubcontractingController):
|
||||
tax_accounts = []
|
||||
total_valuation_amount = 0.0
|
||||
total_actual_tax_amount = 0.0
|
||||
total_actual_tax_on_stock_items = 0.0
|
||||
|
||||
for d in self.get("taxes"):
|
||||
if d.category not in ["Valuation", "Valuation and Total"]:
|
||||
@@ -506,10 +498,13 @@ class BuyingController(SubcontractingController):
|
||||
if d.charge_type == "On Net Total":
|
||||
total_valuation_amount += amount
|
||||
tax_accounts.append(d.account_head)
|
||||
elif d.charge_type == "Actual" and d.get("allocate_full_amount_to_stock_items"):
|
||||
# Allocate the full amount to stock/asset items only (e.g. Freight)
|
||||
total_actual_tax_on_stock_items += amount
|
||||
else:
|
||||
total_actual_tax_amount += amount
|
||||
|
||||
return tax_accounts, total_valuation_amount, total_actual_tax_amount
|
||||
return tax_accounts, total_valuation_amount, total_actual_tax_amount, total_actual_tax_on_stock_items
|
||||
|
||||
def get_item_tax_amount(self, item, tax_accounts):
|
||||
item_tax_amount = 0.0
|
||||
@@ -530,16 +525,75 @@ class BuyingController(SubcontractingController):
|
||||
|
||||
return item_tax_amount
|
||||
|
||||
def get_item_actual_tax_amount(
|
||||
self, item, actual_tax_amount, stock_and_asset_items_amount, stock_and_asset_items_qty
|
||||
):
|
||||
item_proportion = (
|
||||
flt(item.base_net_amount) / stock_and_asset_items_amount
|
||||
if stock_and_asset_items_amount
|
||||
else flt(item.qty) / stock_and_asset_items_qty
|
||||
def distribute_actual_tax_amount(self, stock_and_asset_items, total_on_all_items, total_on_stock_items):
|
||||
"""Distribute "Actual" valuation charges to each item, keyed by row idx.
|
||||
|
||||
`total_on_all_items` is spread across every item by net amount; a non-stock item's
|
||||
share is computed but never capitalized (e.g. a genuine tax). `total_on_stock_items`
|
||||
(flagged `allocate_full_amount_to_stock_items`) is spread across stock/asset items only,
|
||||
so the whole charge is capitalized (e.g. Freight).
|
||||
"""
|
||||
all_items = [d for d in self.get("items") if d.item_code]
|
||||
stock_items = [d for d in all_items if d.item_code in stock_and_asset_items]
|
||||
|
||||
charge_per_item = {}
|
||||
self._spread_charge_over_items(charge_per_item, total_on_all_items, all_items)
|
||||
self._spread_charge_over_items(charge_per_item, total_on_stock_items, stock_items)
|
||||
return charge_per_item
|
||||
|
||||
def _spread_charge_over_items(self, charge_per_item, total_charge, items):
|
||||
"""Add each item's proportional share of `total_charge` into `charge_per_item`.
|
||||
Proportion is by net amount (falling back to qty); any rounding remainder is assigned
|
||||
to the last item in the group."""
|
||||
if not total_charge or not items:
|
||||
return
|
||||
|
||||
total_amount = sum(flt(d.base_net_amount) for d in items)
|
||||
total_qty = sum(flt(d.qty) for d in items)
|
||||
|
||||
# Nothing to proportion against (all rows have zero amount and zero qty)
|
||||
if not total_amount and not total_qty:
|
||||
return
|
||||
|
||||
remaining = total_charge
|
||||
for d in items[:-1]:
|
||||
proportion = flt(d.base_net_amount) / total_amount if total_amount else flt(d.qty) / total_qty
|
||||
charge = flt(proportion * total_charge, self.precision("item_tax_amount", d))
|
||||
charge_per_item[d.idx] = charge_per_item.get(d.idx, 0.0) + charge
|
||||
remaining -= charge
|
||||
|
||||
last = items[-1]
|
||||
charge_per_item[last.idx] = charge_per_item.get(last.idx, 0.0) + flt(
|
||||
remaining, self.precision("item_tax_amount", last)
|
||||
)
|
||||
|
||||
return flt(item_proportion * actual_tax_amount, self.precision("item_tax_amount", item))
|
||||
def get_capitalized_valuation_tax(self):
|
||||
stock_and_asset_items = self.get_stock_items() + self.get_asset_items()
|
||||
all_items = [d for d in self.get("items") if d.item_code]
|
||||
stock_item_idx = {d.idx for d in all_items if d.item_code in stock_and_asset_items}
|
||||
|
||||
capitalized = {}
|
||||
for tax in self.get("taxes"):
|
||||
if tax.category not in ("Valuation", "Valuation and Total"):
|
||||
continue
|
||||
|
||||
amount = flt(tax.base_tax_amount_after_discount_amount) * (
|
||||
-1 if tax.get("add_deduct_tax") == "Deduct" else 1
|
||||
)
|
||||
if not amount:
|
||||
continue
|
||||
|
||||
if tax.charge_type == "Actual" and not tax.get("allocate_full_amount_to_stock_items"):
|
||||
# Spread across all items; only the stock/asset items' share is capitalized.
|
||||
charge_per_item = {}
|
||||
self._spread_charge_over_items(charge_per_item, amount, all_items)
|
||||
amount = sum(
|
||||
charge for item_idx, charge in charge_per_item.items() if item_idx in stock_item_idx
|
||||
)
|
||||
|
||||
capitalized[tax.name] = amount
|
||||
|
||||
return capitalized
|
||||
|
||||
def set_incoming_rate(self):
|
||||
"""
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Case
|
||||
from frappe.utils import cstr, flt
|
||||
|
||||
from erpnext.utilities.product import get_item_codes_by_attributes
|
||||
@@ -135,6 +136,53 @@ def validate_is_incremental(numeric_attribute, attribute, value, item):
|
||||
)
|
||||
|
||||
|
||||
def get_attribute_value_renames(item_attribute):
|
||||
"""Return old to new attribute value mappings for renamed Item Attribute Value rows."""
|
||||
if item_attribute.numeric_values:
|
||||
return {}
|
||||
|
||||
db_value = item_attribute.get_doc_before_save()
|
||||
if not db_value:
|
||||
return {}
|
||||
|
||||
old_values = {d.name: d.attribute_value for d in db_value.item_attribute_values}
|
||||
renames = {}
|
||||
|
||||
for row in item_attribute.item_attribute_values:
|
||||
if row.name in old_values and old_values[row.name] != row.attribute_value:
|
||||
renames[old_values[row.name]] = row.attribute_value
|
||||
|
||||
return renames
|
||||
|
||||
|
||||
def update_variant_attribute_values(item_attribute):
|
||||
"""Propagate renamed Item Attribute Values to Item Variant Attribute on variant items."""
|
||||
value_map = get_attribute_value_renames(item_attribute)
|
||||
if not value_map:
|
||||
return
|
||||
|
||||
item_variant_table = frappe.qb.DocType("Item Variant Attribute")
|
||||
item_table = frappe.qb.DocType("Item")
|
||||
attribute_value = item_variant_table.attribute_value
|
||||
attribute_value_case = Case()
|
||||
|
||||
for old_value, new_value in value_map.items():
|
||||
attribute_value_case = attribute_value_case.when(attribute_value == old_value, new_value)
|
||||
|
||||
(
|
||||
frappe.qb.update(item_variant_table)
|
||||
.join(item_table)
|
||||
.on(item_table.name == item_variant_table.parent)
|
||||
.set(attribute_value, attribute_value_case.else_(attribute_value))
|
||||
.where(item_table.variant_of.isnotnull())
|
||||
.where(item_table.variant_of != "")
|
||||
.where(item_variant_table.attribute == item_attribute.name)
|
||||
.where(attribute_value.isin(list(value_map)))
|
||||
).run()
|
||||
|
||||
frappe.flags.attribute_values = None
|
||||
|
||||
|
||||
def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True):
|
||||
allow_rename_attribute_value = frappe.db.get_single_value(
|
||||
"Item Variant Settings", "allow_rename_attribute_value"
|
||||
|
||||
@@ -10,7 +10,7 @@ from frappe import qb, scrub
|
||||
from frappe.desk.reportview import get_filters_cond, get_match_cond
|
||||
from frappe.permissions import has_permission
|
||||
from frappe.query_builder import Case, Criterion, DocType
|
||||
from frappe.query_builder.functions import Concat, CustomFunction, Length, Locate, Substring, Sum
|
||||
from frappe.query_builder.functions import Concat, CustomFunction, Length, Locate, Lower, Substring, Sum
|
||||
from frappe.utils import nowdate, today, unique
|
||||
from pypika import Order
|
||||
|
||||
@@ -313,11 +313,19 @@ def item_query(
|
||||
.where(date_condition)
|
||||
.where(Criterion.any(search_conditions))
|
||||
.orderby(
|
||||
Case().when(Locate(txt_no_percent, item.name) > 0, Locate(txt_no_percent, item.name)).else_(99999)
|
||||
Case()
|
||||
.when(
|
||||
Locate(Lower(txt_no_percent), Lower(item.name)) > 0,
|
||||
Locate(Lower(txt_no_percent), Lower(item.name)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
.orderby(
|
||||
Case()
|
||||
.when(Locate(txt_no_percent, item.item_name) > 0, Locate(txt_no_percent, item.item_name))
|
||||
.when(
|
||||
Locate(Lower(txt_no_percent), Lower(item.item_name)) > 0,
|
||||
Locate(Lower(txt_no_percent), Lower(item.item_name)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
.orderby(item.idx, order=Order.desc)
|
||||
@@ -406,7 +414,13 @@ def get_project_name(
|
||||
# ordering
|
||||
if txt:
|
||||
# project_name containing search string 'txt' will be given higher precedence
|
||||
q = q.orderby(ifelse(Locate(txt, proj.project_name) > 0, Locate(txt, proj.project_name), 99999))
|
||||
q = q.orderby(
|
||||
ifelse(
|
||||
Locate(Lower(txt), Lower(proj.project_name)) > 0,
|
||||
Locate(Lower(txt), Lower(proj.project_name)),
|
||||
99999,
|
||||
)
|
||||
)
|
||||
q = q.orderby(proj.idx, order=Order.desc).orderby(proj.name)
|
||||
|
||||
if page_len:
|
||||
|
||||
@@ -445,6 +445,8 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
|
||||
doc.pricing_rules = []
|
||||
doc.return_against = source.name
|
||||
doc.set_warehouse = ""
|
||||
if doctype == "Sales Invoice":
|
||||
doc.is_debit_note = 0
|
||||
if doctype == "Sales Invoice" or doctype == "POS Invoice":
|
||||
doc.is_pos = source.is_pos
|
||||
|
||||
|
||||
@@ -744,7 +744,14 @@ class SubcontractingInwardController:
|
||||
"name": ["in", list(data.keys())],
|
||||
"docstatus": 1,
|
||||
},
|
||||
fields=["rate", "name", "required_qty", "received_qty"],
|
||||
fields=[
|
||||
"rate",
|
||||
"name",
|
||||
"required_qty",
|
||||
"received_qty",
|
||||
"returned_qty",
|
||||
"consumed_qty",
|
||||
],
|
||||
)
|
||||
|
||||
doc_updates = {}
|
||||
@@ -752,13 +759,17 @@ class SubcontractingInwardController:
|
||||
current_qty = flt(data[d.name].transfer_qty) * (1 if self._action == "submit" else -1)
|
||||
current_rate = flt(data[d.name].rate)
|
||||
|
||||
# Calculate weighted average rate
|
||||
old_total = d.rate * d.received_qty
|
||||
# Weighted average rate must be computed on the on-hand balance
|
||||
balance_qty = d.received_qty - d.returned_qty - d.consumed_qty
|
||||
old_total = d.rate * balance_qty
|
||||
current_total = current_rate * current_qty
|
||||
|
||||
new_balance_qty = balance_qty + current_qty
|
||||
d.received_qty = d.received_qty + current_qty
|
||||
d.rate = (
|
||||
flt((old_total + current_total) / d.received_qty, precision) if d.received_qty else 0.0
|
||||
flt((old_total + current_total) / new_balance_qty, precision)
|
||||
if new_balance_qty > 0
|
||||
else 0.0
|
||||
)
|
||||
|
||||
if not d.required_qty and not d.received_qty:
|
||||
|
||||
@@ -290,13 +290,19 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
"name",
|
||||
)
|
||||
else:
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select q.name
|
||||
from `tabQuotation` q, `tabQuotation Item` qi
|
||||
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
|
||||
and q.status not in ('Lost', 'Closed')""",
|
||||
self.name,
|
||||
q = frappe.qb.DocType("Quotation")
|
||||
qi = frappe.qb.DocType("Quotation Item")
|
||||
return (
|
||||
frappe.qb.from_(q)
|
||||
.inner_join(qi)
|
||||
.on(q.name == qi.parent)
|
||||
.select(q.name)
|
||||
.where(
|
||||
(q.docstatus == 1)
|
||||
& (qi.prevdoc_docname == self.name)
|
||||
& q.status.notin(["Lost", "Closed"])
|
||||
)
|
||||
.run()
|
||||
)
|
||||
|
||||
def has_ordered_quotation(self):
|
||||
@@ -305,24 +311,20 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
"Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name"
|
||||
)
|
||||
else:
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select q.name
|
||||
from `tabQuotation` q, `tabQuotation Item` qi
|
||||
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
|
||||
and q.status = 'Ordered'""",
|
||||
self.name,
|
||||
q = frappe.qb.DocType("Quotation")
|
||||
qi = frappe.qb.DocType("Quotation Item")
|
||||
return (
|
||||
frappe.qb.from_(q)
|
||||
.inner_join(qi)
|
||||
.on(q.name == qi.parent)
|
||||
.select(q.name)
|
||||
.where((q.docstatus == 1) & (qi.prevdoc_docname == self.name) & (q.status == "Ordered"))
|
||||
.run()
|
||||
)
|
||||
|
||||
def has_lost_quotation(self):
|
||||
lost_quotation = frappe.db.sql(
|
||||
"""
|
||||
select name
|
||||
from `tabQuotation`
|
||||
where docstatus=1
|
||||
and opportunity =%s and status = 'Lost'
|
||||
""",
|
||||
self.name,
|
||||
lost_quotation = frappe.get_all(
|
||||
"Quotation", filters={"docstatus": 1, "opportunity": self.name, "status": "Lost"}
|
||||
)
|
||||
if lost_quotation:
|
||||
if self.has_active_quotation():
|
||||
@@ -371,19 +373,19 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_item_details(item_code: str):
|
||||
item = frappe.db.sql(
|
||||
"""select item_name, stock_uom, image, description, item_group, brand
|
||||
from `tabItem` where name = %s""",
|
||||
item = frappe.db.get_value(
|
||||
"Item",
|
||||
item_code,
|
||||
as_dict=1,
|
||||
["item_name", "stock_uom", "image", "description", "item_group", "brand"],
|
||||
as_dict=True,
|
||||
)
|
||||
return {
|
||||
"item_name": item and item[0]["item_name"] or "",
|
||||
"uom": item and item[0]["stock_uom"] or "",
|
||||
"description": item and item[0]["description"] or "",
|
||||
"image": item and item[0]["image"] or "",
|
||||
"item_group": item and item[0]["item_group"] or "",
|
||||
"brand": item and item[0]["brand"] or "",
|
||||
"item_name": item and item.item_name or "",
|
||||
"uom": item and item.stock_uom or "",
|
||||
"description": item and item.description or "",
|
||||
"image": item and item.image or "",
|
||||
"item_group": item and item.item_group or "",
|
||||
"brand": item and item.brand or "",
|
||||
}
|
||||
|
||||
|
||||
|
||||
47
erpnext/crm/doctype/test_utils.py
Normal file
47
erpnext/crm/doctype/test_utils.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.crm.doctype.utils import get_last_interaction
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestCrmDoctypeUtils(ERPNextTestSuite):
|
||||
def test_get_last_interaction_for_contact(self):
|
||||
"""Covers the converted Communication query (contact path): returns the earliest Received
|
||||
communication across the doctypes the contact is linked to. `creation` is unique, so the
|
||||
LIMIT-1 pick is deterministic and identical on MariaDB and Postgres."""
|
||||
customer = "_Test CRM Util Customer"
|
||||
if not frappe.db.exists("Customer", customer):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Customer",
|
||||
"customer_name": customer,
|
||||
"customer_group": "_Test Customer Group",
|
||||
"territory": "_Test Territory",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
contact = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Contact",
|
||||
"first_name": "CRM Util Test",
|
||||
"links": [{"link_doctype": "Customer", "link_name": customer}],
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
comm = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Communication",
|
||||
"subject": "hi",
|
||||
"content": "first interaction",
|
||||
"sent_or_received": "Received",
|
||||
"reference_doctype": "Customer",
|
||||
"reference_name": customer,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
result = get_last_interaction(contact=contact.name)
|
||||
self.assertIsNotNone(result["last_communication"])
|
||||
self.assertEqual(result["last_communication"]["name"], comm.name)
|
||||
@@ -1,4 +1,5 @@
|
||||
import frappe
|
||||
from frappe.query_builder import Criterion
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -9,37 +10,33 @@ def get_last_interaction(contact: str | None = None, lead: str | None = None):
|
||||
last_communication = None
|
||||
last_issue = None
|
||||
if contact:
|
||||
query_condition = ""
|
||||
values = []
|
||||
communication = frappe.qb.DocType("Communication")
|
||||
link_conditions = []
|
||||
contact = frappe.get_doc("Contact", contact)
|
||||
for link in contact.links:
|
||||
if link.link_doctype == "Customer":
|
||||
last_issue = get_last_issue_from_customer(link.link_name)
|
||||
query_condition += "(`reference_doctype`=%s AND `reference_name`=%s) OR"
|
||||
values += [link.link_doctype, link.link_name]
|
||||
link_conditions.append(
|
||||
(communication.reference_doctype == link.link_doctype)
|
||||
& (communication.reference_name == link.link_name)
|
||||
)
|
||||
|
||||
if query_condition:
|
||||
# remove extra appended 'OR'
|
||||
query_condition = query_condition[:-2]
|
||||
last_communication = frappe.db.sql(
|
||||
f"""
|
||||
SELECT `name`, `content`
|
||||
FROM `tabCommunication`
|
||||
WHERE `sent_or_received`='Received'
|
||||
AND ({query_condition})
|
||||
ORDER BY `creation`
|
||||
LIMIT 1
|
||||
""",
|
||||
values,
|
||||
as_dict=1,
|
||||
) # nosec
|
||||
if link_conditions:
|
||||
last_communication = (
|
||||
frappe.qb.from_(communication)
|
||||
.select(communication.name, communication.content)
|
||||
.where((communication.sent_or_received == "Received") & Criterion.any(link_conditions))
|
||||
.orderby(communication.creation)
|
||||
.limit(1)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
if lead:
|
||||
last_communication = frappe.get_all(
|
||||
"Communication",
|
||||
filters={"reference_doctype": "Lead", "reference_name": lead, "sent_or_received": "Received"},
|
||||
fields=["name", "content"],
|
||||
order_by="`creation` DESC",
|
||||
order_by="creation desc",
|
||||
limit=1,
|
||||
)
|
||||
|
||||
@@ -53,7 +50,7 @@ def get_last_issue_from_customer(customer_name):
|
||||
"Issue",
|
||||
{"customer": customer_name},
|
||||
["name", "subject", "customer"],
|
||||
order_by="`creation` DESC",
|
||||
order_by="creation desc",
|
||||
limit=1,
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import add_days, flt
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -30,17 +31,15 @@ def get_columns(based_on):
|
||||
|
||||
def get_lead_data(filters, based_on):
|
||||
based_on_field = frappe.scrub(based_on)
|
||||
conditions = get_filter_conditions(filters)
|
||||
|
||||
lead_details = frappe.db.sql(
|
||||
f"""
|
||||
select {based_on_field}, name
|
||||
from `tabLead`
|
||||
where {based_on_field} is not null and {based_on_field} != '' {conditions}
|
||||
""",
|
||||
filters,
|
||||
as_dict=1,
|
||||
)
|
||||
lead_filters = [[based_on_field, "is", "set"]]
|
||||
if filters.from_date:
|
||||
lead_filters.append(["creation", ">=", filters.from_date])
|
||||
if filters.to_date:
|
||||
# date(creation) <= to_date, i.e. anything created before the next day
|
||||
lead_filters.append(["creation", "<", add_days(filters.to_date, 1)])
|
||||
|
||||
lead_details = frappe.get_all("Lead", filters=lead_filters, fields=[based_on_field, "name"])
|
||||
|
||||
lead_map = frappe._dict()
|
||||
for d in lead_details:
|
||||
@@ -64,52 +63,36 @@ def get_lead_data(filters, based_on):
|
||||
return data
|
||||
|
||||
|
||||
def get_filter_conditions(filters):
|
||||
conditions = ""
|
||||
if filters.from_date:
|
||||
conditions += " and date(creation) >= %(from_date)s"
|
||||
if filters.to_date:
|
||||
conditions += " and date(creation) <= %(to_date)s"
|
||||
|
||||
return conditions
|
||||
|
||||
|
||||
def get_lead_quotation_count(leads):
|
||||
return frappe.db.sql(
|
||||
"""select count(name) from `tabQuotation`
|
||||
where quotation_to = 'Lead' and party_name in (%s)"""
|
||||
% ", ".join(["%s"] * len(leads)),
|
||||
tuple(leads),
|
||||
)[0][0] # nosec
|
||||
return frappe.db.count("Quotation", {"quotation_to": "Lead", "party_name": ["in", leads]})
|
||||
|
||||
|
||||
def get_lead_opp_count(leads):
|
||||
return frappe.db.sql(
|
||||
"""select count(name) from `tabOpportunity`
|
||||
where opportunity_from = 'Lead' and party_name in (%s)"""
|
||||
% ", ".join(["%s"] * len(leads)),
|
||||
tuple(leads),
|
||||
)[0][0]
|
||||
return frappe.db.count("Opportunity", {"opportunity_from": "Lead", "party_name": ["in", leads]})
|
||||
|
||||
|
||||
def get_quotation_ordered_count(leads):
|
||||
return frappe.db.sql(
|
||||
"""select count(name)
|
||||
from `tabQuotation` where status = 'Ordered' and quotation_to = 'Lead'
|
||||
and party_name in (%s)"""
|
||||
% ", ".join(["%s"] * len(leads)),
|
||||
tuple(leads),
|
||||
)[0][0]
|
||||
return frappe.db.count(
|
||||
"Quotation", {"status": "Ordered", "quotation_to": "Lead", "party_name": ["in", leads]}
|
||||
)
|
||||
|
||||
|
||||
def get_order_amount(leads):
|
||||
return frappe.db.sql(
|
||||
"""select sum(base_net_amount)
|
||||
from `tabSales Order Item`
|
||||
where prevdoc_docname in (
|
||||
select name from `tabQuotation` where status = 'Ordered'
|
||||
and quotation_to = 'Lead' and party_name in (%s)
|
||||
)"""
|
||||
% ", ".join(["%s"] * len(leads)),
|
||||
tuple(leads),
|
||||
so_item = frappe.qb.DocType("Sales Order Item")
|
||||
quotation = frappe.qb.DocType("Quotation")
|
||||
return (
|
||||
frappe.qb.from_(so_item)
|
||||
.select(Sum(so_item.base_net_amount))
|
||||
.where(
|
||||
so_item.prevdoc_docname.isin(
|
||||
frappe.qb.from_(quotation)
|
||||
.select(quotation.name)
|
||||
.where(
|
||||
(quotation.status == "Ordered")
|
||||
& (quotation.quotation_to == "Lead")
|
||||
& quotation.party_name.isin(leads)
|
||||
)
|
||||
)
|
||||
)
|
||||
.run()
|
||||
)[0][0]
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, nowdate
|
||||
|
||||
from erpnext.crm.report.campaign_efficiency.campaign_efficiency import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestCampaignEfficiency(ERPNextTestSuite):
|
||||
def test_lead_count_per_campaign(self):
|
||||
"""execute() groups Leads by utm_campaign over a creation-date window and counts leads per
|
||||
group. Seed two Leads sharing one distinct UTM Campaign, run the report over a window that
|
||||
includes their (now-dated) creation, and assert that campaign's row reports lead_count == 2.
|
||||
The group is unique to this test, so the count is exact rather than a tautology, and both
|
||||
MariaDB and Postgres must return the same row/value."""
|
||||
campaign = "_Test Campaign Eff Campaign"
|
||||
if not frappe.db.exists("UTM Campaign", campaign):
|
||||
frappe.get_doc({"doctype": "UTM Campaign", "__newname": campaign}).insert(ignore_permissions=True)
|
||||
|
||||
for i in range(2):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Lead",
|
||||
"lead_name": f"_Test Campaign Eff Lead {i}",
|
||||
"utm_campaign": campaign,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
# from_date <= creation(now) < to_date + 1 -> window covers the freshly inserted leads
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"from_date": add_days(nowdate(), -7),
|
||||
"to_date": add_days(nowdate(), 1),
|
||||
"based_on": "utm_campaign",
|
||||
}
|
||||
)
|
||||
columns, data = execute(filters)
|
||||
|
||||
row = next((r for r in data if r.get("utm_campaign") == campaign), None)
|
||||
self.assertIsNotNone(row, "campaign row missing from report output")
|
||||
self.assertEqual(row["lead_count"], 2)
|
||||
# no quotations/orders seeded for these leads -> derived counts are zero
|
||||
self.assertEqual(row["quot_count"], 0)
|
||||
self.assertEqual(row["order_count"], 0)
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Avg, Date
|
||||
from pypika import Order
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -17,19 +19,19 @@ def execute(filters=None):
|
||||
},
|
||||
]
|
||||
|
||||
data = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
date(creation) as creation_date,
|
||||
avg(first_response_time) as avg_response_time
|
||||
FROM tabOpportunity
|
||||
WHERE
|
||||
date(creation) between %s and %s
|
||||
and first_response_time > 0
|
||||
GROUP BY creation_date
|
||||
ORDER BY creation_date desc
|
||||
""",
|
||||
(filters.from_date, filters.to_date),
|
||||
opportunity = frappe.qb.DocType("Opportunity")
|
||||
creation_date = Date(opportunity.creation)
|
||||
data = (
|
||||
frappe.qb.from_(opportunity)
|
||||
.select(
|
||||
creation_date.as_("creation_date"), Avg(opportunity.first_response_time).as_("avg_response_time")
|
||||
)
|
||||
.where(
|
||||
creation_date.between(filters.from_date, filters.to_date) & (opportunity.first_response_time > 0)
|
||||
)
|
||||
.groupby(creation_date)
|
||||
.orderby(creation_date, order=Order.desc)
|
||||
.run()
|
||||
)
|
||||
|
||||
return columns, data
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, getdate, nowdate
|
||||
|
||||
from erpnext.crm.report.first_response_time_for_opportunity.first_response_time_for_opportunity import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestFirstResponseTimeForOpportunity(ERPNextTestSuite):
|
||||
def test_avg_first_response_time_row(self):
|
||||
"""The report groups Opportunity by Date(creation) and averages first_response_time where it
|
||||
is > 0, between from_date and to_date. With a single seeded Opportunity created today and a
|
||||
known first_response_time, the report must return a row for today whose averaged value equals
|
||||
the seeded duration on both engines (Date(creation) and Avg via the query builder)."""
|
||||
response_time = 3600 # seconds (Duration)
|
||||
lead_email = "_test_frt_opp@example.com"
|
||||
lead_name = "_Test FRT Opportunity Lead"
|
||||
|
||||
lead = frappe.db.exists("Lead", {"email_id": lead_email})
|
||||
if not lead:
|
||||
lead = (
|
||||
frappe.get_doc({"doctype": "Lead", "lead_name": lead_name, "email_id": lead_email})
|
||||
.insert(ignore_permissions=True)
|
||||
.name
|
||||
)
|
||||
|
||||
opportunity = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Opportunity",
|
||||
"opportunity_from": "Lead",
|
||||
"party_name": lead,
|
||||
"company": "_Test Company",
|
||||
"currency": "INR",
|
||||
"conversion_rate": 1,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
# first_response_time is a read-only computed field; set it directly.
|
||||
frappe.db.set_value(
|
||||
"Opportunity",
|
||||
opportunity.name,
|
||||
"first_response_time",
|
||||
response_time,
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
columns, data = execute(
|
||||
frappe._dict(from_date=add_days(nowdate(), -1), to_date=add_days(nowdate(), 1))
|
||||
)
|
||||
|
||||
# rows are positional lists: [creation_date, avg_response_time]
|
||||
today = getdate(nowdate())
|
||||
row = next((r for r in data if getdate(r[0]) == today), None)
|
||||
self.assertIsNotNone(row, "no report row for today's grouped creation date")
|
||||
self.assertEqual(getdate(row[0]), today)
|
||||
self.assertEqual(row[1], response_time)
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
from frappe.query_builder.functions import Count, Date
|
||||
from frappe.utils import date_diff, flt
|
||||
|
||||
|
||||
@@ -83,56 +84,51 @@ def get_communication_details(filters):
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
comm = frappe.qb.DocType("Communication")
|
||||
|
||||
for d in opportunities:
|
||||
invoice = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
date(creation)
|
||||
FROM
|
||||
`tabSales Invoice`
|
||||
WHERE
|
||||
contact_email = %s AND date(creation) between %s and %s AND docstatus != 2
|
||||
ORDER BY
|
||||
creation
|
||||
LIMIT 1
|
||||
""",
|
||||
(d.contact_email, filters.from_date, filters.to_date),
|
||||
invoice = (
|
||||
frappe.qb.from_(si)
|
||||
.select(Date(si.creation))
|
||||
.where(
|
||||
(si.contact_email == d.contact_email)
|
||||
& Date(si.creation).between(filters.from_date, filters.to_date)
|
||||
& (si.docstatus != 2)
|
||||
)
|
||||
.orderby(si.creation)
|
||||
.limit(1)
|
||||
.run()
|
||||
)
|
||||
|
||||
if not invoice:
|
||||
continue
|
||||
|
||||
communication_count = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
count(*)
|
||||
FROM
|
||||
`tabCommunication`
|
||||
WHERE
|
||||
sender = %s AND date(communication_date) <= %s
|
||||
""",
|
||||
(d.contact_email, invoice),
|
||||
invoice_date = invoice[0][0]
|
||||
|
||||
communication_count = (
|
||||
frappe.qb.from_(comm)
|
||||
.select(Count("*"))
|
||||
.where((comm.sender == d.contact_email) & (Date(comm.communication_date) <= invoice_date))
|
||||
.run()
|
||||
)[0][0]
|
||||
|
||||
if not communication_count:
|
||||
continue
|
||||
|
||||
first_contact = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
date(communication_date)
|
||||
FROM
|
||||
`tabCommunication`
|
||||
WHERE
|
||||
recipients = %s
|
||||
ORDER BY
|
||||
communication_date
|
||||
LIMIT 1
|
||||
""",
|
||||
(d.contact_email),
|
||||
)[0][0]
|
||||
first_contact = (
|
||||
frappe.qb.from_(comm)
|
||||
.select(Date(comm.communication_date))
|
||||
.where((comm.recipients == d.contact_email) & comm.communication_date.isnotnull())
|
||||
.orderby(comm.communication_date)
|
||||
.limit(1)
|
||||
.run()
|
||||
)
|
||||
first_contact = first_contact[0][0] if first_contact else None
|
||||
if not first_contact:
|
||||
continue
|
||||
|
||||
duration = flt(date_diff(invoice[0][0], first_contact))
|
||||
duration = flt(date_diff(invoice_date, first_contact))
|
||||
|
||||
support_tickets = len(frappe.db.get_all("Issue", {"raised_by": d.contact_email}))
|
||||
communication_list.append(
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.crm.report.lead_conversion_time.lead_conversion_time import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestLeadConversionTime(ERPNextTestSuite):
|
||||
def test_first_contact_ignores_null_communication_date(self):
|
||||
"""first_contact ordered by the nullable communication_date and read row[0][0]. With no
|
||||
IS NOT NULL guard, MariaDB (NULLs-first) returned a NULL-dated Communication -> first_contact
|
||||
None -> a wrong duration, while Postgres (NULLs-last) returned the earliest real date. Filtering
|
||||
communication_date IS NOT NULL (and guarding the slice) makes both engines use the earliest
|
||||
real contact date."""
|
||||
email = "_test_lead_conv@example.com"
|
||||
customer_name = "_Test Lead Conv 22d"
|
||||
|
||||
lead = frappe.get_doc({"doctype": "Lead", "lead_name": customer_name, "email_id": email}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Opportunity",
|
||||
"opportunity_from": "Lead",
|
||||
"party_name": lead.name,
|
||||
"company": "_Test Company",
|
||||
"currency": "INR",
|
||||
"conversion_rate": 1,
|
||||
"contact_email": email,
|
||||
"customer_name": customer_name,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
si = create_sales_invoice(do_not_save=1)
|
||||
si.contact_email = email
|
||||
si.save() # draft (docstatus 0 != 2); Date(creation) is today, within range
|
||||
|
||||
# count query filters on `sender`; first_contact filters on `recipients` -> set both
|
||||
real = frappe.get_doc(
|
||||
{"doctype": "Communication", "subject": "real", "sender": email, "recipients": email}
|
||||
).insert(ignore_permissions=True)
|
||||
frappe.db.set_value(
|
||||
"Communication", real.name, "communication_date", add_days(nowdate(), -22), update_modified=False
|
||||
)
|
||||
nulldate = frappe.get_doc(
|
||||
{"doctype": "Communication", "subject": "nulldate", "sender": email, "recipients": email}
|
||||
).insert(ignore_permissions=True)
|
||||
frappe.db.set_value("Communication", nulldate.name, "communication_date", None, update_modified=False)
|
||||
|
||||
data = execute(frappe._dict({"from_date": add_days(nowdate(), -30), "to_date": nowdate()}))[1]
|
||||
# rows are lists: [customer, interactions, duration, support_tickets]
|
||||
row = next((r for r in data if r[0] == customer_name), None)
|
||||
self.assertIsNotNone(row, "lead's converted-customer row missing")
|
||||
# duration must be measured from the earliest REAL contact (22 days), not the NULL-dated one
|
||||
self.assertEqual(row[2], 22.0)
|
||||
@@ -61,30 +61,47 @@ def get_columns():
|
||||
def get_data(filters):
|
||||
lead_details = []
|
||||
lead_filters = get_lead_filters(filters)
|
||||
leads = frappe.get_all("Lead", fields=["name", "lead_name", "company_name"], filters=lead_filters)
|
||||
if not leads:
|
||||
return lead_details
|
||||
|
||||
for lead in frappe.get_all("Lead", fields=["name", "lead_name", "company_name"], filters=lead_filters):
|
||||
data = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
`tabCommunication`.reference_doctype, `tabCommunication`.reference_name,
|
||||
`tabCommunication`.content, `tabCommunication`.communication_date
|
||||
from
|
||||
(
|
||||
(select name, party_name as lead from `tabOpportunity` where opportunity_from='Lead' and party_name = %(lead)s)
|
||||
union
|
||||
(select name, party_name as lead from `tabQuotation` where quotation_to = 'Lead' and party_name = %(lead)s)
|
||||
union
|
||||
(select name, lead from `tabIssue` where lead = %(lead)s and status!='Closed')
|
||||
union
|
||||
(select %(lead)s, %(lead)s)
|
||||
)
|
||||
as ref_document, `tabCommunication`
|
||||
where
|
||||
`tabCommunication`.reference_name = ref_document.name and
|
||||
`tabCommunication`.sent_or_received = 'Received'
|
||||
order by
|
||||
ref_document.lead, `tabCommunication`.creation desc limit %(limit)s""",
|
||||
{"lead": lead.name, "limit": filters.get("no_of_interaction")},
|
||||
lead_names = [lead.name for lead in leads]
|
||||
|
||||
# Collect the documents (and the lead itself) that communications may reference, for all leads in
|
||||
# three bulk queries instead of three per lead.
|
||||
reference_names = {name: {name} for name in lead_names}
|
||||
for opp in frappe.get_all(
|
||||
"Opportunity",
|
||||
filters={"opportunity_from": "Lead", "party_name": ["in", lead_names]},
|
||||
fields=["name", "party_name"],
|
||||
):
|
||||
reference_names[opp.party_name].add(opp.name)
|
||||
for quotation in frappe.get_all(
|
||||
"Quotation",
|
||||
filters={"quotation_to": "Lead", "party_name": ["in", lead_names]},
|
||||
fields=["name", "party_name"],
|
||||
):
|
||||
reference_names[quotation.party_name].add(quotation.name)
|
||||
for issue in frappe.get_all(
|
||||
"Issue",
|
||||
filters={"lead": ["in", lead_names], "status": ["!=", "Closed"]},
|
||||
fields=["name", "lead"],
|
||||
):
|
||||
reference_names[issue.lead].add(issue.name)
|
||||
|
||||
for lead in leads:
|
||||
data = frappe.get_all(
|
||||
"Communication",
|
||||
filters={
|
||||
# constrain the doctype too: names are unique only within a doctype
|
||||
"reference_doctype": ["in", ["Lead", "Opportunity", "Quotation", "Issue"]],
|
||||
"reference_name": ["in", list(reference_names[lead.name])],
|
||||
"sent_or_received": "Received",
|
||||
},
|
||||
fields=["reference_doctype", "reference_name", "content", "communication_date"],
|
||||
order_by="creation desc",
|
||||
limit=filters.get("no_of_interaction"),
|
||||
as_list=True,
|
||||
)
|
||||
|
||||
for lead_info in data:
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.crm.report.prospects_engaged_but_not_converted.prospects_engaged_but_not_converted import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestProspectsEngagedButNotConverted(ERPNextTestSuite):
|
||||
def test_lead_with_received_communications_appears(self):
|
||||
"""The report lists non-converted Leads that have Communications referencing them
|
||||
(reference_doctype="Lead", reference_name=lead.name) with sent_or_received="Received".
|
||||
Seed one Lead and two such Received Communications, then assert the Lead surfaces in the
|
||||
report data and that the emitted row carries the Lead -> reference_doctype/reference_name
|
||||
linkage the get_data() join relies on. Asserting a concrete row (not a count) keeps this a
|
||||
real-state smoke test that exercises the same path on both MariaDB and Postgres."""
|
||||
lead_name = "_Test Prospect Engaged"
|
||||
email = "_test_prospect_engaged@example.com"
|
||||
|
||||
lead = frappe.db.exists("Lead", {"lead_name": lead_name})
|
||||
if lead:
|
||||
lead = frappe.get_doc("Lead", lead)
|
||||
else:
|
||||
lead = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Lead",
|
||||
"lead_name": lead_name,
|
||||
"email_id": email,
|
||||
"company_name": "_Test Prospect Org",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
# A fresh, non-converted Lead is required for it to pass the report's lead filters.
|
||||
self.assertNotEqual(lead.status, "Converted")
|
||||
|
||||
for subject in ("_test prospect engaged 1", "_test prospect engaged 2"):
|
||||
if not frappe.db.exists(
|
||||
"Communication",
|
||||
{
|
||||
"reference_doctype": "Lead",
|
||||
"reference_name": lead.name,
|
||||
"subject": subject,
|
||||
},
|
||||
):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Communication",
|
||||
"communication_type": "Communication",
|
||||
"subject": subject,
|
||||
"content": subject,
|
||||
"sent_or_received": "Received",
|
||||
"reference_doctype": "Lead",
|
||||
"reference_name": lead.name,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
# filters are accessed via .get(...) in the report, so a plain _dict suffices
|
||||
columns, data = execute(frappe._dict(no_of_interaction=1))
|
||||
|
||||
# rows are lists: [lead, lead_name, company_name, reference_doctype, reference_name, content, date]
|
||||
row = next((r for r in data if r[0] == lead.name), None)
|
||||
self.assertIsNotNone(row, "seeded Lead with Received communications missing from report data")
|
||||
self.assertEqual(row[3], "Lead")
|
||||
self.assertEqual(row[4], lead.name)
|
||||
# content comes from one of the two seeded Received communications
|
||||
self.assertIn(row[5], ("_test prospect engaged 1", "_test prospect engaged 2"))
|
||||
|
||||
# no_of_interaction=1 caps the per-lead communications to 1 -> exactly one row for this Lead
|
||||
lead_rows = [r for r in data if r[0] == lead.name]
|
||||
self.assertEqual(len(lead_rows), 1)
|
||||
@@ -596,6 +596,7 @@ accounting_dimension_doctypes = [
|
||||
"Account Closing Balance",
|
||||
"Supplier Quotation",
|
||||
"Supplier Quotation Item",
|
||||
"Request for Quotation Item",
|
||||
"Payment Reconciliation",
|
||||
"Payment Reconciliation Allocation",
|
||||
"Payment Request",
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: hello@frappe.io\n"
|
||||
"POT-Creation-Date: 2026-06-14 10:35+0000\n"
|
||||
"PO-Revision-Date: 2026-06-14 17:01\n"
|
||||
"PO-Revision-Date: 2026-06-18 18:25\n"
|
||||
"Last-Translator: hello@frappe.io\n"
|
||||
"Language-Team: Bosnian\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
@@ -720,8 +720,8 @@ msgid "<h3>About Product Bundle</h3>\n\n"
|
||||
"<p>The package <b>Item</b> will have <code>Is Stock Item</code> as <b>No</b> and <code>Is Sales Item</code> as <b>Yes</b>.</p>\n"
|
||||
"<h4>Example:</h4>\n"
|
||||
"<p>If you are selling Laptops and Backpacks separately and have a special price if the customer buys both, then the Laptop + Backpack will be a new Product Bundle Item.</p>"
|
||||
msgstr "<h3>O Paketu Proizvoda</h3>\n\n"
|
||||
"<p>Spoji grupu <b>artikala</b> u drugi <b>artikal</b>. Ovo je korisno ako spajate određene <b>Artikle</b> u paket i održavate zalihe upakiranih <b>artikala</b>, a ne zbirn <b>artikal</b>.</p>\n"
|
||||
msgstr "<h3>O Paketu Artikala</h3>\n\n"
|
||||
"<p>Spoji grupu <b>artikala</b> u drugi <b>artikal</b>. Ovo je korisno ako spajate određene <b>Artikle</b> u paket i održavate zalihe upakiranih <b>artikala</b>, a ne zbirni <b>artikal</b>.</p>\n"
|
||||
"<p>Paketni <b>Artikal</b> će imati <code>artikle na zalihi</code> kao <b>Ne</b> i <code>Prodajni Artikal</code> kao <b>Da </b>.</p>\n"
|
||||
"<h4>Primjer:</h4>\n"
|
||||
"<p>Ako prodajete prijenosna računala i ruksake odvojeno i imate posebnu cijenu ako Klijent kupi oboje, tada će prijenosno računalo + ruksak biti novi artikal paketa proizvoda.</p>"
|
||||
@@ -1102,7 +1102,7 @@ msgstr "Klijent mora imati primarni kontakt e-poštu."
|
||||
#. Description of the 'Disabled' (Check) field in DocType 'Product Bundle'
|
||||
#: erpnext/selling/doctype/product_bundle/product_bundle.json
|
||||
msgid "A disabled Product Bundle cannot be selected in transactions."
|
||||
msgstr ""
|
||||
msgstr "Onemogućeni Paket Artikal ne može se odabrati u transakcijama."
|
||||
|
||||
#: erpnext/stock/doctype/delivery_trip/delivery_trip.py:59
|
||||
msgid "A driver must be set to submit."
|
||||
@@ -4487,7 +4487,7 @@ msgstr "Dozvoli Uređivanje Količine Jedinice Zaliha za Dokumente Prodaje"
|
||||
#. DocType 'Stock Settings'
|
||||
#: erpnext/stock/doctype/stock_settings/stock_settings.json
|
||||
msgid "Allow to edit stock UOM qty for Stock Entry"
|
||||
msgstr ""
|
||||
msgstr "Omogući uređivanje količine jedinice zaliha za Unos Zaliha"
|
||||
|
||||
#. Label of the allow_to_make_quality_inspection_after_purchase_or_delivery
|
||||
#. (Check) field in DocType 'Stock Settings'
|
||||
@@ -4597,7 +4597,7 @@ msgstr "Alternativni Artikal"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:427
|
||||
msgid "Alternative For Item"
|
||||
msgstr ""
|
||||
msgstr "Artikal Alternativa"
|
||||
|
||||
#. Label of the alternative_item_code (Link) field in DocType 'Item
|
||||
#. Alternative'
|
||||
@@ -6918,7 +6918,7 @@ msgstr "Alat Poređenja Sastavnica"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:178
|
||||
msgid "BOM Component"
|
||||
msgstr ""
|
||||
msgstr "Komponenta Sastavnice"
|
||||
|
||||
#. Label of the bom_conf_tab (Tab Break) field in DocType 'BOM'
|
||||
#: erpnext/manufacturing/doctype/bom/bom.json
|
||||
@@ -6949,7 +6949,7 @@ msgstr "Artikal Sastavnice Konstruktora"
|
||||
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:392
|
||||
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:535
|
||||
msgid "BOM Creator Item with name {0} does not exist"
|
||||
msgstr ""
|
||||
msgstr "Artikal Sastavnice s nazivom {0} ne postoji"
|
||||
|
||||
#. Label of the bom_detail_no (Data) field in DocType 'Purchase Receipt Item
|
||||
#. Supplied'
|
||||
@@ -7049,7 +7049,7 @@ msgstr "Operativno Vrijeme Sastavnice"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:248
|
||||
msgid "BOM Output"
|
||||
msgstr ""
|
||||
msgstr "Sastavnica"
|
||||
|
||||
#: erpnext/stock/report/item_prices/item_prices.py:60
|
||||
msgid "BOM Rate"
|
||||
@@ -8222,13 +8222,13 @@ msgstr "Datum Fakture"
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Bill Even If Previous Invoice Unpaid"
|
||||
msgstr ""
|
||||
msgstr "Fakturiši čak i ako prethodna faktura nije plaćena"
|
||||
|
||||
#. Option for the 'Generate Invoice At' (Select) field in DocType
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Bill N days before period start"
|
||||
msgstr ""
|
||||
msgstr "Fakturiši N dana prije početka perioda"
|
||||
|
||||
#. Label of the bill_no (Data) field in DocType 'Journal Entry'
|
||||
#. Label of the bill_no (Data) field in DocType 'Subcontracting Receipt'
|
||||
@@ -8410,13 +8410,13 @@ msgstr "e-pošta Fakture"
|
||||
#. Label of the billing_heatmap (HTML) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Billing Heatmap"
|
||||
msgstr ""
|
||||
msgstr "Toplinska mapa Fakturisanja"
|
||||
|
||||
#. Label of the billing_history_section (Section Break) field in DocType
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Billing History"
|
||||
msgstr ""
|
||||
msgstr "Historija Fakturisanja"
|
||||
|
||||
#. Label of the billing_hours (Float) field in DocType 'Sales Invoice
|
||||
#. Timesheet'
|
||||
@@ -8450,7 +8450,7 @@ msgstr "Faktura Interval u Planu pretplate mora biti Mjesec koji prati kalendars
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Billing Period"
|
||||
msgstr ""
|
||||
msgstr "Period Fakturisanja"
|
||||
|
||||
#. Label of the billing_rate (Currency) field in DocType 'Activity Cost'
|
||||
#. Label of the billing_rate (Currency) field in DocType 'Timesheet Detail'
|
||||
@@ -9278,7 +9278,7 @@ msgstr "Izračunaj procijenjeno vrijeme dolaska"
|
||||
#. Settings'
|
||||
#: erpnext/selling/doctype/selling_settings/selling_settings.json
|
||||
msgid "Calculate Product Bundle price based on child Item's rates"
|
||||
msgstr "Obračunaj Cijenu Paketa Proizvoda na osnovu cijena Podređenih Artikala"
|
||||
msgstr "Obračunaj Cijenu Paketa Artikala na osnovu cijena Podređenih Artikala"
|
||||
|
||||
#. Description of the 'Hidden Line (Internal Use Only)' (Check) field in
|
||||
#. DocType 'Financial Report Row'
|
||||
@@ -9547,7 +9547,7 @@ msgstr "Otkaži Pretplatu nakon perioda odgode"
|
||||
#. Label of the cancel_at_period_end (Check) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Cancel When Period Ends"
|
||||
msgstr ""
|
||||
msgstr "Otkaži kada se završi period"
|
||||
|
||||
#. Label of the cancelation_date (Date) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
@@ -9556,7 +9556,7 @@ msgstr "Datum Otkazivanja"
|
||||
|
||||
#: erpnext/manufacturing/doctype/job_card/job_card.py:1567
|
||||
msgid "Cancelled Job Card cannot be processed."
|
||||
msgstr ""
|
||||
msgstr "Otkazani Radni Nalog ne može se obraditi."
|
||||
|
||||
#: erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py:76
|
||||
msgid "Cannot Assign Cashier"
|
||||
@@ -9691,7 +9691,7 @@ msgstr "Nije moguće pretvoriti u Grupu jer je odabran Tip Računa."
|
||||
|
||||
#: erpnext/accounts/doctype/sales_invoice/mapper.py:372
|
||||
msgid "Cannot create Intercompany {0}. All items in the source {1} have already been fully invoiced. Please check the existing linked {2}s."
|
||||
msgstr ""
|
||||
msgstr "Nije moguće kreirati {0} između poduzeća. Svi početni artikli {1} su već u potpunosti fakturisani. Provjeri postojeće povezane {2}."
|
||||
|
||||
#: erpnext/stock/doctype/purchase_receipt/services/reservation.py:49
|
||||
msgid "Cannot create Stock Reservation Entries for future dated Purchase Receipts."
|
||||
@@ -9770,7 +9770,7 @@ msgstr "Nije moguće omogućiti račun zaliha po artiklima, jer postoje postoje
|
||||
|
||||
#: erpnext/crm/doctype/crm_settings/crm_settings.py:37
|
||||
msgid "Cannot enable Opportunity creation from Contact Us because the Contact Us form is disabled."
|
||||
msgstr ""
|
||||
msgstr "Nije moguće omogućiti kreiranje prilike iz kontakta jer je kontakt obrazac onemogućen."
|
||||
|
||||
#: erpnext/selling/doctype/sales_order/sales_order.py:624
|
||||
#: erpnext/selling/doctype/sales_order/sales_order.py:647
|
||||
@@ -9878,7 +9878,7 @@ msgstr "Nije moguće započeti brisanje. Drugo brisanje {0} je već u redu čeka
|
||||
|
||||
#: erpnext/manufacturing/doctype/job_card/job_card.py:922
|
||||
msgid "Cannot submit Job Card {0} while it is On Hold. Please resume and complete the job before submission."
|
||||
msgstr ""
|
||||
msgstr "Nije moguće podnijeti Radni Nalog {0} dok je na čekanju. Nastavi i završi posao prije podnošenja."
|
||||
|
||||
#: erpnext/accounts/services/child_item_update.py:283
|
||||
msgid "Cannot update rate as item {0} is already ordered or purchased against this quotation"
|
||||
@@ -13410,7 +13410,7 @@ msgstr "Kreiraj novi trag"
|
||||
|
||||
#: erpnext/selling/doctype/product_bundle/product_bundle.js:16
|
||||
msgid "Create New Version"
|
||||
msgstr ""
|
||||
msgstr "Kreiraj novu verziju"
|
||||
|
||||
#: banking/src/components/common/LinkFieldCombobox.tsx:284
|
||||
msgid "Create New {0}"
|
||||
@@ -14291,12 +14291,12 @@ msgstr "Trenutni Valuta kurs"
|
||||
#. Label of the current_invoice_end (Date) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Current Invoice End"
|
||||
msgstr ""
|
||||
msgstr "Trenutni Završni Datum Fakture"
|
||||
|
||||
#. Label of the current_invoice_start (Date) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Current Invoice Start"
|
||||
msgstr ""
|
||||
msgstr "Trenutni Početni Datum Fakture"
|
||||
|
||||
#. Label of the current_level (Int) field in DocType 'BOM Update Log'
|
||||
#: erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json
|
||||
@@ -17243,7 +17243,7 @@ msgstr "Onemogućeni Bankovni Račun"
|
||||
|
||||
#: erpnext/stock/doctype/packed_item/packed_item.py:216
|
||||
msgid "Disabled Product Bundle"
|
||||
msgstr ""
|
||||
msgstr "Onemogući Paket Artikala"
|
||||
|
||||
#: erpnext/stock/utils.py:434
|
||||
msgid "Disabled Warehouse {0} cannot be used for this transaction."
|
||||
@@ -18939,7 +18939,7 @@ msgstr "Omogući Program Bodova Lojalnosti"
|
||||
#. DocType 'CRM Settings'
|
||||
#: erpnext/crm/doctype/crm_settings/crm_settings.json
|
||||
msgid "Enable Opportunity Creation from Contact Us"
|
||||
msgstr ""
|
||||
msgstr "Omogući Kreiranje Prilika iz Kontaktiraj Nas obrasca"
|
||||
|
||||
#. Label of the enable_parallel_reposting (Check) field in DocType 'Stock
|
||||
#. Reposting Settings'
|
||||
@@ -19151,9 +19151,9 @@ msgid "Enabling this will do the following:\n"
|
||||
msgstr "Omogućavanje ovoga će učiniti sljedeće:\n"
|
||||
"<ul style=\"padding-left:16px\">\n"
|
||||
"<li>Omogućiti uređivanje kolone cjene u svim tabelama Pakiranih/Paketnih artikala.</li>\n"
|
||||
"<li>Izračunati cijene svih <a href=\"/desk/product-bundle\" rel=\"noopener noreferrer\">paketa proizvoda</a> u tabeli artikala na osnovu cijena njihovih podređenih artikala navedenih u tabeli pakiranih/paketiranih artikala. </li>\n"
|
||||
"<li>Izračunati cijene svih <a href=\"/desk/product-bundle\" rel=\"noopener noreferrer\">paketa artikala</a> u tabeli artikala na osnovu cijena njihovih podređenih artikala navedenih u tabeli pakiranih/paketiranih artikala. </li>\n"
|
||||
"</ul>\n"
|
||||
"Napomena: Ako je ovo omogućeno, ažuriranje cjene proizvoda u paketu u tabeli artikala neće promijeniti njegovu cijenu. Cijena će se vratiti na cijenu zasnovanu na podređenim artiklima prilikom spremanja dokumenta."
|
||||
"Napomena: Ako je ovo omogućeno, ažuriranje cjene artikala u paketu u tabeli artikala neće promijeniti njegovu cijenu. Cijena će se vratiti na cijenu zasnovanu na podređenim artiklima prilikom spremanja dokumenta."
|
||||
|
||||
#. Label of the encashment_date (Date) field in DocType 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
@@ -19541,7 +19541,7 @@ msgstr "Uloga Odobravatelja Izuzetka Proračuna"
|
||||
|
||||
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:53
|
||||
msgid "Excess Disassembly"
|
||||
msgstr "Prekomjerna Demontaža"
|
||||
msgstr "Prekomjerno Rastavljanje"
|
||||
|
||||
#: erpnext/manufacturing/report/work_order_consumed_materials/work_order_consumed_materials.js:55
|
||||
msgid "Excess Materials Consumed"
|
||||
@@ -19977,7 +19977,7 @@ msgstr "Račun troškova je obavezan za artikal {0}"
|
||||
#. Description of the 'Enable Deferred Revenue' (Check) field in DocType 'Item'
|
||||
#: erpnext/stock/doctype/item/item.json
|
||||
msgid "Expense for this item will be recognized over a period of months. Eg: prepaid insurance or annual software license"
|
||||
msgstr "Trošak za ovaj artikal bit će priznat tokom nekoliko mjeseci. Npr: unaprijed plaćeno osiguranje ili godišnja licenca za softver"
|
||||
msgstr "Trošak za ovaj artikal bit će priznat tokom nekoliko mjeseci. Npr: unaprijed plaćeno osiguranje ili godišnja licenca za program"
|
||||
|
||||
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:85
|
||||
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:145
|
||||
@@ -20225,7 +20225,7 @@ msgstr "Ažuriranje prioriteta pravila nije uspjelo"
|
||||
|
||||
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:521
|
||||
msgid "Failed to update subscription status for {0} {1}"
|
||||
msgstr ""
|
||||
msgstr "Nije uspjelo ažuriranje statusa pretplate za {0} {1}"
|
||||
|
||||
#. Label of the failure_date (Datetime) field in DocType 'Asset Repair'
|
||||
#: erpnext/assets/doctype/asset_repair/asset_repair.json
|
||||
@@ -20765,7 +20765,7 @@ msgstr "Gotov Proizvod {0} ne odgovara Radnom Nalogu {1}"
|
||||
|
||||
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:71
|
||||
msgid "Finished good quantity being consumed ({0} in stock UOM) must equal the quantity to disassemble ({1}). Do not change the UOM, conversion factor or quantity of the finished good row."
|
||||
msgstr ""
|
||||
msgstr "Količina gotovog proizvoda koja se troši ({0} u jedinici zaliha) mora biti jednaka količini za rastavljanje ({1}). Ne mijenjaj jedinicu, faktor konverzije ili količinu u redu gotovog proizvoda."
|
||||
|
||||
#: erpnext/selling/doctype/sales_order/sales_order.js:615
|
||||
msgid "First Delivery Date"
|
||||
@@ -23435,13 +23435,13 @@ msgstr "Ako je odbrano, ovaj artikal se tretira kao direktna dostava u Prodajnim
|
||||
#. Description of the 'Update Stock' (Check) field in DocType 'Sales Invoice'
|
||||
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.json
|
||||
msgid "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Delivery Note is created separately."
|
||||
msgstr ""
|
||||
msgstr "Ako je oodabrano, ažurira inventar; zalihe i knjigovodstveni unosi se kreiraju zajedno. Ostavi neodabrano ako se Dostavnica kreira zasebno."
|
||||
|
||||
#. Description of the 'Update Stock' (Check) field in DocType 'Purchase
|
||||
#. Invoice'
|
||||
#: erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
|
||||
msgid "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Purchase Receipt is created separately."
|
||||
msgstr ""
|
||||
msgstr "Ako je odabrano, ažurira se inventar; unosi zaliha i knjigoovodstva se kreiraju zajedno. Ostavi neodabrano ako Kupovni Račun kreira zasebno."
|
||||
|
||||
#: erpnext/public/js/setup_wizard.js:56
|
||||
msgid "If checked, we will create demo data for you to explore the system. This demo data can be erased later."
|
||||
@@ -25255,7 +25255,7 @@ msgstr "Nevažeće poduzeće za transakcije među poduzećima."
|
||||
|
||||
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.py:972
|
||||
msgid "Invalid Configuration"
|
||||
msgstr ""
|
||||
msgstr "Nevažeća Konfiguracija"
|
||||
|
||||
#: erpnext/accounts/services/taxes.py:294
|
||||
#: erpnext/assets/doctype/asset/asset.py:361
|
||||
@@ -25273,12 +25273,12 @@ msgstr "Nevažeći Datum Dostave"
|
||||
|
||||
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:110
|
||||
msgid "Invalid Disassembly Item"
|
||||
msgstr ""
|
||||
msgstr "Nevažeći Artikala za Rastavljanje"
|
||||
|
||||
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:76
|
||||
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:125
|
||||
msgid "Invalid Disassembly Quantity"
|
||||
msgstr ""
|
||||
msgstr "Nevažeća Količina za Rastavljanje"
|
||||
|
||||
#: erpnext/selling/page/point_of_sale/pos_item_cart.js:414
|
||||
msgid "Invalid Discount"
|
||||
@@ -26144,7 +26144,7 @@ msgstr "Je Fantomski Artikal"
|
||||
#: erpnext/selling/doctype/sales_order_item/sales_order_item.json
|
||||
#: erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
|
||||
msgid "Is Product Bundle"
|
||||
msgstr ""
|
||||
msgstr "Je Paket Artikala"
|
||||
|
||||
#. Label of the po_required (Select) field in DocType 'Buying Settings'
|
||||
#: erpnext/buying/doctype/buying_settings/buying_settings.json
|
||||
@@ -27652,7 +27652,7 @@ msgstr "Detalji Težine Artikla"
|
||||
#. Name of a report
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.json
|
||||
msgid "Item Where Used"
|
||||
msgstr ""
|
||||
msgstr "Gdje se koristi Artikal"
|
||||
|
||||
#. Label of a Link in the Buying Workspace
|
||||
#. Name of a report
|
||||
@@ -27775,7 +27775,7 @@ msgstr "Artikal {0} dodan je više puta pod isti nadređeni artikal {1} u redovi
|
||||
|
||||
#: erpnext/selling/doctype/product_bundle/product_bundle.js:54
|
||||
msgid "Item {0} already has an active Product Bundle ({1}). Submitting this will create a new version and deactivate {1}."
|
||||
msgstr ""
|
||||
msgstr "Artikal {0} već ima aktivni Paket Artikala ({1}). Podnošenjem ovoga kreiraće te novu verziju i deaktivirati {1}."
|
||||
|
||||
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:119
|
||||
msgid "Item {0} cannot be added as a sub-assembly of itself"
|
||||
@@ -28106,7 +28106,7 @@ msgstr "Stavka Radne Kartice"
|
||||
|
||||
#: erpnext/manufacturing/doctype/job_card/job_card.py:925
|
||||
msgid "Job Card On Hold"
|
||||
msgstr ""
|
||||
msgstr "Radni Nalog je na čekanju"
|
||||
|
||||
#. Name of a DocType
|
||||
#: erpnext/manufacturing/doctype/job_card_operation/job_card_operation.json
|
||||
@@ -30309,7 +30309,7 @@ msgstr "Usklađeno"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:57
|
||||
msgid "Matched Field"
|
||||
msgstr ""
|
||||
msgstr "Usklađeno polje"
|
||||
|
||||
#. Label of the matched_transaction_rule (Link) field in DocType 'Bank
|
||||
#. Transaction'
|
||||
@@ -30928,7 +30928,7 @@ msgstr "Metar/Sekunda"
|
||||
|
||||
#: erpnext/manufacturing/doctype/workstation/workstation.py:546
|
||||
msgid "Method {0} is not allowed to be run on a Job Card."
|
||||
msgstr ""
|
||||
msgstr "Metoda {0} se ne smije izvršavati na Radnom Nalogu."
|
||||
|
||||
#. Name of a UOM
|
||||
#: erpnext/setup/setup_wizard/data/uom_data.json
|
||||
@@ -32258,13 +32258,13 @@ msgstr "Newton"
|
||||
#. Label of the next_billing_period_end (Date) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Next Billing Period End"
|
||||
msgstr ""
|
||||
msgstr "Sljedeći Perioda Fakturisanja Završava"
|
||||
|
||||
#. Label of the next_billing_period_start (Date) field in DocType
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Next Billing Period Start"
|
||||
msgstr ""
|
||||
msgstr "Sljedeći Perioda Fakturisanja Počinje"
|
||||
|
||||
#. Label of the next_depreciation_date (Date) field in DocType 'Asset'
|
||||
#: erpnext/assets/doctype/asset/asset.json
|
||||
@@ -33476,7 +33476,7 @@ msgstr "Samo jedna operacija može imati odabranu opciju 'Je li Gotov Proizvod'
|
||||
#. Description of the 'Is Active' (Check) field in DocType 'Product Bundle'
|
||||
#: erpnext/selling/doctype/product_bundle/product_bundle.json
|
||||
msgid "Only one version of a Product Bundle can be active at a time for a given Parent Item. Activating a version deactivates the previously active one."
|
||||
msgstr ""
|
||||
msgstr "Samo jedna verzija Paketa Artikala može biti aktivna u datom trenutku za dati Nadređeni Artikal. Aktiviranje verzije deaktivira prethodno aktivnu verziju."
|
||||
|
||||
#: erpnext/stock/doctype/stock_entry/stock_entry.py:719
|
||||
msgid "Only one {0} entry can be created against the Work Order {1}"
|
||||
@@ -39083,7 +39083,7 @@ msgstr "Vremenska oznaka knjiženja mora biti nakon {0}"
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Postpaid (bill at period end)"
|
||||
msgstr ""
|
||||
msgstr "Naknadno Plaćeno (faktura na završetku perioda)"
|
||||
|
||||
#. Description of a DocType
|
||||
#: erpnext/crm/doctype/opportunity/opportunity.json
|
||||
@@ -39186,7 +39186,7 @@ msgstr "Preferirana e-pošta"
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Prepaid (bill at period start)"
|
||||
msgstr ""
|
||||
msgstr "Unaprijed Plaćeno (faktura na početku perioda)"
|
||||
|
||||
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:34
|
||||
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:51
|
||||
@@ -40174,7 +40174,7 @@ msgstr "Stanje Paketa Proizvoda"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:278
|
||||
msgid "Product Bundle Component"
|
||||
msgstr ""
|
||||
msgstr "Komponenta Paketa Artikala"
|
||||
|
||||
#. Label of the product_bundle_help (HTML) field in DocType 'POS Invoice'
|
||||
#. Label of the product_bundle_help (HTML) field in DocType 'Sales Invoice'
|
||||
@@ -40195,11 +40195,11 @@ msgstr "Pomoć Paketa Proizvoda"
|
||||
#: erpnext/selling/doctype/product_bundle_item/product_bundle_item.json
|
||||
#: erpnext/stock/doctype/pick_list_item/pick_list_item.json
|
||||
msgid "Product Bundle Item"
|
||||
msgstr "Artikal Paketa Proizvoda"
|
||||
msgstr "Artikal Paketa Artikala"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:305
|
||||
msgid "Product Bundle Parent"
|
||||
msgstr ""
|
||||
msgstr "Nadređeni Paket Artikala"
|
||||
|
||||
#. Description of the 'Product Bundle' (Link) field in DocType 'Purchase
|
||||
#. Invoice Item'
|
||||
@@ -40213,15 +40213,15 @@ msgstr ""
|
||||
#: erpnext/stock/doctype/packed_item/packed_item.json
|
||||
#: erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
|
||||
msgid "Product Bundle version this row was packed from"
|
||||
msgstr ""
|
||||
msgstr "Verzija Paketa Artikala iz koje je ovaj red preuzet iz"
|
||||
|
||||
#: erpnext/stock/doctype/packed_item/packed_item.py:453
|
||||
msgid "Product Bundle {0} is disabled and cannot be used in transactions."
|
||||
msgstr ""
|
||||
msgstr "Paket Artikala {0} je onemogućen i ne može se koristiti u transakcijama."
|
||||
|
||||
#: erpnext/stock/doctype/packed_item/packed_item.py:450
|
||||
msgid "Product Bundle {0} is not submitted"
|
||||
msgstr ""
|
||||
msgstr "Paket Artikala {0} nije podnešen"
|
||||
|
||||
#. Label of the product_discount_scheme_section (Section Break) field in
|
||||
#. DocType 'Pricing Rule'
|
||||
@@ -43881,7 +43881,7 @@ msgstr "Osvježite Plaid Link"
|
||||
#. Option for the 'Status' (Select) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Refunded"
|
||||
msgstr ""
|
||||
msgstr "Povraćeno"
|
||||
|
||||
#: erpnext/stock/reorder_item.py:390
|
||||
msgid "Regards,"
|
||||
@@ -43992,7 +43992,7 @@ msgstr "Povezano"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:50
|
||||
msgid "Related Item"
|
||||
msgstr ""
|
||||
msgstr "Povezani Artikal"
|
||||
|
||||
#. Label of the relation (Data) field in DocType 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
@@ -46064,7 +46064,7 @@ msgstr "Red #{0}: Gotov Proizvod artikla nije navedena zaservisni artikal {1}"
|
||||
|
||||
#: erpnext/manufacturing/doctype/bom/bom.py:371
|
||||
msgid "Row #{0}: Finished Good Item {1} cannot be added in the Secondary Items table."
|
||||
msgstr ""
|
||||
msgstr "Red #{0}: Artikal Gotovog Proizvoda {1} ne može se dodati u tabelu Sekundarnih Artikala."
|
||||
|
||||
#: erpnext/buying/doctype/purchase_order/services/subcontracting.py:28
|
||||
#: erpnext/selling/doctype/sales_order/services/subcontracting.py:27
|
||||
@@ -46155,7 +46155,7 @@ msgstr "Red #{0}: Artikal {1} nije artikal na zalihama"
|
||||
|
||||
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:106
|
||||
msgid "Row #{0}: Item {1} is not part of the source manufacture entry and cannot be added to this disassembly."
|
||||
msgstr ""
|
||||
msgstr "Red #{0}: Artikal {1} nije dio unosa izvornog proizvođača i ne može se dodati ovom rastavljanju."
|
||||
|
||||
#: erpnext/controllers/subcontracting_inward_controller.py:79
|
||||
msgid "Row #{0}: Item {1} mismatch. Changing of item code is not permitted, add another row instead."
|
||||
@@ -46167,7 +46167,7 @@ msgstr "Red #{0}: Artikla {1} se ne slaže. Promjena koda artikla nije dozvoljen
|
||||
|
||||
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:115
|
||||
msgid "Row #{0}: Item {1} quantity ({2} in stock UOM) does not match the quantity derived from the source ({3}). Do not change the UOM, conversion factor or quantity of disassembly rows."
|
||||
msgstr ""
|
||||
msgstr "Red #{0}: Količina artikla {1} ({2} u jedinici zaliha) ne odgovara količini izvedenoj iz izvora ({3}). Ne mijenjaj jedinicu, faktor konverzije ili količinu redova za rastavljanje."
|
||||
|
||||
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:780
|
||||
msgid "Row #{0}: Journal Entry {1} does not have account {2} or already matched against another voucher"
|
||||
@@ -46233,7 +46233,7 @@ msgstr "Red #{0}: Procentualni Gubitka Procesa treba da bude manji od 100% za {1
|
||||
|
||||
#: erpnext/stock/doctype/packed_item/packed_item.py:213
|
||||
msgid "Row #{0}: Product Bundle {1} is disabled and cannot be used in transactions."
|
||||
msgstr ""
|
||||
msgstr "Red #{0}: Paket Artikal {1} je onemogućen i ne može se koristiti u transakcijama."
|
||||
|
||||
#: erpnext/public/js/utils/barcode_scanner.js:425
|
||||
msgid "Row #{0}: Qty increased by {1}"
|
||||
@@ -49490,7 +49490,7 @@ msgstr "Serijski i Šaržni Paket {0} nije podnešen"
|
||||
|
||||
#: erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py:2251
|
||||
msgid "Serial and Batch Bundle {0} is submitted and its entries cannot be modified."
|
||||
msgstr ""
|
||||
msgstr "Serijski i Šaržni Paket {0} je podnešen i njegovi unosi se ne mogu mijenjati."
|
||||
|
||||
#. Label of the section_break_45 (Section Break) field in DocType
|
||||
#. 'Subcontracting Receipt Item'
|
||||
@@ -52668,7 +52668,7 @@ msgstr "Podizvođačka Dostava"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:362
|
||||
msgid "Subcontracting Finished Good"
|
||||
msgstr ""
|
||||
msgstr "Podizvođački Gotov Proizvod"
|
||||
|
||||
#. Label of the subcontracting_inward_tab (Tab Break) field in DocType 'Selling
|
||||
#. Settings'
|
||||
@@ -52852,7 +52852,7 @@ msgstr "Podizvođački Prodajni Nalog"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:336
|
||||
msgid "Subcontracting Service Item"
|
||||
msgstr ""
|
||||
msgstr "Podizvođački Uslužni Artikal"
|
||||
|
||||
#. Label of the subcontract (Tab Break) field in DocType 'Buying Settings'
|
||||
#: erpnext/buying/doctype/buying_settings/buying_settings.json
|
||||
@@ -52900,7 +52900,7 @@ msgstr "Podnesi Ponudu"
|
||||
|
||||
#: erpnext/manufacturing/doctype/job_card/job_card.py:1570
|
||||
msgid "Submitted Job Card cannot be processed."
|
||||
msgstr ""
|
||||
msgstr "Podnešeni Radni Nalog ne može biti obrađen."
|
||||
|
||||
#. Label of the subscription_section (Section Break) field in DocType 'Payment
|
||||
#. Request'
|
||||
@@ -55486,7 +55486,7 @@ msgstr "Otpremljena datoteka nije u važećem MT940 formatu."
|
||||
|
||||
#: erpnext/edi/doctype/code_list/code_list_import.py:40
|
||||
msgid "The uploaded file does not match the selected Code List."
|
||||
msgstr "Učitani fajl ne odgovara odabranoj Listi Kodova."
|
||||
msgstr "Učitana datoteka ne odgovara odabranoj Listi Kodova."
|
||||
|
||||
#: erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.js:10
|
||||
msgid "The user cannot submit the Serial and Batch Bundle manually"
|
||||
@@ -61720,7 +61720,7 @@ msgstr "Ne možete podnijeti nalog bez plaćanja."
|
||||
|
||||
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.py:968
|
||||
msgid "You cannot update stock for a Debit Note. A Debit Note is a financial document that should not affect inventory. Please disable 'Update Stock'."
|
||||
msgstr ""
|
||||
msgstr "Ne možete ažurirati zalihe za debitnu notu. Debitna nota je finansijski dokument koji ne bi trebao utjecati na zalihe. Molimo vas da onemogućite opciju 'Ažuriraj Zalihe'."
|
||||
|
||||
#: erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py:106
|
||||
msgid "You cannot {0} this document because another Period Closing Entry {1} exists after {2}"
|
||||
@@ -62086,7 +62086,7 @@ msgstr "izvodi bilo koje dolje:"
|
||||
#. Item'
|
||||
#: erpnext/stock/doctype/pick_list_item/pick_list_item.json
|
||||
msgid "product bundle item row's name in sales order. Also indicates that picked item is to be used for a product bundle"
|
||||
msgstr "naziv reda artikla paketa proizvoda u prodajnom nalogu. Također označava da odabrani artikal treba koristiti za paket proizvoda"
|
||||
msgstr "naziv reda artikla paketa artikala u prodajnom nalogu. Također označava da odabrani artikal treba koristiti za paket artikala"
|
||||
|
||||
#. Option for the 'Plaid Environment' (Select) field in DocType 'Plaid
|
||||
#. Settings'
|
||||
@@ -62834,7 +62834,7 @@ msgstr "{0}, završi operaciju {1} prije operacije {2}."
|
||||
|
||||
#: erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py:61
|
||||
msgid "{0}, {1} or {2} are the only allowed options."
|
||||
msgstr ""
|
||||
msgstr "{0}, {1} ili {2} su jedine dozvoljene opcije."
|
||||
|
||||
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:525
|
||||
msgid "{0}: Child table (auto-deleted with parent)"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: hello@frappe.io\n"
|
||||
"POT-Creation-Date: 2026-06-14 10:35+0000\n"
|
||||
"PO-Revision-Date: 2026-06-16 17:39\n"
|
||||
"PO-Revision-Date: 2026-06-17 17:52\n"
|
||||
"Last-Translator: hello@frappe.io\n"
|
||||
"Language-Team: Persian\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
@@ -4507,7 +4507,7 @@ msgstr "آیتم جایگزین"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:427
|
||||
msgid "Alternative For Item"
|
||||
msgstr ""
|
||||
msgstr "جایگزین برای آیتم"
|
||||
|
||||
#. Label of the alternative_item_code (Link) field in DocType 'Item
|
||||
#. Alternative'
|
||||
@@ -6828,7 +6828,7 @@ msgstr "ابزار مقایسه BOM"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:178
|
||||
msgid "BOM Component"
|
||||
msgstr ""
|
||||
msgstr "مولفه BOM"
|
||||
|
||||
#. Label of the bom_conf_tab (Tab Break) field in DocType 'BOM'
|
||||
#: erpnext/manufacturing/doctype/bom/bom.json
|
||||
@@ -6859,7 +6859,7 @@ msgstr "آیتم ایجاد کننده BOM"
|
||||
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:392
|
||||
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:535
|
||||
msgid "BOM Creator Item with name {0} does not exist"
|
||||
msgstr ""
|
||||
msgstr "آیتم سازنده BOM با نام {0} وجود ندارد"
|
||||
|
||||
#. Label of the bom_detail_no (Data) field in DocType 'Purchase Receipt Item
|
||||
#. Supplied'
|
||||
@@ -6959,7 +6959,7 @@ msgstr "زمان عملیات BOM"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:248
|
||||
msgid "BOM Output"
|
||||
msgstr ""
|
||||
msgstr "خروجی BOM"
|
||||
|
||||
#: erpnext/stock/report/item_prices/item_prices.py:60
|
||||
msgid "BOM Rate"
|
||||
@@ -7630,7 +7630,7 @@ msgstr "تراکنش بانکی {0} به روز شد"
|
||||
|
||||
#: banking/src/pages/BankReconciliation.tsx:118
|
||||
msgid "Bank Transactions"
|
||||
msgstr ""
|
||||
msgstr "تراکنشهای بانکی"
|
||||
|
||||
#: erpnext/setup/setup_wizard/operations/install_fixtures.py:584
|
||||
msgid "Bank account cannot be named as {0}"
|
||||
@@ -8132,13 +8132,13 @@ msgstr "تاریخ صورتحساب"
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Bill Even If Previous Invoice Unpaid"
|
||||
msgstr ""
|
||||
msgstr "صدور صورتحساب حتی اگر فاکتور قبلی پرداخت نشده باشد"
|
||||
|
||||
#. Option for the 'Generate Invoice At' (Select) field in DocType
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Bill N days before period start"
|
||||
msgstr ""
|
||||
msgstr "صورتحساب N روز قبل از شروع دوره"
|
||||
|
||||
#. Label of the bill_no (Data) field in DocType 'Journal Entry'
|
||||
#. Label of the bill_no (Data) field in DocType 'Subcontracting Receipt'
|
||||
@@ -8320,13 +8320,13 @@ msgstr "ایمیل صورتحساب"
|
||||
#. Label of the billing_heatmap (HTML) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Billing Heatmap"
|
||||
msgstr ""
|
||||
msgstr "نقشه حرارتی صورتحساب"
|
||||
|
||||
#. Label of the billing_history_section (Section Break) field in DocType
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Billing History"
|
||||
msgstr ""
|
||||
msgstr "تاریخچه صورتحساب"
|
||||
|
||||
#. Label of the billing_hours (Float) field in DocType 'Sales Invoice
|
||||
#. Timesheet'
|
||||
@@ -8360,7 +8360,7 @@ msgstr "بازه صورتحساب در طرح اشتراک باید ماه با
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Billing Period"
|
||||
msgstr ""
|
||||
msgstr "دوره صورتحساب"
|
||||
|
||||
#. Label of the billing_rate (Currency) field in DocType 'Activity Cost'
|
||||
#. Label of the billing_rate (Currency) field in DocType 'Timesheet Detail'
|
||||
@@ -8905,11 +8905,11 @@ msgstr "ساختمان ها"
|
||||
|
||||
#: banking/src/components/features/ActionLog/ActionLogDialogBody.tsx:88
|
||||
msgid "Bulk Bank Entry"
|
||||
msgstr ""
|
||||
msgstr "ثبت بانک انبوه"
|
||||
|
||||
#: banking/src/components/features/ActionLog/ActionLogDialogBody.tsx:76
|
||||
msgid "Bulk Payment"
|
||||
msgstr ""
|
||||
msgstr "پرداخت انبوه"
|
||||
|
||||
#: erpnext/utilities/doctype/rename_tool/rename_tool.js:71
|
||||
msgid "Bulk Rename Jobs"
|
||||
@@ -9457,7 +9457,7 @@ msgstr "لغو اشتراک پس از دوره مهلت"
|
||||
#. Label of the cancel_at_period_end (Check) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Cancel When Period Ends"
|
||||
msgstr ""
|
||||
msgstr "لغو هنگام پایان دوره"
|
||||
|
||||
#. Label of the cancelation_date (Date) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
@@ -13320,7 +13320,7 @@ msgstr "ایجاد سرنخ جدید"
|
||||
|
||||
#: erpnext/selling/doctype/product_bundle/product_bundle.js:16
|
||||
msgid "Create New Version"
|
||||
msgstr ""
|
||||
msgstr "ایجاد نسخه جدید"
|
||||
|
||||
#: banking/src/components/common/LinkFieldCombobox.tsx:284
|
||||
msgid "Create New {0}"
|
||||
@@ -14201,12 +14201,12 @@ msgstr "نرخ ارز فعلی"
|
||||
#. Label of the current_invoice_end (Date) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Current Invoice End"
|
||||
msgstr ""
|
||||
msgstr "پایان فاکتور فعلی"
|
||||
|
||||
#. Label of the current_invoice_start (Date) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Current Invoice Start"
|
||||
msgstr ""
|
||||
msgstr "شروع فاکتور فعلی"
|
||||
|
||||
#. Label of the current_level (Int) field in DocType 'BOM Update Log'
|
||||
#: erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json
|
||||
@@ -17153,7 +17153,7 @@ msgstr ""
|
||||
|
||||
#: erpnext/stock/doctype/packed_item/packed_item.py:216
|
||||
msgid "Disabled Product Bundle"
|
||||
msgstr ""
|
||||
msgstr "بسته محصول غیرفعال"
|
||||
|
||||
#: erpnext/stock/utils.py:434
|
||||
msgid "Disabled Warehouse {0} cannot be used for this transaction."
|
||||
@@ -25029,7 +25029,7 @@ msgstr "مرجع فروش داخلی وجود ندارد"
|
||||
#. 'Supplier'
|
||||
#: erpnext/buying/doctype/supplier/supplier.json
|
||||
msgid "Internal Supplier Details"
|
||||
msgstr ""
|
||||
msgstr "جزئیات تأمینکننده داخلی"
|
||||
|
||||
#: erpnext/buying/doctype/supplier/supplier.py:180
|
||||
msgid "Internal Supplier for company {0} already exists"
|
||||
@@ -25060,7 +25060,7 @@ msgstr "مرجع انتقال داخلی وجود ندارد"
|
||||
#. DocType 'Stock Settings'
|
||||
#: erpnext/stock/doctype/stock_settings/stock_settings.json
|
||||
msgid "Internal Transfer Rules"
|
||||
msgstr ""
|
||||
msgstr "قوانین انتقال داخلی"
|
||||
|
||||
#: erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py:37
|
||||
msgid "Internal Transfers"
|
||||
@@ -25196,7 +25196,7 @@ msgstr "نوع سند نامعتبر است"
|
||||
|
||||
#: erpnext/selling/report/sales_analytics/sales_analytics.py:529
|
||||
msgid "Invalid Document Type {0}"
|
||||
msgstr ""
|
||||
msgstr "نوع سند نامعتبر {0}"
|
||||
|
||||
#: erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py:207
|
||||
msgid "Invalid File Type"
|
||||
@@ -25313,7 +25313,7 @@ msgstr "انبار منبع و هدف نامعتبر"
|
||||
|
||||
#: erpnext/selling/report/sales_analytics/sales_analytics.py:507
|
||||
msgid "Invalid Tree Type {0}"
|
||||
msgstr ""
|
||||
msgstr "نوع درخت نامعتبر {0}"
|
||||
|
||||
#: erpnext/edi/doctype/code_list/code_list_import.py:37
|
||||
msgid "Invalid Upload"
|
||||
@@ -25374,11 +25374,11 @@ msgstr "پرسمان جستجوی نامعتبر"
|
||||
|
||||
#: erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py:99
|
||||
msgid "Invalid value {0} for 'Based On'"
|
||||
msgstr ""
|
||||
msgstr "مقدار نامعتبر {0} برای 'Based On'"
|
||||
|
||||
#: erpnext/selling/report/inactive_customers/inactive_customers.py:20
|
||||
msgid "Invalid value {0} for 'Doctype'"
|
||||
msgstr ""
|
||||
msgstr "مقدار نامعتبر {0} برای 'Doctype'"
|
||||
|
||||
#: erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py:109
|
||||
#: erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py:119
|
||||
@@ -25411,7 +25411,7 @@ msgstr "فهرست موجودی"
|
||||
#. Default'
|
||||
#: erpnext/stock/doctype/item_default/item_default.json
|
||||
msgid "Inventory Account"
|
||||
msgstr ""
|
||||
msgstr "حساب موجودی"
|
||||
|
||||
#. Label of the inventory_account_currency (Link) field in DocType 'Item
|
||||
#. Default'
|
||||
@@ -26993,7 +26993,7 @@ msgstr "نام گروه آیتم"
|
||||
|
||||
#: erpnext/setup/doctype/item_group/item_group.js:119
|
||||
msgid "Item Group Override"
|
||||
msgstr ""
|
||||
msgstr "بازتعریف گروه آیتم"
|
||||
|
||||
#: erpnext/setup/doctype/item_group/item_group.js:82
|
||||
msgid "Item Group Tree"
|
||||
@@ -27267,7 +27267,7 @@ msgstr "آیتم موجود نیست"
|
||||
#. Default'
|
||||
#: erpnext/stock/doctype/item_default/item_default.json
|
||||
msgid "Item Override"
|
||||
msgstr ""
|
||||
msgstr "بازتعریف آیتم"
|
||||
|
||||
#. Label of a Link in the Buying Workspace
|
||||
#. Label of a Link in the Selling Workspace
|
||||
@@ -29370,7 +29370,7 @@ msgstr "نگهداری موجودی"
|
||||
#. DocType 'Accounts Settings'
|
||||
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
|
||||
msgid "Maintain same rate throughout internal Transaction"
|
||||
msgstr ""
|
||||
msgstr "حفظ نرخ یکسان در کل تراکنشهای داخلی"
|
||||
|
||||
#. Label of the maintain_same_sales_rate (Check) field in DocType 'Selling
|
||||
#. Settings'
|
||||
@@ -30760,7 +30760,7 @@ msgstr "ادغام پیشرفت"
|
||||
#. Settings'
|
||||
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
|
||||
msgid "Merge similar Account Heads"
|
||||
msgstr ""
|
||||
msgstr "ادغام سر فصلهای حساب مشابه"
|
||||
|
||||
#: erpnext/public/js/utils.js:1090
|
||||
msgid "Merge taxes from multiple documents"
|
||||
@@ -31140,7 +31140,7 @@ msgstr ""
|
||||
|
||||
#: erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py:929
|
||||
msgid "Missing Dependency"
|
||||
msgstr ""
|
||||
msgstr "وابستگی گمشده"
|
||||
|
||||
#: erpnext/stock/report/serial_no_and_batch_traceability/serial_no_and_batch_traceability.py:44
|
||||
msgid "Missing Filters"
|
||||
@@ -31627,7 +31627,7 @@ msgstr "مقدار منفی مجاز نیست"
|
||||
#. Settings'
|
||||
#: erpnext/stock/doctype/stock_settings/stock_settings.json
|
||||
msgid "Negative Stock"
|
||||
msgstr ""
|
||||
msgstr "موجودی منفی"
|
||||
|
||||
#: erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py:1608
|
||||
#: erpnext/stock/serial_batch_bundle.py:1549
|
||||
@@ -32295,7 +32295,7 @@ msgstr "هیچ تامین کننده ای برای Inter Company Transactions ی
|
||||
|
||||
#: erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py:976
|
||||
msgid "No Tables Detected"
|
||||
msgstr ""
|
||||
msgstr "هیچ جدولی شناسایی نشد"
|
||||
|
||||
#: erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py:100
|
||||
msgid "No Tax Withholding data found for the current posting date."
|
||||
@@ -32341,7 +32341,7 @@ msgstr "هیچ BOM فعالی برای آیتم {0} یافت نشد. تحویل
|
||||
|
||||
#: erpnext/stock/doctype/item/item_prices.html:135
|
||||
msgid "No active item prices found."
|
||||
msgstr ""
|
||||
msgstr "هیچ قیمت آیتم فعالی یافت نشد."
|
||||
|
||||
#: erpnext/stock/doctype/item_variant_settings/item_variant_settings.js:46
|
||||
msgid "No additional fields available"
|
||||
@@ -43778,7 +43778,7 @@ msgstr "پیوند شطرنجی را تازه کنید"
|
||||
#. Option for the 'Status' (Select) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Refunded"
|
||||
msgstr ""
|
||||
msgstr "استرداد وجه شده"
|
||||
|
||||
#: erpnext/stock/reorder_item.py:390
|
||||
msgid "Regards,"
|
||||
@@ -43889,7 +43889,7 @@ msgstr "مربوط"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:50
|
||||
msgid "Related Item"
|
||||
msgstr ""
|
||||
msgstr "آیتم مرتبط"
|
||||
|
||||
#. Label of the relation (Data) field in DocType 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
@@ -48858,7 +48858,7 @@ msgstr "مبلغ فروش"
|
||||
#. Default'
|
||||
#: erpnext/stock/doctype/item_default/item_default.json
|
||||
msgid "Selling Cost Center"
|
||||
msgstr ""
|
||||
msgstr "مرکز هزینه فروش"
|
||||
|
||||
#: erpnext/stock/report/item_price_stock/item_price_stock.py:48
|
||||
msgid "Selling Price List"
|
||||
@@ -49026,7 +49026,7 @@ msgstr "شماره های سریال / دسته ای"
|
||||
#. Settings'
|
||||
#: erpnext/stock/doctype/stock_settings/stock_settings.json
|
||||
msgid "Serial Item settings"
|
||||
msgstr ""
|
||||
msgstr "تنظیمات آیتم سریال"
|
||||
|
||||
#. Label of the serial_no (Text) field in DocType 'POS Invoice Item'
|
||||
#. Label of the serial_no (Text) field in DocType 'Purchase Invoice Item'
|
||||
@@ -50640,7 +50640,7 @@ msgstr "نمایش جزئیات پرداخت"
|
||||
#. 'Accounts Settings'
|
||||
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
|
||||
msgid "Show Payment Schedule in print"
|
||||
msgstr ""
|
||||
msgstr "نمایش زمانبندی پرداخت در چاپ"
|
||||
|
||||
#. Label of the show_remarks (Check) field in DocType 'Process Statement Of
|
||||
#. Accounts'
|
||||
@@ -50684,12 +50684,12 @@ msgstr ""
|
||||
#. Settings'
|
||||
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
|
||||
msgid "Show balances in Chart of Accounts"
|
||||
msgstr ""
|
||||
msgstr "نمایش ترازها در نمودار حسابها"
|
||||
|
||||
#. Label of the show_barcode_field (Check) field in DocType 'Stock Settings'
|
||||
#: erpnext/stock/doctype/stock_settings/stock_settings.json
|
||||
msgid "Show barcode field in stock transactions"
|
||||
msgstr ""
|
||||
msgstr "نمایش فیلد بارکد در تراکنشهای موجودی"
|
||||
|
||||
#: erpnext/manufacturing/report/material_requirements_planning_report/material_requirements_planning_report.js:88
|
||||
msgid "Show in Bucket View"
|
||||
@@ -50704,7 +50704,7 @@ msgstr "نمایش در وب سایت"
|
||||
#. Settings'
|
||||
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
|
||||
msgid "Show inclusive tax in print"
|
||||
msgstr ""
|
||||
msgstr "نمایش مالیات فراگیر در چاپ"
|
||||
|
||||
#. Description of the 'Reverse Sign' (Check) field in DocType 'Financial Report
|
||||
#. Row'
|
||||
@@ -50738,7 +50738,7 @@ msgstr "نمایش ثبتهای در انتظار"
|
||||
#. Settings'
|
||||
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
|
||||
msgid "Show taxes as table in print"
|
||||
msgstr ""
|
||||
msgstr "نمایش مالیاتها به صورت جدول در چاپ"
|
||||
|
||||
#: erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.js:80
|
||||
#: erpnext/accounts/report/trial_balance/trial_balance.js:100
|
||||
@@ -50839,7 +50839,7 @@ msgstr ""
|
||||
|
||||
#: erpnext/stock/doctype/stock_entry/stock_entry.py:503
|
||||
msgid "Since there is a process loss of {0} units for the finished good {1}, you should reduce the quantity by {0} units for the finished good {1} in the Items Table."
|
||||
msgstr ""
|
||||
msgstr "از آنجایی که برای کالای نهایی {1}، اتلاف فرآیند {0} واحد وجود دارد، شما باید مقدار {0} واحد برای کالای نهایی {1} در جدول آیتمها را کاهش دهید."
|
||||
|
||||
#: erpnext/manufacturing/doctype/bom/bom.py:355
|
||||
msgid "Since you have enabled 'Track Semi Finished Goods', at least one operation must have 'Is Final Finished Good' checked. For that set the FG / Semi FG Item as {0} against an operation."
|
||||
@@ -51444,7 +51444,7 @@ msgstr ""
|
||||
#. Label of the statement_password (Password) field in DocType 'Bank Account'
|
||||
#: erpnext/accounts/doctype/bank_account/bank_account.json
|
||||
msgid "Statement PDF Password"
|
||||
msgstr ""
|
||||
msgstr "گذرواژه PDF صورتحساب"
|
||||
|
||||
#: erpnext/accounts/report/general_ledger/general_ledger.html:145
|
||||
msgid "Statement Period"
|
||||
@@ -52288,7 +52288,7 @@ msgstr ""
|
||||
#. Label of the stock_frozen_upto (Date) field in DocType 'Stock Settings'
|
||||
#: erpnext/stock/doctype/stock_settings/stock_settings.json
|
||||
msgid "Stock frozen up to"
|
||||
msgstr ""
|
||||
msgstr "موجودی منجمد تا"
|
||||
|
||||
#: erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py:1140
|
||||
msgid "Stock has been unreserved for work order {0}."
|
||||
@@ -52556,7 +52556,7 @@ msgstr ""
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:362
|
||||
msgid "Subcontracting Finished Good"
|
||||
msgstr ""
|
||||
msgstr "کالای نهایی پیمانکاری فرعی"
|
||||
|
||||
#. Label of the subcontracting_inward_tab (Tab Break) field in DocType 'Selling
|
||||
#. Settings'
|
||||
@@ -52740,7 +52740,7 @@ msgstr ""
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:336
|
||||
msgid "Subcontracting Service Item"
|
||||
msgstr ""
|
||||
msgstr "آیتم خدمات پیمانکاری فرعی"
|
||||
|
||||
#. Label of the subcontract (Tab Break) field in DocType 'Buying Settings'
|
||||
#: erpnext/buying/doctype/buying_settings/buying_settings.json
|
||||
@@ -52776,7 +52776,7 @@ msgstr "فاکتورهای تولید شده را ارسال کنید"
|
||||
#. Settings'
|
||||
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
|
||||
msgid "Submit Journal entries"
|
||||
msgstr ""
|
||||
msgstr "ارسال ثبتهای دفتر روزنامه"
|
||||
|
||||
#: erpnext/manufacturing/doctype/work_order/work_order.js:185
|
||||
msgid "Submit this Work Order for further processing."
|
||||
@@ -52788,7 +52788,7 @@ msgstr "پیشفاکتور خود را ارسال کنید"
|
||||
|
||||
#: erpnext/manufacturing/doctype/job_card/job_card.py:1570
|
||||
msgid "Submitted Job Card cannot be processed."
|
||||
msgstr ""
|
||||
msgstr "کارت شغلی ارسالشده قابل پردازش نیست."
|
||||
|
||||
#. Label of the subscription_section (Section Break) field in DocType 'Payment
|
||||
#. Request'
|
||||
@@ -53733,7 +53733,7 @@ msgstr "جدول برای آیتم که در وب سایت نشان داده خ
|
||||
#: banking/src/components/features/BankStatementImporter/PDF/PDFTableEditor.tsx:312
|
||||
#: banking/src/components/features/BankStatementImporter/PDF/PDFTableEditor.tsx:329
|
||||
msgid "Table {0}"
|
||||
msgstr ""
|
||||
msgstr "جدول {0}"
|
||||
|
||||
#. Name of a UOM
|
||||
#: erpnext/setup/setup_wizard/data/uom_data.json
|
||||
@@ -54133,7 +54133,7 @@ msgstr "شناسه مالیاتی: {0}"
|
||||
#. Label of the taxation_section (Section Break) field in DocType 'Supplier'
|
||||
#: erpnext/buying/doctype/supplier/supplier.json
|
||||
msgid "Tax Identification"
|
||||
msgstr ""
|
||||
msgstr "شناسایی مالیات"
|
||||
|
||||
#. Label of a Card Break in the Invoicing Workspace
|
||||
#: erpnext/accounts/workspace/invoicing/invoicing.json
|
||||
@@ -55545,7 +55545,7 @@ msgstr "هنگام انجام اقدام خطایی رخ داد."
|
||||
|
||||
#: banking/src/components/ui/error-banner.tsx:21
|
||||
msgid "There was an error."
|
||||
msgstr ""
|
||||
msgstr "خطایی رخ داده است."
|
||||
|
||||
#: erpnext/accounts/doctype/bank/bank.js:112
|
||||
#: erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js:119
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: hello@frappe.io\n"
|
||||
"POT-Creation-Date: 2026-06-14 10:35+0000\n"
|
||||
"PO-Revision-Date: 2026-06-14 17:01\n"
|
||||
"PO-Revision-Date: 2026-06-18 18:25\n"
|
||||
"Last-Translator: hello@frappe.io\n"
|
||||
"Language-Team: Croatian\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
@@ -1102,7 +1102,7 @@ msgstr "Klijent mora imati primarni kontakt e-poštu."
|
||||
#. Description of the 'Disabled' (Check) field in DocType 'Product Bundle'
|
||||
#: erpnext/selling/doctype/product_bundle/product_bundle.json
|
||||
msgid "A disabled Product Bundle cannot be selected in transactions."
|
||||
msgstr ""
|
||||
msgstr "Onemogućeni Paket Artikal ne može se odabrati u transakcijama."
|
||||
|
||||
#: erpnext/stock/doctype/delivery_trip/delivery_trip.py:59
|
||||
msgid "A driver must be set to submit."
|
||||
@@ -4487,7 +4487,7 @@ msgstr "Dopusti Uređivanje Količine Jedinice Zaliha za Dokumente Prodaje"
|
||||
#. DocType 'Stock Settings'
|
||||
#: erpnext/stock/doctype/stock_settings/stock_settings.json
|
||||
msgid "Allow to edit stock UOM qty for Stock Entry"
|
||||
msgstr ""
|
||||
msgstr "Omogući uređivanje količine jedinice zaliha za Unos Zaliha"
|
||||
|
||||
#. Label of the allow_to_make_quality_inspection_after_purchase_or_delivery
|
||||
#. (Check) field in DocType 'Stock Settings'
|
||||
@@ -4597,7 +4597,7 @@ msgstr "Alternativni Artikal"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:427
|
||||
msgid "Alternative For Item"
|
||||
msgstr ""
|
||||
msgstr "Artikal Alternativa"
|
||||
|
||||
#. Label of the alternative_item_code (Link) field in DocType 'Item
|
||||
#. Alternative'
|
||||
@@ -6918,7 +6918,7 @@ msgstr "Alat Poređenja Sastavnica"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:178
|
||||
msgid "BOM Component"
|
||||
msgstr ""
|
||||
msgstr "Komponenta Sastavnice"
|
||||
|
||||
#. Label of the bom_conf_tab (Tab Break) field in DocType 'BOM'
|
||||
#: erpnext/manufacturing/doctype/bom/bom.json
|
||||
@@ -6949,7 +6949,7 @@ msgstr "Artikal Sastavnice Konstruktora"
|
||||
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:392
|
||||
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:535
|
||||
msgid "BOM Creator Item with name {0} does not exist"
|
||||
msgstr ""
|
||||
msgstr "Artikal Sastavnice s nazivom {0} ne postoji"
|
||||
|
||||
#. Label of the bom_detail_no (Data) field in DocType 'Purchase Receipt Item
|
||||
#. Supplied'
|
||||
@@ -7049,7 +7049,7 @@ msgstr "Operativno Vrijeme Sastavnice"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:248
|
||||
msgid "BOM Output"
|
||||
msgstr ""
|
||||
msgstr "Sastavnica"
|
||||
|
||||
#: erpnext/stock/report/item_prices/item_prices.py:60
|
||||
msgid "BOM Rate"
|
||||
@@ -8222,13 +8222,13 @@ msgstr "Datum Fakture"
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Bill Even If Previous Invoice Unpaid"
|
||||
msgstr ""
|
||||
msgstr "Fakturiraj čak i ako prethodna faktura nije plaćena"
|
||||
|
||||
#. Option for the 'Generate Invoice At' (Select) field in DocType
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Bill N days before period start"
|
||||
msgstr ""
|
||||
msgstr "Fakturiraj N dana prije početka perioda"
|
||||
|
||||
#. Label of the bill_no (Data) field in DocType 'Journal Entry'
|
||||
#. Label of the bill_no (Data) field in DocType 'Subcontracting Receipt'
|
||||
@@ -8410,13 +8410,13 @@ msgstr "e-pošta Fakture"
|
||||
#. Label of the billing_heatmap (HTML) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Billing Heatmap"
|
||||
msgstr ""
|
||||
msgstr "Toplinska mapa Fakturisanja"
|
||||
|
||||
#. Label of the billing_history_section (Section Break) field in DocType
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Billing History"
|
||||
msgstr ""
|
||||
msgstr "Povijest Fakturiranja"
|
||||
|
||||
#. Label of the billing_hours (Float) field in DocType 'Sales Invoice
|
||||
#. Timesheet'
|
||||
@@ -8450,7 +8450,7 @@ msgstr "Faktura Interval u Planu pretplate mora biti Mjesec koji prati kalendars
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Billing Period"
|
||||
msgstr ""
|
||||
msgstr "Razdoblje Fakturiranja"
|
||||
|
||||
#. Label of the billing_rate (Currency) field in DocType 'Activity Cost'
|
||||
#. Label of the billing_rate (Currency) field in DocType 'Timesheet Detail'
|
||||
@@ -9547,7 +9547,7 @@ msgstr "Otkaži Pretplatu nakon perioda odgode"
|
||||
#. Label of the cancel_at_period_end (Check) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Cancel When Period Ends"
|
||||
msgstr ""
|
||||
msgstr "Otkaži po završetku razdoblja"
|
||||
|
||||
#. Label of the cancelation_date (Date) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
@@ -9556,7 +9556,7 @@ msgstr "Datum Otkazivanja"
|
||||
|
||||
#: erpnext/manufacturing/doctype/job_card/job_card.py:1567
|
||||
msgid "Cancelled Job Card cannot be processed."
|
||||
msgstr ""
|
||||
msgstr "Otkazani Radni Nalog ne može se obraditi."
|
||||
|
||||
#: erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py:76
|
||||
msgid "Cannot Assign Cashier"
|
||||
@@ -9691,7 +9691,7 @@ msgstr "Nije moguće pretvoriti u Grupu jer je odabran Tip Računa."
|
||||
|
||||
#: erpnext/accounts/doctype/sales_invoice/mapper.py:372
|
||||
msgid "Cannot create Intercompany {0}. All items in the source {1} have already been fully invoiced. Please check the existing linked {2}s."
|
||||
msgstr ""
|
||||
msgstr "Nije moguće stvoriti međutvrtku {0}. Svi artikli u izvoru {1} već su u potpunosti fakturirani. Provjeri postojeće povezane {2}."
|
||||
|
||||
#: erpnext/stock/doctype/purchase_receipt/services/reservation.py:49
|
||||
msgid "Cannot create Stock Reservation Entries for future dated Purchase Receipts."
|
||||
@@ -9770,7 +9770,7 @@ msgstr "Nije moguće omogućiti račun zaliha po stavkama jer postoje postojeći
|
||||
|
||||
#: erpnext/crm/doctype/crm_settings/crm_settings.py:37
|
||||
msgid "Cannot enable Opportunity creation from Contact Us because the Contact Us form is disabled."
|
||||
msgstr ""
|
||||
msgstr "Nije moguće omogućiti stvaranje prilike iz Kontaktirajte Nas jer je obrazac Kontaktirajte Nas onemogućen."
|
||||
|
||||
#: erpnext/selling/doctype/sales_order/sales_order.py:624
|
||||
#: erpnext/selling/doctype/sales_order/sales_order.py:647
|
||||
@@ -9878,7 +9878,7 @@ msgstr "Brisanje nije moguće. Drugo brisanje {0} je već u redu čekanja/pokre
|
||||
|
||||
#: erpnext/manufacturing/doctype/job_card/job_card.py:922
|
||||
msgid "Cannot submit Job Card {0} while it is On Hold. Please resume and complete the job before submission."
|
||||
msgstr ""
|
||||
msgstr "Nije moguće podnijeti Radni Nalog {0} dok je na čekanju. Nastavi i završi posao prije podnošenja."
|
||||
|
||||
#: erpnext/accounts/services/child_item_update.py:283
|
||||
msgid "Cannot update rate as item {0} is already ordered or purchased against this quotation"
|
||||
@@ -13410,7 +13410,7 @@ msgstr "Kreiraj novi trag"
|
||||
|
||||
#: erpnext/selling/doctype/product_bundle/product_bundle.js:16
|
||||
msgid "Create New Version"
|
||||
msgstr ""
|
||||
msgstr "Stvori novu verziju"
|
||||
|
||||
#: banking/src/components/common/LinkFieldCombobox.tsx:284
|
||||
msgid "Create New {0}"
|
||||
@@ -14291,12 +14291,12 @@ msgstr "Trenutni Valuta kurs"
|
||||
#. Label of the current_invoice_end (Date) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Current Invoice End"
|
||||
msgstr ""
|
||||
msgstr "Trenutni Završni Datum Fakture"
|
||||
|
||||
#. Label of the current_invoice_start (Date) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Current Invoice Start"
|
||||
msgstr ""
|
||||
msgstr "Trenutni Početni Datum Fakture"
|
||||
|
||||
#. Label of the current_level (Int) field in DocType 'BOM Update Log'
|
||||
#: erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json
|
||||
@@ -17243,7 +17243,7 @@ msgstr "Onemogućeni Bankovni Račun"
|
||||
|
||||
#: erpnext/stock/doctype/packed_item/packed_item.py:216
|
||||
msgid "Disabled Product Bundle"
|
||||
msgstr ""
|
||||
msgstr "Onemogući Paket Artikala"
|
||||
|
||||
#: erpnext/stock/utils.py:434
|
||||
msgid "Disabled Warehouse {0} cannot be used for this transaction."
|
||||
@@ -18939,7 +18939,7 @@ msgstr "Omogući Program Bodova Lojalnosti"
|
||||
#. DocType 'CRM Settings'
|
||||
#: erpnext/crm/doctype/crm_settings/crm_settings.json
|
||||
msgid "Enable Opportunity Creation from Contact Us"
|
||||
msgstr ""
|
||||
msgstr "Omogući stvaranje Prilika iz Kontaktiraj Nas obrasca"
|
||||
|
||||
#. Label of the enable_parallel_reposting (Check) field in DocType 'Stock
|
||||
#. Reposting Settings'
|
||||
@@ -20225,7 +20225,7 @@ msgstr "Nije uspjelo ažuriranje prioriteta pravila"
|
||||
|
||||
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:521
|
||||
msgid "Failed to update subscription status for {0} {1}"
|
||||
msgstr ""
|
||||
msgstr "Nije uspjelo ažuriranje statusa pretplate za {0} {1}"
|
||||
|
||||
#. Label of the failure_date (Datetime) field in DocType 'Asset Repair'
|
||||
#: erpnext/assets/doctype/asset_repair/asset_repair.json
|
||||
@@ -20765,7 +20765,7 @@ msgstr "Gotov Proizvod {0} ne odgovara Radnom Nalogu {1}"
|
||||
|
||||
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:71
|
||||
msgid "Finished good quantity being consumed ({0} in stock UOM) must equal the quantity to disassemble ({1}). Do not change the UOM, conversion factor or quantity of the finished good row."
|
||||
msgstr ""
|
||||
msgstr "Količina gotovog proizvoda koja se troši ({0} u jedinici zaliha) mora biti jednaka količini za rastavljanje ({1}). Ne mijenjaj jedinicu, faktor konverzije ili količinu u redu gotovog proizvoda."
|
||||
|
||||
#: erpnext/selling/doctype/sales_order/sales_order.js:615
|
||||
msgid "First Delivery Date"
|
||||
@@ -23435,13 +23435,13 @@ msgstr "Ako je odbrano, ovaj artikal se tretira kao direktna dostava u Prodajnim
|
||||
#. Description of the 'Update Stock' (Check) field in DocType 'Sales Invoice'
|
||||
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.json
|
||||
msgid "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Delivery Note is created separately."
|
||||
msgstr ""
|
||||
msgstr "Ako je oodabrano, ažurira inventar; zalihe i knjigovodstveni unosi se kreiraju zajedno. Ostavi neodabrano ako se Dostavnica kreira zasebno."
|
||||
|
||||
#. Description of the 'Update Stock' (Check) field in DocType 'Purchase
|
||||
#. Invoice'
|
||||
#: erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
|
||||
msgid "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Purchase Receipt is created separately."
|
||||
msgstr ""
|
||||
msgstr "Ako je odabrano, ažurira se inventar; unosi zaliha i knjigoovodstva se kreiraju zajedno. Ostavi neodabrano ako Kupovni Račun kreira zasebno."
|
||||
|
||||
#: erpnext/public/js/setup_wizard.js:56
|
||||
msgid "If checked, we will create demo data for you to explore the system. This demo data can be erased later."
|
||||
@@ -25255,7 +25255,7 @@ msgstr "Nevažeća Tvrtka za transakcije između tvrtki."
|
||||
|
||||
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.py:972
|
||||
msgid "Invalid Configuration"
|
||||
msgstr ""
|
||||
msgstr "Nevažeća Konfiguracija"
|
||||
|
||||
#: erpnext/accounts/services/taxes.py:294
|
||||
#: erpnext/assets/doctype/asset/asset.py:361
|
||||
@@ -25273,12 +25273,12 @@ msgstr "Nevažeći Datum Dostave"
|
||||
|
||||
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:110
|
||||
msgid "Invalid Disassembly Item"
|
||||
msgstr ""
|
||||
msgstr "Nevažeći Artikala za Rastavljanje"
|
||||
|
||||
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:76
|
||||
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:125
|
||||
msgid "Invalid Disassembly Quantity"
|
||||
msgstr ""
|
||||
msgstr "Nevažeća Količina za Rastavljanje"
|
||||
|
||||
#: erpnext/selling/page/point_of_sale/pos_item_cart.js:414
|
||||
msgid "Invalid Discount"
|
||||
@@ -26144,7 +26144,7 @@ msgstr "Je Fantomska Stavka"
|
||||
#: erpnext/selling/doctype/sales_order_item/sales_order_item.json
|
||||
#: erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
|
||||
msgid "Is Product Bundle"
|
||||
msgstr ""
|
||||
msgstr "Je Paket Artikala"
|
||||
|
||||
#. Label of the po_required (Select) field in DocType 'Buying Settings'
|
||||
#: erpnext/buying/doctype/buying_settings/buying_settings.json
|
||||
@@ -27652,7 +27652,7 @@ msgstr "Detalji Težine Artikla"
|
||||
#. Name of a report
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.json
|
||||
msgid "Item Where Used"
|
||||
msgstr ""
|
||||
msgstr "Gdje se koristi Artikal"
|
||||
|
||||
#. Label of a Link in the Buying Workspace
|
||||
#. Name of a report
|
||||
@@ -27775,7 +27775,7 @@ msgstr "Artikal {0} dodan je više puta pod isti nadređeni artikal {1} u redovi
|
||||
|
||||
#: erpnext/selling/doctype/product_bundle/product_bundle.js:54
|
||||
msgid "Item {0} already has an active Product Bundle ({1}). Submitting this will create a new version and deactivate {1}."
|
||||
msgstr ""
|
||||
msgstr "{0} već ima aktivan Paket Artikala ({1}). Podnošenjem ovog stvorit će se nova verzija i deaktivirati {1}."
|
||||
|
||||
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:119
|
||||
msgid "Item {0} cannot be added as a sub-assembly of itself"
|
||||
@@ -28106,7 +28106,7 @@ msgstr "Artikal Radne Kartice"
|
||||
|
||||
#: erpnext/manufacturing/doctype/job_card/job_card.py:925
|
||||
msgid "Job Card On Hold"
|
||||
msgstr ""
|
||||
msgstr "Radni Nalog je na čekanju"
|
||||
|
||||
#. Name of a DocType
|
||||
#: erpnext/manufacturing/doctype/job_card_operation/job_card_operation.json
|
||||
@@ -30309,7 +30309,7 @@ msgstr "Usklađeno"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:57
|
||||
msgid "Matched Field"
|
||||
msgstr ""
|
||||
msgstr "Usklađeno polje"
|
||||
|
||||
#. Label of the matched_transaction_rule (Link) field in DocType 'Bank
|
||||
#. Transaction'
|
||||
@@ -30928,7 +30928,7 @@ msgstr "Metar/Sekunda"
|
||||
|
||||
#: erpnext/manufacturing/doctype/workstation/workstation.py:546
|
||||
msgid "Method {0} is not allowed to be run on a Job Card."
|
||||
msgstr ""
|
||||
msgstr "Metodu {0} nije dopušteno pokretati na Radnom Nalogu."
|
||||
|
||||
#. Name of a UOM
|
||||
#: erpnext/setup/setup_wizard/data/uom_data.json
|
||||
@@ -32258,13 +32258,13 @@ msgstr "Newton"
|
||||
#. Label of the next_billing_period_end (Date) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Next Billing Period End"
|
||||
msgstr ""
|
||||
msgstr "Sljedeći Perioda Fakturiranja Završava"
|
||||
|
||||
#. Label of the next_billing_period_start (Date) field in DocType
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Next Billing Period Start"
|
||||
msgstr ""
|
||||
msgstr "Sljedeći Perioda Fakturiranja Počinje"
|
||||
|
||||
#. Label of the next_depreciation_date (Date) field in DocType 'Asset'
|
||||
#: erpnext/assets/doctype/asset/asset.json
|
||||
@@ -33476,7 +33476,7 @@ msgstr "Samo jedna operacija može imati odabranu opciju 'Je li Gotov Proizvod'
|
||||
#. Description of the 'Is Active' (Check) field in DocType 'Product Bundle'
|
||||
#: erpnext/selling/doctype/product_bundle/product_bundle.json
|
||||
msgid "Only one version of a Product Bundle can be active at a time for a given Parent Item. Activating a version deactivates the previously active one."
|
||||
msgstr ""
|
||||
msgstr "Samo jedna verzija Paketa Artikala može biti aktivna u datom trenutku za dati Nadređeni Artikal. Aktiviranje verzije deaktivira prethodno aktivnu verziju."
|
||||
|
||||
#: erpnext/stock/doctype/stock_entry/stock_entry.py:719
|
||||
msgid "Only one {0} entry can be created against the Work Order {1}"
|
||||
@@ -39083,7 +39083,7 @@ msgstr "Vremenska oznaka knjiženja mora biti nakon {0}"
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Postpaid (bill at period end)"
|
||||
msgstr ""
|
||||
msgstr "Naknadno Plaćanje (faktura na kraju razdoblja)"
|
||||
|
||||
#. Description of a DocType
|
||||
#: erpnext/crm/doctype/opportunity/opportunity.json
|
||||
@@ -39186,7 +39186,7 @@ msgstr "Preferirana e-pošta"
|
||||
#. 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Prepaid (bill at period start)"
|
||||
msgstr ""
|
||||
msgstr "Unaprijed Plaćeno (faktura na početku razdoblja)"
|
||||
|
||||
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:34
|
||||
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:51
|
||||
@@ -40174,7 +40174,7 @@ msgstr "Stanje Paketa Proizvoda"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:278
|
||||
msgid "Product Bundle Component"
|
||||
msgstr ""
|
||||
msgstr "Komponenta Paketa Artikala"
|
||||
|
||||
#. Label of the product_bundle_help (HTML) field in DocType 'POS Invoice'
|
||||
#. Label of the product_bundle_help (HTML) field in DocType 'Sales Invoice'
|
||||
@@ -40199,7 +40199,7 @@ msgstr "Artikal Paketa Proizvoda"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:305
|
||||
msgid "Product Bundle Parent"
|
||||
msgstr ""
|
||||
msgstr "Nadređeni Paket Artikala"
|
||||
|
||||
#. Description of the 'Product Bundle' (Link) field in DocType 'Purchase
|
||||
#. Invoice Item'
|
||||
@@ -40213,15 +40213,15 @@ msgstr ""
|
||||
#: erpnext/stock/doctype/packed_item/packed_item.json
|
||||
#: erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
|
||||
msgid "Product Bundle version this row was packed from"
|
||||
msgstr ""
|
||||
msgstr "Verzija Paketa Artikala iz koje je ovaj red preuzet iz"
|
||||
|
||||
#: erpnext/stock/doctype/packed_item/packed_item.py:453
|
||||
msgid "Product Bundle {0} is disabled and cannot be used in transactions."
|
||||
msgstr ""
|
||||
msgstr "Paket Artikala {0} je onemogućen i ne može se koristiti u transakcijama."
|
||||
|
||||
#: erpnext/stock/doctype/packed_item/packed_item.py:450
|
||||
msgid "Product Bundle {0} is not submitted"
|
||||
msgstr ""
|
||||
msgstr "Paket Artikala {0} nije podnešen"
|
||||
|
||||
#. Label of the product_discount_scheme_section (Section Break) field in
|
||||
#. DocType 'Pricing Rule'
|
||||
@@ -43881,7 +43881,7 @@ msgstr "Osvježite Plaid Link"
|
||||
#. Option for the 'Status' (Select) field in DocType 'Subscription'
|
||||
#: erpnext/accounts/doctype/subscription/subscription.json
|
||||
msgid "Refunded"
|
||||
msgstr ""
|
||||
msgstr "Povraćeno"
|
||||
|
||||
#: erpnext/stock/reorder_item.py:390
|
||||
msgid "Regards,"
|
||||
@@ -43992,7 +43992,7 @@ msgstr "Povezano"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:50
|
||||
msgid "Related Item"
|
||||
msgstr ""
|
||||
msgstr "Povezani Artikal"
|
||||
|
||||
#. Label of the relation (Data) field in DocType 'Employee'
|
||||
#: erpnext/setup/doctype/employee/employee.json
|
||||
@@ -46064,7 +46064,7 @@ msgstr "Red #{0}: Gotov Proizvod artikla nije navedena zaservisni artikal {1}"
|
||||
|
||||
#: erpnext/manufacturing/doctype/bom/bom.py:371
|
||||
msgid "Row #{0}: Finished Good Item {1} cannot be added in the Secondary Items table."
|
||||
msgstr ""
|
||||
msgstr "Red #{0}: Artikal Gotovog Proizvoda {1} ne može se dodati u tablicu Sekundarnih Artikala."
|
||||
|
||||
#: erpnext/buying/doctype/purchase_order/services/subcontracting.py:28
|
||||
#: erpnext/selling/doctype/sales_order/services/subcontracting.py:27
|
||||
@@ -46155,7 +46155,7 @@ msgstr "Red #{0}: Artikal {1} nije artikal na zalihama"
|
||||
|
||||
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:106
|
||||
msgid "Row #{0}: Item {1} is not part of the source manufacture entry and cannot be added to this disassembly."
|
||||
msgstr ""
|
||||
msgstr "Red #{0}: Artikal {1} nije dio unosa izvornog proizvođača i ne može se dodati ovom rastavljanju."
|
||||
|
||||
#: erpnext/controllers/subcontracting_inward_controller.py:79
|
||||
msgid "Row #{0}: Item {1} mismatch. Changing of item code is not permitted, add another row instead."
|
||||
@@ -46167,7 +46167,7 @@ msgstr "Red #{0}: Artikla {1} se ne slaže. Promjena koda artikla nije dozvoljen
|
||||
|
||||
#: erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py:115
|
||||
msgid "Row #{0}: Item {1} quantity ({2} in stock UOM) does not match the quantity derived from the source ({3}). Do not change the UOM, conversion factor or quantity of disassembly rows."
|
||||
msgstr ""
|
||||
msgstr "Red #{0}: Količina artikla {1} ({2} u jedinici zaliha) ne odgovara količini izvedeno iz izvora ({3}). Ne mijenjaj jedinicu, faktor konverzije ili količinu redova za rastavljanje."
|
||||
|
||||
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:780
|
||||
msgid "Row #{0}: Journal Entry {1} does not have account {2} or already matched against another voucher"
|
||||
@@ -46233,7 +46233,7 @@ msgstr "Red #{0}: Postotnii Gubitka Procesa treba da bude manji od 100% za {1} a
|
||||
|
||||
#: erpnext/stock/doctype/packed_item/packed_item.py:213
|
||||
msgid "Row #{0}: Product Bundle {1} is disabled and cannot be used in transactions."
|
||||
msgstr ""
|
||||
msgstr "Red #{0}: Paket Artikal {1} je onemogućen i ne može se koristiti u transakcijama."
|
||||
|
||||
#: erpnext/public/js/utils/barcode_scanner.js:425
|
||||
msgid "Row #{0}: Qty increased by {1}"
|
||||
@@ -49490,7 +49490,7 @@ msgstr "Serijski i Šaržni Paket {0} nije podnešen"
|
||||
|
||||
#: erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py:2251
|
||||
msgid "Serial and Batch Bundle {0} is submitted and its entries cannot be modified."
|
||||
msgstr ""
|
||||
msgstr "Serijski i Šaržni Paket {0} je podnešen i njegovi unosi se ne mogu mijenjati."
|
||||
|
||||
#. Label of the section_break_45 (Section Break) field in DocType
|
||||
#. 'Subcontracting Receipt Item'
|
||||
@@ -52668,7 +52668,7 @@ msgstr "Podizvođačka Dostava"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:362
|
||||
msgid "Subcontracting Finished Good"
|
||||
msgstr ""
|
||||
msgstr "Podizvođački Gotov Proizvod"
|
||||
|
||||
#. Label of the subcontracting_inward_tab (Tab Break) field in DocType 'Selling
|
||||
#. Settings'
|
||||
@@ -52852,7 +52852,7 @@ msgstr "Podizvođački Prodajni Nalog"
|
||||
|
||||
#: erpnext/stock/report/item_where_used/item_where_used.py:336
|
||||
msgid "Subcontracting Service Item"
|
||||
msgstr ""
|
||||
msgstr "Podizvođački Uslužni Artikal"
|
||||
|
||||
#. Label of the subcontract (Tab Break) field in DocType 'Buying Settings'
|
||||
#: erpnext/buying/doctype/buying_settings/buying_settings.json
|
||||
@@ -52900,7 +52900,7 @@ msgstr "Podnesi Ponudu"
|
||||
|
||||
#: erpnext/manufacturing/doctype/job_card/job_card.py:1570
|
||||
msgid "Submitted Job Card cannot be processed."
|
||||
msgstr ""
|
||||
msgstr "Podnešeni Radni Nalog ne može biti obrađen."
|
||||
|
||||
#. Label of the subscription_section (Section Break) field in DocType 'Payment
|
||||
#. Request'
|
||||
@@ -61720,7 +61720,7 @@ msgstr "Ne možete podnijeti nalog bez plaćanja."
|
||||
|
||||
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.py:968
|
||||
msgid "You cannot update stock for a Debit Note. A Debit Note is a financial document that should not affect inventory. Please disable 'Update Stock'."
|
||||
msgstr ""
|
||||
msgstr "Ne možete ažurirati zalihe za Terećenje. Terećenje je financijski dokument koji ne bi trebao utjecati na zalihe. Onemogući opciju 'Ažuriraj Zalihe'."
|
||||
|
||||
#: erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py:106
|
||||
msgid "You cannot {0} this document because another Period Closing Entry {1} exists after {2}"
|
||||
@@ -62834,7 +62834,7 @@ msgstr "{0}, završi operaciju {1} prije operacije {2}."
|
||||
|
||||
#: erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py:61
|
||||
msgid "{0}, {1} or {2} are the only allowed options."
|
||||
msgstr ""
|
||||
msgstr "{0}, {1} ili {2} su jedine dopuštene opcije."
|
||||
|
||||
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:525
|
||||
msgid "{0}: Child table (auto-deleted with parent)"
|
||||
|
||||
@@ -116,9 +116,12 @@ def _subitems_query(company, bom_no, include_non_stock_items, parent_qty, planne
|
||||
|
||||
def _subitem_columns(bom_item, bom, item, item_default, item_uom, parent_qty, planned_qty):
|
||||
qty = IfNull(parent_qty * Sum(bom_item.stock_qty / IfNull(bom.quantity, 1)) * planned_qty, 0).as_("qty")
|
||||
# only item_code is grouped; the rest are functionally dependent on the grouped item (item
|
||||
# attributes) or arbitrary per BOM Item on MySQL -> Max() keeps the GROUP BY valid on postgres
|
||||
# while returning the same value MySQL picked.
|
||||
# only item_code is grouped; the remaining item-attribute columns are functionally dependent on it,
|
||||
# so Max() returns their single value on both engines. is_phantom_item is the exception: the same
|
||||
# item_code can sit on a phantom line and a real-RM line in one BOM, and get_subitems() drops any
|
||||
# row whose is_phantom_item is truthy. Max() would let a single phantom line mask the real material
|
||||
# and silently drop it; Min() instead treats the item as phantom only when EVERY line is phantom, so
|
||||
# a real raw material is never lost. Deterministic and identical on MariaDB and Postgres.
|
||||
return [
|
||||
bom_item.item_code,
|
||||
Max(item.default_material_request_type).as_("default_material_request_type"),
|
||||
@@ -136,7 +139,7 @@ def _subitem_columns(bom_item, bom, item, item_default, item_uom, parent_qty, pl
|
||||
Max(item_uom.conversion_factor).as_("conversion_factor"),
|
||||
Max(bom.item).as_("main_bom_item"),
|
||||
Max(bom.name).as_("main_bom"),
|
||||
Max(bom_item.is_phantom_item).as_("is_phantom_item"),
|
||||
Min(bom_item.is_phantom_item).as_("is_phantom_item"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -179,22 +179,25 @@ def _sub_assembly_rm_query(company, bom_no, include_non_stock_items, planned_qty
|
||||
.on((item.name == item_uom.parent) & (item_uom.uom == item.purchase_uom))
|
||||
.select(*_sub_assembly_rm_columns(bei, bom, item, item_default, item_uom, planned_qty))
|
||||
.where(_sub_assembly_rm_filter(bei, bom, item, bom_no, include_non_stock_items))
|
||||
.groupby(bei.item_code, bei.stock_uom)
|
||||
.groupby(bei.item_code, bei.stock_uom, bei.bom_no, bei.is_phantom_item)
|
||||
).run(as_dict=True)
|
||||
|
||||
|
||||
def _sub_assembly_rm_columns(bei, bom, item, item_default, item_uom, planned_qty):
|
||||
# only item_code/stock_uom are grouped; every other column is functionally dependent on the
|
||||
# grouped item (item attributes) or arbitrary per BOM Item on MySQL -> Max() keeps the GROUP BY
|
||||
# valid on postgres while returning the same value MySQL picked.
|
||||
# Grouped by item_code/stock_uom plus bom_no/is_phantom_item: those two MUST come from the same
|
||||
# BOM Item row -- the consumer keys on (item_code, bom_no) and recurses on is_phantom_item, so an
|
||||
# independent Max() per column could pair a bom_no from one line with is_phantom_item from another
|
||||
# and recurse into the wrong sub-BOM. Grouping them keeps the pair coherent and the GROUP BY valid
|
||||
# on postgres. The remaining columns are functionally dependent on the grouped item; Max() returns
|
||||
# their single value on both engines.
|
||||
return [
|
||||
(IfNull(Sum(bei.stock_qty / IfNull(bom.quantity, 1)), 0) * planned_qty).as_("qty"),
|
||||
Max(item.item_name).as_("item_name"),
|
||||
Max(item.name).as_("item_code"),
|
||||
Max(bei.description).as_("description"),
|
||||
bei.stock_uom,
|
||||
Max(bei.is_phantom_item).as_("is_phantom_item"),
|
||||
Max(bei.bom_no).as_("bom_no"),
|
||||
bei.is_phantom_item,
|
||||
bei.bom_no,
|
||||
Max(item.min_order_qty).as_("min_order_qty"),
|
||||
Max(bei.source_warehouse).as_("source_warehouse"),
|
||||
Max(item.default_material_request_type).as_("default_material_request_type"),
|
||||
|
||||
@@ -2862,6 +2862,104 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
"The phantom BOM was not re-exploded for the second po_item.",
|
||||
)
|
||||
|
||||
def test_sub_assembly_rm_query_keeps_bom_no_phantom_pair_coherent(self):
|
||||
"""bom_no and is_phantom_item must stay paired to the same BOM Item line.
|
||||
|
||||
When a component is listed more than once in a sub-assembly BOM pointing at different
|
||||
sub-BOMs (one phantom, one not), grouping only by (item_code, stock_uom) collapsed both
|
||||
lines into one row, and the independent Max() per column could pair the phantom flag of
|
||||
one line with the bom_no of the other. The consumer keys on (item_code, bom_no) and
|
||||
recurses on is_phantom_item, so an incoherent pair recurses into the wrong sub-BOM.
|
||||
Grouping also by (bom_no, is_phantom_item) yields one coherent row per distinct sub-BOM.
|
||||
"""
|
||||
from erpnext.manufacturing.doctype.production_plan.services.sub_assembly_queries import (
|
||||
_sub_assembly_rm_query,
|
||||
)
|
||||
|
||||
rm_phantom = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
rm_normal = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
component = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
|
||||
# Phantom sub-BOM first (smaller auto-name); non-phantom second (larger name) -> the name
|
||||
# the old Max(bom_no) would pick, while Max(is_phantom_item)=1 came from the phantom line.
|
||||
phantom_bom = make_bom(item=component, raw_materials=[rm_phantom], do_not_save=True)
|
||||
phantom_bom.is_phantom_bom = 1
|
||||
phantom_bom.save()
|
||||
phantom_bom.submit()
|
||||
normal_bom = make_bom(item=component, raw_materials=[rm_normal])
|
||||
|
||||
# Sub-assembly BOM lists `component` twice, once via each sub-BOM.
|
||||
sa_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
sa_bom = make_bom(item=sa_item, raw_materials=[component], do_not_save=True)
|
||||
sa_bom.items[0].bom_no = phantom_bom.name
|
||||
component_doc = frappe.get_doc("Item", component)
|
||||
sa_bom.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": component,
|
||||
"qty": 1,
|
||||
"uom": component_doc.stock_uom,
|
||||
"stock_uom": component_doc.stock_uom,
|
||||
"bom_no": normal_bom.name,
|
||||
},
|
||||
)
|
||||
sa_bom.save()
|
||||
sa_bom.submit()
|
||||
|
||||
rows = _sub_assembly_rm_query(
|
||||
company="_Test Company", bom_no=sa_bom.name, include_non_stock_items=1, planned_qty=1
|
||||
)
|
||||
by_bom_no = {row.bom_no: row for row in rows if row.item_code == component}
|
||||
|
||||
# One coherent row per distinct sub-BOM, each carrying its own phantom flag.
|
||||
self.assertIn(phantom_bom.name, by_bom_no)
|
||||
self.assertIn(normal_bom.name, by_bom_no)
|
||||
self.assertEqual(by_bom_no[phantom_bom.name].is_phantom_item, 1)
|
||||
self.assertEqual(by_bom_no[normal_bom.name].is_phantom_item, 0)
|
||||
|
||||
def test_subitems_query_keeps_real_rm_listed_alongside_phantom(self):
|
||||
"""bom_explosion._subitems_query groups BOM lines by item_code, and get_subitems() drops any
|
||||
grouped row whose is_phantom_item is truthy. When one item_code is listed in a BOM both as a
|
||||
phantom sub-assembly and as a plain raw material, Max(is_phantom_item)=1 made get_subitems
|
||||
silently drop the real material. Min(is_phantom_item) keeps it (phantom only when every line
|
||||
is phantom) and is deterministic on MariaDB and Postgres.
|
||||
"""
|
||||
from erpnext.manufacturing.doctype.production_plan.services.bom_explosion import _subitems_query
|
||||
|
||||
component = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
rm_phantom = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
|
||||
phantom_bom = make_bom(item=component, raw_materials=[rm_phantom], do_not_save=True)
|
||||
phantom_bom.is_phantom_bom = 1
|
||||
phantom_bom.save()
|
||||
phantom_bom.submit()
|
||||
# the phantom BOM is auto-set as the component's default; clear it so the second component line
|
||||
# stays a plain (non-phantom) raw material instead of inheriting the phantom BOM as its bom_no.
|
||||
frappe.db.set_value("Item", component, "default_bom", "")
|
||||
frappe.clear_document_cache("Item", component)
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
parent = make_bom(item=fg_item, raw_materials=[component], do_not_save=True)
|
||||
parent.items[0].bom_no = phantom_bom.name # phantom line -> is_phantom_item = 1
|
||||
component_doc = frappe.get_doc("Item", component)
|
||||
parent.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": component,
|
||||
"qty": 1,
|
||||
"uom": component_doc.stock_uom,
|
||||
"stock_uom": component_doc.stock_uom,
|
||||
},
|
||||
) # plain raw-material line (no bom_no) -> is_phantom_item = 0
|
||||
parent.save()
|
||||
parent.submit()
|
||||
|
||||
rows = _subitems_query("_Test Company", parent.name, 1, 1, 1)
|
||||
component_rows = [r for r in rows if r.item_code == component]
|
||||
self.assertEqual(len(component_rows), 1)
|
||||
# Min() keeps the real material; the old Max() returned 1 and get_subitems dropped it.
|
||||
self.assertEqual(component_rows[0].is_phantom_item, 0)
|
||||
|
||||
|
||||
def create_production_plan(**args):
|
||||
"""
|
||||
|
||||
@@ -191,17 +191,25 @@ class RequiredItemsService:
|
||||
frappe.qb.from_(ste)
|
||||
.inner_join(ste_child)
|
||||
.on(ste_child.parent == ste.name)
|
||||
# original_item is arbitrary per grouped item_code on MySQL -> Max() keeps the GROUP BY valid
|
||||
# on postgres while returning the same value (it is only used as a dict key fallback below)
|
||||
# original_item becomes the output dict key below, so it must stay coherent per row: the
|
||||
# same item_code can be transferred both for itself (original_item NULL) and as a substitute
|
||||
# for another required item (original_item set). Max() over a single item_code group could
|
||||
# pick the substitute's original_item and misattribute the item's own transfer to it. Group
|
||||
# by (item_code, original_item) so each pair sums separately, then accumulate into the keyed
|
||||
# dict (two distinct rows can resolve to the same key, e.g. A's own transfer and B-for-A).
|
||||
.select(
|
||||
ste_child.item_code,
|
||||
fn.Max(ste_child.original_item).as_("original_item"),
|
||||
ste_child.original_item,
|
||||
fn.Sum(ste_child.transfer_qty).as_("qty"),
|
||||
)
|
||||
.where(self._material_transfer_filter(ste, is_return))
|
||||
.groupby(ste_child.item_code)
|
||||
.groupby(ste_child.item_code, ste_child.original_item)
|
||||
)
|
||||
return frappe._dict({d.original_item or d.item_code: d.qty for d in (query.run(as_dict=1) or [])})
|
||||
qty_by_item = frappe._dict()
|
||||
for d in query.run(as_dict=1) or []:
|
||||
key = d.original_item or d.item_code
|
||||
qty_by_item[key] = (qty_by_item.get(key) or 0.0) + flt(d.qty)
|
||||
return qty_by_item
|
||||
|
||||
def _material_transfer_filter(self, ste, is_return):
|
||||
return (
|
||||
|
||||
@@ -4815,6 +4815,57 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
# generated qty (3.0 for 8 units) differs from the BOM-scaled qty (7.5 for 20 units)
|
||||
self.assertEqual(flt(row.qty, 6), 3.0)
|
||||
|
||||
def test_transferred_qty_not_misattributed_between_item_and_its_substitute(self):
|
||||
"""When one item is transferred both for itself and as a substitute for another required item,
|
||||
each transfer must be credited to the right required item.
|
||||
|
||||
_material_transfer_qty_by_item grouped Stock Entry Detail by item_code only and picked
|
||||
Max(original_item); for item B transferred once for itself (original_item NULL) and once as a
|
||||
substitute for A (original_item=A), Max picked A and credited B's whole transfer to A, leaving
|
||||
B at 0. Grouping by (item_code, original_item) and accumulating into the keyed dict attributes
|
||||
each transfer correctly, deterministically on MariaDB and Postgres.
|
||||
"""
|
||||
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
|
||||
|
||||
source_warehouse = "Stores - _TC"
|
||||
fg_item = make_item("Test WO SelfSub FG", {"is_stock_item": 1}).name
|
||||
item_a = make_item("Test WO SelfSub RM A", {"is_stock_item": 1, "allow_alternative_item": 1}).name
|
||||
item_b = make_item("Test WO SelfSub RM B", {"is_stock_item": 1, "allow_alternative_item": 1}).name
|
||||
|
||||
# B is a registered alternative for A
|
||||
if not frappe.db.exists("Item Alternative", {"item_code": item_a, "alternative_item_code": item_b}):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Alternative",
|
||||
"item_code": item_a,
|
||||
"alternative_item_code": item_b,
|
||||
"two_way": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
# stock B generously (covers B-for-A plus B-for-itself)
|
||||
for item, qty in ((item_a, 50), (item_b, 100)):
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code=item, target=source_warehouse, qty=qty, basic_rate=100
|
||||
)
|
||||
|
||||
make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=[item_a, item_b])
|
||||
wo = make_wo_order_test_record(item=fg_item, qty=10, source_warehouse=source_warehouse)
|
||||
|
||||
transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10))
|
||||
transfer.save()
|
||||
# substitute B for the A line; the existing B line stays as B's own transfer
|
||||
for d in transfer.items:
|
||||
if d.item_code == item_a:
|
||||
d.item_code = item_b
|
||||
d.original_item = item_a
|
||||
transfer.submit()
|
||||
|
||||
qty_by_item = RequiredItemsService(wo)._material_transfer_qty_by_item(is_return=0)
|
||||
# B transferred as a substitute for A -> credited to A; B transferred for itself -> credited to B.
|
||||
self.assertEqual(flt(qty_by_item.get(item_a)), 10.0)
|
||||
self.assertEqual(flt(qty_by_item.get(item_b)), 10.0)
|
||||
|
||||
|
||||
def get_reserved_entries(voucher_no, warehouse=None):
|
||||
doctype = frappe.qb.DocType("Stock Reservation Entry")
|
||||
|
||||
@@ -233,13 +233,33 @@ def get_bom_data(filters):
|
||||
else:
|
||||
query = query.where(bin.warehouse == filters.get("warehouse"))
|
||||
|
||||
if bom_item_table == "BOM Item":
|
||||
query = query.select(
|
||||
Max(bom_item.bom_no).as_("bom_no"), Max(bom_item.is_phantom_item).as_("is_phantom_item")
|
||||
)
|
||||
|
||||
data = query.run(as_dict=True)
|
||||
return explode_phantom_boms(data, filters) if bom_item_table == "BOM Item" else data
|
||||
|
||||
if bom_item_table == "BOM Item":
|
||||
# bom_no + is_phantom_item drive whether/which sub-BOM explode_phantom_boms recurses into, so
|
||||
# they must come from the SAME BOM Item line. Aggregating each independently (Max) could pair a
|
||||
# bom_no from one line with is_phantom_item from another when an item_code repeats in the BOM.
|
||||
# Rows are grouped by item_code (one qty_per_unit total per component), so pick one coherent
|
||||
# representative line: the first line, but upgrade to the first phantom line if any exists, so a
|
||||
# phantom sub-BOM is never dropped just because a non-phantom line happens to be listed first.
|
||||
representative = {}
|
||||
for line in frappe.get_all(
|
||||
"BOM Item",
|
||||
filters={"parent": filters.get("bom"), "parenttype": "BOM"},
|
||||
fields=["item_code", "bom_no", "is_phantom_item"],
|
||||
order_by="idx",
|
||||
):
|
||||
existing = representative.get(line.item_code)
|
||||
if existing is None or (line.is_phantom_item and not existing.is_phantom_item):
|
||||
representative[line.item_code] = line
|
||||
for row in data:
|
||||
line = representative.get(row.item_code)
|
||||
if line:
|
||||
row.bom_no = line.bom_no
|
||||
row.is_phantom_item = line.is_phantom_item
|
||||
return explode_phantom_boms(data, filters)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def explode_phantom_boms(data, filters):
|
||||
|
||||
@@ -79,6 +79,73 @@ class TestBOMStockAnalysis(ERPNextTestSuite):
|
||||
)
|
||||
self.assertEqual(footer.get("description"), expected_min)
|
||||
|
||||
def _build_duplicate_component_bom(self, phantom_first):
|
||||
"""Parent BOM that lists one `component` twice, once via a phantom sub-BOM and once via a
|
||||
non-phantom sub-BOM. `phantom_first` controls which line is at idx 1. Returns the names of
|
||||
(parent_bom, rm_phantom, rm_normal, component)."""
|
||||
rm_phantom = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
rm_normal = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
component = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
|
||||
# Phantom sub-BOM created first -> smaller auto-name; non-phantom second -> larger name,
|
||||
# which is exactly what the old Max(bom_no) would (incorrectly) pick.
|
||||
phantom_bom = make_bom(item=component, raw_materials=[rm_phantom], do_not_save=True)
|
||||
phantom_bom.is_phantom_bom = 1
|
||||
phantom_bom.save()
|
||||
phantom_bom.submit()
|
||||
normal_bom = make_bom(item=component, raw_materials=[rm_normal])
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
first_bom, second_bom = (
|
||||
(phantom_bom.name, normal_bom.name) if phantom_first else (normal_bom.name, phantom_bom.name)
|
||||
)
|
||||
parent = make_bom(item=fg_item, raw_materials=[component], do_not_save=True)
|
||||
parent.items[0].bom_no = first_bom
|
||||
component_doc = frappe.get_doc("Item", component)
|
||||
parent.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": component,
|
||||
"qty": 1,
|
||||
"uom": component_doc.stock_uom,
|
||||
"stock_uom": component_doc.stock_uom,
|
||||
"bom_no": second_bom,
|
||||
},
|
||||
)
|
||||
parent.save()
|
||||
parent.submit()
|
||||
return parent.name, rm_phantom, rm_normal, component
|
||||
|
||||
def _assert_phantom_exploded(self, parent_bom, rm_phantom, rm_normal, component):
|
||||
raw_data = bom_stock_analysis_report(filters={"qty_to_make": 1, "bom": parent_bom})[1]
|
||||
items = {row.get("item") for row in raw_data if row}
|
||||
# Phantom sub-BOM exploded -> its raw material appears; the component row is replaced.
|
||||
self.assertIn(rm_phantom, items)
|
||||
self.assertNotIn(component, items)
|
||||
# The non-phantom line's sub-BOM must NOT be mis-exploded.
|
||||
self.assertNotIn(rm_normal, items)
|
||||
|
||||
def test_phantom_explosion_picks_coherent_sub_bom(self):
|
||||
"""bom_no and is_phantom_item must come from the SAME BOM Item line.
|
||||
|
||||
When a component is listed more than once in a BOM pointing at different sub-BOMs
|
||||
(one phantom, one not), the report groups both lines into a single row by item_code.
|
||||
Aggregating bom_no and is_phantom_item with independent Max() could pair the phantom
|
||||
flag of one line with the bom_no of the other, so explode_phantom_boms recurses into
|
||||
the wrong sub-BOM. We now take one coherent representative line, so the phantom sub-BOM
|
||||
is the one exploded.
|
||||
"""
|
||||
self._assert_phantom_exploded(*self._build_duplicate_component_bom(phantom_first=True))
|
||||
|
||||
def test_phantom_explosion_when_phantom_line_is_not_first(self):
|
||||
"""The phantom flag must win regardless of line order.
|
||||
|
||||
If the non-phantom line is listed first (idx 1) and the phantom line second, a naive
|
||||
first-line representative would drop the phantom flag and skip the sub-BOM explosion.
|
||||
The representative is phantom-preferring, so the phantom sub-BOM is still exploded.
|
||||
"""
|
||||
self._assert_phantom_exploded(*self._build_duplicate_component_bom(phantom_first=False))
|
||||
|
||||
|
||||
def split_data_and_footer(raw_data):
|
||||
"""Separate component rows from the footer row. Skips blank spacer rows."""
|
||||
|
||||
@@ -488,3 +488,4 @@ erpnext.patches.v16_0.rename_secondary_item_type_field
|
||||
erpnext.patches.v16_0.submit_existing_product_bundles #1
|
||||
erpnext.patches.v16_0.migrate_subscription_generate_invoice_at
|
||||
erpnext.patches.v16_0.rename_subscription_billing_period_fields
|
||||
erpnext.patches.v16_0.drop_redundant_serial_no_index_from_sabb
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from frappe.database.utils import drop_index_if_exists
|
||||
|
||||
|
||||
def execute():
|
||||
drop_index_if_exists("tabSerial and Batch Entry", "serial_no")
|
||||
drop_index_if_exists("tabSerial and Batch Entry", "warehouse")
|
||||
drop_index_if_exists("tabSerial and Batch Entry", "type_of_transaction")
|
||||
@@ -43,9 +43,13 @@ class ActivityCost(Document):
|
||||
|
||||
def check_unique(self):
|
||||
if self.employee:
|
||||
if frappe.db.sql(
|
||||
"""select name from `tabActivity Cost` where employee_name= %s and activity_type= %s and name != %s""",
|
||||
(self.employee_name, self.activity_type, self.name),
|
||||
if frappe.db.exists(
|
||||
"Activity Cost",
|
||||
{
|
||||
"employee_name": self.employee_name,
|
||||
"activity_type": self.activity_type,
|
||||
"name": ["!=", self.name],
|
||||
},
|
||||
):
|
||||
frappe.throw(
|
||||
_("Activity Cost exists for Employee {0} against Activity Type - {1}").format(
|
||||
@@ -54,9 +58,13 @@ class ActivityCost(Document):
|
||||
DuplicationError,
|
||||
)
|
||||
else:
|
||||
if frappe.db.sql(
|
||||
"""select name from `tabActivity Cost` where ifnull(employee, '')='' and activity_type= %s and name != %s""",
|
||||
(self.activity_type, self.name),
|
||||
if frappe.db.exists(
|
||||
"Activity Cost",
|
||||
{
|
||||
"employee": ["is", "not set"],
|
||||
"activity_type": self.activity_type,
|
||||
"name": ["!=", self.name],
|
||||
},
|
||||
):
|
||||
frappe.throw(
|
||||
_("Default Activity Cost exists for Activity Type - {0}").format(self.activity_type),
|
||||
|
||||
@@ -4,15 +4,14 @@
|
||||
import frappe
|
||||
from email_reply_parser import EmailReplyParser
|
||||
from frappe import _, qb
|
||||
from frappe.desk.reportview import get_match_cond
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import Interval
|
||||
from frappe.query_builder.functions import Count, CurDate, Date, Sum, UnixTimestamp
|
||||
from frappe.query_builder import Case, Interval
|
||||
from frappe.query_builder.functions import Count, CurDate, Date, Locate, Lower, Sum, UnixTimestamp
|
||||
from frappe.utils import add_days, flt, get_datetime, get_link_to_form, get_time, nowtime, today
|
||||
from frappe.utils.user import is_website_user
|
||||
from pypika import Order
|
||||
|
||||
from erpnext import get_default_company
|
||||
from erpnext.controllers.queries import get_filters_cond
|
||||
from erpnext.controllers.website_list_for_contact import get_customers_suppliers
|
||||
from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
|
||||
|
||||
@@ -74,16 +73,15 @@ class Project(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
def onload(self):
|
||||
timesheet_detail = frappe.qb.DocType("Timesheet Detail")
|
||||
self.set_onload(
|
||||
"activity_summary",
|
||||
frappe.db.sql(
|
||||
"""select activity_type,
|
||||
sum(hours) as total_hours
|
||||
from `tabTimesheet Detail` where project=%s and docstatus < 2 group by activity_type
|
||||
order by total_hours desc""",
|
||||
self.name,
|
||||
as_dict=True,
|
||||
),
|
||||
frappe.qb.from_(timesheet_detail)
|
||||
.select(timesheet_detail.activity_type, Sum(timesheet_detail.hours).as_("total_hours"))
|
||||
.where((timesheet_detail.project == self.name) & (timesheet_detail.docstatus < 2))
|
||||
.groupby(timesheet_detail.activity_type)
|
||||
.orderby("total_hours", order=frappe.qb.desc)
|
||||
.run(as_dict=True),
|
||||
)
|
||||
|
||||
def before_print(self, settings=None):
|
||||
@@ -102,7 +100,7 @@ class Project(Document):
|
||||
"""
|
||||
Copy tasks from template
|
||||
"""
|
||||
if self.project_template and not frappe.db.get_all("Task", dict(project=self.name), limit=1):
|
||||
if self.project_template and not frappe.db.exists("Task", {"project": self.name}):
|
||||
# has a template, and no loaded tasks, so lets create
|
||||
if not self.expected_start_date:
|
||||
# project starts today
|
||||
@@ -240,8 +238,29 @@ class Project(Document):
|
||||
|
||||
def after_insert(self):
|
||||
self.copy_from_template("after_insert")
|
||||
if self.sales_order:
|
||||
frappe.db.set_value("Sales Order", self.sales_order, "project", self.name)
|
||||
self.link_with_sales_order()
|
||||
|
||||
def link_with_sales_order(self) -> None:
|
||||
"""Back-link the source Sales Order to this project.
|
||||
|
||||
The link is set only when the Sales Order is not already tied to another
|
||||
project, so projects created concurrently for the same Sales Order cannot
|
||||
overwrite each other's reference.
|
||||
"""
|
||||
if not self.sales_order:
|
||||
return
|
||||
|
||||
existing_project = frappe.db.get_value("Sales Order", self.sales_order, "project")
|
||||
if existing_project and existing_project != self.name:
|
||||
frappe.msgprint(
|
||||
_("Sales Order {0} is already linked to Project {1}, skipping the link.").format(
|
||||
self.sales_order, existing_project
|
||||
),
|
||||
alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
frappe.db.set_value("Sales Order", self.sales_order, "project", self.name)
|
||||
|
||||
def on_trash(self):
|
||||
frappe.db.set_value("Sales Order", {"project": self.name}, "project", "")
|
||||
@@ -267,32 +286,25 @@ class Project(Document):
|
||||
if (self.percent_complete_method == "Task Completion" and total > 0) or (
|
||||
not self.percent_complete_method and total > 0
|
||||
):
|
||||
completed = frappe.db.sql(
|
||||
"""select count(name) from tabTask where
|
||||
project=%s and status in ('Cancelled', 'Completed')""",
|
||||
self.name,
|
||||
)[0][0]
|
||||
completed = frappe.db.count(
|
||||
"Task", {"project": self.name, "status": ["in", ["Cancelled", "Completed"]]}
|
||||
)
|
||||
self.percent_complete = flt(flt(completed) / total * 100, 2)
|
||||
|
||||
if self.percent_complete_method == "Task Progress" and total > 0:
|
||||
progress = frappe.db.sql(
|
||||
"""select sum(progress) from tabTask where
|
||||
project=%s""",
|
||||
self.name,
|
||||
task = frappe.qb.DocType("Task")
|
||||
progress = (
|
||||
frappe.qb.from_(task).select(Sum(task.progress)).where(task.project == self.name).run()
|
||||
)[0][0]
|
||||
self.percent_complete = flt(flt(progress) / total, 2)
|
||||
|
||||
if self.percent_complete_method == "Task Weight" and total > 0:
|
||||
weight_sum = frappe.db.sql(
|
||||
"""select sum(task_weight) from tabTask where
|
||||
project=%s""",
|
||||
self.name,
|
||||
task = frappe.qb.DocType("Task")
|
||||
weight_sum = (
|
||||
frappe.qb.from_(task).select(Sum(task.task_weight)).where(task.project == self.name).run()
|
||||
)[0][0]
|
||||
weighted_progress = frappe.db.sql(
|
||||
"""select progress, task_weight from tabTask where
|
||||
project=%s""",
|
||||
self.name,
|
||||
as_dict=1,
|
||||
weighted_progress = frappe.get_all(
|
||||
"Task", filters={"project": self.name}, fields=["progress", "task_weight"]
|
||||
)
|
||||
pct_complete = 0
|
||||
for row in weighted_progress:
|
||||
@@ -353,10 +365,12 @@ class Project(Document):
|
||||
self.total_purchase_cost = total_purchase_cost and total_purchase_cost[0][0] or 0
|
||||
|
||||
def update_sales_amount(self):
|
||||
total_sales_amount = frappe.db.sql(
|
||||
"""select sum(base_net_total)
|
||||
from `tabSales Order` where project = %s and docstatus=1""",
|
||||
self.name,
|
||||
so = frappe.qb.DocType("Sales Order")
|
||||
total_sales_amount = (
|
||||
frappe.qb.from_(so)
|
||||
.select(Sum(so.base_net_total))
|
||||
.where((so.project == self.name) & (so.docstatus == 1))
|
||||
.run()
|
||||
)
|
||||
|
||||
self.total_sales_amount = total_sales_amount and total_sales_amount[0][0] or 0
|
||||
@@ -365,25 +379,31 @@ class Project(Document):
|
||||
self.total_billed_amount = self.get_billed_amount_from_parent() + self.get_billed_amount_from_child()
|
||||
|
||||
def get_billed_amount_from_parent(self):
|
||||
total_billed_amount = frappe.db.sql(
|
||||
"""select sum(base_net_amount)
|
||||
from `tabSales Invoice` si join `tabSales Invoice Item` si_item on si_item.parent = si.name
|
||||
where si_item.project is null
|
||||
and si.project is not null
|
||||
and si.project = %s
|
||||
and si.docstatus = 1""",
|
||||
self.name,
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
si_item = frappe.qb.DocType("Sales Invoice Item")
|
||||
total_billed_amount = (
|
||||
frappe.qb.from_(si)
|
||||
.join(si_item)
|
||||
.on(si_item.parent == si.name)
|
||||
.select(Sum(si_item.base_net_amount))
|
||||
.where(
|
||||
si_item.project.isnull()
|
||||
& si.project.isnotnull()
|
||||
& (si.project == self.name)
|
||||
& (si.docstatus == 1)
|
||||
)
|
||||
.run()
|
||||
)
|
||||
|
||||
return total_billed_amount and total_billed_amount[0][0] or 0
|
||||
|
||||
def get_billed_amount_from_child(self):
|
||||
total_billed_amount = frappe.db.sql(
|
||||
"""select sum(base_net_amount)
|
||||
from `tabSales Invoice Item`
|
||||
where project = %s
|
||||
and docstatus = 1""",
|
||||
self.name,
|
||||
si_item = frappe.qb.DocType("Sales Invoice Item")
|
||||
total_billed_amount = (
|
||||
frappe.qb.from_(si_item)
|
||||
.select(Sum(si_item.base_net_amount))
|
||||
.where((si_item.project == self.name) & (si_item.docstatus == 1))
|
||||
.run()
|
||||
)
|
||||
|
||||
return total_billed_amount and total_billed_amount[0][0] or 0
|
||||
@@ -499,28 +519,43 @@ def get_list_context(context=None):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_users_for_project(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
conditions = []
|
||||
return frappe.db.sql(
|
||||
"""select name, concat_ws(' ', first_name, middle_name, last_name)
|
||||
from `tabUser`
|
||||
where enabled=1
|
||||
and name not in ("Guest", "Administrator")
|
||||
and ({key} like %(txt)s
|
||||
or full_name like %(txt)s)
|
||||
{fcond} {mcond}
|
||||
order by
|
||||
(case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end),
|
||||
(case when locate(%(_txt)s, full_name) > 0 then locate(%(_txt)s, full_name) else 99999 end),
|
||||
idx desc,
|
||||
name, full_name
|
||||
limit %(page_len)s offset %(start)s""".format(
|
||||
**{
|
||||
"key": searchfield,
|
||||
"fcond": get_filters_cond(doctype, filters, conditions),
|
||||
"mcond": get_match_cond(doctype),
|
||||
}
|
||||
),
|
||||
{"txt": "%%%s%%" % txt, "_txt": txt.replace("%", ""), "start": start, "page_len": page_len},
|
||||
User = frappe.qb.DocType("User")
|
||||
search_str = f"%{txt}%"
|
||||
txt_no_percent = txt.replace("%", "")
|
||||
|
||||
query = frappe.qb.get_query(
|
||||
"User",
|
||||
fields=["name", "full_name"],
|
||||
filters=filters,
|
||||
ignore_permissions=False,
|
||||
)
|
||||
|
||||
return (
|
||||
query.where(User.enabled == 1)
|
||||
.where(User.name.notin(["Guest", "Administrator"]))
|
||||
.where(User[searchfield].like(search_str) | User.full_name.like(search_str))
|
||||
.orderby(
|
||||
Case()
|
||||
.when(
|
||||
Locate(Lower(txt_no_percent), Lower(User.name)) > 0,
|
||||
Locate(Lower(txt_no_percent), Lower(User.name)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
.orderby(
|
||||
Case()
|
||||
.when(
|
||||
Locate(Lower(txt_no_percent), Lower(User.full_name)) > 0,
|
||||
Locate(Lower(txt_no_percent), Lower(User.full_name)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
.orderby(User.idx, order=Order.desc)
|
||||
.orderby(User.name)
|
||||
.orderby(User.full_name)
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
.run()
|
||||
)
|
||||
|
||||
|
||||
@@ -580,11 +615,7 @@ def weekly_reminder():
|
||||
|
||||
|
||||
def allow_to_make_project_update(project, time, frequency):
|
||||
data = frappe.db.sql(
|
||||
""" SELECT name from `tabProject Update`
|
||||
WHERE project = %s and date = %s """,
|
||||
(project, today()),
|
||||
)
|
||||
data = frappe.get_all("Project Update", filters={"project": project, "date": today()}, pluck="name")
|
||||
|
||||
# len(data) > 1 condition is checked for twicely frequency
|
||||
if data and (frequency in ["Daily", "Weekly"] or len(data) > 1):
|
||||
|
||||
@@ -174,6 +174,25 @@ class TestProject(ERPNextTestSuite):
|
||||
so.reload()
|
||||
self.assertFalse(so.project)
|
||||
|
||||
def test_sales_order_link_is_not_overwritten_by_second_project(self):
|
||||
so = make_sales_order()
|
||||
|
||||
first_project = make_project_from_so(so.name).save()
|
||||
so.reload()
|
||||
self.assertEqual(so.project, first_project.name)
|
||||
|
||||
# A second project for the same sales order must not steal the link.
|
||||
second_project = frappe.get_doc(
|
||||
doctype="Project",
|
||||
project_name="Second project for same sales order",
|
||||
company=so.company,
|
||||
sales_order=so.name,
|
||||
).insert()
|
||||
self.assertEqual(second_project.sales_order, so.name)
|
||||
|
||||
so.reload()
|
||||
self.assertEqual(so.project, first_project.name)
|
||||
|
||||
def test_project_with_template_tasks_having_common_name(self):
|
||||
# Step - 1: Create Template Parent Tasks
|
||||
template_parent_task1 = create_task(subject="Parent Task - 1", is_template=1, is_group=1)
|
||||
|
||||
@@ -76,10 +76,9 @@ class Task(NestedSet):
|
||||
nsm_parent_field = "parent_task"
|
||||
|
||||
def get_customer_details(self):
|
||||
cust = frappe.db.sql("select customer_name from `tabCustomer` where name=%s", self.customer)
|
||||
if cust:
|
||||
ret = {"customer_name": cust and cust[0][0] or ""}
|
||||
return ret
|
||||
customer_name = frappe.db.get_value("Customer", self.customer, "customer_name")
|
||||
if customer_name:
|
||||
return {"customer_name": customer_name or ""}
|
||||
|
||||
def validate(self):
|
||||
self.validate_dates()
|
||||
@@ -252,9 +251,11 @@ class Task(NestedSet):
|
||||
for d in check_list:
|
||||
task_list, count = [self.name], 0
|
||||
while len(task_list) > count:
|
||||
tasks = frappe.db.sql(
|
||||
" select {} from `tabTask Depends On` where {} = {} ".format(d[0], d[1], "%s"),
|
||||
cstr(task_list[count]),
|
||||
tasks = frappe.get_all(
|
||||
"Task Depends On",
|
||||
filters={d[1]: cstr(task_list[count])},
|
||||
fields=[d[0]],
|
||||
as_list=True,
|
||||
)
|
||||
count = count + 1
|
||||
for b in tasks:
|
||||
@@ -268,30 +269,34 @@ class Task(NestedSet):
|
||||
|
||||
def reschedule_dependent_tasks(self):
|
||||
end_date = self.exp_end_date or self.act_end_date
|
||||
if end_date:
|
||||
for task_name in frappe.db.sql(
|
||||
"""
|
||||
select name from `tabTask` as parent
|
||||
where parent.project = %(project)s
|
||||
and parent.name in (
|
||||
select parent from `tabTask Depends On` as child
|
||||
where child.task = %(task)s and child.project = %(project)s)
|
||||
""",
|
||||
{"project": self.project, "task": self.name},
|
||||
as_dict=1,
|
||||
if not end_date:
|
||||
return
|
||||
|
||||
dependent_parents = frappe.get_all(
|
||||
"Task Depends On",
|
||||
filters={"task": self.name, "project": self.project},
|
||||
pluck="parent",
|
||||
)
|
||||
if not dependent_parents:
|
||||
return
|
||||
|
||||
for task_name in frappe.get_all(
|
||||
"Task",
|
||||
filters={"project": self.project, "name": ["in", dependent_parents]},
|
||||
pluck="name",
|
||||
):
|
||||
task = frappe.get_doc("Task", task_name)
|
||||
if (
|
||||
task.exp_start_date
|
||||
and task.exp_end_date
|
||||
and task.exp_start_date < end_date
|
||||
and task.status == "Open"
|
||||
):
|
||||
task = frappe.get_doc("Task", task_name.name)
|
||||
if (
|
||||
task.exp_start_date
|
||||
and task.exp_end_date
|
||||
and task.exp_start_date < end_date
|
||||
and task.status == "Open"
|
||||
):
|
||||
task_duration = date_diff(task.exp_end_date, task.exp_start_date)
|
||||
task.exp_start_date = add_days(end_date, 1)
|
||||
task.exp_end_date = add_days(task.exp_start_date, task_duration)
|
||||
task.flags.ignore_recursion_check = True
|
||||
task.save()
|
||||
task_duration = date_diff(task.exp_end_date, task.exp_start_date)
|
||||
task.exp_start_date = add_days(end_date, 1)
|
||||
task.exp_end_date = add_days(task.exp_start_date, task_duration)
|
||||
task.flags.ignore_recursion_check = True
|
||||
task.save()
|
||||
|
||||
def has_webform_permission(self):
|
||||
project_user = frappe.db.get_value(
|
||||
@@ -337,27 +342,23 @@ def check_if_child_exists(name: str):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_project(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
from erpnext.controllers.queries import get_match_cond
|
||||
from frappe.query_builder import Criterion
|
||||
|
||||
meta = frappe.get_meta(doctype)
|
||||
searchfields = meta.get_search_fields()
|
||||
search_columns = ", " + ", ".join(searchfields) if searchfields else ""
|
||||
search_cond = " or " + " or ".join(field + " like %(txt)s" for field in searchfields)
|
||||
searchfields = frappe.get_meta(doctype).get_search_fields()
|
||||
|
||||
return frappe.db.sql(
|
||||
f""" select name {search_columns} from `tabProject`
|
||||
where %(key)s like %(txt)s
|
||||
%(mcond)s
|
||||
{search_cond}
|
||||
order by name
|
||||
limit %(page_len)s offset %(start)s""",
|
||||
{
|
||||
"key": searchfield,
|
||||
"txt": "%" + txt + "%",
|
||||
"mcond": get_match_cond(doctype),
|
||||
"start": start,
|
||||
"page_len": page_len,
|
||||
},
|
||||
Project = frappe.qb.DocType("Project")
|
||||
search_str = f"%{txt}%"
|
||||
search_fields = list(dict.fromkeys([searchfield, *searchfields]))
|
||||
search_conditions = [Project[field].like(search_str) for field in search_fields]
|
||||
|
||||
query = frappe.qb.get_query("Project", fields=["name", *searchfields], ignore_permissions=False)
|
||||
|
||||
return (
|
||||
query.where(Criterion.any(search_conditions))
|
||||
.orderby(Project.name)
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
.run()
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -126,6 +126,80 @@ class TestTimesheet(ERPNextTestSuite):
|
||||
self.assertEqual(ts.per_billed, 100)
|
||||
self.assertEqual(ts.time_logs[0].sales_invoice, sales_invoice.name)
|
||||
|
||||
def _bill_timesheet_into_invoice(self, emp):
|
||||
"""Submit a billable timesheet into a Sales Invoice; return (timesheet, invoice)."""
|
||||
timesheet = make_timesheet(emp, simulate=True, is_billable=1)
|
||||
sales_invoice = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR")
|
||||
sales_invoice.due_date = nowdate()
|
||||
sales_invoice.submit()
|
||||
timesheet.reload()
|
||||
# Submitting links the timesheet detail to the invoice and marks it billed
|
||||
self.assertEqual(timesheet.time_logs[0].sales_invoice, sales_invoice.name)
|
||||
self.assertEqual(timesheet.status, "Billed")
|
||||
return timesheet, sales_invoice
|
||||
|
||||
def test_timesheet_billing_link_lifecycle(self):
|
||||
emp = make_employee("test_employee_6@salary.com", company="_Test Company")
|
||||
|
||||
with self.subTest("link released on cancel"):
|
||||
timesheet, sales_invoice = self._bill_timesheet_into_invoice(emp)
|
||||
sales_invoice.reload()
|
||||
sales_invoice.cancel()
|
||||
timesheet.reload()
|
||||
self.assertFalse(timesheet.time_logs[0].sales_invoice)
|
||||
self.assertNotEqual(timesheet.status, "Billed")
|
||||
|
||||
with self.subTest("link released on sales return"):
|
||||
timesheet, sales_invoice = self._bill_timesheet_into_invoice(emp)
|
||||
sales_return = make_sales_return(sales_invoice.name)
|
||||
sales_return.insert()
|
||||
sales_return.submit()
|
||||
timesheet.reload()
|
||||
self.assertFalse(timesheet.time_logs[0].sales_invoice)
|
||||
|
||||
def test_timesheet_billing_validations(self):
|
||||
emp = make_employee("test_employee_6@salary.com", company="_Test Company")
|
||||
|
||||
with self.subTest("unsubmitted timesheet is rejected"):
|
||||
draft = make_timesheet(emp, simulate=True, is_billable=1, do_not_submit=True)
|
||||
sales_invoice = self._invoice_with_timesheet_row(draft.name, draft.time_logs[0].name)
|
||||
self.assertRaises(frappe.ValidationError, sales_invoice.save)
|
||||
|
||||
with self.subTest("already invoiced detail is rejected"):
|
||||
timesheet, _ = self._bill_timesheet_into_invoice(emp)
|
||||
sales_invoice = self._invoice_with_timesheet_row(timesheet.name, timesheet.time_logs[0].name)
|
||||
self.assertRaises(frappe.ValidationError, sales_invoice.save)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Projects Settings", {"fetch_timesheet_in_sales_invoice": 1})
|
||||
def test_timesheet_billing_data_population(self):
|
||||
emp = make_employee("test_employee_6@salary.com", company="_Test Company")
|
||||
|
||||
with self.subTest("blank hours/amount are back-filled from the timesheet"):
|
||||
timesheet = make_timesheet(emp, simulate=True, is_billable=1)
|
||||
sales_invoice = self._invoice_with_timesheet_row(
|
||||
timesheet.name, timesheet.time_logs[0].name, with_amounts=False
|
||||
)
|
||||
sales_invoice.save()
|
||||
self.assertEqual(sales_invoice.timesheets[0].billing_hours, 2)
|
||||
self.assertEqual(sales_invoice.timesheets[0].billing_amount, 100)
|
||||
|
||||
with self.subTest("project invoice auto-fetches the project's timesheets"):
|
||||
project = frappe.get_value("Project", {"project_name": "_Test Project"})
|
||||
make_timesheet(emp, simulate=True, is_billable=1, project=project, company="_Test Company")
|
||||
sales_invoice = create_sales_invoice(do_not_save=True)
|
||||
sales_invoice.project = project
|
||||
sales_invoice.set("timesheets", [])
|
||||
sales_invoice.save()
|
||||
self.assertTrue(sales_invoice.timesheets)
|
||||
|
||||
def _invoice_with_timesheet_row(self, time_sheet, timesheet_detail, with_amounts=True):
|
||||
sales_invoice = create_sales_invoice(do_not_save=True)
|
||||
row = {"time_sheet": time_sheet, "timesheet_detail": timesheet_detail}
|
||||
if with_amounts:
|
||||
row.update({"billing_hours": 2, "billing_amount": 100})
|
||||
sales_invoice.append("timesheets", row)
|
||||
return sales_invoice
|
||||
|
||||
def test_timesheet_time_overlap(self):
|
||||
emp = make_employee("test_employee_6@salary.com", company="_Test Company")
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Concat, Date, Round
|
||||
from frappe.utils import flt, get_datetime, getdate
|
||||
from frappe.utils.deprecations import deprecated
|
||||
|
||||
from erpnext.controllers.queries import get_match_cond
|
||||
from erpnext.setup.utils import get_exchange_rate
|
||||
|
||||
|
||||
@@ -308,41 +308,41 @@ def get_projectwise_timesheet_data(
|
||||
from_time: str | None = None,
|
||||
to_time: str | None = None,
|
||||
):
|
||||
condition = ""
|
||||
tsd = frappe.qb.DocType("Timesheet Detail")
|
||||
ts = frappe.qb.DocType("Timesheet")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(tsd)
|
||||
.inner_join(ts)
|
||||
.on(ts.name == tsd.parent)
|
||||
.select(
|
||||
tsd.name.as_("name"),
|
||||
tsd.parent.as_("time_sheet"),
|
||||
tsd.from_time.as_("from_time"),
|
||||
tsd.to_time.as_("to_time"),
|
||||
tsd.billing_hours.as_("billing_hours"),
|
||||
tsd.billing_amount.as_("billing_amount"),
|
||||
tsd.activity_type.as_("activity_type"),
|
||||
tsd.description.as_("description"),
|
||||
ts.currency.as_("currency"),
|
||||
tsd.project_name.as_("project_name"),
|
||||
)
|
||||
.where(
|
||||
(tsd.parenttype == "Timesheet")
|
||||
& (tsd.docstatus == 1)
|
||||
& (tsd.is_billable == 1)
|
||||
& tsd.sales_invoice.isnull()
|
||||
)
|
||||
)
|
||||
|
||||
if project:
|
||||
condition += "AND tsd.project = %(project)s "
|
||||
query = query.where(tsd.project == project)
|
||||
if parent:
|
||||
condition += "AND tsd.parent = %(parent)s "
|
||||
query = query.where(tsd.parent == parent)
|
||||
if from_time and to_time:
|
||||
condition += "AND CAST(tsd.from_time as DATE) BETWEEN %(from_time)s AND %(to_time)s"
|
||||
query = query.where(Date(tsd.from_time).between(from_time, to_time))
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
tsd.name as name,
|
||||
tsd.parent as time_sheet,
|
||||
tsd.from_time as from_time,
|
||||
tsd.to_time as to_time,
|
||||
tsd.billing_hours as billing_hours,
|
||||
tsd.billing_amount as billing_amount,
|
||||
tsd.activity_type as activity_type,
|
||||
tsd.description as description,
|
||||
ts.currency as currency,
|
||||
tsd.project_name as project_name
|
||||
FROM `tabTimesheet Detail` tsd
|
||||
INNER JOIN `tabTimesheet` ts
|
||||
ON ts.name = tsd.parent
|
||||
WHERE
|
||||
tsd.parenttype = 'Timesheet'
|
||||
AND tsd.docstatus = 1
|
||||
AND tsd.is_billable = 1
|
||||
AND tsd.sales_invoice is NULL
|
||||
{condition}
|
||||
ORDER BY tsd.from_time ASC
|
||||
"""
|
||||
|
||||
filters = {"project": project, "parent": parent, "from_time": from_time, "to_time": to_time}
|
||||
|
||||
return frappe.db.sql(query, filters, as_dict=1)
|
||||
return query.orderby(tsd.from_time).run(as_dict=1)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -372,25 +372,28 @@ def get_timesheet(doctype: str, txt: str, searchfield: str, start: int, page_len
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
condition = ""
|
||||
if filters.get("project"):
|
||||
condition = "and tsd.project = %(project)s"
|
||||
tsd = frappe.qb.DocType("Timesheet Detail")
|
||||
ts = frappe.qb.DocType("Timesheet")
|
||||
|
||||
return frappe.db.sql(
|
||||
f"""select distinct tsd.parent from `tabTimesheet Detail` tsd,
|
||||
`tabTimesheet` ts where
|
||||
ts.status in ('Submitted', 'Payslip') and tsd.parent = ts.name and
|
||||
tsd.docstatus = 1 and ts.total_billable_amount > 0
|
||||
and tsd.parent LIKE %(txt)s {condition}
|
||||
order by tsd.parent limit %(page_len)s offset %(start)s""",
|
||||
{
|
||||
"txt": "%" + txt + "%",
|
||||
"start": start,
|
||||
"page_len": page_len,
|
||||
"project": filters.get("project"),
|
||||
},
|
||||
query = (
|
||||
frappe.qb.from_(tsd)
|
||||
.inner_join(ts)
|
||||
.on(tsd.parent == ts.name)
|
||||
.select(tsd.parent)
|
||||
.distinct()
|
||||
.where(
|
||||
ts.status.isin(["Submitted", "Payslip"])
|
||||
& (tsd.docstatus == 1)
|
||||
& (ts.total_billable_amount > 0)
|
||||
& tsd.parent.like(f"%{txt}%")
|
||||
)
|
||||
)
|
||||
|
||||
if filters.get("project"):
|
||||
query = query.where(tsd.project == filters.get("project"))
|
||||
|
||||
return query.orderby(tsd.parent).limit(page_len).offset(start).run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_timesheet_data(name: str, project: str):
|
||||
@@ -500,27 +503,37 @@ def get_events(start: str, end: str, filters: str | None = None):
|
||||
:param end: End date-time.
|
||||
:param filters: Filters (JSON).
|
||||
"""
|
||||
from erpnext.utilities.query import get_event_conditions_qb
|
||||
|
||||
filters = json.loads(filters) if filters else {}
|
||||
from frappe.desk.calendar import get_event_conditions
|
||||
|
||||
conditions = get_event_conditions("Timesheet", filters)
|
||||
tsd = frappe.qb.DocType("Timesheet Detail")
|
||||
ts = frappe.qb.DocType("Timesheet")
|
||||
|
||||
return frappe.db.sql(
|
||||
"""select `tabTimesheet Detail`.name as name,
|
||||
`tabTimesheet Detail`.docstatus as status, `tabTimesheet Detail`.parent as parent,
|
||||
from_time as start_date, hours, activity_type,
|
||||
`tabTimesheet Detail`.project, to_time as end_date,
|
||||
CONCAT(`tabTimesheet Detail`.parent, ' (', ROUND(hours,2),' hrs)') as title
|
||||
from `tabTimesheet Detail`, `tabTimesheet`
|
||||
where `tabTimesheet Detail`.parent = `tabTimesheet`.name
|
||||
and `tabTimesheet`.docstatus < 2
|
||||
and (from_time <= %(end)s and to_time >= %(start)s) {conditions} {match_cond}
|
||||
""".format(conditions=conditions, match_cond=get_match_cond("Timesheet")),
|
||||
{"start": start, "end": end},
|
||||
as_dict=True,
|
||||
update={"allDay": 0},
|
||||
query = (
|
||||
frappe.qb.from_(tsd)
|
||||
.inner_join(ts)
|
||||
.on(tsd.parent == ts.name)
|
||||
.select(
|
||||
tsd.name.as_("name"),
|
||||
tsd.docstatus.as_("status"),
|
||||
tsd.parent.as_("parent"),
|
||||
tsd.from_time.as_("start_date"),
|
||||
tsd.hours,
|
||||
tsd.activity_type,
|
||||
tsd.project,
|
||||
tsd.to_time.as_("end_date"),
|
||||
Concat(tsd.parent, " (", Round(tsd.hours, 2), " hrs)").as_("title"),
|
||||
)
|
||||
.where((ts.docstatus < 2) & (tsd.from_time <= end) & (tsd.to_time >= start))
|
||||
)
|
||||
|
||||
# user-permission match conditions + calendar filters on Timesheet (query-builder form)
|
||||
for condition in get_event_conditions_qb("Timesheet", filters):
|
||||
query = query.where(condition)
|
||||
|
||||
return query.run(as_dict=True, update={"allDay": 0})
|
||||
|
||||
|
||||
def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20, order_by="creation"):
|
||||
user = frappe.session.user
|
||||
|
||||
@@ -4,19 +4,16 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.desk.reportview import build_match_conditions
|
||||
from frappe.utils import add_days, getdate
|
||||
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
if not filters:
|
||||
filters = {}
|
||||
elif filters.get("from_date") or filters.get("to_date"):
|
||||
filters["from_time"] = "00:00:00"
|
||||
filters["to_time"] = "24:00:00"
|
||||
filters = filters or {}
|
||||
|
||||
columns = get_column()
|
||||
conditions = get_conditions(filters)
|
||||
data = get_data(conditions, filters)
|
||||
data = get_data(filters)
|
||||
|
||||
return columns, data
|
||||
|
||||
@@ -36,30 +33,39 @@ def get_column():
|
||||
]
|
||||
|
||||
|
||||
def get_data(conditions, filters):
|
||||
time_sheet = frappe.db.sql(
|
||||
""" select `tabTimesheet`.name, `tabTimesheet`.employee, `tabTimesheet`.employee_name,
|
||||
`tabTimesheet Detail`.from_time, `tabTimesheet Detail`.to_time, `tabTimesheet Detail`.hours,
|
||||
`tabTimesheet Detail`.activity_type, `tabTimesheet Detail`.task, `tabTimesheet Detail`.project,
|
||||
`tabTimesheet`.status from `tabTimesheet Detail`, `tabTimesheet` where
|
||||
`tabTimesheet Detail`.parent = `tabTimesheet`.name and %s order by `tabTimesheet`.name"""
|
||||
% (conditions),
|
||||
filters,
|
||||
as_list=1,
|
||||
def get_data(filters):
|
||||
ts = frappe.qb.DocType("Timesheet")
|
||||
tsd = frappe.qb.DocType("Timesheet Detail")
|
||||
|
||||
# Base the query on Timesheet so get_query applies its user-permission match conditions
|
||||
# (the qb form of build_match_conditions); Timesheet Detail rows are inner-joined on.
|
||||
query = (
|
||||
frappe.qb.get_query(
|
||||
"Timesheet",
|
||||
fields=["name", "employee", "employee_name"],
|
||||
ignore_permissions=False,
|
||||
)
|
||||
.inner_join(tsd)
|
||||
.on(tsd.parent == ts.name)
|
||||
.select(
|
||||
tsd.from_time,
|
||||
tsd.to_time,
|
||||
tsd.hours,
|
||||
tsd.activity_type,
|
||||
tsd.task,
|
||||
tsd.project,
|
||||
ts.status,
|
||||
)
|
||||
.where(ts.docstatus == 1)
|
||||
)
|
||||
|
||||
return time_sheet
|
||||
|
||||
|
||||
def get_conditions(filters):
|
||||
conditions = "`tabTimesheet`.docstatus = 1"
|
||||
if filters.get("from_date"):
|
||||
conditions += " and `tabTimesheet Detail`.from_time >= timestamp(%(from_date)s, %(from_time)s)"
|
||||
query = query.where(tsd.from_time >= get_combine_datetime(filters.get("from_date"), "00:00:00"))
|
||||
|
||||
if filters.get("to_date"):
|
||||
conditions += " and `tabTimesheet Detail`.to_time <= timestamp(%(to_date)s, %(to_time)s)"
|
||||
# upper bound is the end of to_date, i.e. midnight of the next day
|
||||
# (matches the original `timestamp(to_date, '24:00:00')`)
|
||||
end_of_to_date = get_combine_datetime(add_days(getdate(filters.get("to_date")), 1), "00:00:00")
|
||||
query = query.where(tsd.to_time <= end_of_to_date)
|
||||
|
||||
match_conditions = build_match_conditions("Timesheet")
|
||||
if match_conditions:
|
||||
conditions += " and (%s)" % match_conditions
|
||||
|
||||
return conditions
|
||||
return query.orderby(ts.name).run(as_list=True)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.projects.doctype.timesheet.test_timesheet import make_timesheet
|
||||
from erpnext.projects.report.daily_timesheet_summary.daily_timesheet_summary import execute
|
||||
from erpnext.setup.doctype.employee.test_employee import make_employee
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestDailyTimesheetSummary(ERPNextTestSuite):
|
||||
def test_submitted_timesheet_in_summary(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
employee = make_employee("test_employee_6@salary.com", company="_Test Company")
|
||||
timesheet = make_timesheet(employee, simulate=True)
|
||||
|
||||
_columns, data = execute({"from_date": today(), "to_date": today()})
|
||||
|
||||
# Row column order: [Timesheet.name, employee, employee_name, from_time, to_time,
|
||||
# hours, activity_type, task, project, status]. The converted join must surface the
|
||||
# submitted timesheet for today; row[0] holds the Timesheet name.
|
||||
names = [row[0] for row in data]
|
||||
self.assertIn(timesheet.name, names)
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -50,19 +51,28 @@ def get_columns():
|
||||
|
||||
|
||||
def get_project_details():
|
||||
return frappe.db.sql(
|
||||
""" select name, project_name, status, company, customer, estimated_costing,
|
||||
expected_start_date, expected_end_date from tabProject where docstatus < 2""",
|
||||
as_dict=1,
|
||||
return frappe.get_all(
|
||||
"Project",
|
||||
filters={"docstatus": ["<", 2]},
|
||||
fields=[
|
||||
"name",
|
||||
"project_name",
|
||||
"status",
|
||||
"company",
|
||||
"customer",
|
||||
"estimated_costing",
|
||||
"expected_start_date",
|
||||
"expected_end_date",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def get_purchased_items_cost():
|
||||
pr_items = frappe.db.sql(
|
||||
"""select project, sum(base_net_amount) as amount
|
||||
from `tabPurchase Receipt Item` where ifnull(project, '') != ''
|
||||
and docstatus = 1 group by project""",
|
||||
as_dict=1,
|
||||
pr_items = frappe.get_all(
|
||||
"Purchase Receipt Item",
|
||||
filters={"project": ["is", "set"], "docstatus": 1},
|
||||
fields=["project", {"SUM": "base_net_amount", "as": "amount"}],
|
||||
group_by="project",
|
||||
)
|
||||
|
||||
pr_item_map = {}
|
||||
@@ -73,12 +83,20 @@ def get_purchased_items_cost():
|
||||
|
||||
|
||||
def get_issued_items_cost():
|
||||
se_items = frappe.db.sql(
|
||||
"""select se.project, sum(se_item.amount) as amount
|
||||
from `tabStock Entry` se, `tabStock Entry Detail` se_item
|
||||
where se.name = se_item.parent and se.docstatus = 1 and ifnull(se_item.t_warehouse, '') = ''
|
||||
and se.project != '' group by se.project""",
|
||||
as_dict=1,
|
||||
se = frappe.qb.DocType("Stock Entry")
|
||||
se_item = frappe.qb.DocType("Stock Entry Detail")
|
||||
se_items = (
|
||||
frappe.qb.from_(se)
|
||||
.inner_join(se_item)
|
||||
.on(se.name == se_item.parent)
|
||||
.select(se.project, Sum(se_item.amount).as_("amount"))
|
||||
.where(
|
||||
(se.docstatus == 1)
|
||||
& (se_item.t_warehouse.isnull() | (se_item.t_warehouse == ""))
|
||||
& (se.project != "")
|
||||
)
|
||||
.groupby(se.project)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
se_item_map = {}
|
||||
@@ -89,21 +107,28 @@ def get_issued_items_cost():
|
||||
|
||||
|
||||
def get_delivered_items_cost():
|
||||
dn_items = frappe.db.sql(
|
||||
"""select dn.project, sum(dn_item.base_net_amount) as amount
|
||||
from `tabDelivery Note` dn, `tabDelivery Note Item` dn_item
|
||||
where dn.name = dn_item.parent and dn.docstatus = 1 and ifnull(dn.project, '') != ''
|
||||
group by dn.project""",
|
||||
as_dict=1,
|
||||
dn = frappe.qb.DocType("Delivery Note")
|
||||
dn_item = frappe.qb.DocType("Delivery Note Item")
|
||||
dn_items = (
|
||||
frappe.qb.from_(dn)
|
||||
.inner_join(dn_item)
|
||||
.on(dn.name == dn_item.parent)
|
||||
.select(dn.project, Sum(dn_item.base_net_amount).as_("amount"))
|
||||
.where((dn.docstatus == 1) & (dn.project != ""))
|
||||
.groupby(dn.project)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
si_items = frappe.db.sql(
|
||||
"""select si.project, sum(si_item.base_net_amount) as amount
|
||||
from `tabSales Invoice` si, `tabSales Invoice Item` si_item
|
||||
where si.name = si_item.parent and si.docstatus = 1 and si.update_stock = 1
|
||||
and si.is_pos = 1 and ifnull(si.project, '') != ''
|
||||
group by si.project""",
|
||||
as_dict=1,
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
si_item = frappe.qb.DocType("Sales Invoice Item")
|
||||
si_items = (
|
||||
frappe.qb.from_(si)
|
||||
.inner_join(si_item)
|
||||
.on(si.name == si_item.parent)
|
||||
.select(si.project, Sum(si_item.base_net_amount).as_("amount"))
|
||||
.where((si.docstatus == 1) & (si.update_stock == 1) & (si.is_pos == 1) & (si.project != ""))
|
||||
.groupby(si.project)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
dn_item_map = {}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt, random_string, today
|
||||
|
||||
from erpnext.projects.report.project_wise_stock_tracking.project_wise_stock_tracking import execute
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestProjectWiseStockTracking(ERPNextTestSuite):
|
||||
def test_project_wise_stock_tracking(self):
|
||||
project = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Project",
|
||||
"project_name": "_Test PWST " + random_string(10),
|
||||
"status": "Open",
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert()
|
||||
|
||||
# Issued cost: a project-tagged Material Issue (t_warehouse empty) -> get_issued_items_cost.
|
||||
make_stock_entry(item_code="_Test Item", qty=10, to_warehouse="_Test Warehouse - _TC", rate=100)
|
||||
issue = make_stock_entry(
|
||||
item_code="_Test Item", qty=4, from_warehouse="_Test Warehouse - _TC", do_not_save=True
|
||||
)
|
||||
issue.project = project.name
|
||||
issue.save()
|
||||
issue.submit()
|
||||
expected_issued_cost = issue.items[0].amount
|
||||
|
||||
# Purchased cost: a submitted Purchase Receipt Item tagged to the project. Inserted directly
|
||||
# (no parent receipt) so get_purchased_items_cost has data without running Purchase Receipt
|
||||
# validation (which would also pull in the landed-cost-voucher path).
|
||||
self.make_child_row("Purchase Receipt Item", "Purchase Receipt", 300, project=project.name)
|
||||
|
||||
# Delivered cost: a submitted Delivery Note + line; the report joins on the parent's project.
|
||||
dn = self.make_parent_row("Delivery Note", company="_Test Company", customer="_Test Customer")
|
||||
frappe.db.set_value("Delivery Note", dn, "project", project.name)
|
||||
self.make_child_row("Delivery Note Item", "Delivery Note", 200, parent=dn)
|
||||
|
||||
_columns, data = execute(filters=None)
|
||||
row = next((r for r in data if r[0] == project.name), None)
|
||||
# get_project_details must surface the freshly created project.
|
||||
self.assertIsNotNone(row, "Project row missing from report output")
|
||||
|
||||
self.assertEqual(flt(row[1]), 300) # get_purchased_items_cost (GROUP BY project)
|
||||
self.assertEqual(flt(row[2]), flt(expected_issued_cost)) # get_issued_items_cost
|
||||
self.assertEqual(flt(row[3]), 200) # get_delivered_items_cost
|
||||
|
||||
def make_parent_row(self, doctype, **fields):
|
||||
doc = frappe.new_doc(doctype)
|
||||
for key, value in fields.items():
|
||||
doc.set(key, value)
|
||||
doc.posting_date = today()
|
||||
doc.docstatus = 1
|
||||
doc.flags.name_set = True
|
||||
doc.name = frappe.generate_hash("pwst", 12)
|
||||
doc.db_insert()
|
||||
return doc.name
|
||||
|
||||
def make_child_row(self, doctype, parenttype, base_net_amount, project=None, parent=None):
|
||||
row = frappe.new_doc(doctype)
|
||||
row.parenttype = parenttype
|
||||
row.parentfield = "items"
|
||||
row.parent = parent or frappe.generate_hash("pwst", 12)
|
||||
row.idx = 1
|
||||
row.item_code = "_Test Item"
|
||||
row.item_name = "_Test Item"
|
||||
row.base_net_amount = base_net_amount
|
||||
if project:
|
||||
row.project = project
|
||||
row.docstatus = 1
|
||||
row.flags.name_set = True
|
||||
row.name = frappe.generate_hash("pwst", 12)
|
||||
row.db_insert()
|
||||
return row.name
|
||||
@@ -5,28 +5,25 @@
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder import Case
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def query_task(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
from frappe.desk.reportview import build_match_conditions
|
||||
search_str = f"%{txt}%"
|
||||
prefix_str = f"{txt}%"
|
||||
|
||||
search_string = "%%%s%%" % txt
|
||||
order_by_string = "%s%%" % txt
|
||||
match_conditions = build_match_conditions("Task")
|
||||
match_conditions = (f"and ({match_conditions})") if match_conditions else ""
|
||||
Task = frappe.qb.DocType("Task")
|
||||
query = frappe.qb.get_query("Task", fields=["name", "subject"], ignore_permissions=False)
|
||||
|
||||
return frappe.db.sql(
|
||||
"""select name, subject from `tabTask`
|
||||
where (`{}` like {} or `subject` like {}) {}
|
||||
order by
|
||||
case when `subject` like {} then 0 else 1 end,
|
||||
case when `{}` like {} then 0 else 1 end,
|
||||
`{}`,
|
||||
subject
|
||||
limit {} offset {}""".format(
|
||||
searchfield, "%s", "%s", match_conditions, "%s", searchfield, "%s", searchfield, "%s", "%s"
|
||||
),
|
||||
(search_string, search_string, order_by_string, order_by_string, page_len, start),
|
||||
return (
|
||||
query.where(Task[searchfield].like(search_str) | Task.subject.like(search_str))
|
||||
.orderby(Case().when(Task.subject.like(prefix_str), 0).else_(1))
|
||||
.orderby(Case().when(Task[searchfield].like(prefix_str), 0).else_(1))
|
||||
.orderby(Task[searchfield])
|
||||
.orderby(Task.subject)
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
.run()
|
||||
)
|
||||
|
||||
@@ -602,6 +602,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
refresh() {
|
||||
erpnext.toggle_naming_series();
|
||||
erpnext.hide_company(this.frm);
|
||||
// Remember the currency the rendered document is denominated in, so that a
|
||||
// real currency change can be told apart from a mere exchange rate refresh
|
||||
// (e.g. triggered by a date change).
|
||||
this._doc_currency = this.frm.doc.currency;
|
||||
this.set_dynamic_labels();
|
||||
this.setup_sms();
|
||||
this.setup_quality_inspection();
|
||||
@@ -1472,6 +1476,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
let me = this;
|
||||
this.set_dynamic_labels();
|
||||
let company_currency = this.get_company_currency();
|
||||
// Currency the stored margins/actual charges are denominated in, captured
|
||||
// before this trigger updates the tracker for the next one.
|
||||
let previous_currency = this._doc_currency;
|
||||
this._doc_currency = this.frm.doc.currency;
|
||||
// Added `load_after_mapping` to determine if document is loading after mapping from another doc
|
||||
if (
|
||||
this.frm.doc.currency &&
|
||||
@@ -1484,8 +1492,14 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
company_currency,
|
||||
function (exchange_rate) {
|
||||
if (exchange_rate != me.frm.doc.conversion_rate) {
|
||||
me.set_margin_amount_based_on_currency(exchange_rate);
|
||||
me.set_actual_charges_based_on_currency(exchange_rate);
|
||||
// Margins and actual charges are amounts in the transaction
|
||||
// currency; convert them only when the currency itself changed,
|
||||
// not when just the exchange rate was refreshed (e.g. by a date
|
||||
// change), otherwise the entered margin keeps shrinking.
|
||||
if (previous_currency !== me.frm.doc.currency) {
|
||||
me.set_margin_amount_based_on_currency(exchange_rate);
|
||||
me.set_actual_charges_based_on_currency(exchange_rate);
|
||||
}
|
||||
me.frm.set_value("conversion_rate", exchange_rate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cstr, nowdate
|
||||
from frappe.utils.data import fmt_money
|
||||
from frappe.utils.jinja import render_template
|
||||
@@ -29,37 +31,34 @@ def execute(filters=None):
|
||||
return [], []
|
||||
|
||||
columns = get_columns()
|
||||
conditions = ""
|
||||
if filters.supplier_group:
|
||||
conditions += "AND s.supplier_group = %s" % frappe.db.escape(filters.get("supplier_group"))
|
||||
|
||||
data = frappe.db.sql(
|
||||
f"""
|
||||
SELECT
|
||||
s.supplier_group as "supplier_group",
|
||||
gl.party AS "supplier",
|
||||
s.tax_id as "tax_id",
|
||||
SUM(gl.debit_in_account_currency) AS "payments"
|
||||
FROM
|
||||
`tabGL Entry` gl
|
||||
INNER JOIN `tabSupplier` s
|
||||
WHERE
|
||||
s.name = gl.party
|
||||
AND s.irs_1099 = 1
|
||||
AND gl.fiscal_year = %(fiscal_year)s
|
||||
AND gl.party_type = 'Supplier'
|
||||
AND gl.company = %(company)s
|
||||
{conditions}
|
||||
|
||||
GROUP BY
|
||||
gl.party
|
||||
|
||||
ORDER BY
|
||||
gl.party DESC""",
|
||||
{"fiscal_year": filters.fiscal_year, "company": filters.company},
|
||||
as_dict=True,
|
||||
gl = frappe.qb.DocType("GL Entry")
|
||||
s = frappe.qb.DocType("Supplier")
|
||||
query = (
|
||||
frappe.qb.from_(gl)
|
||||
.inner_join(s)
|
||||
.on(s.name == gl.party)
|
||||
.select(
|
||||
s.supplier_group.as_("supplier_group"),
|
||||
gl.party.as_("supplier"),
|
||||
s.tax_id.as_("tax_id"),
|
||||
Sum(gl.debit_in_account_currency).as_("payments"),
|
||||
)
|
||||
.where(
|
||||
(s.irs_1099 == 1)
|
||||
& (gl.fiscal_year == filters.fiscal_year)
|
||||
& (gl.party_type == "Supplier")
|
||||
& (gl.company == filters.company)
|
||||
)
|
||||
.groupby(gl.party, s.supplier_group, s.tax_id)
|
||||
.orderby(gl.party, order=frappe.qb.desc)
|
||||
)
|
||||
|
||||
if filters.supplier_group:
|
||||
query = query.where(s.supplier_group == filters.supplier_group)
|
||||
|
||||
data = query.run(as_dict=True)
|
||||
|
||||
return columns, data
|
||||
|
||||
|
||||
@@ -125,20 +124,15 @@ def irs_1099_print(filters: str):
|
||||
|
||||
|
||||
def get_payer_address_html(company):
|
||||
address_list = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
name
|
||||
FROM
|
||||
tabAddress
|
||||
WHERE
|
||||
is_your_company_address = 1
|
||||
ORDER BY
|
||||
address_type="Postal" DESC, address_type="Billing" DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
{"company": company},
|
||||
as_dict=True,
|
||||
address = frappe.qb.DocType("Address")
|
||||
address_list = (
|
||||
frappe.qb.from_(address)
|
||||
.select(address.name)
|
||||
.where(address.is_your_company_address == 1)
|
||||
.orderby(Case().when(address.address_type == "Postal", 1).else_(0), order=frappe.qb.desc)
|
||||
.orderby(Case().when(address.address_type == "Billing", 1).else_(0), order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
address_display = ""
|
||||
@@ -150,23 +144,19 @@ def get_payer_address_html(company):
|
||||
|
||||
|
||||
def get_street_address_html(party_type, party):
|
||||
address_list = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
link.parent
|
||||
FROM
|
||||
`tabDynamic Link` link,
|
||||
`tabAddress` address
|
||||
WHERE
|
||||
link.parenttype = "Address"
|
||||
AND link.link_name = %(party)s
|
||||
ORDER BY
|
||||
address.address_type="Postal" DESC,
|
||||
address.address_type="Billing" DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
{"party": party},
|
||||
as_dict=True,
|
||||
link = frappe.qb.DocType("Dynamic Link")
|
||||
address = frappe.qb.DocType("Address")
|
||||
address_list = (
|
||||
frappe.qb.from_(link)
|
||||
.inner_join(address)
|
||||
.on(address.name == link.parent)
|
||||
.select(link.parent)
|
||||
.where((link.parenttype == "Address") & (link.link_name == party))
|
||||
.orderby(Case().when(address.address_type == "Postal", 1).else_(0), order=frappe.qb.desc)
|
||||
.orderby(Case().when(address.address_type == "Billing", 1).else_(0), order=frappe.qb.desc)
|
||||
.orderby(link.parent) # deterministic LIMIT-1 tie-break across engines
|
||||
.limit(1)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
street_address = city_state = ""
|
||||
|
||||
42
erpnext/regional/report/irs_1099/test_irs_1099.py
Normal file
42
erpnext/regional/report/irs_1099/test_irs_1099.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.regional.report.irs_1099.irs_1099 import get_street_address_html
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestIRS1099StreetAddress(ERPNextTestSuite):
|
||||
def test_street_address_prefers_postal(self):
|
||||
"""The original query cross-joined Address with no join predicate, so its
|
||||
`ORDER BY address_type='Postal' DESC` sorted on an arbitrary cross-joined row and never
|
||||
controlled which link.parent (Address) was returned. The conversion joins address.name ==
|
||||
link.parent so the Postal/Billing preference actually applies; a `link.parent` tie-break keeps
|
||||
the LIMIT-1 pick deterministic across engines when several addresses share the top type."""
|
||||
party = "_Test 1099 Address Supplier"
|
||||
if not frappe.db.exists("Supplier", party):
|
||||
frappe.get_doc(
|
||||
{"doctype": "Supplier", "supplier_name": party, "supplier_group": "_Test Supplier Group"}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
def mk_addr(title, address_type, line1):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Address",
|
||||
"address_title": title,
|
||||
"address_type": address_type,
|
||||
"address_line1": line1,
|
||||
"city": "Testville",
|
||||
"country": "United States",
|
||||
"links": [{"link_doctype": "Supplier", "link_name": party}],
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
mk_addr("_Test 1099 Billing", "Billing", "1 Billing St")
|
||||
mk_addr("_Test 1099 Postal", "Postal", "9 Postal Rd")
|
||||
|
||||
street, _city_state = get_street_address_html("Supplier", party)
|
||||
# the Postal address must win over the Billing one (deterministically, on both engines)
|
||||
self.assertIn("9 Postal Rd", street)
|
||||
self.assertNotIn("1 Billing St", street)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user