Merge branch 'develop' of https://github.com/frappe/erpnext into validate-company-linked-address-field

This commit is contained in:
Pugazhendhi Velu
2025-11-11 11:44:41 +00:00
93 changed files with 41876 additions and 276087 deletions

View File

@@ -309,8 +309,8 @@ def get_dimensions(with_cost_center_and_project=False):
if with_cost_center_and_project: if with_cost_center_and_project:
dimension_filters.extend( dimension_filters.extend(
[ [
{"fieldname": "cost_center", "document_type": "Cost Center"}, frappe._dict({"fieldname": "cost_center", "document_type": "Cost Center"}),
{"fieldname": "project", "document_type": "Project"}, frappe._dict({"fieldname": "project", "document_type": "Project"}),
] ]
) )

View File

@@ -61,6 +61,22 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
}, },
}; };
}); });
this.frm.set_query("cost_center", "payments", () => {
return {
filters: {
company: this.frm.doc.company,
is_group: 0,
},
};
});
this.frm.set_query("cost_center", "allocation", () => {
return {
filters: {
company: this.frm.doc.company,
is_group: 0,
},
};
});
} }
refresh() { refresh() {

View File

@@ -72,7 +72,7 @@ class PaymentReconciliation(Document):
self.common_filter_conditions = [] self.common_filter_conditions = []
self.accounting_dimension_filter_conditions = [] self.accounting_dimension_filter_conditions = []
self.ple_posting_date_filter = [] self.ple_posting_date_filter = []
self.dimensions = get_dimensions()[0] self.dimensions = get_dimensions(with_cost_center_and_project=True)[0]
def load_from_db(self): def load_from_db(self):
# 'modified' attribute is required for `run_doc_method` to work properly. # 'modified' attribute is required for `run_doc_method` to work properly.

View File

@@ -119,6 +119,7 @@ def get_assets_details(assets):
fields = [ fields = [
"name as asset", "name as asset",
"asset_name",
"net_purchase_amount", "net_purchase_amount",
"opening_accumulated_depreciation", "opening_accumulated_depreciation",
"asset_category", "asset_category",
@@ -143,6 +144,12 @@ def get_columns():
"options": "Asset", "options": "Asset",
"width": 120, "width": 120,
}, },
{
"label": _("Asset Name"),
"fieldname": "asset_name",
"fieldtype": "Data",
"width": 140,
},
{ {
"label": _("Depreciation Date"), "label": _("Depreciation Date"),
"fieldname": "depreciation_date", "fieldname": "depreciation_date",

View File

@@ -1,5 +1,5 @@
{ {
"add_total_row": 1, "add_total_row": 0,
"add_translate_data": 0, "add_translate_data": 0,
"columns": [], "columns": [],
"creation": "2013-12-06 13:22:23", "creation": "2013-12-06 13:22:23",
@@ -10,7 +10,7 @@
"idx": 3, "idx": 3,
"is_standard": "Yes", "is_standard": "Yes",
"letterhead": null, "letterhead": null,
"modified": "2025-08-13 12:47:27.645023", "modified": "2025-11-05 15:47:59.597853",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "General Ledger", "name": "General Ledger",

View File

@@ -21,6 +21,7 @@ def get_ordered_to_be_billed_data(args, filters=None):
doctype = frappe.qb.DocType(doctype) doctype = frappe.qb.DocType(doctype)
child_doctype = frappe.qb.DocType(child_tab) child_doctype = frappe.qb.DocType(child_tab)
item = frappe.qb.DocType("Item")
docname = filters.get(args.get("reference_field"), None) docname = filters.get(args.get("reference_field"), None)
project_field = get_project_field(doctype, child_doctype, party) project_field = get_project_field(doctype, child_doctype, party)
@@ -29,6 +30,8 @@ def get_ordered_to_be_billed_data(args, filters=None):
frappe.qb.from_(doctype) frappe.qb.from_(doctype)
.inner_join(child_doctype) .inner_join(child_doctype)
.on(doctype.name == child_doctype.parent) .on(doctype.name == child_doctype.parent)
.join(item)
.on(item.name == child_doctype.item_code)
.select( .select(
doctype.name, doctype.name,
doctype[args.get("date")].as_("date"), doctype[args.get("date")].as_("date"),
@@ -54,6 +57,7 @@ def get_ordered_to_be_billed_data(args, filters=None):
& (doctype.company == filters.get("company")) & (doctype.company == filters.get("company"))
& (doctype.posting_date <= filters.get("posting_date")) & (doctype.posting_date <= filters.get("posting_date"))
& (child_doctype.amount > 0) & (child_doctype.amount > 0)
& (item.is_stock_item == 1)
& ( & (
child_doctype.base_amount child_doctype.base_amount
- Round(child_doctype.billed_amt * IfNull(doctype.conversion_rate, 1), precision) - Round(child_doctype.billed_amt * IfNull(doctype.conversion_rate, 1), precision)

View File

@@ -1,32 +1,37 @@
{ {
"add_total_row": 1, "add_total_row": 0,
"apply_user_permissions": 1, "add_translate_data": 0,
"creation": "2013-06-13 18:46:55", "columns": [],
"disabled": 0, "creation": "2013-06-13 18:46:55",
"docstatus": 0, "disabled": 0,
"doctype": "Report", "docstatus": 0,
"idx": 3, "doctype": "Report",
"is_standard": "Yes", "filters": [],
"modified": "2018-02-21 01:28:31.261299", "idx": 3,
"modified_by": "Administrator", "is_standard": "Yes",
"module": "Accounts", "letterhead": null,
"name": "Purchase Invoice Trends", "modified": "2025-11-05 11:55:49.950442",
"owner": "Administrator", "modified_by": "Administrator",
"ref_doctype": "Purchase Invoice", "module": "Accounts",
"report_name": "Purchase Invoice Trends", "name": "Purchase Invoice Trends",
"report_type": "Script Report", "owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Purchase Invoice",
"report_name": "Purchase Invoice Trends",
"report_type": "Script Report",
"roles": [ "roles": [
{ {
"role": "Accounts User" "role": "Accounts User"
}, },
{ {
"role": "Purchase User" "role": "Purchase User"
}, },
{ {
"role": "Accounts Manager" "role": "Accounts Manager"
}, },
{ {
"role": "Auditor" "role": "Auditor"
} }
] ],
} "timeout": 0
}

View File

@@ -1,26 +1,31 @@
{ {
"add_total_row": 1, "add_total_row": 0,
"apply_user_permissions": 1, "add_translate_data": 0,
"creation": "2013-06-13 18:44:21", "columns": [],
"disabled": 0, "creation": "2013-06-13 18:44:21",
"docstatus": 0, "disabled": 0,
"doctype": "Report", "docstatus": 0,
"idx": 3, "doctype": "Report",
"is_standard": "Yes", "filters": [],
"modified": "2018-02-21 01:28:03.622485", "idx": 3,
"modified_by": "Administrator", "is_standard": "Yes",
"module": "Accounts", "letterhead": null,
"name": "Sales Invoice Trends", "modified": "2025-11-05 11:55:50.070651",
"owner": "Administrator", "modified_by": "Administrator",
"ref_doctype": "Sales Invoice", "module": "Accounts",
"report_name": "Sales Invoice Trends", "name": "Sales Invoice Trends",
"report_type": "Script Report", "owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Sales Invoice",
"report_name": "Sales Invoice Trends",
"report_type": "Script Report",
"roles": [ "roles": [
{ {
"role": "Accounts Manager" "role": "Accounts Manager"
}, },
{ {
"role": "Accounts User" "role": "Accounts User"
} }
] ],
} "timeout": 0
}

View File

@@ -1786,24 +1786,22 @@ def check_and_delete_linked_reports(report):
frappe.delete_doc("Desktop Icon", icon) frappe.delete_doc("Desktop Icon", icon)
def create_err_and_its_journals(companies: list | None = None) -> None: def create_err_and_its_journals(company: dict) -> None:
if companies: err = frappe.new_doc("Exchange Rate Revaluation")
for company in companies: err.company = company.name
err = frappe.new_doc("Exchange Rate Revaluation") err.posting_date = nowdate()
err.company = company.name err.rounding_loss_allowance = 0.0
err.posting_date = nowdate()
err.rounding_loss_allowance = 0.0
err.fetch_and_calculate_accounts_data() err.fetch_and_calculate_accounts_data()
if err.accounts: if err.accounts:
err.save().submit() err.save().submit()
response = err.make_jv_entries() response = err.make_jv_entries()
if company.submit_err_jv: if company.submit_err_jv:
jv = response.get("revaluation_jv", None) jv = response.get("revaluation_jv", None)
jv and frappe.get_doc("Journal Entry", jv).submit() jv and frappe.get_doc("Journal Entry", jv).submit()
jv = response.get("zero_balance_jv", None) jv = response.get("zero_balance_jv", None)
jv and frappe.get_doc("Journal Entry", jv).submit() jv and frappe.get_doc("Journal Entry", jv).submit()
def _auto_create_exchange_rate_revaluation_for(frequency: str) -> None: def _auto_create_exchange_rate_revaluation_for(frequency: str) -> None:
@@ -1816,7 +1814,14 @@ def _auto_create_exchange_rate_revaluation_for(frequency: str) -> None:
filters={"auto_exchange_rate_revaluation": 1, "auto_err_frequency": frequency}, filters={"auto_exchange_rate_revaluation": 1, "auto_err_frequency": frequency},
fields=["name", "submit_err_jv"], fields=["name", "submit_err_jv"],
) )
create_err_and_its_journals(companies)
if companies:
for company in companies:
frappe.enqueue(
"erpnext.accounts.utils.create_err_and_its_journals",
company=company,
queue="long",
)
def auto_create_exchange_rate_revaluation_daily() -> None: def auto_create_exchange_rate_revaluation_daily() -> None:

View File

@@ -340,7 +340,6 @@
"label": "Maintenance Required" "label": "Maintenance Required"
}, },
{ {
"allow_on_submit": 1,
"default": "Draft", "default": "Draft",
"fieldname": "status", "fieldname": "status",
"fieldtype": "Select", "fieldtype": "Select",
@@ -348,7 +347,7 @@
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Status", "label": "Status",
"no_copy": 1, "no_copy": 1,
"options": "Draft\nSubmitted\nPartially Depreciated\nFully Depreciated\nSold\nScrapped\nIn Maintenance\nOut of Order\nIssue\nReceipt\nCapitalized\nWork In Progress", "options": "Draft\nSubmitted\nCancelled\nPartially Depreciated\nFully Depreciated\nSold\nScrapped\nIn Maintenance\nOut of Order\nIssue\nReceipt\nCapitalized\nWork In Progress",
"read_only": 1 "read_only": 1
}, },
{ {
@@ -601,7 +600,7 @@
"link_fieldname": "target_asset" "link_fieldname": "target_asset"
} }
], ],
"modified": "2025-05-23 00:53:54.249309", "modified": "2025-11-04 22:39:00.817405",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Assets", "module": "Assets",
"name": "Asset", "name": "Asset",

View File

@@ -100,6 +100,7 @@ class Asset(AccountsController):
status: DF.Literal[ status: DF.Literal[
"Draft", "Draft",
"Submitted", "Submitted",
"Cancelled",
"Partially Depreciated", "Partially Depreciated",
"Fully Depreciated", "Fully Depreciated",
"Sold", "Sold",
@@ -463,6 +464,7 @@ class Asset(AccountsController):
"asset_name": self.asset_name, "asset_name": self.asset_name,
"target_location": self.location, "target_location": self.location,
"to_employee": self.custodian, "to_employee": self.custodian,
"company": self.company,
} }
] ]
asset_movement = frappe.get_doc( asset_movement = frappe.get_doc(

View File

@@ -128,7 +128,7 @@
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
"label": "Asset", "label": "Asset",
"link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",null]]]", "link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",\"Cancelled\",null]]]",
"options": "Asset", "options": "Asset",
"reqd": 1 "reqd": 1
}, },
@@ -261,7 +261,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2025-07-18 15:59:53.981224", "modified": "2025-11-04 23:06:43.644846",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Assets", "module": "Assets",
"name": "Asset Repair", "name": "Asset Repair",

