mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-04 20:59:11 +00:00
Merge pull request #52598 from frappe/version-15-hotfix
chore: release v15
This commit is contained in:
@@ -48,6 +48,7 @@
|
|||||||
"fieldname": "amount",
|
"fieldname": "amount",
|
||||||
"fieldtype": "Currency",
|
"fieldtype": "Currency",
|
||||||
"label": "Amount",
|
"label": "Amount",
|
||||||
|
"options": "currency",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -603,6 +603,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
|
"depends_on": "eval:doc.items.every((item) => !item.pr_detail)",
|
||||||
"fieldname": "update_stock",
|
"fieldname": "update_stock",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Update Stock",
|
"label": "Update Stock",
|
||||||
@@ -1659,7 +1660,7 @@
|
|||||||
"idx": 204,
|
"idx": 204,
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-08-04 19:19:11.380664",
|
"modified": "2026-02-05 20:45:16.964500",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Purchase Invoice",
|
"name": "Purchase Invoice",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ from erpnext.accounts.utils import get_account_currency, get_fiscal_year, update
|
|||||||
from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
|
from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
|
||||||
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
|
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
|
||||||
from erpnext.buying.utils import check_on_hold_or_closed_status
|
from erpnext.buying.utils import check_on_hold_or_closed_status
|
||||||
from erpnext.controllers.accounts_controller import validate_account_head
|
from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head
|
||||||
from erpnext.controllers.buying_controller import BuyingController
|
from erpnext.controllers.buying_controller import BuyingController
|
||||||
from erpnext.stock import get_warehouse_account_map
|
from erpnext.stock import get_warehouse_account_map
|
||||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||||
@@ -2083,6 +2083,19 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
|||||||
if isinstance(args, str):
|
if isinstance(args, str):
|
||||||
args = json.loads(args)
|
args = json.loads(args)
|
||||||
|
|
||||||
|
def post_parent_process(source_parent, target_parent):
|
||||||
|
remove_items_with_zero_qty(target_parent)
|
||||||
|
set_missing_values(source_parent, target_parent)
|
||||||
|
|
||||||
|
def remove_items_with_zero_qty(target_parent):
|
||||||
|
target_parent.items = [row for row in target_parent.get("items") if row.get("qty") != 0]
|
||||||
|
|
||||||
|
def set_missing_values(source_parent, target_parent):
|
||||||
|
target_parent.run_method("set_missing_values")
|
||||||
|
if args and args.get("merge_taxes"):
|
||||||
|
merge_taxes(source_parent, target_parent)
|
||||||
|
target_parent.run_method("calculate_taxes_and_totals")
|
||||||
|
|
||||||
def update_item(obj, target, source_parent):
|
def update_item(obj, target, source_parent):
|
||||||
target.qty = flt(obj.qty) - flt(obj.received_qty)
|
target.qty = flt(obj.qty) - flt(obj.received_qty)
|
||||||
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
|
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
|
||||||
@@ -2122,7 +2135,11 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
|||||||
"postprocess": update_item,
|
"postprocess": update_item,
|
||||||
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
|
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
|
||||||
},
|
},
|
||||||
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges"},
|
"Purchase Taxes and Charges": {
|
||||||
|
"doctype": "Purchase Taxes and Charges",
|
||||||
|
"reset_value": not (args and args.get("merge_taxes")),
|
||||||
|
"ignore": args.get("merge_taxes") if args else 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
target_doc,
|
target_doc,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -701,6 +701,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
|
"depends_on": "eval:doc.items.every((item) => !item.dn_detail)",
|
||||||
"fieldname": "update_stock",
|
"fieldname": "update_stock",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"hide_days": 1,
|
"hide_days": 1,
|
||||||
@@ -2199,7 +2200,7 @@
|
|||||||
"link_fieldname": "consolidated_invoice"
|
"link_fieldname": "consolidated_invoice"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2025-09-09 14:48:59.472826",
|
"modified": "2026-02-05 20:43:44.732805",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Sales Invoice",
|
"name": "Sales Invoice",
|
||||||
|
|||||||
@@ -5,15 +5,16 @@ from collections import OrderedDict
|
|||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _, qb, scrub
|
from frappe import _, qb, scrub
|
||||||
from frappe.query_builder import Order
|
from frappe.query_builder import Case, Order
|
||||||
|
from frappe.query_builder.functions import Coalesce
|
||||||
from frappe.utils import cint, flt, formatdate
|
from frappe.utils import cint, flt, formatdate
|
||||||
|
from pypika.terms import ExistsCriterion
|
||||||
|
|
||||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||||
get_accounting_dimensions,
|
get_accounting_dimensions,
|
||||||
get_dimension_with_children,
|
get_dimension_with_children,
|
||||||
)
|
)
|
||||||
from erpnext.accounts.report.financial_statements import get_cost_centers_with_children
|
from erpnext.accounts.report.financial_statements import get_cost_centers_with_children
|
||||||
from erpnext.controllers.queries import get_match_cond
|
|
||||||
from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition
|
from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition
|
||||||
from erpnext.stock.utils import get_incoming_rate
|
from erpnext.stock.utils import get_incoming_rate
|
||||||
|
|
||||||
@@ -176,7 +177,9 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
|||||||
column_names = get_column_names()
|
column_names = get_column_names()
|
||||||
|
|
||||||
# to display item as Item Code: Item Name
|
# to display item as Item Code: Item Name
|
||||||
columns[0] = "Sales Invoice:Link/Item:300"
|
columns[0]["fieldname"] = "sales_invoice"
|
||||||
|
columns[0]["options"] = "Item"
|
||||||
|
columns[0]["width"] = 300
|
||||||
# removing Item Code and Item Name columns
|
# removing Item Code and Item Name columns
|
||||||
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
|
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
|
||||||
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
|
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
|
||||||
@@ -203,7 +206,11 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
|||||||
|
|
||||||
data.append(row)
|
data.append(row)
|
||||||
|
|
||||||
total_gross_profit = total_base_amount - total_buying_amount
|
total_gross_profit = flt(
|
||||||
|
total_base_amount + abs(total_buying_amount)
|
||||||
|
if total_buying_amount < 0
|
||||||
|
else total_base_amount - total_buying_amount,
|
||||||
|
)
|
||||||
data.append(
|
data.append(
|
||||||
frappe._dict(
|
frappe._dict(
|
||||||
{
|
{
|
||||||
@@ -215,7 +222,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
|||||||
"buying_amount": total_buying_amount,
|
"buying_amount": total_buying_amount,
|
||||||
"gross_profit": total_gross_profit,
|
"gross_profit": total_gross_profit,
|
||||||
"gross_profit_%": flt(
|
"gross_profit_%": flt(
|
||||||
(total_gross_profit / total_base_amount) * 100.0,
|
(total_gross_profit / abs(total_base_amount)) * 100.0,
|
||||||
cint(frappe.db.get_default("currency_precision")) or 3,
|
cint(frappe.db.get_default("currency_precision")) or 3,
|
||||||
)
|
)
|
||||||
if total_base_amount
|
if total_base_amount
|
||||||
@@ -248,9 +255,13 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_
|
|||||||
|
|
||||||
data.append(row)
|
data.append(row)
|
||||||
|
|
||||||
total_gross_profit = total_base_amount - total_buying_amount
|
total_gross_profit = flt(
|
||||||
|
total_base_amount + abs(total_buying_amount)
|
||||||
|
if total_buying_amount < 0
|
||||||
|
else total_base_amount - total_buying_amount,
|
||||||
|
)
|
||||||
currency_precision = cint(frappe.db.get_default("currency_precision")) or 3
|
currency_precision = cint(frappe.db.get_default("currency_precision")) or 3
|
||||||
gross_profit_percent = (total_gross_profit / total_base_amount * 100.0) if total_base_amount else 0
|
gross_profit_percent = (total_gross_profit / abs(total_base_amount) * 100.0) if total_base_amount else 0
|
||||||
|
|
||||||
total_row = {
|
total_row = {
|
||||||
group_columns[0]: "Total",
|
group_columns[0]: "Total",
|
||||||
@@ -581,10 +592,15 @@ class GrossProfitGenerator:
|
|||||||
base_amount += row.base_amount
|
base_amount += row.base_amount
|
||||||
|
|
||||||
# calculate gross profit
|
# calculate gross profit
|
||||||
row.gross_profit = flt(row.base_amount - row.buying_amount, self.currency_precision)
|
row.gross_profit = flt(
|
||||||
|
row.base_amount + abs(row.buying_amount)
|
||||||
|
if row.buying_amount < 0
|
||||||
|
else row.base_amount - row.buying_amount,
|
||||||
|
self.currency_precision,
|
||||||
|
)
|
||||||
if row.base_amount:
|
if row.base_amount:
|
||||||
row.gross_profit_percent = flt(
|
row.gross_profit_percent = flt(
|
||||||
(row.gross_profit / row.base_amount) * 100.0,
|
(row.gross_profit / abs(row.base_amount)) * 100.0,
|
||||||
self.currency_precision,
|
self.currency_precision,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -673,9 +689,14 @@ class GrossProfitGenerator:
|
|||||||
return new_row
|
return new_row
|
||||||
|
|
||||||
def set_average_gross_profit(self, new_row):
|
def set_average_gross_profit(self, new_row):
|
||||||
new_row.gross_profit = flt(new_row.base_amount - new_row.buying_amount, self.currency_precision)
|
new_row.gross_profit = flt(
|
||||||
|
new_row.base_amount + abs(new_row.buying_amount)
|
||||||
|
if new_row.buying_amount < 0
|
||||||
|
else new_row.base_amount - new_row.buying_amount,
|
||||||
|
self.currency_precision,
|
||||||
|
)
|
||||||
new_row.gross_profit_percent = (
|
new_row.gross_profit_percent = (
|
||||||
flt(((new_row.gross_profit / new_row.base_amount) * 100.0), self.currency_precision)
|
flt(((new_row.gross_profit / abs(new_row.base_amount)) * 100.0), self.currency_precision)
|
||||||
if new_row.base_amount
|
if new_row.base_amount
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
@@ -851,129 +872,173 @@ class GrossProfitGenerator:
|
|||||||
return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0
|
return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0
|
||||||
|
|
||||||
def load_invoice_items(self):
|
def load_invoice_items(self):
|
||||||
conditions = ""
|
self.si_list = []
|
||||||
if self.filters.company:
|
|
||||||
conditions += " and `tabSales Invoice`.company = %(company)s"
|
SalesInvoice = frappe.qb.DocType("Sales Invoice")
|
||||||
if self.filters.from_date:
|
base_query = self.prepare_invoice_query()
|
||||||
conditions += " and posting_date >= %(from_date)s"
|
|
||||||
if self.filters.to_date:
|
|
||||||
conditions += " and posting_date <= %(to_date)s"
|
|
||||||
|
|
||||||
if self.filters.include_returned_invoices:
|
if self.filters.include_returned_invoices:
|
||||||
conditions += " and (is_return = 0 or (is_return=1 and return_against is null))"
|
invoice_query = base_query.where(
|
||||||
|
(SalesInvoice.is_return == 0)
|
||||||
|
| ((SalesInvoice.is_return == 1) & SalesInvoice.return_against.isnull())
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
conditions += " and is_return = 0"
|
invoice_query = base_query.where(SalesInvoice.is_return == 0)
|
||||||
|
|
||||||
if self.filters.item_group:
|
self.si_list += invoice_query.run(as_dict=True)
|
||||||
conditions += f" and {get_item_group_condition(self.filters.item_group)}"
|
self.prepare_vouchers_to_ignore()
|
||||||
|
|
||||||
if self.filters.sales_person:
|
ret_invoice_query = base_query.where(
|
||||||
conditions += """
|
(SalesInvoice.is_return == 1) & SalesInvoice.return_against.isnotnull()
|
||||||
and exists(select 1
|
)
|
||||||
from `tabSales Team` st
|
if self.vouchers_to_ignore:
|
||||||
where st.parent = `tabSales Invoice`.name
|
ret_invoice_query = ret_invoice_query.where(
|
||||||
and st.sales_person = %(sales_person)s)
|
SalesInvoice.return_against.notin(self.vouchers_to_ignore)
|
||||||
"""
|
)
|
||||||
|
|
||||||
|
self.si_list += ret_invoice_query.run(as_dict=True)
|
||||||
|
|
||||||
|
def prepare_invoice_query(self):
|
||||||
|
SalesInvoice = frappe.qb.DocType("Sales Invoice")
|
||||||
|
SalesInvoiceItem = frappe.qb.DocType("Sales Invoice Item")
|
||||||
|
Item = frappe.qb.DocType("Item")
|
||||||
|
SalesTeam = frappe.qb.DocType("Sales Team")
|
||||||
|
PaymentSchedule = frappe.qb.DocType("Payment Schedule")
|
||||||
|
|
||||||
|
query = (
|
||||||
|
frappe.qb.from_(SalesInvoice)
|
||||||
|
.join(SalesInvoiceItem)
|
||||||
|
.on(SalesInvoiceItem.parent == SalesInvoice.name)
|
||||||
|
.join(Item)
|
||||||
|
.on(Item.name == SalesInvoiceItem.item_code)
|
||||||
|
.where((SalesInvoice.docstatus == 1) & (SalesInvoice.is_opening != "Yes"))
|
||||||
|
)
|
||||||
|
|
||||||
|
query = self.apply_common_filters(query, SalesInvoice, SalesInvoiceItem, SalesTeam, Item)
|
||||||
|
|
||||||
|
query = query.select(
|
||||||
|
SalesInvoiceItem.parenttype,
|
||||||
|
SalesInvoiceItem.parent,
|
||||||
|
SalesInvoice.posting_date,
|
||||||
|
SalesInvoice.posting_time,
|
||||||
|
SalesInvoice.project,
|
||||||
|
SalesInvoice.update_stock,
|
||||||
|
SalesInvoice.customer,
|
||||||
|
SalesInvoice.customer_group,
|
||||||
|
SalesInvoice.customer_name,
|
||||||
|
SalesInvoice.territory,
|
||||||
|
SalesInvoiceItem.item_code,
|
||||||
|
SalesInvoice.base_net_total.as_("invoice_base_net_total"),
|
||||||
|
SalesInvoiceItem.item_name,
|
||||||
|
SalesInvoiceItem.description,
|
||||||
|
SalesInvoiceItem.warehouse,
|
||||||
|
SalesInvoiceItem.item_group,
|
||||||
|
SalesInvoiceItem.brand,
|
||||||
|
SalesInvoiceItem.so_detail,
|
||||||
|
SalesInvoiceItem.sales_order,
|
||||||
|
SalesInvoiceItem.dn_detail,
|
||||||
|
SalesInvoiceItem.delivery_note,
|
||||||
|
SalesInvoiceItem.stock_qty.as_("qty"),
|
||||||
|
SalesInvoiceItem.base_net_rate,
|
||||||
|
SalesInvoiceItem.base_net_amount,
|
||||||
|
SalesInvoiceItem.name.as_("item_row"),
|
||||||
|
SalesInvoice.is_return,
|
||||||
|
SalesInvoiceItem.cost_center,
|
||||||
|
SalesInvoiceItem.serial_and_batch_bundle,
|
||||||
|
)
|
||||||
|
|
||||||
if self.filters.group_by == "Sales Person":
|
if self.filters.group_by == "Sales Person":
|
||||||
sales_person_cols = """, sales.sales_person,
|
query = query.select(
|
||||||
sales.allocated_percentage * `tabSales Invoice Item`.base_net_amount / 100 as allocated_amount,
|
SalesTeam.sales_person,
|
||||||
sales.incentives
|
(SalesTeam.allocated_percentage * SalesInvoiceItem.base_net_amount / 100).as_(
|
||||||
"""
|
"allocated_amount"
|
||||||
sales_team_table = "left join `tabSales Team` sales on sales.parent = `tabSales Invoice`.name"
|
),
|
||||||
else:
|
SalesTeam.incentives,
|
||||||
sales_person_cols = ""
|
)
|
||||||
sales_team_table = ""
|
|
||||||
|
query = query.left_join(SalesTeam).on(SalesTeam.parent == SalesInvoice.name)
|
||||||
|
|
||||||
if self.filters.group_by == "Payment Term":
|
if self.filters.group_by == "Payment Term":
|
||||||
payment_term_cols = """,if(`tabSales Invoice`.is_return = 1,
|
query = query.select(
|
||||||
'{}',
|
Case()
|
||||||
coalesce(schedule.payment_term, '{}')) as payment_term,
|
.when(SalesInvoice.is_return == 1, _("Sales Return"))
|
||||||
schedule.invoice_portion,
|
.else_(Coalesce(PaymentSchedule.payment_term, _("No Terms")))
|
||||||
schedule.payment_amount """.format(_("Sales Return"), _("No Terms"))
|
.as_("payment_term"),
|
||||||
payment_term_table = """ left join `tabPayment Schedule` schedule on schedule.parent = `tabSales Invoice`.name and
|
PaymentSchedule.invoice_portion,
|
||||||
`tabSales Invoice`.is_return = 0 """
|
PaymentSchedule.payment_amount,
|
||||||
else:
|
)
|
||||||
payment_term_cols = ""
|
|
||||||
payment_term_table = ""
|
|
||||||
|
|
||||||
if self.filters.get("sales_invoice"):
|
query = query.left_join(PaymentSchedule).on(
|
||||||
conditions += " and `tabSales Invoice`.name = %(sales_invoice)s"
|
(PaymentSchedule.parent == SalesInvoice.name) & (SalesInvoice.is_return == 0)
|
||||||
|
)
|
||||||
|
|
||||||
if self.filters.get("item_code"):
|
query = query.orderby(SalesInvoice.posting_date, order=Order.desc).orderby(
|
||||||
conditions += " and `tabSales Invoice Item`.item_code = %(item_code)s"
|
SalesInvoice.posting_time, order=Order.desc
|
||||||
|
)
|
||||||
|
|
||||||
if self.filters.get("cost_center"):
|
return query
|
||||||
|
|
||||||
|
def apply_common_filters(self, query, SalesInvoice, SalesInvoiceItem, SalesTeam, Item):
|
||||||
|
if self.filters.company:
|
||||||
|
query = query.where(SalesInvoice.company == self.filters.company)
|
||||||
|
|
||||||
|
if self.filters.from_date:
|
||||||
|
query = query.where(SalesInvoice.posting_date >= self.filters.from_date)
|
||||||
|
|
||||||
|
if self.filters.to_date:
|
||||||
|
query = query.where(SalesInvoice.posting_date <= self.filters.to_date)
|
||||||
|
|
||||||
|
if self.filters.item_group:
|
||||||
|
query = query.where(get_item_group_condition(self.filters.item_group, Item))
|
||||||
|
|
||||||
|
if self.filters.sales_person:
|
||||||
|
query = query.where(
|
||||||
|
ExistsCriterion(
|
||||||
|
frappe.qb.from_(SalesTeam)
|
||||||
|
.select(1)
|
||||||
|
.where(
|
||||||
|
(SalesTeam.parent == SalesInvoice.name)
|
||||||
|
& (SalesTeam.sales_person == self.filters.sales_person)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.filters.sales_invoice:
|
||||||
|
query = query.where(SalesInvoice.name == self.filters.sales_invoice)
|
||||||
|
|
||||||
|
if self.filters.item_code:
|
||||||
|
query = query.where(SalesInvoiceItem.item_code == self.filters.item_code)
|
||||||
|
|
||||||
|
if self.filters.cost_center:
|
||||||
self.filters.cost_center = frappe.parse_json(self.filters.get("cost_center"))
|
self.filters.cost_center = frappe.parse_json(self.filters.get("cost_center"))
|
||||||
self.filters.cost_center = get_cost_centers_with_children(self.filters.cost_center)
|
self.filters.cost_center = get_cost_centers_with_children(self.filters.cost_center)
|
||||||
conditions += " and `tabSales Invoice Item`.cost_center in %(cost_center)s"
|
query = query.where(SalesInvoiceItem.cost_center.isin(self.filters.cost_center))
|
||||||
|
|
||||||
if self.filters.get("project"):
|
if self.filters.project:
|
||||||
self.filters.project = frappe.parse_json(self.filters.get("project"))
|
self.filters.project = frappe.parse_json(self.filters.get("project"))
|
||||||
conditions += " and `tabSales Invoice Item`.project in %(project)s"
|
query = query.where(SalesInvoiceItem.project.isin(self.filters.project))
|
||||||
|
|
||||||
accounting_dimensions = get_accounting_dimensions(as_list=False)
|
for dim in get_accounting_dimensions(as_list=False) or []:
|
||||||
if accounting_dimensions:
|
if self.filters.get(dim.fieldname):
|
||||||
for dimension in accounting_dimensions:
|
if frappe.get_cached_value("DocType", dim.document_type, "is_tree"):
|
||||||
if self.filters.get(dimension.fieldname):
|
self.filters[dim.fieldname] = get_dimension_with_children(
|
||||||
if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"):
|
dim.document_type, self.filters.get(dim.fieldname)
|
||||||
self.filters[dimension.fieldname] = get_dimension_with_children(
|
|
||||||
dimension.document_type, self.filters.get(dimension.fieldname)
|
|
||||||
)
|
)
|
||||||
conditions += (
|
query = query.where(SalesInvoiceItem[dim.fieldname].isin(self.filters[dim.fieldname]))
|
||||||
f" and `tabSales Invoice Item`.{dimension.fieldname} in %({dimension.fieldname})s"
|
|
||||||
|
if self.filters.warehouse:
|
||||||
|
lft, rgt = frappe.db.get_value("Warehouse", self.filters.warehouse, ["lft", "rgt"])
|
||||||
|
WH = frappe.qb.DocType("Warehouse")
|
||||||
|
query = query.where(
|
||||||
|
SalesInvoiceItem.warehouse.isin(
|
||||||
|
frappe.qb.from_(WH).select(WH.name).where((WH.lft >= lft) & (WH.rgt <= rgt))
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
conditions += (
|
|
||||||
f" and `tabSales Invoice Item`.{dimension.fieldname} in %({dimension.fieldname})s"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.filters.get("warehouse"):
|
return query
|
||||||
warehouse_details = frappe.db.get_value(
|
|
||||||
"Warehouse", self.filters.get("warehouse"), ["lft", "rgt"], as_dict=1
|
|
||||||
)
|
|
||||||
if warehouse_details:
|
|
||||||
conditions += f" and `tabSales Invoice Item`.warehouse in (select name from `tabWarehouse` wh where wh.lft >= {warehouse_details.lft} and wh.rgt <= {warehouse_details.rgt} and warehouse = wh.name)"
|
|
||||||
|
|
||||||
self.si_list = frappe.db.sql(
|
def prepare_vouchers_to_ignore(self):
|
||||||
"""
|
self.vouchers_to_ignore = tuple(row["parent"] for row in self.si_list)
|
||||||
select
|
|
||||||
`tabSales Invoice Item`.parenttype, `tabSales Invoice Item`.parent,
|
|
||||||
`tabSales Invoice`.posting_date, `tabSales Invoice`.posting_time,
|
|
||||||
`tabSales Invoice`.project, `tabSales Invoice`.update_stock,
|
|
||||||
`tabSales Invoice`.customer, `tabSales Invoice`.customer_group, `tabSales Invoice`.customer_name,
|
|
||||||
`tabSales Invoice`.territory, `tabSales Invoice Item`.item_code,
|
|
||||||
`tabSales Invoice`.base_net_total as "invoice_base_net_total",
|
|
||||||
`tabSales Invoice Item`.item_name, `tabSales Invoice Item`.description,
|
|
||||||
`tabSales Invoice Item`.warehouse, `tabSales Invoice Item`.item_group,
|
|
||||||
`tabSales Invoice Item`.brand, `tabSales Invoice Item`.so_detail,
|
|
||||||
`tabSales Invoice Item`.sales_order, `tabSales Invoice Item`.dn_detail,
|
|
||||||
`tabSales Invoice Item`.delivery_note, `tabSales Invoice Item`.stock_qty as qty,
|
|
||||||
`tabSales Invoice Item`.base_net_rate, `tabSales Invoice Item`.base_net_amount,
|
|
||||||
`tabSales Invoice Item`.name as "item_row", `tabSales Invoice`.is_return,
|
|
||||||
`tabSales Invoice Item`.cost_center, `tabSales Invoice Item`.serial_and_batch_bundle
|
|
||||||
{sales_person_cols}
|
|
||||||
{payment_term_cols}
|
|
||||||
from
|
|
||||||
`tabSales Invoice` inner join `tabSales Invoice Item`
|
|
||||||
on `tabSales Invoice Item`.parent = `tabSales Invoice`.name
|
|
||||||
join `tabItem` item on item.name = `tabSales Invoice Item`.item_code
|
|
||||||
{sales_team_table}
|
|
||||||
{payment_term_table}
|
|
||||||
where
|
|
||||||
`tabSales Invoice`.docstatus=1 and `tabSales Invoice`.is_opening!='Yes' {conditions} {match_cond}
|
|
||||||
order by
|
|
||||||
`tabSales Invoice`.posting_date desc, `tabSales Invoice`.posting_time desc""".format(
|
|
||||||
conditions=conditions,
|
|
||||||
sales_person_cols=sales_person_cols,
|
|
||||||
sales_team_table=sales_team_table,
|
|
||||||
payment_term_cols=payment_term_cols,
|
|
||||||
payment_term_table=payment_term_table,
|
|
||||||
match_cond=get_match_cond("Sales Invoice"),
|
|
||||||
),
|
|
||||||
self.filters,
|
|
||||||
as_dict=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_delivery_notes(self):
|
def get_delivery_notes(self):
|
||||||
self.delivery_notes = frappe._dict({})
|
self.delivery_notes = frappe._dict({})
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ class TestGrossProfit(FrappeTestCase):
|
|||||||
"selling_amount": -100.0,
|
"selling_amount": -100.0,
|
||||||
"buying_amount": 0.0,
|
"buying_amount": 0.0,
|
||||||
"gross_profit": -100.0,
|
"gross_profit": -100.0,
|
||||||
"gross_profit_%": 100.0,
|
"gross_profit_%": -100.0,
|
||||||
}
|
}
|
||||||
gp_entry = [x for x in data if x.parent_invoice == sinv.name]
|
gp_entry = [x for x in data if x.parent_invoice == sinv.name]
|
||||||
self.assertDictContainsSubset(expected_entry, gp_entry[0])
|
self.assertDictContainsSubset(expected_entry, gp_entry[0])
|
||||||
@@ -642,21 +642,24 @@ class TestGrossProfit(FrappeTestCase):
|
|||||||
def test_profit_for_later_period_return(self):
|
def test_profit_for_later_period_return(self):
|
||||||
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
|
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
|
||||||
|
|
||||||
|
sales_inv_date = month_start_date
|
||||||
|
return_inv_date = add_days(month_end_date, 1)
|
||||||
|
|
||||||
# create sales invoice on month start date
|
# create sales invoice on month start date
|
||||||
sinv = self.create_sales_invoice(qty=1, rate=100, do_not_save=True, do_not_submit=True)
|
sinv = self.create_sales_invoice(qty=1, rate=100, do_not_save=True, do_not_submit=True)
|
||||||
sinv.set_posting_time = 1
|
sinv.set_posting_time = 1
|
||||||
sinv.posting_date = month_start_date
|
sinv.posting_date = sales_inv_date
|
||||||
sinv.save().submit()
|
sinv.save().submit()
|
||||||
|
|
||||||
# create credit note on next month start date
|
# create credit note on next month start date
|
||||||
cr_note = make_sales_return(sinv.name)
|
cr_note = make_sales_return(sinv.name)
|
||||||
cr_note.set_posting_time = 1
|
cr_note.set_posting_time = 1
|
||||||
cr_note.posting_date = add_days(month_end_date, 1)
|
cr_note.posting_date = return_inv_date
|
||||||
cr_note.save().submit()
|
cr_note.save().submit()
|
||||||
|
|
||||||
# apply filters for invoiced period
|
# apply filters for invoiced period
|
||||||
filters = frappe._dict(
|
filters = frappe._dict(
|
||||||
company=self.company, from_date=month_start_date, to_date=month_end_date, group_by="Invoice"
|
company=self.company, from_date=month_start_date, to_date=month_start_date, group_by="Invoice"
|
||||||
)
|
)
|
||||||
|
|
||||||
_, data = execute(filters=filters)
|
_, data = execute(filters=filters)
|
||||||
@@ -668,7 +671,7 @@ class TestGrossProfit(FrappeTestCase):
|
|||||||
self.assertEqual(total.get("gross_profit_%"), 100.0)
|
self.assertEqual(total.get("gross_profit_%"), 100.0)
|
||||||
|
|
||||||
# extend filters upto returned period
|
# extend filters upto returned period
|
||||||
filters.update(to_date=add_days(month_end_date, 1))
|
filters.update({"to_date": return_inv_date})
|
||||||
|
|
||||||
_, data = execute(filters=filters)
|
_, data = execute(filters=filters)
|
||||||
total = data[-1]
|
total = data[-1]
|
||||||
@@ -677,3 +680,63 @@ class TestGrossProfit(FrappeTestCase):
|
|||||||
self.assertEqual(total.buying_amount, 0.0)
|
self.assertEqual(total.buying_amount, 0.0)
|
||||||
self.assertEqual(total.gross_profit, 0.0)
|
self.assertEqual(total.gross_profit, 0.0)
|
||||||
self.assertEqual(total.get("gross_profit_%"), 0.0)
|
self.assertEqual(total.get("gross_profit_%"), 0.0)
|
||||||
|
|
||||||
|
# apply filters only on returned period
|
||||||
|
filters.update({"from_date": return_inv_date, "to_date": return_inv_date})
|
||||||
|
_, data = execute(filters=filters)
|
||||||
|
total = data[-1]
|
||||||
|
|
||||||
|
self.assertEqual(total.selling_amount, -100.0)
|
||||||
|
self.assertEqual(total.buying_amount, 0.0)
|
||||||
|
self.assertEqual(total.gross_profit, -100.0)
|
||||||
|
self.assertEqual(total.get("gross_profit_%"), -100.0)
|
||||||
|
|
||||||
|
def test_sales_person_wise_gross_profit(self):
|
||||||
|
sales_person = make_sales_person("_Test Sales Person")
|
||||||
|
|
||||||
|
posting_date = get_first_day(nowdate())
|
||||||
|
qty = 10
|
||||||
|
rate = 100
|
||||||
|
|
||||||
|
sinv = self.create_sales_invoice(qty=qty, rate=rate, do_not_save=True, do_not_submit=True)
|
||||||
|
sinv.set_posting_time = 1
|
||||||
|
sinv.posting_date = posting_date
|
||||||
|
sinv.append(
|
||||||
|
"sales_team",
|
||||||
|
{
|
||||||
|
"sales_person": sales_person.name,
|
||||||
|
"allocated_percentage": 100,
|
||||||
|
"allocated_amount": 1000.0,
|
||||||
|
"commission_rate": 5,
|
||||||
|
"incentives": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
sinv.save().submit()
|
||||||
|
|
||||||
|
filters = frappe._dict(
|
||||||
|
company=self.company, from_date=posting_date, to_date=posting_date, group_by="Sales Person"
|
||||||
|
)
|
||||||
|
|
||||||
|
_, data = execute(filters=filters)
|
||||||
|
total = data[-1]
|
||||||
|
|
||||||
|
self.assertEqual(total[5], 1000.0)
|
||||||
|
self.assertEqual(total[6], 0.0)
|
||||||
|
self.assertEqual(total[7], 1000.0)
|
||||||
|
self.assertEqual(total[8], 100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def make_sales_person(sales_person_name="_Test Sales Person"):
|
||||||
|
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):
|
||||||
|
sales_person_doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Sales Person",
|
||||||
|
"is_group": 0,
|
||||||
|
"parent_sales_person": "Sales Team",
|
||||||
|
"sales_person_name": sales_person_name,
|
||||||
|
}
|
||||||
|
).insert(ignore_permissions=True)
|
||||||
|
else:
|
||||||
|
sales_person_doc = frappe.get_doc("Sales Person", {"sales_person_name": sales_person_name})
|
||||||
|
|
||||||
|
return sales_person_doc
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.utils import cstr, get_link_to_form
|
from frappe.utils import cstr, get_datetime, get_link_to_form
|
||||||
|
|
||||||
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
|
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ class AssetMovement(Document):
|
|||||||
for d in self.assets:
|
for d in self.assets:
|
||||||
self.validate_asset(d)
|
self.validate_asset(d)
|
||||||
self.validate_movement(d)
|
self.validate_movement(d)
|
||||||
|
self.validate_transaction_date(d)
|
||||||
|
|
||||||
def validate_asset(self, d):
|
def validate_asset(self, d):
|
||||||
status, company = frappe.db.get_value("Asset", d.asset, ["status", "company"])
|
status, company = frappe.db.get_value("Asset", d.asset, ["status", "company"])
|
||||||
@@ -51,6 +52,18 @@ class AssetMovement(Document):
|
|||||||
else:
|
else:
|
||||||
self.validate_employee(d)
|
self.validate_employee(d)
|
||||||
|
|
||||||
|
def validate_transaction_date(self, d):
|
||||||
|
previous_movement_date = frappe.db.get_value(
|
||||||
|
"Asset Movement",
|
||||||
|
[["Asset Movement Item", "asset", "=", d.asset], ["docstatus", "=", 1]],
|
||||||
|
"transaction_date",
|
||||||
|
order_by="transaction_date desc",
|
||||||
|
)
|
||||||
|
if previous_movement_date and get_datetime(previous_movement_date) > get_datetime(
|
||||||
|
self.transaction_date
|
||||||
|
):
|
||||||
|
frappe.throw(_("Transaction date can't be earlier than previous movement date"))
|
||||||
|
|
||||||
def validate_location_and_employee(self, d):
|
def validate_location_and_employee(self, d):
|
||||||
self.validate_location(d)
|
self.validate_location(d)
|
||||||
self.validate_employee(d)
|
self.validate_employee(d)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.utils import now
|
from frappe.utils import add_days, now
|
||||||
|
|
||||||
from erpnext.assets.doctype.asset.test_asset import create_asset_data
|
from erpnext.assets.doctype.asset.test_asset import create_asset, create_asset_data
|
||||||
from erpnext.setup.doctype.employee.test_employee import make_employee
|
from erpnext.setup.doctype.employee.test_employee import make_employee
|
||||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||||
|
|
||||||
@@ -147,6 +147,33 @@ class TestAssetMovement(unittest.TestCase):
|
|||||||
movement1.cancel()
|
movement1.cancel()
|
||||||
self.assertEqual(frappe.db.get_value("Asset", asset.name, "location"), "Test Location")
|
self.assertEqual(frappe.db.get_value("Asset", asset.name, "location"), "Test Location")
|
||||||
|
|
||||||
|
def test_movement_transaction_date(self):
|
||||||
|
asset = create_asset(item_code="Macbook Pro", do_not_save=1)
|
||||||
|
asset.save().submit()
|
||||||
|
|
||||||
|
if not frappe.db.exists("Location", "Test Location 2"):
|
||||||
|
frappe.get_doc({"doctype": "Location", "location_name": "Test Location 2"}).insert()
|
||||||
|
|
||||||
|
asset_creation_date = frappe.db.get_value(
|
||||||
|
"Asset Movement",
|
||||||
|
[["Asset Movement Item", "asset", "=", asset.name], ["docstatus", "=", 1]],
|
||||||
|
"transaction_date",
|
||||||
|
)
|
||||||
|
asset_movement = create_asset_movement(
|
||||||
|
purpose="Transfer",
|
||||||
|
company=asset.company,
|
||||||
|
assets=[
|
||||||
|
{
|
||||||
|
"asset": asset.name,
|
||||||
|
"source_location": "Test Location",
|
||||||
|
"target_location": "Test Location 2",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
transaction_date=add_days(asset_creation_date, -1),
|
||||||
|
do_not_save=True,
|
||||||
|
)
|
||||||
|
self.assertRaises(frappe.ValidationError, asset_movement.save)
|
||||||
|
|
||||||
|
|
||||||
def create_asset_movement(**args):
|
def create_asset_movement(**args):
|
||||||
args = frappe._dict(args)
|
args = frappe._dict(args)
|
||||||
@@ -165,8 +192,9 @@ def create_asset_movement(**args):
|
|||||||
"reference_name": args.reference_name,
|
"reference_name": args.reference_name,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if not args.do_not_save:
|
||||||
movement.insert()
|
movement.insert(ignore_if_duplicate=True)
|
||||||
|
if not args.do_not_submit:
|
||||||
movement.submit()
|
movement.submit()
|
||||||
|
|
||||||
return movement
|
return movement
|
||||||
|
|||||||
@@ -139,14 +139,6 @@ frappe.ui.form.on("Supplier", {
|
|||||||
// indicators
|
// indicators
|
||||||
erpnext.utils.set_party_dashboard_indicators(frm);
|
erpnext.utils.set_party_dashboard_indicators(frm);
|
||||||
}
|
}
|
||||||
|
|
||||||
frm.set_query("supplier_group", () => {
|
|
||||||
return {
|
|
||||||
filters: {
|
|
||||||
is_group: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
get_supplier_group_details: function (frm) {
|
get_supplier_group_details: function (frm) {
|
||||||
frappe.call({
|
frappe.call({
|
||||||
|
|||||||
@@ -165,6 +165,7 @@
|
|||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Supplier Group",
|
"label": "Supplier Group",
|
||||||
|
"link_filters": "[[\"Supplier Group\",\"is_group\",\"=\",0]]",
|
||||||
"oldfieldname": "supplier_type",
|
"oldfieldname": "supplier_type",
|
||||||
"oldfieldtype": "Link",
|
"oldfieldtype": "Link",
|
||||||
"options": "Supplier Group"
|
"options": "Supplier Group"
|
||||||
@@ -485,7 +486,7 @@
|
|||||||
"link_fieldname": "party"
|
"link_fieldname": "party"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2024-05-08 18:02:57.342931",
|
"modified": "2026-02-06 12:58:01.398824",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Buying",
|
"module": "Buying",
|
||||||
"name": "Supplier",
|
"name": "Supplier",
|
||||||
|
|||||||
@@ -38,18 +38,18 @@ class EmailCampaign(Document):
|
|||||||
def set_date(self):
|
def set_date(self):
|
||||||
if getdate(self.start_date) < getdate(today()):
|
if getdate(self.start_date) < getdate(today()):
|
||||||
frappe.throw(_("Start Date cannot be before the current date"))
|
frappe.throw(_("Start Date cannot be before the current date"))
|
||||||
|
|
||||||
# set the end date as start date + max(send after days) in campaign schedule
|
# set the end date as start date + max(send after days) in campaign schedule
|
||||||
send_after_days = []
|
campaign = frappe.get_cached_doc("Campaign", self.campaign_name)
|
||||||
campaign = frappe.get_doc("Campaign", self.campaign_name)
|
send_after_days = [entry.send_after_days for entry in campaign.get("campaign_schedules")]
|
||||||
for entry in campaign.get("campaign_schedules"):
|
|
||||||
send_after_days.append(entry.send_after_days)
|
if not send_after_days:
|
||||||
try:
|
|
||||||
self.end_date = add_days(getdate(self.start_date), max(send_after_days))
|
|
||||||
except ValueError:
|
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_("Please set up the Campaign Schedule in the Campaign {0}").format(self.campaign_name)
|
_("Please set up the Campaign Schedule in the Campaign {0}").format(self.campaign_name)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.end_date = add_days(getdate(self.start_date), max(send_after_days))
|
||||||
|
|
||||||
def validate_lead(self):
|
def validate_lead(self):
|
||||||
lead_email_id = frappe.db.get_value("Lead", self.recipient, "email_id")
|
lead_email_id = frappe.db.get_value("Lead", self.recipient, "email_id")
|
||||||
if not lead_email_id:
|
if not lead_email_id:
|
||||||
@@ -77,58 +77,128 @@ class EmailCampaign(Document):
|
|||||||
start_date = getdate(self.start_date)
|
start_date = getdate(self.start_date)
|
||||||
end_date = getdate(self.end_date)
|
end_date = getdate(self.end_date)
|
||||||
today_date = getdate(today())
|
today_date = getdate(today())
|
||||||
|
|
||||||
if start_date > today_date:
|
if start_date > today_date:
|
||||||
self.db_set("status", "Scheduled", update_modified=False)
|
new_status = "Scheduled"
|
||||||
elif end_date >= today_date:
|
elif end_date >= today_date:
|
||||||
self.db_set("status", "In Progress", update_modified=False)
|
new_status = "In Progress"
|
||||||
elif end_date < today_date:
|
else:
|
||||||
self.db_set("status", "Completed", update_modified=False)
|
new_status = "Completed"
|
||||||
|
|
||||||
|
if self.status != new_status:
|
||||||
|
self.db_set("status", new_status, update_modified=False)
|
||||||
|
|
||||||
|
|
||||||
# called through hooks to send campaign mails to leads
|
# called through hooks to send campaign mails to leads
|
||||||
def send_email_to_leads_or_contacts():
|
def send_email_to_leads_or_contacts():
|
||||||
|
today_date = getdate(today())
|
||||||
|
|
||||||
|
# Get all active email campaigns in a single query
|
||||||
email_campaigns = frappe.get_all(
|
email_campaigns = frappe.get_all(
|
||||||
"Email Campaign", filters={"status": ("not in", ["Unsubscribed", "Completed", "Scheduled"])}
|
"Email Campaign",
|
||||||
|
filters={"status": "In Progress"},
|
||||||
|
fields=["name", "campaign_name", "email_campaign_for", "recipient", "start_date", "sender"],
|
||||||
)
|
)
|
||||||
for camp in email_campaigns:
|
|
||||||
email_campaign = frappe.get_doc("Email Campaign", camp.name)
|
if not email_campaigns:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Process each email campaign
|
||||||
|
for email_campaign in email_campaigns:
|
||||||
|
try:
|
||||||
campaign = frappe.get_cached_doc("Campaign", email_campaign.campaign_name)
|
campaign = frappe.get_cached_doc("Campaign", email_campaign.campaign_name)
|
||||||
|
except frappe.DoesNotExistError:
|
||||||
|
frappe.log_error(
|
||||||
|
title=_("Email Campaign Error"),
|
||||||
|
message=_("Campaign {0} not found").format(email_campaign.campaign_name),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find schedules that match today
|
||||||
for entry in campaign.get("campaign_schedules"):
|
for entry in campaign.get("campaign_schedules"):
|
||||||
scheduled_date = add_days(email_campaign.get("start_date"), entry.get("send_after_days"))
|
try:
|
||||||
if scheduled_date == getdate(today()):
|
scheduled_date = add_days(getdate(email_campaign.start_date), entry.get("send_after_days"))
|
||||||
|
if scheduled_date == today_date:
|
||||||
send_mail(entry, email_campaign)
|
send_mail(entry, email_campaign)
|
||||||
|
except Exception:
|
||||||
|
frappe.log_error(
|
||||||
|
title=_("Email Campaign Send Error"),
|
||||||
|
message=_("Failed to send email for campaign {0} to {1}").format(
|
||||||
|
email_campaign.name, email_campaign.recipient
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_mail(entry, email_campaign):
|
def send_mail(entry, email_campaign):
|
||||||
recipient_list = []
|
campaign_for = email_campaign.get("email_campaign_for")
|
||||||
if email_campaign.email_campaign_for == "Email Group":
|
recipient = email_campaign.get("recipient")
|
||||||
for member in frappe.db.get_list(
|
sender_user = email_campaign.get("sender")
|
||||||
"Email Group Member", filters={"email_group": email_campaign.get("recipient")}, fields=["email"]
|
campaign_name = email_campaign.get("name")
|
||||||
):
|
|
||||||
recipient_list.append(member["email"])
|
|
||||||
else:
|
|
||||||
recipient_list.append(
|
|
||||||
frappe.db.get_value(
|
|
||||||
email_campaign.email_campaign_for, email_campaign.get("recipient"), "email_id"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
email_template = frappe.get_doc("Email Template", entry.get("email_template"))
|
# Get recipient emails
|
||||||
sender = frappe.db.get_value("User", email_campaign.get("sender"), "email")
|
if campaign_for == "Email Group":
|
||||||
context = {"doc": frappe.get_doc(email_campaign.email_campaign_for, email_campaign.recipient)}
|
recipient_list = frappe.get_all(
|
||||||
# send mail and link communication to document
|
"Email Group Member",
|
||||||
|
filters={"email_group": recipient, "unsubscribed": 0},
|
||||||
|
pluck="email",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
email_id = frappe.db.get_value(campaign_for, recipient, "email_id")
|
||||||
|
if not email_id:
|
||||||
|
frappe.log_error(
|
||||||
|
title=_("Email Campaign Error"),
|
||||||
|
message=_("No email found for {0} {1}").format(campaign_for, recipient),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
recipient_list = [email_id]
|
||||||
|
|
||||||
|
if not recipient_list:
|
||||||
|
frappe.log_error(
|
||||||
|
title=_("Email Campaign Error"),
|
||||||
|
message=_("No recipients found for campaign {0}").format(campaign_name),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get email template and sender
|
||||||
|
email_template = frappe.get_cached_doc("Email Template", entry.get("email_template"))
|
||||||
|
sender = frappe.db.get_value("User", sender_user, "email") if sender_user else None
|
||||||
|
|
||||||
|
# Build context for template rendering
|
||||||
|
if campaign_for != "Email Group":
|
||||||
|
context = {"doc": frappe.get_doc(campaign_for, recipient)}
|
||||||
|
else:
|
||||||
|
# For email groups, use the email group document as context
|
||||||
|
context = {"doc": frappe.get_doc("Email Group", recipient)}
|
||||||
|
|
||||||
|
# Render template
|
||||||
|
subject = frappe.render_template(email_template.get("subject"), context)
|
||||||
|
content = frappe.render_template(email_template.response_, context)
|
||||||
|
|
||||||
|
try:
|
||||||
comm = make(
|
comm = make(
|
||||||
doctype="Email Campaign",
|
doctype="Email Campaign",
|
||||||
name=email_campaign.name,
|
name=campaign_name,
|
||||||
subject=frappe.render_template(email_template.get("subject"), context),
|
subject=subject,
|
||||||
content=frappe.render_template(email_template.response_, context),
|
content=content,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
bcc=recipient_list,
|
recipients=recipient_list,
|
||||||
communication_medium="Email",
|
communication_medium="Email",
|
||||||
sent_or_received="Sent",
|
sent_or_received="Sent",
|
||||||
send_email=True,
|
send_email=False,
|
||||||
email_template=email_template.name,
|
email_template=email_template.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
frappe.sendmail(
|
||||||
|
recipients=recipient_list,
|
||||||
|
subject=subject,
|
||||||
|
content=content,
|
||||||
|
sender=sender,
|
||||||
|
communication=comm["name"],
|
||||||
|
queue_separately=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
frappe.log_error(title="Email Campaign Failed.")
|
||||||
|
|
||||||
return comm
|
return comm
|
||||||
|
|
||||||
|
|
||||||
@@ -140,7 +210,12 @@ def unsubscribe_recipient(unsubscribe, method):
|
|||||||
|
|
||||||
# called through hooks to update email campaign status daily
|
# called through hooks to update email campaign status daily
|
||||||
def set_email_campaign_status():
|
def set_email_campaign_status():
|
||||||
email_campaigns = frappe.get_all("Email Campaign", filters={"status": ("!=", "Unsubscribed")})
|
email_campaigns = frappe.get_all(
|
||||||
for entry in email_campaigns:
|
"Email Campaign",
|
||||||
email_campaign = frappe.get_doc("Email Campaign", entry.name)
|
filters={"status": ("!=", "Unsubscribed")},
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
|
||||||
|
for name in email_campaigns:
|
||||||
|
email_campaign = frappe.get_doc("Email Campaign", name)
|
||||||
email_campaign.update_status()
|
email_campaign.update_status()
|
||||||
|
|||||||
@@ -478,7 +478,7 @@ class ProductionPlan(Document):
|
|||||||
|
|
||||||
item_details = get_item_details(data.item_code, throw=False)
|
item_details = get_item_details(data.item_code, throw=False)
|
||||||
if self.combine_items:
|
if self.combine_items:
|
||||||
bom_no = item_details.bom_no
|
bom_no = item_details.get("bom_no")
|
||||||
if data.get("bom_no"):
|
if data.get("bom_no"):
|
||||||
bom_no = data.get("bom_no")
|
bom_no = data.get("bom_no")
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from frappe.utils import getdate, today
|
|||||||
|
|
||||||
from erpnext.stock.report.stock_analytics.stock_analytics import get_period, get_period_date_ranges
|
from erpnext.stock.report.stock_analytics.stock_analytics import get_period, get_period_date_ranges
|
||||||
|
|
||||||
|
WORK_ORDER_STATUS_LIST = ["Not Started", "Overdue", "Pending", "Completed", "Closed", "Stopped"]
|
||||||
|
|
||||||
|
|
||||||
def execute(filters=None):
|
def execute(filters=None):
|
||||||
columns = get_columns(filters)
|
columns = get_columns(filters)
|
||||||
@@ -16,119 +18,98 @@ def execute(filters=None):
|
|||||||
|
|
||||||
|
|
||||||
def get_columns(filters):
|
def get_columns(filters):
|
||||||
columns = [{"label": _("Status"), "fieldname": "Status", "fieldtype": "Data", "width": 140}]
|
columns = [{"label": _("Status"), "fieldname": "status", "fieldtype": "Data", "width": 140}]
|
||||||
|
|
||||||
ranges = get_period_date_ranges(filters)
|
ranges = get_period_date_ranges(filters)
|
||||||
|
|
||||||
for _dummy, end_date in ranges:
|
for _dummy, end_date in ranges:
|
||||||
period = get_period(end_date, filters)
|
period = get_period(end_date, filters)
|
||||||
|
|
||||||
columns.append({"label": _(period), "fieldname": scrub(period), "fieldtype": "Float", "width": 120})
|
columns.append({"label": _(period), "fieldname": scrub(period), "fieldtype": "Float", "width": 120})
|
||||||
|
|
||||||
return columns
|
return columns
|
||||||
|
|
||||||
|
|
||||||
def get_periodic_data(filters, entry):
|
def get_work_orders(filters):
|
||||||
periodic_data = {
|
from_date = filters.get("from_date")
|
||||||
"Not Started": {},
|
to_date = filters.get("to_date")
|
||||||
"Overdue": {},
|
|
||||||
"Pending": {},
|
|
||||||
"Completed": {},
|
|
||||||
"Closed": {},
|
|
||||||
"Stopped": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
ranges = get_period_date_ranges(filters)
|
WorkOrder = frappe.qb.DocType("Work Order")
|
||||||
|
|
||||||
for from_date, end_date in ranges:
|
return (
|
||||||
period = get_period(end_date, filters)
|
frappe.qb.from_(WorkOrder)
|
||||||
for d in entry:
|
.select(WorkOrder.creation, WorkOrder.actual_end_date, WorkOrder.planned_end_date, WorkOrder.status)
|
||||||
if getdate(from_date) <= getdate(d.creation) <= getdate(end_date) and d.status not in [
|
.where(
|
||||||
"Draft",
|
(WorkOrder.docstatus == 1)
|
||||||
"Submitted",
|
& (WorkOrder.company == filters.get("company"))
|
||||||
"Completed",
|
& (
|
||||||
"Cancelled",
|
(WorkOrder.creation.between(from_date, to_date))
|
||||||
]:
|
| (WorkOrder.actual_end_date.between(from_date, to_date))
|
||||||
if d.status in ["Not Started", "Closed", "Stopped"]:
|
)
|
||||||
periodic_data = update_periodic_data(periodic_data, d.status, period)
|
)
|
||||||
elif getdate(today()) > getdate(d.planned_end_date):
|
.run(as_dict=True)
|
||||||
periodic_data = update_periodic_data(periodic_data, "Overdue", period)
|
)
|
||||||
elif getdate(today()) < getdate(d.planned_end_date):
|
|
||||||
periodic_data = update_periodic_data(periodic_data, "Pending", period)
|
|
||||||
|
|
||||||
if (
|
|
||||||
getdate(from_date) <= getdate(d.actual_end_date) <= getdate(end_date)
|
|
||||||
and d.status == "Completed"
|
|
||||||
):
|
|
||||||
periodic_data = update_periodic_data(periodic_data, "Completed", period)
|
|
||||||
|
|
||||||
return periodic_data
|
|
||||||
|
|
||||||
|
|
||||||
def update_periodic_data(periodic_data, status, period):
|
|
||||||
if periodic_data.get(status).get(period):
|
|
||||||
periodic_data[status][period] += 1
|
|
||||||
else:
|
|
||||||
periodic_data[status][period] = 1
|
|
||||||
|
|
||||||
return periodic_data
|
|
||||||
|
|
||||||
|
|
||||||
def get_data(filters, columns):
|
def get_data(filters, columns):
|
||||||
data = []
|
ranges = build_ranges(filters)
|
||||||
entry = frappe.get_all(
|
period_labels = [scrub(pd) for _fd, _td, pd in ranges]
|
||||||
"Work Order",
|
periodic_data = {status: {pd: 0 for pd in period_labels} for status in WORK_ORDER_STATUS_LIST}
|
||||||
fields=[
|
entries = get_work_orders(filters)
|
||||||
"creation",
|
|
||||||
"actual_end_date",
|
|
||||||
"planned_end_date",
|
|
||||||
"status",
|
|
||||||
],
|
|
||||||
filters={"docstatus": 1, "company": filters["company"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
periodic_data = get_periodic_data(filters, entry)
|
for d in entries:
|
||||||
|
if d.status == "Completed":
|
||||||
|
if not d.actual_end_date:
|
||||||
|
continue
|
||||||
|
|
||||||
labels = ["Not Started", "Overdue", "Pending", "Completed", "Closed", "Stopped"]
|
if period := scrub(get_period_for_date(getdate(d.actual_end_date), ranges)):
|
||||||
chart_data = get_chart_data(periodic_data, columns)
|
periodic_data["Completed"][period] += 1
|
||||||
ranges = get_period_date_ranges(filters)
|
continue
|
||||||
|
|
||||||
for label in labels:
|
creation_date = getdate(d.creation)
|
||||||
work = {}
|
period = scrub(get_period_for_date(creation_date, ranges))
|
||||||
work["Status"] = _(label)
|
if not period:
|
||||||
for _dummy, end_date in ranges:
|
continue
|
||||||
period = get_period(end_date, filters)
|
|
||||||
if periodic_data.get(label).get(period):
|
if d.status in ("Not Started", "Closed", "Stopped"):
|
||||||
work[scrub(period)] = periodic_data.get(label).get(period)
|
periodic_data[d.status][period] += 1
|
||||||
else:
|
else:
|
||||||
work[scrub(period)] = 0.0
|
if d.planned_end_date and getdate(today()) > getdate(d.planned_end_date):
|
||||||
data.append(work)
|
periodic_data["Overdue"][period] += 1
|
||||||
|
else:
|
||||||
|
periodic_data["Pending"][period] += 1
|
||||||
|
|
||||||
return data, chart_data
|
data = []
|
||||||
|
for status in WORK_ORDER_STATUS_LIST:
|
||||||
|
row = {"status": _(status)}
|
||||||
|
for _fd, _td, period in ranges:
|
||||||
|
row[scrub(period)] = periodic_data[status].get(scrub(period), 0)
|
||||||
|
data.append(row)
|
||||||
|
|
||||||
|
chart = get_chart_data(periodic_data, columns)
|
||||||
|
return data, chart
|
||||||
|
|
||||||
|
|
||||||
|
def get_period_for_date(date, ranges):
|
||||||
|
for from_date, to_date, period in ranges:
|
||||||
|
if from_date <= date <= to_date:
|
||||||
|
return period
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_ranges(filters):
|
||||||
|
ranges = []
|
||||||
|
for from_date, end_date in get_period_date_ranges(filters):
|
||||||
|
period = get_period(end_date, filters)
|
||||||
|
ranges.append((getdate(from_date), getdate(end_date), period))
|
||||||
|
return ranges
|
||||||
|
|
||||||
|
|
||||||
def get_chart_data(periodic_data, columns):
|
def get_chart_data(periodic_data, columns):
|
||||||
labels = [d.get("label") for d in columns[1:]]
|
period_labels = [d.get("label") for d in columns[1:]]
|
||||||
|
period_fieldnames = [d.get("fieldname") for d in columns[1:]]
|
||||||
|
|
||||||
not_start, overdue, pending, completed, closed, stopped = [], [], [], [], [], []
|
|
||||||
datasets = []
|
datasets = []
|
||||||
|
for status in WORK_ORDER_STATUS_LIST:
|
||||||
|
values = [periodic_data.get(status, {}).get(fieldname, 0) for fieldname in period_fieldnames]
|
||||||
|
datasets.append({"name": _(status), "values": values})
|
||||||
|
|
||||||
for d in labels:
|
return {"data": {"labels": period_labels, "datasets": datasets}, "type": "line"}
|
||||||
not_start.append(periodic_data.get("Not Started").get(d))
|
|
||||||
overdue.append(periodic_data.get("Overdue").get(d))
|
|
||||||
pending.append(periodic_data.get("Pending").get(d))
|
|
||||||
completed.append(periodic_data.get("Completed").get(d))
|
|
||||||
closed.append(periodic_data.get("Closed").get(d))
|
|
||||||
stopped.append(periodic_data.get("Stopped").get(d))
|
|
||||||
|
|
||||||
datasets.append({"name": _("Not Started"), "values": not_start})
|
|
||||||
datasets.append({"name": _("Overdue"), "values": overdue})
|
|
||||||
datasets.append({"name": _("Pending"), "values": pending})
|
|
||||||
datasets.append({"name": _("Completed"), "values": completed})
|
|
||||||
datasets.append({"name": _("Closed"), "values": closed})
|
|
||||||
datasets.append({"name": _("Stopped"), "values": stopped})
|
|
||||||
|
|
||||||
chart = {"data": {"labels": labels, "datasets": datasets}}
|
|
||||||
chart["type"] = "line"
|
|
||||||
|
|
||||||
return chart
|
|
||||||
|
|||||||
@@ -429,3 +429,4 @@ execute:frappe.db.set_single_value("Accounts Settings", "show_account_balance",
|
|||||||
erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter #2025-12-11
|
erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter #2025-12-11
|
||||||
erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges
|
erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges
|
||||||
erpnext.patches.v16_0.set_ordered_qty_in_quotation_item
|
erpnext.patches.v16_0.set_ordered_qty_in_quotation_item
|
||||||
|
erpnext.patches.v15_0.replace_http_with_https_in_sales_partner
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import frappe
|
||||||
|
from frappe import qb
|
||||||
|
from pypika.functions import Replace
|
||||||
|
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
sp = frappe.qb.DocType("Sales Partner")
|
||||||
|
qb.update(sp).set(sp.partner_website, Replace(sp.partner_website, "http://", "https://")).where(
|
||||||
|
sp.partner_website.rlike("^http://.*")
|
||||||
|
).run()
|
||||||
@@ -974,12 +974,12 @@ erpnext.utils.map_current_doc = function (opts) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (query_args.filters || query_args.query) {
|
if (query_args.filters || query_args.query) {
|
||||||
opts.get_query = () => query_args;
|
opts.get_query = () => JSON.parse(JSON.stringify(query_args));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.source_doctype) {
|
if (opts.source_doctype) {
|
||||||
let data_fields = [];
|
let data_fields = [];
|
||||||
if (["Purchase Receipt", "Delivery Note"].includes(opts.source_doctype)) {
|
if (["Purchase Receipt", "Delivery Note", "Purchase Invoice"].includes(opts.source_doctype)) {
|
||||||
let target_meta = frappe.get_meta(cur_frm.doc.doctype);
|
let target_meta = frappe.get_meta(cur_frm.doc.doctype);
|
||||||
if (target_meta.fields.find((f) => f.fieldname === "taxes")) {
|
if (target_meta.fields.find((f) => f.fieldname === "taxes")) {
|
||||||
data_fields.push({
|
data_fields.push({
|
||||||
|
|||||||
@@ -617,6 +617,9 @@ def handle_mandatory_error(e, customer, lead_name):
|
|||||||
def get_ordered_items(quotation: str):
|
def get_ordered_items(quotation: str):
|
||||||
return frappe._dict(
|
return frappe._dict(
|
||||||
frappe.get_all(
|
frappe.get_all(
|
||||||
"Quotation Item", {"docstatus": 1, "parent": quotation}, ["name", "ordered_qty"], as_list=True
|
"Quotation Item",
|
||||||
|
{"docstatus": 1, "parent": quotation, "ordered_qty": (">", 0)},
|
||||||
|
["name", "ordered_qty"],
|
||||||
|
as_list=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1484,9 +1484,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
|
"depends_on": "eval:doc.order_type == 'Maintenance';",
|
||||||
"fieldname": "skip_delivery_note",
|
"fieldname": "skip_delivery_note",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"hidden": 1,
|
|
||||||
"hide_days": 1,
|
"hide_days": 1,
|
||||||
"hide_seconds": 1,
|
"hide_seconds": 1,
|
||||||
"label": "Skip Delivery Note",
|
"label": "Skip Delivery Note",
|
||||||
@@ -1671,7 +1671,7 @@
|
|||||||
"idx": 105,
|
"idx": 105,
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-07-28 12:14:29.760988",
|
"modified": "2026-02-06 11:06:16.092658",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Selling",
|
"module": "Selling",
|
||||||
"name": "Sales Order",
|
"name": "Sales Order",
|
||||||
|
|||||||
@@ -57,6 +57,28 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
frappe.set_user("Administrator")
|
frappe.set_user("Administrator")
|
||||||
|
|
||||||
|
def test_sales_order_skip_delivery_note(self):
|
||||||
|
so = make_sales_order(do_not_submit=True)
|
||||||
|
so.order_type = "Maintenance"
|
||||||
|
so.skip_delivery_note = 1
|
||||||
|
so.append(
|
||||||
|
"items",
|
||||||
|
{
|
||||||
|
"item_code": "_Test Item 2",
|
||||||
|
"qty": 2,
|
||||||
|
"rate": 100,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
so.save()
|
||||||
|
so.submit()
|
||||||
|
|
||||||
|
so.reload()
|
||||||
|
si = make_sales_invoice(so.name)
|
||||||
|
si.insert()
|
||||||
|
si.submit()
|
||||||
|
so.reload()
|
||||||
|
self.assertEqual(so.status, "Completed")
|
||||||
|
|
||||||
@change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
|
@change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
|
||||||
def test_sales_order_with_negative_rate(self):
|
def test_sales_order_with_negative_rate(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -162,8 +162,6 @@ class EmailDigest(Document):
|
|||||||
context.purchase_order_list,
|
context.purchase_order_list,
|
||||||
context.purchase_orders_items_overdue_list,
|
context.purchase_orders_items_overdue_list,
|
||||||
) = self.get_purchase_orders_items_overdue_list()
|
) = self.get_purchase_orders_items_overdue_list()
|
||||||
if not context.purchase_order_list:
|
|
||||||
frappe.throw(_("No items to be received are overdue"))
|
|
||||||
|
|
||||||
if not context:
|
if not context:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -50,8 +50,17 @@ class SalesPartner(WebsiteGenerator):
|
|||||||
if not self.route:
|
if not self.route:
|
||||||
self.route = "partners/" + self.scrub(self.partner_name)
|
self.route = "partners/" + self.scrub(self.partner_name)
|
||||||
super().validate()
|
super().validate()
|
||||||
if self.partner_website and not self.partner_website.startswith("http"):
|
if self.partner_website:
|
||||||
self.partner_website = "http://" + self.partner_website
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
|
# scrub http
|
||||||
|
parts = urlsplit(self.partner_website)
|
||||||
|
if not parts.netloc and parts.path:
|
||||||
|
parts = parts._replace(netloc=parts.path, path="")
|
||||||
|
if not parts.scheme or parts.scheme == "http":
|
||||||
|
parts = parts._replace(scheme="https")
|
||||||
|
|
||||||
|
self.partner_website = urlunsplit(parts)
|
||||||
|
|
||||||
def get_context(self, context):
|
def get_context(self, context):
|
||||||
address = frappe.db.get_value(
|
address = frappe.db.get_value(
|
||||||
|
|||||||
@@ -129,6 +129,74 @@ class TestBatch(FrappeTestCase):
|
|||||||
for d in batches:
|
for d in batches:
|
||||||
self.assertEqual(d.qty, batchwise_qty[(d.batch_no, d.warehouse)])
|
self.assertEqual(d.qty, batchwise_qty[(d.batch_no, d.warehouse)])
|
||||||
|
|
||||||
|
def test_batch_qty_on_pos_creation(self):
|
||||||
|
from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import (
|
||||||
|
init_user_and_profile,
|
||||||
|
)
|
||||||
|
from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice
|
||||||
|
from erpnext.accounts.doctype.pos_opening_entry.test_pos_opening_entry import create_opening_entry
|
||||||
|
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||||
|
get_auto_batch_nos,
|
||||||
|
)
|
||||||
|
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||||
|
create_batch_item_with_batch,
|
||||||
|
)
|
||||||
|
|
||||||
|
session_user = frappe.session.user
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create batch item
|
||||||
|
create_batch_item_with_batch("_Test BATCH ITEM", "TestBatch-RS 02")
|
||||||
|
|
||||||
|
# Create stock entry
|
||||||
|
se = make_stock_entry(
|
||||||
|
target="_Test Warehouse - _TC",
|
||||||
|
item_code="_Test BATCH ITEM",
|
||||||
|
qty=30,
|
||||||
|
basic_rate=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
se.reload()
|
||||||
|
|
||||||
|
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
|
||||||
|
|
||||||
|
# Create opening entry
|
||||||
|
session_user = frappe.session.user
|
||||||
|
test_user, pos_profile = init_user_and_profile()
|
||||||
|
create_opening_entry(pos_profile, test_user.name)
|
||||||
|
|
||||||
|
# POS Invoice 1, for the batch without bundle
|
||||||
|
pos_inv1 = create_pos_invoice(item="_Test BATCH ITEM", rate=300, qty=15, do_not_save=1)
|
||||||
|
pos_inv1.append(
|
||||||
|
"payments",
|
||||||
|
{"mode_of_payment": "Cash", "amount": 4500},
|
||||||
|
)
|
||||||
|
pos_inv1.items[0].batch_no = batch_no
|
||||||
|
pos_inv1.save()
|
||||||
|
pos_inv1.submit()
|
||||||
|
pos_inv1.reload()
|
||||||
|
|
||||||
|
# Get auto batch nos after pos invoice
|
||||||
|
batches = get_auto_batch_nos(
|
||||||
|
frappe._dict(
|
||||||
|
{
|
||||||
|
"item_code": "_Test BATCH ITEM",
|
||||||
|
"warehouse": "_Test Warehouse - _TC",
|
||||||
|
"for_stock_levels": True,
|
||||||
|
"ignore_reserved_stock": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check batch qty after pos invoice
|
||||||
|
row = _find_batch_row(batches, batch_no, "_Test Warehouse - _TC")
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
self.assertEqual(row.qty, 30)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Set user to session user
|
||||||
|
frappe.set_user(session_user)
|
||||||
|
|
||||||
def test_stock_entry_incoming(self):
|
def test_stock_entry_incoming(self):
|
||||||
"""Test batch creation via Stock Entry (Work Order)"""
|
"""Test batch creation via Stock Entry (Work Order)"""
|
||||||
|
|
||||||
@@ -624,6 +692,10 @@ def create_price_list_for_batch(item_code, batch, rate):
|
|||||||
).insert()
|
).insert()
|
||||||
|
|
||||||
|
|
||||||
|
def _find_batch_row(batches, batch_no, warehouse):
|
||||||
|
return next((b for b in batches if b.batch_no == batch_no and b.warehouse == warehouse), None)
|
||||||
|
|
||||||
|
|
||||||
def make_new_batch(**args):
|
def make_new_batch(**args):
|
||||||
args = frappe._dict(args)
|
args = frappe._dict(args)
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ class InventoryDimension(Document):
|
|||||||
self.source_fieldname = scrub(self.dimension_name)
|
self.source_fieldname = scrub(self.dimension_name)
|
||||||
|
|
||||||
if not self.target_fieldname:
|
if not self.target_fieldname:
|
||||||
self.target_fieldname = scrub(self.reference_document)
|
self.target_fieldname = scrub(self.dimension_name)
|
||||||
|
|
||||||
def on_update(self):
|
def on_update(self):
|
||||||
self.add_custom_fields()
|
self.add_custom_fields()
|
||||||
|
|||||||
@@ -118,12 +118,12 @@ class TestInventoryDimension(FrappeTestCase):
|
|||||||
inward.load_from_db()
|
inward.load_from_db()
|
||||||
|
|
||||||
sle_data = frappe.db.get_value(
|
sle_data = frappe.db.get_value(
|
||||||
"Stock Ledger Entry", {"voucher_no": inward.name}, ["shelf", "warehouse"], as_dict=1
|
"Stock Ledger Entry", {"voucher_no": inward.name}, ["to_shelf", "warehouse"], as_dict=1
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(inward.items[0].to_shelf, "Shelf 1")
|
self.assertEqual(inward.items[0].to_shelf, "Shelf 1")
|
||||||
self.assertEqual(sle_data.warehouse, warehouse)
|
self.assertEqual(sle_data.warehouse, warehouse)
|
||||||
self.assertEqual(sle_data.shelf, "Shelf 1")
|
self.assertEqual(sle_data.to_shelf, "Shelf 1")
|
||||||
|
|
||||||
outward = make_stock_entry(
|
outward = make_stock_entry(
|
||||||
item_code=item_code,
|
item_code=item_code,
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ frappe.ui.form.on("Material Request", {
|
|||||||
|
|
||||||
frm.set_query("from_warehouse", "items", function (doc) {
|
frm.set_query("from_warehouse", "items", function (doc) {
|
||||||
return {
|
return {
|
||||||
filters: { company: doc.company },
|
filters: {
|
||||||
|
company: doc.company,
|
||||||
|
is_group: 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,19 +65,28 @@ frappe.ui.form.on("Material Request", {
|
|||||||
|
|
||||||
frm.set_query("warehouse", "items", function (doc) {
|
frm.set_query("warehouse", "items", function (doc) {
|
||||||
return {
|
return {
|
||||||
filters: { company: doc.company },
|
filters: {
|
||||||
|
company: doc.company,
|
||||||
|
is_group: 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
frm.set_query("set_warehouse", function (doc) {
|
frm.set_query("set_warehouse", function (doc) {
|
||||||
return {
|
return {
|
||||||
filters: { company: doc.company },
|
filters: {
|
||||||
|
company: doc.company,
|
||||||
|
is_group: 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
frm.set_query("set_from_warehouse", function (doc) {
|
frm.set_query("set_from_warehouse", function (doc) {
|
||||||
return {
|
return {
|
||||||
filters: { company: doc.company },
|
filters: {
|
||||||
|
company: doc.company,
|
||||||
|
is_group: 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -706,6 +706,9 @@ def make_stock_entry(source_name, target_doc=None):
|
|||||||
target.purpose = source.material_request_type
|
target.purpose = source.material_request_type
|
||||||
target.from_warehouse = source.set_from_warehouse
|
target.from_warehouse = source.set_from_warehouse
|
||||||
target.to_warehouse = source.set_warehouse
|
target.to_warehouse = source.set_warehouse
|
||||||
|
if source.material_request_type == "Material Issue":
|
||||||
|
target.from_warehouse = source.set_warehouse
|
||||||
|
target.to_warehouse = None
|
||||||
|
|
||||||
if source.job_card:
|
if source.job_card:
|
||||||
target.purpose = "Material Transfer for Manufacture"
|
target.purpose = "Material Transfer for Manufacture"
|
||||||
|
|||||||
@@ -902,15 +902,27 @@ class TestMaterialRequest(FrappeTestCase):
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry
|
from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry
|
||||||
|
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||||
|
|
||||||
mr = make_material_request(material_request_type="Material Transfer")
|
new_item = create_item("_Test Pick List Item", is_stock_item=1)
|
||||||
|
item_code = new_item.name
|
||||||
|
|
||||||
|
make_stock_entry(
|
||||||
|
item_code=item_code,
|
||||||
|
target="_Test Warehouse - _TC",
|
||||||
|
qty=10,
|
||||||
|
do_not_save=False,
|
||||||
|
do_not_submit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
mr = make_material_request(item_code=item_code, material_request_type="Material Transfer")
|
||||||
pl = create_pick_list(mr.name)
|
pl = create_pick_list(mr.name)
|
||||||
pl.save()
|
pl.save()
|
||||||
pl.locations[0].qty = 5
|
pl.locations[0].qty = 5
|
||||||
pl.locations[0].stock_qty = 5
|
pl.locations[0].stock_qty = 5
|
||||||
pl.submit()
|
pl.submit()
|
||||||
|
|
||||||
to_warehouse = create_warehouse("Test To Warehouse")
|
to_warehouse = create_warehouse("_Test Warehouse - _TC")
|
||||||
|
|
||||||
se_data = create_stock_entry(json.dumps(pl.as_dict()))
|
se_data = create_stock_entry(json.dumps(pl.as_dict()))
|
||||||
se = frappe.get_doc(se_data)
|
se = frappe.get_doc(se_data)
|
||||||
@@ -970,6 +982,19 @@ class TestMaterialRequest(FrappeTestCase):
|
|||||||
|
|
||||||
self.assertRaises(frappe.ValidationError, end_transit_2.submit)
|
self.assertRaises(frappe.ValidationError, end_transit_2.submit)
|
||||||
|
|
||||||
|
def test_make_stock_entry_material_issue_warehouse_mapping(self):
|
||||||
|
"""Test to ensure while making stock entry from material request of type Material Issue, warehouse is mapped correctly"""
|
||||||
|
mr = make_material_request(material_request_type="Material Issue", do_not_submit=True)
|
||||||
|
mr.set_warehouse = "_Test Warehouse - _TC"
|
||||||
|
mr.save()
|
||||||
|
mr.submit()
|
||||||
|
|
||||||
|
se = make_stock_entry(mr.name)
|
||||||
|
self.assertEqual(se.from_warehouse, "_Test Warehouse - _TC")
|
||||||
|
self.assertIsNone(se.to_warehouse)
|
||||||
|
se.save()
|
||||||
|
se.submit()
|
||||||
|
|
||||||
|
|
||||||
def get_in_transit_warehouse(company):
|
def get_in_transit_warehouse(company):
|
||||||
if not frappe.db.exists("Warehouse Type", "Transit"):
|
if not frappe.db.exists("Warehouse Type", "Transit"):
|
||||||
|
|||||||
@@ -1429,6 +1429,9 @@ class SerialandBatchBundle(Document):
|
|||||||
def throw_negative_batch(self, batch_no, available_qty, precision):
|
def throw_negative_batch(self, batch_no, available_qty, precision):
|
||||||
from erpnext.stock.stock_ledger import NegativeStockError
|
from erpnext.stock.stock_ledger import NegativeStockError
|
||||||
|
|
||||||
|
if frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch"):
|
||||||
|
return
|
||||||
|
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_(
|
_(
|
||||||
"""
|
"""
|
||||||
@@ -2546,6 +2549,9 @@ def get_auto_batch_nos(kwargs):
|
|||||||
qty = flt(kwargs.qty)
|
qty = flt(kwargs.qty)
|
||||||
|
|
||||||
stock_ledgers_batches = get_stock_ledgers_batches(kwargs)
|
stock_ledgers_batches = get_stock_ledgers_batches(kwargs)
|
||||||
|
|
||||||
|
pos_invoice_batches = frappe._dict()
|
||||||
|
if not kwargs.for_stock_levels:
|
||||||
pos_invoice_batches = get_reserved_batches_for_pos(kwargs)
|
pos_invoice_batches = get_reserved_batches_for_pos(kwargs)
|
||||||
|
|
||||||
sre_reserved_batches = frappe._dict()
|
sre_reserved_batches = frappe._dict()
|
||||||
|
|||||||
@@ -524,6 +524,9 @@ class StockReconciliation(StockController):
|
|||||||
if abs(difference_amount) > 0:
|
if abs(difference_amount) > 0:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
float_precision = frappe.db.get_default("float_precision") or 3
|
||||||
|
item_dict["rate"] = flt(item_dict.get("rate"), float_precision)
|
||||||
|
item.valuation_rate = flt(item.valuation_rate, float_precision) if item.valuation_rate else None
|
||||||
if (
|
if (
|
||||||
(item.qty is None or item.qty == item_dict.get("qty"))
|
(item.qty is None or item.qty == item_dict.get("qty"))
|
||||||
and (item.valuation_rate is None or item.valuation_rate == item_dict.get("rate"))
|
and (item.valuation_rate is None or item.valuation_rate == item_dict.get("rate"))
|
||||||
|
|||||||
@@ -1698,6 +1698,101 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
|
|||||||
|
|
||||||
self.assertEqual(docstatus, 2)
|
self.assertEqual(docstatus, 2)
|
||||||
|
|
||||||
|
def test_stock_reco_with_opening_stock_with_diff_inventory(self):
|
||||||
|
from erpnext.stock.doctype.inventory_dimension.test_inventory_dimension import (
|
||||||
|
create_inventory_dimension,
|
||||||
|
)
|
||||||
|
|
||||||
|
if frappe.db.exists("DocType", "Plant"):
|
||||||
|
return
|
||||||
|
|
||||||
|
doctype = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "DocType",
|
||||||
|
"name": "Plant",
|
||||||
|
"module": "Stock",
|
||||||
|
"custom": 1,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "plant_name",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Plant Name",
|
||||||
|
"reqd": 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"autoname": "field:plant_name",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
doctype.insert(ignore_permissions=True)
|
||||||
|
create_inventory_dimension(dimension_name="ID-Plant", reference_document="Plant")
|
||||||
|
|
||||||
|
plant_a = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Plant",
|
||||||
|
"plant_name": "Plant A",
|
||||||
|
}
|
||||||
|
).insert(ignore_permissions=True)
|
||||||
|
|
||||||
|
plant_b = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Plant",
|
||||||
|
"plant_name": "Plant B",
|
||||||
|
}
|
||||||
|
).insert(ignore_permissions=True)
|
||||||
|
|
||||||
|
warehouse = "_Test Warehouse - _TC"
|
||||||
|
|
||||||
|
item_code = "Item-Test"
|
||||||
|
item = self.make_item(item_code, {"is_stock_item": 1})
|
||||||
|
|
||||||
|
sr = frappe.new_doc("Stock Reconciliation")
|
||||||
|
sr.purpose = "Opening Stock"
|
||||||
|
sr.posting_date = nowdate()
|
||||||
|
sr.posting_time = nowtime()
|
||||||
|
sr.company = "_Test Company"
|
||||||
|
|
||||||
|
sr.append(
|
||||||
|
"items",
|
||||||
|
{
|
||||||
|
"item_code": item.name,
|
||||||
|
"warehouse": warehouse,
|
||||||
|
"qty": 5,
|
||||||
|
"valuation_rate": 100,
|
||||||
|
"id_plant": plant_a.name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
sr.append(
|
||||||
|
"items",
|
||||||
|
{
|
||||||
|
"item_code": item.name,
|
||||||
|
"warehouse": warehouse,
|
||||||
|
"qty": 3,
|
||||||
|
"valuation_rate": 110,
|
||||||
|
"id_plant": plant_b.name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
sr.insert()
|
||||||
|
sr.submit()
|
||||||
|
|
||||||
|
self.assertEqual(len(sr.items), 2)
|
||||||
|
sle_count = frappe.db.count(
|
||||||
|
"Stock Ledger Entry",
|
||||||
|
{"voucher_type": "Stock Reconciliation", "voucher_no": sr.name, "is_cancelled": 0},
|
||||||
|
)
|
||||||
|
self.assertEqual(sle_count, 2)
|
||||||
|
sle = frappe.get_all(
|
||||||
|
"Stock Ledger Entry",
|
||||||
|
{"voucher_type": "Stock Reconciliation", "voucher_no": sr.name, "is_cancelled": 0},
|
||||||
|
["item_code", "id_plant", "actual_qty", "valuation_rate"],
|
||||||
|
)
|
||||||
|
for s in sle:
|
||||||
|
if s.id_plant == plant_a.name:
|
||||||
|
self.assertEqual(s.actual_qty, 5)
|
||||||
|
elif s.id_plant == plant_b.name:
|
||||||
|
self.assertEqual(s.actual_qty, 3)
|
||||||
|
|
||||||
|
|
||||||
def create_batch_item_with_batch(item_name, batch_id):
|
def create_batch_item_with_batch(item_name, batch_id):
|
||||||
batch_item_doc = create_item(item_name, is_stock_item=1)
|
batch_item_doc = create_item(item_name, is_stock_item=1)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"disable_serial_no_and_batch_selector",
|
"disable_serial_no_and_batch_selector",
|
||||||
"use_serial_batch_fields",
|
"use_serial_batch_fields",
|
||||||
"do_not_update_serial_batch_on_creation_of_auto_bundle",
|
"do_not_update_serial_batch_on_creation_of_auto_bundle",
|
||||||
|
"allow_negative_stock_for_batch",
|
||||||
"serial_and_batch_bundle_section",
|
"serial_and_batch_bundle_section",
|
||||||
"set_serial_and_batch_bundle_naming_based_on_naming_series",
|
"set_serial_and_batch_bundle_naming_based_on_naming_series",
|
||||||
"section_break_gnhq",
|
"section_break_gnhq",
|
||||||
@@ -538,6 +539,13 @@
|
|||||||
"fieldname": "validate_material_transfer_warehouses",
|
"fieldname": "validate_material_transfer_warehouses",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Validate Material Transfer Warehouses"
|
"label": "Validate Material Transfer Warehouses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"description": "If enabled, the system will allow negative stock entries for the batch, but this could calculate the valuation rate incorrectly, so avoid using this option.",
|
||||||
|
"fieldname": "allow_negative_stock_for_batch",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Allow Negative Stock for Batch"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"icon": "icon-cog",
|
"icon": "icon-cog",
|
||||||
@@ -545,7 +553,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-11-11 11:35:39.864923",
|
"modified": "2026-02-09 15:01:12.466175",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Stock",
|
"module": "Stock",
|
||||||
"name": "Stock Settings",
|
"name": "Stock Settings",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class StockSettings(Document):
|
|||||||
allow_from_pr: DF.Check
|
allow_from_pr: DF.Check
|
||||||
allow_internal_transfer_at_arms_length_price: DF.Check
|
allow_internal_transfer_at_arms_length_price: DF.Check
|
||||||
allow_negative_stock: DF.Check
|
allow_negative_stock: DF.Check
|
||||||
|
allow_negative_stock_for_batch: DF.Check
|
||||||
allow_partial_reservation: DF.Check
|
allow_partial_reservation: DF.Check
|
||||||
allow_to_edit_stock_uom_qty_for_purchase: DF.Check
|
allow_to_edit_stock_uom_qty_for_purchase: DF.Check
|
||||||
allow_to_edit_stock_uom_qty_for_sales: DF.Check
|
allow_to_edit_stock_uom_qty_for_sales: DF.Check
|
||||||
|
|||||||
Reference in New Issue
Block a user