View File

@@ -82,11 +82,21 @@ class AssetRepair(AccountsController):
def validate_purchase_invoices(self): def validate_purchase_invoices(self):
for d in self.invoices: for d in self.invoices:
self.validate_purchase_invoice_status(d.purchase_invoice)
invoice_items = self.get_invoice_items(d.purchase_invoice) invoice_items = self.get_invoice_items(d.purchase_invoice)
self.validate_service_purchase_invoice(d.purchase_invoice, invoice_items) self.validate_service_purchase_invoice(d.purchase_invoice, invoice_items)
self.validate_expense_account(d, invoice_items) self.validate_expense_account(d, invoice_items)
self.validate_purchase_invoice_repair_cost(d, invoice_items) self.validate_purchase_invoice_repair_cost(d, invoice_items)
def validate_purchase_invoice_status(self, purchase_invoice):
docstatus = frappe.db.get_value("Purchase Invoice", purchase_invoice, "docstatus")
if docstatus == 0:
frappe.throw(
_("{0} is still in Draft. Please submit it before saving the Asset Repair.").format(
get_link_to_form("Purchase Invoice", purchase_invoice)
)
)
def get_invoice_items(self, pi): def get_invoice_items(self, pi):
invoice_items = frappe.get_all( invoice_items = frappe.get_all(
"Purchase Invoice Item", "Purchase Invoice Item",

View File

@@ -76,6 +76,46 @@ class TestRequestforQuotation(IntegrationTestCase):
self.assertEqual(sq1.get("items")[0].item_code, "_Test Item") self.assertEqual(sq1.get("items")[0].item_code, "_Test Item")
self.assertEqual(sq1.get("items")[0].qty, 5) self.assertEqual(sq1.get("items")[0].qty, 5)
def test_make_supplier_quotation_with_taxes(self):
"""Test automatic tax addition when supplier quotation is created from RFQ taxes_and_charges are set"""
# Create a Purchase Taxes and Charges Template for testing
tax_template = frappe.new_doc("Purchase Taxes and Charges Template")
tax_template.doctype = "Purchase Taxes and Charges Template"
tax_template.title = "_Test Purchase Taxes Template for RFQ"
tax_template.company = "_Test Company"
tax_template.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"description": "VAT",
"rate": 10,
},
)
tax_template.save()
rfq = make_request_for_quotation()
supplier = rfq.get("suppliers")[0].supplier
tax_rule = frappe.new_doc("Tax Rule")
tax_rule.company = "_Test Company"
tax_rule.tax_type = "Purchase"
tax_rule.supplier = supplier
tax_rule.purchase_tax_template = tax_template.name
tax_rule.save()
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=supplier)
# Verify that taxes_and_charges is set from get_party_details
self.assertEqual(sq.taxes_and_charges, tax_template.name)
# Verify that taxes are automatically added
self.assertGreaterEqual(len(sq.get("taxes")), 1)
tax_rule.delete()
tax_template.delete()
def test_make_supplier_quotation_with_special_characters(self): def test_make_supplier_quotation_with_special_characters(self):
frappe.delete_doc_if_exists("Supplier", "_Test Supplier '1", force=1) frappe.delete_doc_if_exists("Supplier", "_Test Supplier '1", force=1)
supplier = frappe.new_doc("Supplier") supplier = frappe.new_doc("Supplier")

View File

@@ -1,29 +1,34 @@
{ {
"add_total_row": 1, "add_total_row": 0,
"apply_user_permissions": 1, "add_translate_data": 0,
"creation": "2013-06-13 18:45:01", "columns": [],
"disabled": 0, "creation": "2013-06-13 18:45:01",
"docstatus": 0, "disabled": 0,
"doctype": "Report", "docstatus": 0,
"idx": 3, "doctype": "Report",
"is_standard": "Yes", "filters": [],
"modified": "2018-02-21 01:28:37.416562", "idx": 3,
"modified_by": "Administrator", "is_standard": "Yes",
"module": "Buying", "letterhead": null,
"name": "Purchase Order Trends", "modified": "2025-11-05 11:55:50.058154",
"owner": "Administrator", "modified_by": "Administrator",
"ref_doctype": "Purchase Order", "module": "Buying",
"report_name": "Purchase Order Trends", "name": "Purchase Order Trends",
"report_type": "Script Report", "owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Purchase Order",
"report_name": "Purchase Order Trends",
"report_type": "Script Report",
"roles": [ "roles": [
{ {
"role": "Stock User" "role": "Stock User"
}, },
{ {
"role": "Purchase Manager" "role": "Purchase Manager"
}, },
{ {
"role": "Purchase User" "role": "Purchase User"
} }
] ],
} "timeout": 0
}

View File

@@ -39,6 +39,8 @@ from erpnext.accounts.doctype.pricing_rule.utils import (
) )
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
from erpnext.accounts.party import ( from erpnext.accounts.party import (
PURCHASE_TRANSACTION_TYPES,
SALES_TRANSACTION_TYPES,
get_party_account, get_party_account,
get_party_account_currency, get_party_account_currency,
get_party_gle_currency, get_party_gle_currency,
@@ -2958,6 +2960,104 @@ class AccountsController(TransactionBase):
x["transaction_currency"] = self.currency x["transaction_currency"] = self.currency
x["transaction_exchange_rate"] = self.get("conversion_rate") or 1 x["transaction_exchange_rate"] = self.get("conversion_rate") or 1
def after_mapping(self, source_doc):
self.set_discount_amount_after_mapping(source_doc)
def set_discount_amount_after_mapping(self, source_doc):
"""
Ensures that Additional Discount Amount is not copied repeatedly
for multiple mappings of a single source transaction.
"""
# source and target doctypes should both be buying / selling
for transaction_types in (PURCHASE_TRANSACTION_TYPES, SALES_TRANSACTION_TYPES):
if self.doctype in transaction_types and source_doc.doctype in transaction_types:
break
else:
return
# ensure both doctypes have discount_amount field
if not self.meta.get_field("discount_amount") or not source_doc.meta.get_field("discount_amount"):
return
# ensure discount_amount is set in source doc
if not source_doc.discount_amount:
return
# ensure additional_discount_percentage is not set in the source doc
if source_doc.get("additional_discount_percentage"):
return
item_doctype = self.meta.get_field("items").options
doctype_table = frappe.qb.DocType(self.doctype)
item_table = frappe.qb.DocType(item_doctype)
is_same_doctype = self.doctype == source_doc.doctype
is_return = self.get("is_return") and is_same_doctype
if is_same_doctype and not is_return:
# should never happen
# you don't map to the same doctype without it being a return
return
query = (
frappe.qb.from_(doctype_table)
.where(doctype_table.docstatus == 1)
.where(doctype_table.discount_amount != 0)
.select(Sum(doctype_table.discount_amount))
)
if is_return:
query = query.where(doctype_table.is_return == 1).where(
doctype_table.return_against == source_doc.name
)
else:
item_meta = frappe.get_meta(item_doctype)
reference_fieldname = next(
(
row.fieldname
for row in item_meta.fields
if row.fieldtype == "Link"
and row.options == source_doc.doctype
and not row.get("is_custom_field")
),
None,
)
if not reference_fieldname:
return
query = query.where(
doctype_table.name.isin(
frappe.qb.from_(item_table)
.select(item_table.parent)
.where(item_table[reference_fieldname] == source_doc.name)
.distinct()
)
)
result = query.run()
if not result:
return
discount_already_applied = result[0][0]
if not discount_already_applied:
return
if is_return:
# returns have negative discount
discount_already_applied *= -1
discount_amount = max(source_doc.discount_amount - discount_already_applied, 0)
if discount_amount and is_return:
discount_amount *= -1
self.discount_amount = flt(discount_amount, self.precision("discount_amount"))
self.calculate_taxes_and_totals()
@frappe.whitelist() @frappe.whitelist()
def get_tax_rate(account_head): def get_tax_rate(account_head):

View File

@@ -15,6 +15,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
from erpnext.accounts.party import get_party_details from erpnext.accounts.party import get_party_details
from erpnext.buying.utils import update_last_purchase_rate, validate_for_items from erpnext.buying.utils import update_last_purchase_rate, validate_for_items
from erpnext.controllers.accounts_controller import get_taxes_and_charges
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return from erpnext.controllers.sales_and_purchase_return import get_rate_for_return
from erpnext.controllers.subcontracting_controller import SubcontractingController from erpnext.controllers.subcontracting_controller import SubcontractingController
from erpnext.stock.get_item_details import get_conversion_factor, get_item_defaults from erpnext.stock.get_item_details import get_conversion_factor, get_item_defaults
@@ -216,6 +217,12 @@ class BuyingController(SubcontractingController):
self.set_missing_item_details(for_validate) self.set_missing_item_details(for_validate)
if self.meta.get_field("taxes"):
if self.get("taxes_and_charges") and not self.get("taxes") and not for_validate:
taxes = get_taxes_and_charges("Purchase Taxes and Charges Template", self.taxes_and_charges)
for tax in taxes:
self.append("taxes", tax)
def set_supplier_from_item_default(self): def set_supplier_from_item_default(self):
if self.meta.get_field("supplier") and not self.supplier: if self.meta.get_field("supplier") and not self.supplier:
for d in self.get("items"): for d in self.get("items"):

View File

@@ -2074,6 +2074,11 @@ def make_bundle_for_material_transfer(**kwargs):
row.is_outward = 1 row.is_outward = 1
row.warehouse = kwargs.warehouse row.warehouse = kwargs.warehouse
row.posting_datetime = bundle_doc.posting_datetime
row.voucher_type = bundle_doc.voucher_type
row.voucher_no = bundle_doc.voucher_no
row.voucher_detail_no = bundle_doc.voucher_detail_no
row.type_of_transaction = bundle_doc.type_of_transaction
bundle_doc.set_incoming_rate() bundle_doc.set_incoming_rate()
bundle_doc.calculate_qty_and_amount() bundle_doc.calculate_qty_and_amount()

View File

@@ -719,6 +719,25 @@ class calculate_taxes_and_totals:
self.doc.precision("discount_amount"), self.doc.precision("discount_amount"),
) )
discount_amount = self.doc.discount_amount or 0
grand_total = self.doc.grand_total
# validate that discount amount cannot exceed the total before discount
if (
(grand_total >= 0 and discount_amount > grand_total)
or (grand_total < 0 and discount_amount < grand_total) # returns
):
frappe.throw(
_(
"Additional Discount Amount ({discount_amount}) cannot exceed "
"the total before such discount ({total_before_discount})"
).format(
discount_amount=self.doc.get_formatted("discount_amount"),
total_before_discount=self.doc.get_formatted("grand_total"),
),
title=_("Invalid Discount Amount"),
)
def apply_discount_amount(self): def apply_discount_amount(self):
if self.doc.discount_amount: if self.doc.discount_amount:
if not self.doc.apply_discount_on: if not self.doc.apply_discount_on:

View File

@@ -2381,3 +2381,173 @@ class TestAccountsController(IntegrationTestCase):
self.assertRaises(frappe.ValidationError, si.save) self.assertRaises(frappe.ValidationError, si.save)
si.contact_person = customer_contact.name si.contact_person = customer_contact.name
si.save() si.save()
def test_discount_amount_not_mapped_repeatedly_for_sales_transactions(self):
"""
Test that additional discount amount is not copied repeatedly
when creating multiple delivery notes from a single sales order with discount_amount set
"""
from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
# Create a sales order with discount amount
so = make_sales_order(qty=10, rate=100, do_not_submit=True)
so.apply_discount_on = "Net Total"
so.discount_amount = 100
so.save()
so.submit()
# Create first delivery note from sales order (partial qty)
dn1 = make_delivery_note(so.name)
dn1.items[0].qty = 5
dn1.save()
dn1.submit()
# First delivery note should have full discount amount
self.assertEqual(dn1.discount_amount, 100)
self.assertEqual(dn1.grand_total, 400)
# Create second delivery note from the same sales order (remaining qty)
dn2 = make_delivery_note(so.name)
dn2.items[0].qty = 5
dn2.save()
dn2.submit()
# Second delivery note should have discount_amount set to 0
# because discount was already fully applied in first delivery note
self.assertEqual(dn2.discount_amount, 0)
self.assertEqual(dn2.grand_total, 500)
def test_discount_amount_not_mapped_repeatedly_for_purchase_transactions(self):
"""
Test that additional discount amount is not copied repeatedly
when creating multiple purchase receipts from a single purchase order with discount_amount set
"""
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
# Create a purchase order with discount amount
po = create_purchase_order(qty=10, rate=100, do_not_submit=True)
po.apply_discount_on = "Net Total"
po.discount_amount = 100
po.save()
po.submit()
# Create first purchase receipt from purchase order (partial qty)
pr1 = make_purchase_receipt(po.name)
pr1.items[0].qty = 5
pr1.save()
pr1.submit()
# First purchase receipt should have full discount amount
self.assertEqual(pr1.discount_amount, 100)
self.assertEqual(pr1.grand_total, 400)
# Create second purchase receipt from the same purchase order (remaining qty)
pr2 = make_purchase_receipt(po.name)
pr2.items[0].qty = 5
pr2.save()
pr2.submit()
# Second purchase receipt should have discount_amount set to 0
# because discount was already fully applied in first purchase receipt
self.assertEqual(pr2.discount_amount, 0)
self.assertEqual(pr2.grand_total, 500)
def test_discount_amount_partial_application_in_mapped_transactions(self):
"""
Test that discount amount is partially applied when some discount
has already been used in previous mapped transactions
"""
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
# Create a sales order with discount amount
so = make_sales_order(qty=10, rate=100, do_not_submit=True)
so.apply_discount_on = "Net Total"
so.discount_amount = 200
so.save()
so.submit()
self.assertEqual(so.discount_amount, 200)
self.assertEqual(so.grand_total, 800)
# Create first invoice with partial discount (manually set lower discount)
si1 = make_sales_invoice(so.name)
si1.items[0].qty = 5
si1.discount_amount = 50 # Partial discount application
si1.save()
si1.submit()
self.assertEqual(si1.discount_amount, 50)
self.assertEqual(si1.grand_total, 450)
# Create second invoice from the same sales order
si2 = make_sales_invoice(so.name)
si2.items[0].qty = 5
si2.save()
si2.submit()
# Second invoice should have remaining discount (200 - 50 = 150)
self.assertEqual(si2.discount_amount, 150)
self.assertEqual(si2.grand_total, 350)
def test_discount_amount_not_mapped_when_percentage_is_set(self):
"""
Test that discount amount is not adjusted when additional_discount_percentage
is set in the source document (as it will be recalculated based on percentage)
"""
from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
# Create a sales order with discount percentage instead of amount
so = make_sales_order(qty=10, rate=100, do_not_submit=True)
so.apply_discount_on = "Net Total"
so.additional_discount_percentage = 10 # 10% discount
so.save()
so.submit()
self.assertEqual(so.discount_amount, 100) # 10% of 1000
self.assertEqual(so.grand_total, 900)
# Create delivery note from sales order
dn = make_delivery_note(so.name)
dn.items[0].qty = 5
dn.save()
# Delivery note should have discount amount recalculated based on percentage
# and not affected by the repeated mapping logic
self.assertEqual(dn.additional_discount_percentage, 10)
self.assertEqual(dn.discount_amount, 50) # 10% of 500
def test_discount_amount_for_multiple_returns(self):
"""
Test that discount amount is correctly adjusted when multiple return invoices
are created against the same original invoice to prevent over-returning discount
"""
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
# Create original sales invoice with discount
si = create_sales_invoice(qty=10, rate=100, do_not_submit=True)
si.apply_discount_on = "Net Total"
si.discount_amount = 100
si.save()
si.submit()
# Create first return - Frappe will copy full discount by default, we need to adjust it
return_si_1 = make_sales_return(si.name)
return_si_1.items[0].qty = -6 # Return 6 out of 10 items
# Manually set discount to match the proportion (60% of discount)
return_si_1.discount_amount = -60
return_si_1.save()
return_si_1.submit()
self.assertEqual(return_si_1.discount_amount, -60)
# Create second return for remaining items
return_si_2 = make_sales_return(si.name)
return_si_2.items[0].qty = -4 # Return remaining 4 out of 10 items
return_si_2.save()
# Second return should only get remaining discount (100 - 60 = 40)
self.assertEqual(return_si_2.discount_amount, -40)

View File

@@ -191,6 +191,9 @@ def get_data(filters, conditions):
des[j + inc] = row1[0][j] des[j + inc] = row1[0][j]
data.append(des) data.append(des)
total_row = calculate_total_row(data1, conditions["columns"])
data.append(total_row)
else: else:
data = frappe.db.sql( data = frappe.db.sql(
""" select {} from `tab{}` t1, `tab{} Item` t2 {} """ select {} from `tab{}` t1, `tab{} Item` t2 {}
@@ -214,9 +217,32 @@ def get_data(filters, conditions):
as_list=1, as_list=1,
) )
total_row = calculate_total_row(data, conditions["columns"])
data.append(total_row)
return data return data
def calculate_total_row(data, columns):
def wrap_in_quotes(label):
return f"'{label}'"
total_values = {}
for i, col in enumerate(columns):
if "Float" in col or "Currency/currency" in col:
total_values[i] = 0
for row in data:
for i in total_values.keys():
total_values[i] += row[i] if row[i] is not None else 0
total_row = [wrap_in_quotes(_("Total"))]
for i in range(1, len(columns)):
total_row.append(total_values.get(i, None))
return total_row
def get_mon(dt): def get_mon(dt):
return getdate(dt).strftime("%b") return getdate(dt).strftime("%b")

View File

@@ -663,3 +663,8 @@ default_log_clearing_doctypes = {
export_python_type_annotations = True export_python_type_annotations = True
fields_for_group_similar_items = ["qty", "amount"] fields_for_group_similar_items = ["qty", "amount"]
# Translation
# ------------
# List of apps whose translatable strings should be excluded from this app's translations.
ignore_translatable_strings_from = ["frappe"]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -836,7 +836,7 @@ class BOM(WebsiteGenerator):
self.base_operating_cost = 0 self.base_operating_cost = 0
if self.get("with_operations"): if self.get("with_operations"):
for d in self.get("operations"): for d in self.get("operations"):
if d.workstation: if d.workstation or d.workstation_type:
self.update_rate_and_time(d, update_hour_rate) self.update_rate_and_time(d, update_hour_rate)
operating_cost = d.operating_cost operating_cost = d.operating_cost
@@ -857,7 +857,13 @@ class BOM(WebsiteGenerator):
def update_rate_and_time(self, row, update_hour_rate=False): def update_rate_and_time(self, row, update_hour_rate=False):
if not row.hour_rate or update_hour_rate: if not row.hour_rate or update_hour_rate:
hour_rate = flt(frappe.get_cached_value("Workstation", row.workstation, "hour_rate")) hour_rate = 0
if row.workstation:
hour_rate = flt(frappe.get_cached_value("Workstation", row.workstation, "hour_rate"))
elif row.workstation_type:
hour_rate = flt(
frappe.get_cached_value("Workstation Type", row.workstation_type, "hour_rate")
)
if hour_rate: if hour_rate:
row.hour_rate = ( row.hour_rate = (

View File

@@ -923,6 +923,10 @@ class JobCard(Document):
wo.update_operation_status() wo.update_operation_status()
wo.calculate_operating_cost() wo.calculate_operating_cost()
wo.set_actual_dates() wo.set_actual_dates()
if wo.track_semi_finished_goods and time_data:
wo.status = "In Process"
wo.save() wo.save()
def get_current_operation_data(self): def get_current_operation_data(self):

View File

@@ -15,6 +15,7 @@
"bom_section", "bom_section",
"update_bom_costs_automatically", "update_bom_costs_automatically",
"column_break_lhyt", "column_break_lhyt",
"allow_editing_of_items_and_quantities_in_work_order",
"section_break_6", "section_break_6",
"default_wip_warehouse", "default_wip_warehouse",
"default_fg_warehouse", "default_fg_warehouse",
@@ -261,13 +262,20 @@
"fieldname": "transfer_extra_materials_percentage", "fieldname": "transfer_extra_materials_percentage",
"fieldtype": "Percent", "fieldtype": "Percent",
"label": "Transfer Extra Raw Materials to WIP (%)" "label": "Transfer Extra Raw Materials to WIP (%)"
},
{
"default": "0",
"description": "If enabled, the system will allow users to edit the raw materials and their quantities in the Work Order. The system will not reset the quantities as per the BOM, if the user has changed them.",
"fieldname": "allow_editing_of_items_and_quantities_in_work_order",
"fieldtype": "Check",
"label": "Allow Editing of Items and Quantities in Work Order"
} }
], ],
"icon": "icon-wrench", "icon": "icon-wrench",
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-09-08 19:48:31.726126", "modified": "2025-11-07 14:52:56.241459",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Manufacturing Settings", "name": "Manufacturing Settings",

View File

@@ -18,6 +18,7 @@ class ManufacturingSettings(Document):
from frappe.types import DF from frappe.types import DF
add_corrective_operation_cost_in_finished_good_valuation: DF.Check add_corrective_operation_cost_in_finished_good_valuation: DF.Check
allow_editing_of_items_and_quantities_in_work_order: DF.Check
allow_overtime: DF.Check allow_overtime: DF.Check
allow_production_on_holidays: DF.Check allow_production_on_holidays: DF.Check
backflush_raw_materials_based_on: DF.Literal["BOM", "Material Transferred for Manufacture"] backflush_raw_materials_based_on: DF.Literal["BOM", "Material Transferred for Manufacture"]

View File

@@ -239,6 +239,11 @@ frappe.ui.form.on("Work Order", {
frm.trigger("add_custom_button_to_return_components"); frm.trigger("add_custom_button_to_return_components");
frm.trigger("allow_alternative_item"); frm.trigger("allow_alternative_item");
frm.trigger("hide_reserve_stock_button"); frm.trigger("hide_reserve_stock_button");
frm.trigger("toggle_items_editable");
},
toggle_items_editable(frm) {
frm.toggle_enable("required_items", frm.doc.__onload?.allow_editing_items === 1 ? 1 : 0);
}, },
hide_reserve_stock_button(frm) { hide_reserve_stock_button(frm) {

View File

@@ -151,6 +151,7 @@ class WorkOrder(Document):
def onload(self): def onload(self):
ms = frappe.get_doc("Manufacturing Settings") ms = frappe.get_doc("Manufacturing Settings")
self.set_onload("allow_editing_items", ms.allow_editing_of_items_and_quantities_in_work_order)
self.set_onload("material_consumption", ms.material_consumption) self.set_onload("material_consumption", ms.material_consumption)
self.set_onload("backflush_raw_materials_based_on", ms.backflush_raw_materials_based_on) self.set_onload("backflush_raw_materials_based_on", ms.backflush_raw_materials_based_on)
self.set_onload("overproduction_percentage", ms.overproduction_percentage_for_work_order) self.set_onload("overproduction_percentage", ms.overproduction_percentage_for_work_order)
@@ -205,7 +206,11 @@ class WorkOrder(Document):
validate_uom_is_integer(self, "stock_uom", ["required_qty"]) validate_uom_is_integer(self, "stock_uom", ["required_qty"])
self.set_required_items(reset_only_qty=len(self.get("required_items"))) if not len(self.get("required_items")) or not frappe.db.get_single_value(
"Manufacturing Settings", "allow_editing_of_items_and_quantities_in_work_order"
):
self.set_required_items(reset_only_qty=len(self.get("required_items")))
self.enable_auto_reserve_stock() self.enable_auto_reserve_stock()
self.validate_operations_sequence() self.validate_operations_sequence()
self.validate_subcontracting_inward_order() self.validate_subcontracting_inward_order()
@@ -566,6 +571,10 @@ class WorkOrder(Document):
): ):
status = "In Process" status = "In Process"
if self.track_semi_finished_goods and status != "Completed":
if op_status := self.get_status_based_on_operation():
status = op_status
if status == "Not Started" and self.reserve_stock: if status == "Not Started" and self.reserve_stock:
for row in self.required_items: for row in self.required_items:
if not row.stock_reserved_qty: if not row.stock_reserved_qty:
@@ -579,6 +588,11 @@ class WorkOrder(Document):
return status return status
def get_status_based_on_operation(self):
for row in self.operations:
if row.status != "Completed":
return "In Process"
def update_work_order_qty(self): def update_work_order_qty(self):
"""Update **Manufactured Qty** and **Material Transferred for Qty** in Work Order """Update **Manufactured Qty** and **Material Transferred for Qty** in Work Order
based on Stock Entry""" based on Stock Entry"""
@@ -1229,13 +1243,18 @@ class WorkOrder(Document):
"fixed_time", "fixed_time",
"skip_material_transfer", "skip_material_transfer",
"backflush_from_wip_warehouse", "backflush_from_wip_warehouse",
"set_cost_based_on_bom_qty",
], ],
order_by="idx", order_by="idx",
) )
for d in data: for d in data:
if not d.fixed_time: if not d.fixed_time:
d.time_in_mins = flt(d.time_in_mins) * flt(qty) if d.set_cost_based_on_bom_qty:
d.time_in_mins = flt(d.time_in_mins) * flt(flt(qty) / flt(d.batch_size or 1))
else:
d.time_in_mins = flt(d.time_in_mins) * flt(qty)
d.status = "Pending" d.status = "Pending"
if self.track_semi_finished_goods and not d.sequence_id: if self.track_semi_finished_goods and not d.sequence_id:
@@ -1258,7 +1277,7 @@ class WorkOrder(Document):
operations.extend(_get_operations(node.name, qty=node.exploded_qty / node.bom_qty)) operations.extend(_get_operations(node.name, qty=node.exploded_qty / node.bom_qty))
bom_qty = frappe.get_cached_value("BOM", self.bom_no, "quantity") bom_qty = frappe.get_cached_value("BOM", self.bom_no, "quantity")
operations.extend(_get_operations(self.bom_no, qty=1.0 / bom_qty)) operations.extend(_get_operations(self.bom_no, qty=bom_qty))
for correct_index, operation in enumerate(operations, start=1): for correct_index, operation in enumerate(operations, start=1):
operation.idx = correct_index operation.idx = correct_index

View File

@@ -160,10 +160,9 @@ def get_column(filters):
}, },
{ {
"label": _("Document Type"), "label": _("Document Type"),
"fieldtype": "Link", "fieldtype": "Data",
"fieldname": "document_type", "fieldname": "document_type",
"width": 150, "width": 150,
"options": "DocType",
}, },
{ {
"label": _("Document Name"), "label": _("Document Name"),

View File

@@ -444,3 +444,4 @@ erpnext.patches.v16_0.rename_subcontracted_quantity
erpnext.patches.v16_0.add_new_stock_entry_types erpnext.patches.v16_0.add_new_stock_entry_types
erpnext.patches.v15_0.set_asset_status_if_not_already_set erpnext.patches.v15_0.set_asset_status_if_not_already_set
erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing
erpnext.patches.v16_0.update_serial_batch_entries

View File

@@ -0,0 +1,17 @@
import frappe
def execute():
if frappe.db.has_table("Serial and Batch Entry"):
frappe.db.sql(
"""
UPDATE `tabSerial and Batch Entry` SABE, `tabSerial and Batch Bundle` SABB
SET
SABE.posting_datetime = SABB.posting_datetime,
SABE.voucher_type = SABB.voucher_type,
SABE.voucher_no = SABB.voucher_no,
SABE.voucher_detail_no = SABB.voucher_detail_no,
SABE.type_of_transaction = SABB.type_of_transaction
WHERE SABE.parent = SABB.name
"""
)

View File

@@ -17,6 +17,10 @@ class CircularReferenceError(frappe.ValidationError):
pass pass
class ParentIsGroupError(frappe.ValidationError):
pass
class Task(NestedSet): class Task(NestedSet):
# begin: auto-generated types # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # This code is auto-generated. Do not modify anything in this block.
@@ -84,6 +88,7 @@ class Task(NestedSet):
self.validate_dependencies_for_template_task() self.validate_dependencies_for_template_task()
self.validate_completed_on() self.validate_completed_on()
self.set_default_end_date_if_missing() self.set_default_end_date_if_missing()
self.validate_parent_is_group()
def validate_dates(self): def validate_dates(self):
self.validate_from_to_dates("exp_start_date", "exp_end_date") self.validate_from_to_dates("exp_start_date", "exp_end_date")
@@ -158,20 +163,36 @@ class Task(NestedSet):
def validate_parent_template_task(self): def validate_parent_template_task(self):
if self.parent_task: if self.parent_task:
if not frappe.db.get_value("Task", self.parent_task, "is_template"): if not frappe.db.get_value("Task", self.parent_task, "is_template"):
parent_task_format = f"""<a href="/app/task/{self.parent_task}">{self.parent_task}</a>""" frappe.throw(
frappe.throw(_("Parent Task {0} is not a Template Task").format(parent_task_format)) _("Parent Task {0} is not a Template Task").format(
get_link_to_form("Task", self.parent_task)
)
)
def validate_depends_on_tasks(self): def validate_depends_on_tasks(self):
if self.depends_on: if self.depends_on:
for task in self.depends_on: for task in self.depends_on:
if not frappe.db.get_value("Task", task.task, "is_template"): if not frappe.db.get_value("Task", task.task, "is_template"):
dependent_task_format = f"""<a href="/app/task/{task.task}">{task.task}</a>""" frappe.throw(
frappe.throw(_("Dependent Task {0} is not a Template Task").format(dependent_task_format)) _("Dependent Task {0} is not a Template Task").format(
get_link_to_form("Task", task.task)
)
)
def validate_completed_on(self): def validate_completed_on(self):
if self.completed_on and getdate(self.completed_on) > getdate(): if self.completed_on and getdate(self.completed_on) > getdate():
frappe.throw(_("Completed On cannot be greater than Today")) frappe.throw(_("Completed On cannot be greater than Today"))
def validate_parent_is_group(self):
if self.parent_task:
if not frappe.db.get_value("Task", self.parent_task, "is_group"):
frappe.throw(
_("Parent Task {0} must be a Group Task").format(
get_link_to_form("Task", self.parent_task)
),
ParentIsGroupError,
)
def update_depends_on(self): def update_depends_on(self):
depends_on_tasks = "" depends_on_tasks = ""
for d in self.depends_on: for d in self.depends_on:

View File

@@ -6,7 +6,7 @@ import frappe
from frappe.tests import IntegrationTestCase from frappe.tests import IntegrationTestCase
from frappe.utils import add_days, getdate, nowdate from frappe.utils import add_days, getdate, nowdate
from erpnext.projects.doctype.task.task import CircularReferenceError from erpnext.projects.doctype.task.task import CircularReferenceError, ParentIsGroupError
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
@@ -119,6 +119,20 @@ class TestTask(ERPNextTestSuite):
self.assertEqual(frappe.db.get_value("Task", task.name, "status"), "Overdue") self.assertEqual(frappe.db.get_value("Task", task.name, "status"), "Overdue")
def test_parent_task_must_be_group(self):
parent_task = create_task(
subject="_Test Parent Task Non Group",
is_group=0,
)
child_task = create_task(
subject="_Test Child Task",
parent_task=parent_task.name,
save=False,
)
self.assertRaises(ParentIsGroupError, child_task.save)
def create_task( def create_task(
subject, subject,

View File

@@ -21,6 +21,7 @@ frappe.ui.form.on("Timesheet", {
filters: { filters: {
project: child.project, project: child.project,
status: ["!=", "Cancelled"], status: ["!=", "Cancelled"],
is_group: 0,
}, },
}; };
}; };

View File

@@ -90,7 +90,7 @@ class TimesheetDetail(Document):
) )
self.billing_amount = self.billing_rate * (self.billing_hours or 0) self.billing_amount = self.billing_rate * (self.billing_hours or 0)
self.costing_amount = self.costing_rate * (self.billing_hours or self.hours or 0) self.costing_amount = self.costing_rate * (self.hours or 0)
def validate_dates(self): def validate_dates(self):
"""Validate that to_time is not before from_time.""" """Validate that to_time is not before from_time."""

View File

@@ -2,7 +2,6 @@
// License: GNU General Public License v3. See license.txt // License: GNU General Public License v3. See license.txt
frappe.provide("erpnext.buying"); frappe.provide("erpnext.buying");
// cur_frm.add_fetch('project', 'cost_center', 'cost_center');
erpnext.buying = { erpnext.buying = {
setup_buying_controller: function () { setup_buying_controller: function () {
@@ -11,6 +10,7 @@ erpnext.buying = {
super.setup(); super.setup();
this.toggle_enable_for_stock_uom("allow_to_edit_stock_uom_qty_for_purchase"); this.toggle_enable_for_stock_uom("allow_to_edit_stock_uom_qty_for_purchase");
this.frm.email_field = "contact_email"; this.frm.email_field = "contact_email";
this.frm.add_fetch("project", "cost_center", "cost_center");
} }
onload(doc, cdt, cdn) { onload(doc, cdt, cdn) {
@@ -174,15 +174,13 @@ erpnext.buying = {
shipping_address: this.frm.doc.shipping_address, shipping_address: this.frm.doc.shipping_address,
}, },
callback: (r) => { callback: (r) => {
if (!this.frm.doc.billing_address) if (!r.message) return;
this.frm.set_value("billing_address", r.message.primary_address || "");
if ( this.frm.set_value("billing_address", r.message.primary_address || "");
!frappe.meta.has_field(this.frm.doc.doctype, "shipping_address") ||
this.frm.doc.shipping_address if (frappe.meta.has_field(this.frm.doc.doctype, "shipping_address")) {
) this.frm.set_value("shipping_address", r.message.shipping_address || "");
return; }
this.frm.set_value("shipping_address", r.message.shipping_address || "");
}, },
}); });
erpnext.utils.set_letter_head(this.frm); erpnext.utils.set_letter_head(this.frm);

View File

@@ -1559,7 +1559,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
make_purchase_order() { make_purchase_order() {
let pending_items = this.frm.doc.items.some((item) => { let pending_items = this.frm.doc.items.some((item) => {
let pending_qty = flt(item.stock_qty) - flt(item.ordered_qty); const pending_qty = flt(item.stock_qty) - this.get_ordered_qty(item, this.frm.doc);
return pending_qty > 0; return pending_qty > 0;
}); });
if (!pending_items) { if (!pending_items) {
@@ -1713,8 +1713,10 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
// calculate ordered qty based on packed items in case of product bundle // calculate ordered qty based on packed items in case of product bundle
let packed_items = so.packed_items.filter((pi) => pi.parent_detail_docname == item.name); let packed_items = so.packed_items.filter((pi) => pi.parent_detail_docname == item.name);
if (packed_items && packed_items.length) { if (packed_items && packed_items.length) {
ordered_qty = packed_items.reduce((sum, pi) => sum + flt(pi.ordered_qty), 0); const all_packed_items_ordered = packed_items.every(
ordered_qty = ordered_qty / packed_items.length; (pi) => flt(pi.ordered_qty) >= flt(pi.qty)
);
ordered_qty = all_packed_items_ordered ? item.stock_qty : 0;
} }
} }
return ordered_qty; return ordered_qty;

View File

@@ -1675,7 +1675,8 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t
"pricing_rules", "pricing_rules",
], ],
"postprocess": update_item_for_packed_item, "postprocess": update_item_for_packed_item,
"condition": lambda doc: doc.parent_item in items_to_map, "condition": lambda doc: doc.parent_item in items_to_map
and flt(doc.ordered_qty) < flt(doc.qty),
}, },
}, },
target_doc, target_doc,
@@ -1813,7 +1814,8 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None):
"pricing_rules", "pricing_rules",
], ],
"postprocess": update_item_for_packed_item, "postprocess": update_item_for_packed_item,
"condition": lambda doc: doc.parent_item in items_to_map, "condition": lambda doc: doc.parent_item in items_to_map
and flt(doc.ordered_qty) < flt(doc.qty),
}, },
}, },
target_doc, target_doc,

View File

@@ -1,32 +1,37 @@
{ {
"add_total_row": 1, "add_total_row": 0,
"apply_user_permissions": 1, "add_translate_data": 0,
"creation": "2013-06-07 16:01:16", "columns": [],
"disabled": 0, "creation": "2013-06-07 16:01:16",
"docstatus": 0, "disabled": 0,
"doctype": "Report", "docstatus": 0,
"idx": 3, "doctype": "Report",
"is_standard": "Yes", "filters": [],
"modified": "2018-02-21 01:28:14.928929", "idx": 3,
"modified_by": "Administrator", "is_standard": "Yes",
"module": "Selling", "letterhead": null,
"name": "Quotation Trends", "modified": "2025-11-05 11:55:50.127020",
"owner": "Administrator", "modified_by": "Administrator",
"ref_doctype": "Quotation", "module": "Selling",
"report_name": "Quotation Trends", "name": "Quotation Trends",
"report_type": "Script Report", "owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Quotation",
"report_name": "Quotation Trends",
"report_type": "Script Report",
"roles": [ "roles": [
{ {
"role": "Sales User" "role": "Sales User"
}, },
{ {
"role": "Sales Manager" "role": "Sales Manager"
}, },
{ {
"role": "Maintenance Manager" "role": "Maintenance Manager"
}, },
{ {
"role": "Maintenance User" "role": "Maintenance User"
} }
] ],
} "timeout": 0
}

View File

@@ -1,35 +1,40 @@
{ {
"add_total_row": 1, "add_total_row": 0,
"apply_user_permissions": 1, "add_translate_data": 0,
"creation": "2013-06-13 18:43:30", "columns": [],
"disabled": 0, "creation": "2013-06-13 18:43:30",
"docstatus": 0, "disabled": 0,
"doctype": "Report", "docstatus": 0,
"idx": 3, "doctype": "Report",
"is_standard": "Yes", "filters": [],
"modified": "2018-02-20 08:05:46.191588", "idx": 3,
"modified_by": "Administrator", "is_standard": "Yes",
"module": "Selling", "letterhead": null,
"name": "Sales Order Trends", "modified": "2025-11-05 11:55:50.096303",
"owner": "Administrator", "modified_by": "Administrator",
"ref_doctype": "Sales Order", "module": "Selling",
"report_name": "Sales Order Trends", "name": "Sales Order Trends",
"report_type": "Script Report", "owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Sales Order",
"report_name": "Sales Order Trends",
"report_type": "Script Report",
"roles": [ "roles": [
{ {
"role": "Sales User" "role": "Sales User"
}, },
{ {
"role": "Sales Manager" "role": "Sales Manager"
}, },
{ {
"role": "Maintenance User" "role": "Maintenance User"
}, },
{ {
"role": "Accounts User" "role": "Accounts User"
}, },
{ {
"role": "Stock User" "role": "Stock User"
} }
] ],
} "timeout": 0
}

View File

@@ -53,6 +53,15 @@ frappe.ui.form.on("Company", {
}, },
}; };
}); });
frm.set_query("default_warehouse_for_sales_return", function () {
return {
filters: {
company: frm.doc.name,
is_group: 0,
},
};
});
}, },
company_name: function (frm) { company_name: function (frm) {

View File

@@ -475,6 +475,7 @@ def get_doctypes_to_be_ignored():
"Item Default", "Item Default",
"Customer", "Customer",
"Supplier", "Supplier",
"Department",
] ]
doctypes_to_be_ignored.extend(frappe.get_hooks("company_data_to_be_ignored") or []) doctypes_to_be_ignored.extend(frappe.get_hooks("company_data_to_be_ignored") or [])

View File

@@ -185,9 +185,6 @@ class Item(Document):
if not self.item_name: if not self.item_name:
self.item_name = self.item_code self.item_name = self.item_code
if not strip_html(cstr(self.description)).strip():
self.description = self.item_name
self.validate_uom() self.validate_uom()
self.validate_description() self.validate_description()
self.add_default_uom_in_conversion_factor_table() self.add_default_uom_in_conversion_factor_table()

View File

@@ -812,13 +812,6 @@ class TestItem(IntegrationTestCase):
self.assertTrue(get_data(warehouse="_Test Warehouse - _TC")) self.assertTrue(get_data(warehouse="_Test Warehouse - _TC"))
self.assertTrue(get_data(item_group="All Item Groups")) self.assertTrue(get_data(item_group="All Item Groups"))
def test_empty_description(self):
item = make_item(properties={"description": "<p></p>"})
self.assertEqual(item.description, item.item_name)
item.description = ""
item.save()
self.assertEqual(item.description, item.item_name)
def test_item_type_field_change(self): def test_item_type_field_change(self):
"""Check if critical fields like `is_stock_item`, `has_batch_no` are not changed if transactions exist.""" """Check if critical fields like `is_stock_item`, `has_batch_no` are not changed if transactions exist."""
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice

View File

@@ -83,6 +83,21 @@ class MaterialRequest(BuyingController):
work_order: DF.Link | None work_order: DF.Link | None
# end: auto-generated types # end: auto-generated types
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.status_updater = [
{
"source_dt": "Material Request Item",
"target_dt": "Sales Order Item",
"target_field": "ordered_qty",
"target_parent_dt": "Sales Order",
"target_parent_field": "",
"join_field": "sales_order_item",
"target_ref_field": "stock_qty",
"source_field": "stock_qty",
}
]
def check_if_already_pulled(self): def check_if_already_pulled(self):
pass pass
@@ -206,10 +221,10 @@ class MaterialRequest(BuyingController):
def on_submit(self): def on_submit(self):
self.update_requested_qty_in_production_plan() self.update_requested_qty_in_production_plan()
self.update_requested_qty() self.update_requested_qty()
if self.material_request_type == "Purchase" and frappe.db.exists( if self.material_request_type == "Purchase":
"Budget", {"applicable_on_material_request": 1, "docstatus": 1} self.update_prevdoc_status()
): if frappe.db.exists("Budget", {"applicable_on_material_request": 1, "docstatus": 1}):
self.validate_budget() self.validate_budget()
def before_save(self): def before_save(self):
self.set_status(update=True) self.set_status(update=True)
@@ -887,6 +902,16 @@ def raise_work_orders(material_request):
@frappe.whitelist() @frappe.whitelist()
def create_pick_list(source_name, target_doc=None): def create_pick_list(source_name, target_doc=None):
def update_item(obj, target, source_parent):
qty = (
flt(flt(obj.stock_qty) - flt(obj.ordered_qty)) / target.conversion_factor
if flt(obj.stock_qty) > flt(obj.ordered_qty)
else 0
)
target.qty = qty
target.stock_qty = qty * obj.conversion_factor
target.conversion_factor = obj.conversion_factor
doc = get_mapped_doc( doc = get_mapped_doc(
"Material Request", "Material Request",
source_name, source_name,
@@ -899,6 +924,11 @@ def create_pick_list(source_name, target_doc=None):
"Material Request Item": { "Material Request Item": {
"doctype": "Pick List Item", "doctype": "Pick List Item",
"field_map": {"name": "material_request_item", "stock_qty": "stock_qty"}, "field_map": {"name": "material_request_item", "stock_qty": "stock_qty"},
"postprocess": update_item,
"condition": lambda doc: (
flt(doc.ordered_qty, doc.precision("ordered_qty"))
< flt(doc.stock_qty, doc.precision("ordered_qty"))
),
}, },
}, },
target_doc, target_doc,

View File

@@ -13,6 +13,7 @@ from frappe.utils import flt, today
from erpnext.controllers.accounts_controller import InvalidQtyError from erpnext.controllers.accounts_controller import InvalidQtyError
from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.material_request.material_request import ( from erpnext.stock.doctype.material_request.material_request import (
create_pick_list,
make_in_transit_stock_entry, make_in_transit_stock_entry,
make_purchase_order, make_purchase_order,
make_stock_entry, make_stock_entry,
@@ -933,6 +934,48 @@ class TestMaterialRequest(IntegrationTestCase):
self.assertEqual(mr.per_ordered, 100) self.assertEqual(mr.per_ordered, 100)
self.assertEqual(mr.status, "Ordered") self.assertEqual(mr.status, "Ordered")
def test_material_request_qty_over_sales_order_limit(self):
from erpnext.controllers.status_updater import OverAllowanceError
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
so = make_sales_order()
mr = make_material_request(qty=100, do_not_submit=True)
mr.items[0].sales_order = so.name
mr.items[0].sales_order_item = so.items[0].name
mr.save()
self.assertRaises(OverAllowanceError, mr.submit)
def test_pending_qty_in_pick_list(self):
"""Test for pick list mapped doc qty from partially received Material Request Transfer"""
import json
from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry
mr = make_material_request(material_request_type="Material Transfer")
pl = create_pick_list(mr.name)
pl.save()
pl.locations[0].qty = 5
pl.locations[0].stock_qty = 5
pl.submit()
to_warehouse = create_warehouse("Test To Warehouse")
se_data = create_stock_entry(json.dumps(pl.as_dict()))
se = frappe.get_doc(se_data)
se.items[0].t_warehouse = to_warehouse
se.save()
se.submit()
pl.load_from_db()
self.assertEqual(pl.locations[0].picked_qty, se.items[0].qty)
mr.load_from_db()
self.assertEqual(mr.status, "Partially Received")
pl_for_pending = create_pick_list(mr.name)
self.assertEqual(pl_for_pending.locations[0].qty, 5)
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"):

View File

@@ -1552,8 +1552,8 @@ def update_stock_entry_items_with_no_reference(pick_list, stock_entry):
def update_common_item_properties(item, location): def update_common_item_properties(item, location):
item.item_code = location.item_code item.item_code = location.item_code
item.s_warehouse = location.warehouse item.s_warehouse = location.warehouse
item.qty = location.picked_qty * location.conversion_factor
item.transfer_qty = location.picked_qty item.transfer_qty = location.picked_qty
item.qty = location.qty
item.uom = location.uom item.uom = location.uom
item.conversion_factor = location.conversion_factor item.conversion_factor = location.conversion_factor
item.stock_uom = location.stock_uom item.stock_uom = location.stock_uom

View File

@@ -283,9 +283,11 @@ class QualityInspection(Document):
def min_max_criteria_passed(self, reading): def min_max_criteria_passed(self, reading):
"""Determine whether all readings fall in the acceptable range.""" """Determine whether all readings fall in the acceptable range."""
has_reading = False
for i in range(1, 11): for i in range(1, 11):
reading_value = reading.get("reading_" + str(i)) reading_value = reading.get("reading_" + str(i))
if reading_value is not None and reading_value.strip(): if reading_value is not None and reading_value.strip():
has_reading = True
result = ( result = (
flt(reading.get("min_value")) flt(reading.get("min_value"))
<= parse_float(reading_value) <= parse_float(reading_value)
@@ -293,7 +295,7 @@ class QualityInspection(Document):
) )
if not result: if not result:
return False return False
return True return has_reading
def set_status_based_on_acceptance_formula(self, reading): def set_status_based_on_acceptance_formula(self, reading):
if not reading.acceptance_formula: if not reading.acceptance_formula:

View File

@@ -292,7 +292,7 @@ def create_quality_inspection(**args):
if not args.readings: if not args.readings:
create_quality_inspection_parameter("Size") create_quality_inspection_parameter("Size")
readings = {"specification": "Size", "min_value": 0, "max_value": 10} readings = {"specification": "Size", "min_value": 0, "max_value": 10, "reading_1": "5"}
if args.status == "Rejected": if args.status == "Rejected":
readings["reading_1"] = "12" # status is auto set in child on save readings["reading_1"] = "12" # status is auto set in child on save
else: else:

View File

@@ -1300,11 +1300,25 @@ class SerialandBatchBundle(Document):
def before_submit(self): def before_submit(self):
self.validate_serial_and_batch_data() self.validate_serial_and_batch_data()
self.validate_serial_and_batch_no_for_returned() self.validate_serial_and_batch_no_for_returned()
self.set_child_details()
self.set_source_document_no() self.set_source_document_no()
def on_submit(self): def on_submit(self):
self.validate_serial_nos_inventory() self.validate_serial_nos_inventory()
def set_child_details(self):
for row in self.entries:
for field in [
"warehouse",
"posting_datetime",
"voucher_type",
"voucher_no",
"voucher_detail_no",
"type_of_transaction",
]:
if not row.get(field) or row.get(field) != self.get(field):
row.set(field, self.get(field))
def set_source_document_no(self): def set_source_document_no(self):
if self.flags.ignore_validate_serial_batch: if self.flags.ignore_validate_serial_batch:
return return
@@ -3033,7 +3047,3 @@ def get_stock_reco_details(voucher_detail_no):
], ],
as_dict=True, as_dict=True,
) )
def on_doctype_update():
frappe.db.add_index("Serial and Batch Bundle", ["item_code", "warehouse", "posting_datetime", "creation"])

View File

@@ -19,7 +19,13 @@
"is_outward", "is_outward",
"stock_queue", "stock_queue",
"section_break_gmim", "section_break_gmim",
"reference_for_reservation" "reference_for_reservation",
"voucher_type",
"voucher_no",
"column_break_eykr",
"posting_datetime",
"type_of_transaction",
"voucher_detail_no"
], ],
"fields": [ "fields": [
{ {
@@ -73,6 +79,7 @@
"fieldtype": "Float", "fieldtype": "Float",
"label": "Valuation Rate", "label": "Valuation Rate",
"no_copy": 1, "no_copy": 1,
"non_negative": 1,
"read_only": 1, "read_only": 1,
"read_only_depends_on": "eval:parent.type_of_transaction == \"Outward\"" "read_only_depends_on": "eval:parent.type_of_transaction == \"Outward\""
}, },
@@ -134,18 +141,55 @@
"no_copy": 1, "no_copy": 1,
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1
},
{
"fieldname": "posting_datetime",
"fieldtype": "Datetime",
"label": "Posting Datetime",
"read_only": 1
},
{
"fieldname": "voucher_type",
"fieldtype": "Data",
"label": "Voucher Type",
"read_only": 1
},
{
"fieldname": "voucher_no",
"fieldtype": "Data",
"label": "Voucher No",
"read_only": 1
},
{
"fieldname": "voucher_detail_no",
"fieldtype": "Data",
"label": "Voucher Detail No",
"read_only": 1,
"search_index": 1
},
{
"fieldname": "type_of_transaction",
"fieldtype": "Data",
"label": "Type of Transaction",
"read_only": 1,
"search_index": 1
},
{
"fieldname": "column_break_eykr",
"fieldtype": "Column Break"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-01-02 21:51:52.528916", "modified": "2025-11-09 23:28:35.191959",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Serial and Batch Entry", "name": "Serial and Batch Entry",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []
} }

View File

@@ -1,7 +1,7 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt # For license information, please see license.txt
# import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
@@ -22,12 +22,21 @@ class SerialandBatchEntry(Document):
parent: DF.Data parent: DF.Data
parentfield: DF.Data parentfield: DF.Data
parenttype: DF.Data parenttype: DF.Data
posting_datetime: DF.Datetime | None
qty: DF.Float qty: DF.Float
reference_for_reservation: DF.Data | None reference_for_reservation: DF.Data | None
serial_no: DF.Link | None serial_no: DF.Link | None
stock_queue: DF.SmallText | None stock_queue: DF.SmallText | None
stock_value_difference: DF.Float stock_value_difference: DF.Float
type_of_transaction: DF.Data | None
voucher_detail_no: DF.Data | None
voucher_no: DF.Data | None
voucher_type: DF.Data | None
warehouse: DF.Link | None warehouse: DF.Link | None
# end: auto-generated types # end: auto-generated types
pass pass
def on_doctype_update():
frappe.db.add_index("Serial and Batch Entry", ["warehouse", "batch_no", "posting_datetime"])

View File

@@ -1098,6 +1098,21 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
}; };
}); });
this.frm.set_query("uom", "items", function (doc, cdt, cdn) {
let row = locals[cdt][cdn];
if (!row.item_code) {
return;
}
return {
query: "erpnext.controllers.queries.get_item_uom_query",
filters: {
item_code: row.item_code,
},
};
});
this.frm.fields_dict.items.grid.get_field("expense_account").get_query = function () { this.frm.fields_dict.items.grid.get_field("expense_account").get_query = function () {
if (erpnext.is_perpetual_inventory_enabled(me.frm.doc.company)) { if (erpnext.is_perpetual_inventory_enabled(me.frm.doc.company)) {
return { return {

View File

@@ -875,43 +875,45 @@ class StockEntry(StockController, SubcontractingInwardController):
and target to ensure a meaningful transfer is occurring. and target to ensure a meaningful transfer is occurring.
Raises: Raises:
frappe.ValidationError: If warehouses are same and no inventory dimensions differ frappe.ValidationError: If warehouses are same and no inventory dimensions differ
""" """
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
inventory_dimensions = get_inventory_dimensions() if frappe.get_single_value("Stock Settings", "validate_material_transfer_warehouses"):
if self.purpose == "Material Transfer": from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
for item in self.items:
if cstr(item.s_warehouse) == cstr(item.t_warehouse): inventory_dimensions = get_inventory_dimensions()
if not inventory_dimensions: if self.purpose == "Material Transfer":
frappe.throw( for item in self.items:
_( if cstr(item.s_warehouse) == cstr(item.t_warehouse):
"Row #{0}: Source and Target Warehouse cannot be the same for Material Transfer" if not inventory_dimensions:
).format(item.idx),
title=_("Invalid Source and Target Warehouse"),
)
else:
difference_found = False
for dimension in inventory_dimensions:
fieldname = (
dimension.source_fieldname
if dimension.source_fieldname.startswith("to_")
else f"to_{dimension.source_fieldname}"
)
if (
item.get(dimension.source_fieldname)
and item.get(fieldname)
and item.get(dimension.source_fieldname) != item.get(fieldname)
):
difference_found = True
break
if not difference_found:
frappe.throw( frappe.throw(
_( _(
"Row #{0}: Source, Target Warehouse and Inventory Dimensions cannot be the exact same for Material Transfer" "Row #{0}: Source and Target Warehouse cannot be the same for Material Transfer"
).format(item.idx), ).format(item.idx),
title=_("Invalid Source and Target Warehouse"), title=_("Invalid Source and Target Warehouse"),
) )
else:
difference_found = False
for dimension in inventory_dimensions:
fieldname = (
dimension.source_fieldname
if dimension.source_fieldname.startswith("to_")
else f"to_{dimension.source_fieldname}"
)
if (
item.get(dimension.source_fieldname)
and item.get(fieldname)
and item.get(dimension.source_fieldname) != item.get(fieldname)
):
difference_found = True
break
if not difference_found:
frappe.throw(
_(
"Row #{0}: Source, Target Warehouse and Inventory Dimensions cannot be the exact same for Material Transfer"
).format(item.idx),
title=_("Invalid Source and Target Warehouse"),
)
def get_matched_items(self, item_code): def get_matched_items(self, item_code):
for row in self.items: for row in self.items:

View File

@@ -36,6 +36,7 @@
"show_barcode_field", "show_barcode_field",
"clean_description_html", "clean_description_html",
"allow_internal_transfer_at_arms_length_price", "allow_internal_transfer_at_arms_length_price",
"validate_material_transfer_warehouses",
"serial_and_batch_item_settings_tab", "serial_and_batch_item_settings_tab",
"section_break_7", "section_break_7",
"allow_existing_serial_no", "allow_existing_serial_no",
@@ -538,6 +539,13 @@
"label": "Update Price List Based On", "label": "Update Price List Based On",
"mandatory_depends_on": "eval: doc.auto_insert_price_list_rate_if_missing", "mandatory_depends_on": "eval: doc.auto_insert_price_list_rate_if_missing",
"options": "Rate\nPrice List Rate" "options": "Rate\nPrice List Rate"
},
{
"default": "0",
"description": "If enabled, the source and target warehouse in the Material Transfer Stock Entry must be different else an error will be thrown. If inventory dimensions are present, same source and target warehouse can be allowed but atleast any one of the inventory dimension fields must be different.",
"fieldname": "validate_material_transfer_warehouses",
"fieldtype": "Check",
"label": "Validate Material Transfer Warehouses"
} }
], ],
"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-10-23 13:16:10.527190", "modified": "2025-11-11 11:35:39.864923",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Settings", "name": "Stock Settings",

View File

@@ -68,6 +68,7 @@ class StockSettings(Document):
update_price_list_based_on: DF.Literal["Rate", "Price List Rate"] update_price_list_based_on: DF.Literal["Rate", "Price List Rate"]
use_naming_series: DF.Check use_naming_series: DF.Check
use_serial_batch_fields: DF.Check use_serial_batch_fields: DF.Check
validate_material_transfer_warehouses: DF.Check
valuation_method: DF.Literal["FIFO", "Moving Average", "LIFO"] valuation_method: DF.Literal["FIFO", "Moving Average", "LIFO"]
# end: auto-generated types # end: auto-generated types
@@ -190,26 +191,6 @@ class StockSettings(Document):
) )
) )
else:
# Don't allow if there are negative stock
from frappe.query_builder.functions import Round
precision = frappe.db.get_single_value("System Settings", "float_precision") or 3
bin = frappe.qb.DocType("Bin")
bin_with_negative_stock = (
frappe.qb.from_(bin)
.select(bin.name)
.where(Round(bin.actual_qty, precision) < 0)
.limit(1)
).run()
if bin_with_negative_stock:
frappe.throw(
_("As there are negative stock, you can not enable {0}.").format(
frappe.bold(_("Stock Reservation"))
)
)
# Enable -> Disable # Enable -> Disable
else: else:
# Don't allow if there are open Stock Reservation Entries # Don't allow if there are open Stock Reservation Entries

View File

@@ -1,32 +1,37 @@
{ {
"add_total_row": 1, "add_total_row": 0,
"apply_user_permissions": 1, "add_translate_data": 0,
"creation": "2013-06-13 18:42:11", "columns": [],
"disabled": 0, "creation": "2013-06-13 18:42:11",
"docstatus": 0, "disabled": 0,
"doctype": "Report", "docstatus": 0,
"idx": 3, "doctype": "Report",
"is_standard": "Yes", "filters": [],
"modified": "2018-02-21 01:28:47.049042", "idx": 3,
"modified_by": "Administrator", "is_standard": "Yes",
"module": "Stock", "letterhead": null,
"name": "Delivery Note Trends", "modified": "2025-11-05 11:55:50.114173",
"owner": "Administrator", "modified_by": "Administrator",
"ref_doctype": "Delivery Note", "module": "Stock",
"report_name": "Delivery Note Trends", "name": "Delivery Note Trends",
"report_type": "Script Report", "owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Delivery Note",
"report_name": "Delivery Note Trends",
"report_type": "Script Report",
"roles": [ "roles": [
{ {
"role": "Stock User" "role": "Stock User"
}, },
{ {
"role": "Stock Manager" "role": "Stock Manager"
}, },
{ {
"role": "Sales User" "role": "Sales User"
}, },
{ {
"role": "Accounts User" "role": "Accounts User"
} }
] ],
} "timeout": 0
}

View File

@@ -1,32 +1,37 @@
{ {
"add_total_row": 1, "add_total_row": 0,
"apply_user_permissions": 1, "add_translate_data": 0,
"creation": "2013-06-13 18:45:44", "columns": [],
"disabled": 0, "creation": "2013-06-13 18:45:44",
"docstatus": 0, "disabled": 0,
"doctype": "Report", "docstatus": 0,
"idx": 3, "doctype": "Report",
"is_standard": "Yes", "filters": [],
"modified": "2018-02-21 01:28:22.682161", "idx": 3,
"modified_by": "Administrator", "is_standard": "Yes",
"module": "Stock", "letterhead": null,
"name": "Purchase Receipt Trends", "modified": "2025-11-05 11:55:49.983683",
"owner": "Administrator", "modified_by": "Administrator",
"ref_doctype": "Purchase Receipt", "module": "Stock",
"report_name": "Purchase Receipt Trends", "name": "Purchase Receipt Trends",
"report_type": "Script Report", "owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Purchase Receipt",
"report_name": "Purchase Receipt Trends",
"report_type": "Script Report",
"roles": [ "roles": [
{ {
"role": "Stock Manager" "role": "Stock Manager"
}, },
{ {
"role": "Stock User" "role": "Stock User"
}, },
{ {
"role": "Purchase User" "role": "Purchase User"
}, },
{ {
"role": "Accounts User" "role": "Accounts User"
} }
] ],
} "timeout": 0
}

View File

@@ -25,7 +25,7 @@ frappe.query_reports["Stock Qty vs Serial No Count"] = {
reqd: 1, reqd: 1,
}, },
{ {
fieldname: "show_disables_items", fieldname: "show_disabled_items",
label: __("Show Disabled Items"), label: __("Show Disabled Items"),
fieldtype: "Check", fieldtype: "Check",
}, },

View File

@@ -9,7 +9,7 @@ from frappe import _
def execute(filters=None): def execute(filters=None):
validate_warehouse(filters) validate_warehouse(filters)
columns = get_columns() columns = get_columns()
data = get_data(filters.warehouse, filters.show_disables_items) data = get_data(filters.warehouse, filters.show_disabled_items)
return columns, data return columns, data
@@ -38,9 +38,9 @@ def get_columns():
return columns return columns
def get_data(warehouse, show_disables_items): def get_data(warehouse, show_disabled_items):
filters = {"has_serial_no": True} filters = {"has_serial_no": True}
if not show_disables_items: if not show_disabled_items:
filters["disabled"] = False filters["disabled"] = False
serial_item_list = frappe.get_all( serial_item_list = frappe.get_all(
"Item", "Item",

View File

@@ -2,7 +2,7 @@ from collections import defaultdict
import frappe import frappe
from frappe import _, bold from frappe import _, bold
from frappe.model.naming import make_autoname from frappe.model.naming import NamingSeries, make_autoname, parse_naming_series
from frappe.query_builder import Case from frappe.query_builder import Case
from frappe.query_builder.functions import CombineDatetime, Sum, Timestamp from frappe.query_builder.functions import CombineDatetime, Sum, Timestamp
from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, now, nowtime, today from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, now, nowtime, today
@@ -742,7 +742,7 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
"Serial and Batch Bundle", self.sle.serial_and_batch_bundle, "total_amount" "Serial and Batch Bundle", self.sle.serial_and_batch_bundle, "total_amount"
) )
else: else:
entries = self.get_batch_no_ledgers() entries = self.get_batch_stock_before_date()
self.stock_value_change = 0.0 self.stock_value_change = 0.0
self.batch_avg_rate = defaultdict(float) self.batch_avg_rate = defaultdict(float)
self.available_qty = defaultdict(float) self.available_qty = defaultdict(float)
@@ -751,8 +751,9 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
for ledger in entries: for ledger in entries:
self.stock_value_differece[ledger.batch_no] += flt(ledger.incoming_rate) self.stock_value_differece[ledger.batch_no] += flt(ledger.incoming_rate)
self.available_qty[ledger.batch_no] += flt(ledger.qty) self.available_qty[ledger.batch_no] += flt(ledger.qty)
self.total_qty[ledger.batch_no] += flt(ledger.qty)
entries = self.get_batch_wise_total_available_qty() entries = self.get_batch_stock_after_date()
for row in entries: for row in entries:
self.total_qty[row.batch_no] += flt(row.total_qty) self.total_qty[row.batch_no] += flt(row.total_qty)
@@ -760,29 +761,33 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
self.calculate_avg_rate_for_non_batchwise_valuation() self.calculate_avg_rate_for_non_batchwise_valuation()
self.set_stock_value_difference() self.set_stock_value_difference()
def get_batch_wise_total_available_qty(self) -> list[dict]: def get_batch_stock_after_date(self) -> list[dict]:
# Get total qty of each batch no from Serial and Batch Bundle without checking time condition # Get total qty of each batch no from Serial and Batch Bundle without checking time condition
if not self.batchwise_valuation_batches: if not self.batchwise_valuation_batches:
return [] return []
parent = frappe.qb.DocType("Serial and Batch Bundle")
child = frappe.qb.DocType("Serial and Batch Entry") child = frappe.qb.DocType("Serial and Batch Entry")
timestamp_condition = ""
if self.sle.posting_datetime:
timestamp_condition = child.posting_datetime > self.sle.posting_datetime
if self.sle.creation:
timestamp_condition |= (child.posting_datetime == self.sle.posting_datetime) & (
child.creation > self.sle.creation
)
query = ( query = (
frappe.qb.from_(parent) frappe.qb.from_(child)
.inner_join(child)
.on(parent.name == child.parent)
.select( .select(
child.batch_no, child.batch_no,
Sum(child.qty).as_("total_qty"), Sum(child.qty).as_("total_qty"),
) )
.where( .where(
(parent.warehouse == self.sle.warehouse) (child.warehouse == self.sle.warehouse)
& (parent.item_code == self.sle.item_code)
& (child.batch_no.isin(self.batchwise_valuation_batches)) & (child.batch_no.isin(self.batchwise_valuation_batches))
& (parent.docstatus == 1) & (child.docstatus == 1)
& (parent.is_cancelled == 0) & (child.type_of_transaction.isin(["Inward", "Outward"]))
& (parent.type_of_transaction.isin(["Inward", "Outward"]))
) )
.for_update() .for_update()
.groupby(child.batch_no) .groupby(child.batch_no)
@@ -790,47 +795,45 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
# Important to exclude the current voucher detail no / voucher no to calculate the correct stock value difference # Important to exclude the current voucher detail no / voucher no to calculate the correct stock value difference
if self.sle.voucher_detail_no: if self.sle.voucher_detail_no:
query = query.where(parent.voucher_detail_no != self.sle.voucher_detail_no) query = query.where(child.voucher_detail_no != self.sle.voucher_detail_no)
elif self.sle.voucher_no: elif self.sle.voucher_no:
query = query.where(parent.voucher_no != self.sle.voucher_no) query = query.where(child.voucher_no != self.sle.voucher_no)
query = query.where(parent.voucher_type != "Pick List") query = query.where(child.voucher_type != "Pick List")
if timestamp_condition:
query = query.where(timestamp_condition)
return query.run(as_dict=True) return query.run(as_dict=True)
def get_batch_no_ledgers(self) -> list[dict]: def get_batch_stock_before_date(self) -> list[dict]:
# Get batch wise stock value difference from Serial and Batch Bundle considering time condition # Get batch wise stock value difference from Serial and Batch Bundle considering time condition
if not self.batchwise_valuation_batches: if not self.batchwise_valuation_batches:
return [] return []
parent = frappe.qb.DocType("Serial and Batch Bundle")
child = frappe.qb.DocType("Serial and Batch Entry") child = frappe.qb.DocType("Serial and Batch Entry")
timestamp_condition = "" timestamp_condition = ""
if self.sle.posting_datetime: if self.sle.posting_datetime:
timestamp_condition = parent.posting_datetime < self.sle.posting_datetime timestamp_condition = child.posting_datetime < self.sle.posting_datetime
if self.sle.creation: if self.sle.creation:
timestamp_condition |= (parent.posting_datetime == self.sle.posting_datetime) & ( timestamp_condition |= (child.posting_datetime == self.sle.posting_datetime) & (
parent.creation < self.sle.creation child.creation < self.sle.creation
) )
query = ( query = (
frappe.qb.from_(parent) frappe.qb.from_(child)
.inner_join(child)
.on(parent.name == child.parent)
.select( .select(
child.batch_no, child.batch_no,
Sum(child.stock_value_difference).as_("incoming_rate"), Sum(child.stock_value_difference).as_("incoming_rate"),
Sum(child.qty).as_("qty"), Sum(child.qty).as_("qty"),
) )
.where( .where(
(parent.warehouse == self.sle.warehouse) (child.warehouse == self.sle.warehouse)
& (parent.item_code == self.sle.item_code)
& (child.batch_no.isin(self.batchwise_valuation_batches)) & (child.batch_no.isin(self.batchwise_valuation_batches))
& (parent.docstatus == 1) & (child.docstatus == 1)
& (parent.is_cancelled == 0) & (child.type_of_transaction.isin(["Inward", "Outward"]))
& (parent.type_of_transaction.isin(["Inward", "Outward"]))
) )
.for_update() .for_update()
.groupby(child.batch_no) .groupby(child.batch_no)
@@ -838,11 +841,11 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
# Important to exclude the current voucher detail no / voucher no to calculate the correct stock value difference # Important to exclude the current voucher detail no / voucher no to calculate the correct stock value difference
if self.sle.voucher_detail_no: if self.sle.voucher_detail_no:
query = query.where(parent.voucher_detail_no != self.sle.voucher_detail_no) query = query.where(child.voucher_detail_no != self.sle.voucher_detail_no)
elif self.sle.voucher_no: elif self.sle.voucher_no:
query = query.where(parent.voucher_no != self.sle.voucher_no) query = query.where(child.voucher_no != self.sle.voucher_no)
query = query.where(parent.voucher_type != "Pick List") query = query.where(child.voucher_type != "Pick List")
if timestamp_condition: if timestamp_condition:
query = query.where(timestamp_condition) query = query.where(timestamp_condition)
@@ -1371,6 +1374,12 @@ class SerialBatchCreation:
if self.get("voucher_type"): if self.get("voucher_type"):
voucher_type = self.get("voucher_type") voucher_type = self.get("voucher_type")
obj = NamingSeries(self.serial_no_series)
current_value = obj.get_current_value()
def get_series(partial_series, digits):
return f"{current_value:0{digits}d}"
posting_date = frappe.db.get_value( posting_date = frappe.db.get_value(
voucher_type, voucher_type,
voucher_no, voucher_no,
@@ -1378,7 +1387,9 @@ class SerialBatchCreation:
) )
for _i in range(abs(cint(self.actual_qty))): for _i in range(abs(cint(self.actual_qty))):
serial_no = make_autoname(self.serial_no_series, "Serial No") current_value += 1
serial_no = parse_naming_series(self.serial_no_series, number_generator=get_series)
sr_nos.append(serial_no) sr_nos.append(serial_no)
serial_nos_details.append( serial_nos_details.append(
( (
@@ -1423,6 +1434,8 @@ class SerialBatchCreation:
frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details)) frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
obj.update_counter(current_value)
return sr_nos return sr_nos

View File

@@ -114,7 +114,6 @@
"fieldtype": "Text Editor", "fieldtype": "Text Editor",
"label": "Description", "label": "Description",
"print_width": "300px", "print_width": "300px",
"reqd": 1,
"width": "300px" "width": "300px"
}, },
{ {
@@ -415,7 +414,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-03-02 17:05:28.386492", "modified": "2025-08-10 22:37:39.863628",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Subcontracting", "module": "Subcontracting",
"name": "Subcontracting Order Item", "name": "Subcontracting Order Item",
@@ -429,4 +428,4 @@
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@@ -19,7 +19,7 @@ class SubcontractingOrderItem(Document):
bom: DF.Link bom: DF.Link
conversion_factor: DF.Float conversion_factor: DF.Float
cost_center: DF.Link | None cost_center: DF.Link | None
description: DF.TextEditor description: DF.TextEditor | None
expected_delivery_date: DF.Date | None expected_delivery_date: DF.Date | None
expense_account: DF.Link | None expense_account: DF.Link | None
image: DF.Attach | None image: DF.Attach | None

View File

@@ -21,8 +21,8 @@
"list_title": "", "list_title": "",
"login_required": 1, "login_required": 1,
"max_attachment_size": 0, "max_attachment_size": 0,
"modified": "2024-01-24 10:28:35.026064", "modified": "2025-10-27 21:05:21.125639",
"modified_by": "rohitw1991@gmail.com", "modified_by": "Administrator",
"module": "Utilities", "module": "Utilities",
"name": "addresses", "name": "addresses",
"owner": "Administrator", "owner": "Administrator",
@@ -113,7 +113,7 @@
"fieldname": "state", "fieldname": "state",
"fieldtype": "Data", "fieldtype": "Data",
"hidden": 0, "hidden": 0,
"label": "State", "label": "State/Province",
"max_length": 0, "max_length": 0,
"max_value": 0, "max_value": 0,
"read_only": 0, "read_only": 0,
@@ -197,4 +197,4 @@
"show_in_filter": 0 "show_in_filter": 0
} }
] ]
} }