Merge branch 'develop' into fix-no-account-in-gl-entry

This commit is contained in:
Mihir Kandoi
2025-07-28 14:50:26 +05:30
committed by GitHub
63 changed files with 54686 additions and 72862 deletions

View File

@@ -42,6 +42,7 @@
"show_payment_schedule_in_print",
"item_price_settings_section",
"maintain_same_internal_transaction_rate",
"fetch_valuation_rate_for_internal_transaction",
"column_break_feyo",
"maintain_same_rate_action",
"role_to_override_stop_action",
@@ -644,6 +645,12 @@
"fieldname": "drop_ar_procedures",
"fieldtype": "Button",
"label": "Drop Procedures"
},
{
"default": "0",
"fieldname": "fetch_valuation_rate_for_internal_transaction",
"fieldtype": "Check",
"label": "Fetch Valuation Rate for Internal Transaction"
}
],
"grid_page_length": 50,
@@ -652,7 +659,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-06-23 15:55:33.346398",
"modified": "2025-07-18 13:56:47.192437",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -49,6 +49,7 @@ class AccountsSettings(Document):
enable_immutable_ledger: DF.Check
enable_party_matching: DF.Check
exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"]
fetch_valuation_rate_for_internal_transaction: DF.Check
frozen_accounts_modifier: DF.Link | None
general_ledger_remarks_length: DF.Int
ignore_account_closing_balance: DF.Check

View File

@@ -462,4 +462,9 @@ def rename_temporarily_named_docs(doctype):
f"UPDATE `tab{doctype}` SET name = %s, to_rename = 0, modified = %s where name = %s",
(newname, now(), oldname),
)
for hook_type in ("on_gle_rename", "on_sle_rename"):
for hook in frappe.get_hooks(hook_type):
frappe.call(hook, newname=newname, oldname=oldname)
frappe.db.commit()

View File

@@ -1145,9 +1145,7 @@ class JournalEntry(AccountsController):
def set_print_format_fields(self):
bank_amount = party_amount = total_amount = 0.0
currency = (
bank_account_currency
) = party_account_currency = pay_to_recd_from = self.pay_to_recd_from = None
currency = bank_account_currency = party_account_currency = pay_to_recd_from = None
party_type = None
for d in self.get("accounts"):
if d.party_type in ["Customer", "Supplier"] and d.party:

View File

@@ -579,6 +579,18 @@ class TestJournalEntry(IntegrationTestCase):
]
self.assertEqual(expected, actual)
def test_pay_to_recd_from(self):
jv = make_journal_entry("_Test Cash - _TC", "_Test Bank - _TC", 100, save=False)
jv.pay_to_recd_from = "_Test Receiver"
jv.save()
self.assertEqual(jv.pay_to_recd_from, "_Test Receiver")
jv.pay_to_recd_from = "_Test Receiver 2"
jv.save()
jv.submit()
self.assertEqual(jv.pay_to_recd_from, "_Test Receiver 2")
def make_journal_entry(
account1,

View File

@@ -30,24 +30,6 @@
"project",
"dimension_col_break",
"cost_center",
"customer_po_details",
"po_no",
"column_break_23",
"po_date",
"address_and_contact",
"customer_address",
"address_display",
"contact_person",
"contact_display",
"contact_mobile",
"contact_email",
"territory",
"col_break4",
"shipping_address_name",
"shipping_address",
"company_address",
"company_address_display",
"company_contact_person",
"currency_and_price_list",
"currency",
"conversion_rate",
@@ -91,14 +73,6 @@
"base_total_taxes_and_charges",
"column_break_47",
"total_taxes_and_charges",
"loyalty_points_redemption",
"loyalty_points",
"loyalty_amount",
"redeem_loyalty_points",
"column_break_77",
"loyalty_program",
"loyalty_redemption_account",
"loyalty_redemption_cost_center",
"section_break_49",
"coupon_code",
"apply_discount_on",
@@ -118,13 +92,7 @@
"in_words",
"total_advance",
"outstanding_amount",
"advances_section",
"allocate_advances_automatically",
"get_advances",
"advances",
"payment_schedule_section",
"payment_terms_template",
"payment_schedule",
"payments_tab",
"payments_section",
"cash_bank_account",
"payments",
@@ -137,6 +105,10 @@
"column_break_90",
"change_amount",
"account_for_change_amount",
"advances_section",
"allocate_advances_automatically",
"get_advances",
"advances",
"column_break4",
"write_off_amount",
"base_write_off_amount",
@@ -144,9 +116,41 @@
"column_break_74",
"write_off_account",
"write_off_cost_center",
"loyalty_points_redemption",
"loyalty_points",
"loyalty_amount",
"redeem_loyalty_points",
"column_break_77",
"loyalty_program",
"loyalty_redemption_account",
"loyalty_redemption_cost_center",
"contact_and_address_tab",
"address_and_contact",
"customer_address",
"address_display",
"contact_person",
"contact_display",
"contact_mobile",
"contact_email",
"territory",
"col_break4",
"shipping_address_name",
"shipping_address",
"company_address",
"company_address_display",
"company_contact_person",
"terms_tab",
"payment_schedule_section",
"payment_terms_template",
"payment_schedule",
"terms_section_break",
"tc_name",
"terms",
"more_info_tab",
"customer_po_details",
"po_no",
"column_break_23",
"po_date",
"edit_printing_settings",
"letter_head",
"group_same_items",
@@ -398,7 +402,6 @@
"label": "Customer's Purchase Order Date"
},
{
"collapsible": 1,
"fieldname": "address_and_contact",
"fieldtype": "Section Break",
"label": "Address and Contact"
@@ -1050,7 +1053,6 @@
"print_hide": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:(!doc.is_pos && !doc.is_return)",
"fieldname": "payment_schedule_section",
"fieldtype": "Section Break",
@@ -1130,8 +1132,10 @@
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "section_break_88",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"label": "Changes"
},
{
"depends_on": "is_pos",
@@ -1218,7 +1222,6 @@
"print_hide": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "terms",
"fieldname": "terms_section_break",
"fieldtype": "Section Break",
@@ -1570,12 +1573,32 @@
"label": "Company Contact Person",
"options": "Contact",
"print_hide": 1
},
{
"fieldname": "payments_tab",
"fieldtype": "Tab Break",
"label": "Payments"
},
{
"fieldname": "contact_and_address_tab",
"fieldtype": "Tab Break",
"label": "Address & Contact"
},
{
"fieldname": "terms_tab",
"fieldtype": "Tab Break",
"label": "Terms"
},
{
"fieldname": "more_info_tab",
"fieldtype": "Tab Break",
"label": "More Info"
}
],
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2025-07-17 16:51:40.886083",
"modified": "2025-07-18 16:50:30.516162",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",

View File

@@ -86,7 +86,7 @@ def set_gl_entries_by_account(dimension_list, filters, account, gl_entries_by_ac
"finance_book": cstr(filters.get("finance_book")),
}
gl_filters["dimensions"] = set(dimension_list)
gl_filters["dimensions"] = tuple(set(dimension_list))
if filters.get("include_default_book_entries"):
gl_filters["company_fb"] = frappe.get_cached_value("Company", filters.company, "default_finance_book")
@@ -179,7 +179,7 @@ def accumulate_values_into_parents(accounts, accounts_by_name, dimension_list):
def get_condition(dimension):
conditions = []
conditions.append(f"{frappe.scrub(dimension)} in (%(dimensions)s)")
conditions.append(f"{frappe.scrub(dimension)} in %(dimensions)s")
return " and {}".format(" and ".join(conditions)) if conditions else ""

View File

@@ -210,7 +210,7 @@ def get_gl_entries(filters, accounting_dimensions):
)
if filters.get("presentation_currency"):
return convert_to_presentation_currency(gl_entries, currency_map)
return convert_to_presentation_currency(gl_entries, currency_map, filters)
else:
return gl_entries

View File

@@ -178,7 +178,7 @@ def get_columns(additional_table_columns, filters):
"fieldname": "invoice",
"fieldtype": "Link",
"options": "Purchase Invoice",
"width": 120,
"width": 150,
},
{"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 120},
]
@@ -310,8 +310,8 @@ def apply_conditions(query, pi, pii, filters):
def get_items(filters, additional_table_columns):
doctype = "Purchase Invoice"
pi = frappe.qb.DocType(doctype)
pii = frappe.qb.DocType(f"{doctype} Item")
pi = frappe.qb.DocType(doctype).as_("invoice")
pii = frappe.qb.DocType(f"{doctype} Item").as_("invoice_item")
Item = frappe.qb.DocType("Item")
query = (
frappe.qb.from_(pi)
@@ -331,6 +331,7 @@ def get_items(filters, additional_table_columns):
pi.unrealized_profit_loss_account,
pii.item_code,
pii.description,
pii.item_name,
pii.item_group,
pii.item_name.as_("pi_item_name"),
pii.item_group.as_("pi_item_group"),
@@ -374,7 +375,7 @@ def get_items(filters, additional_table_columns):
if match_conditions:
query += " and " + match_conditions
query = apply_order_by_conditions(query, pi, pii, filters)
query = apply_order_by_conditions(query, filters)
return frappe.db.sql(query, params, as_dict=True)

View File

@@ -199,7 +199,7 @@ def get_columns(additional_table_columns, filters):
"fieldname": "invoice",
"fieldtype": "Link",
"options": "Sales Invoice",
"width": 120,
"width": 150,
},
{"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 120},
]
@@ -395,15 +395,15 @@ def apply_conditions(query, si, sii, sip, filters, additional_conditions=None):
return query
def apply_order_by_conditions(query, si, ii, filters):
def apply_order_by_conditions(query, filters):
if not filters.get("group_by"):
query += f" order by {si.posting_date} desc, {ii.item_group} desc"
query += "order by invoice.posting_date desc, invoice_item.item_group desc"
elif filters.get("group_by") == "Invoice":
query += f" order by {ii.parent} desc"
query += "order by invoice_item.parent desc"
elif filters.get("group_by") == "Item":
query += f" order by {ii.item_code}"
query += "order by invoice_item.item_code"
elif filters.get("group_by") == "Item Group":
query += f" order by {ii.item_group}"
query += "order by invoice_item.item_group"
elif filters.get("group_by") in ("Customer", "Customer Group", "Territory", "Supplier"):
filter_field = frappe.scrub(filters.get("group_by"))
query += f" order by {filter_field} desc"
@@ -413,9 +413,9 @@ def apply_order_by_conditions(query, si, ii, filters):
def get_items(filters, additional_query_columns, additional_conditions=None):
doctype = "Sales Invoice"
si = frappe.qb.DocType(doctype)
sip = frappe.qb.DocType(f"{doctype} Payment")
sii = frappe.qb.DocType(f"{doctype} Item")
si = frappe.qb.DocType("Sales Invoice").as_("invoice")
sii = frappe.qb.DocType("Sales Invoice Item").as_("invoice_item")
sip = frappe.qb.DocType("Sales Invoice Payment")
item = frappe.qb.DocType("Item")
query = (
@@ -493,7 +493,7 @@ def get_items(filters, additional_query_columns, additional_conditions=None):
if match_conditions:
query += " and " + match_conditions
query = apply_order_by_conditions(query, si, sii, filters)
query = apply_order_by_conditions(query, filters)
return frappe.db.sql(query, params, as_dict=True)
@@ -763,25 +763,13 @@ def add_total_row(
def get_display_value(filters, group_by_field, item):
if filters.get("group_by") == "Item":
if item.get("item_code") != item.get("item_name"):
value = (
cstr(item.get("item_code"))
+ "<br><br>"
+ "<span style='font-weight: normal'>"
+ cstr(item.get("item_name"))
+ "</span>"
)
value = f"{item.get('item_code')}: {item.get('item_name')}"
else:
value = item.get("item_code", "")
elif filters.get("group_by") in ("Customer", "Supplier"):
party = frappe.scrub(filters.get("group_by"))
if item.get(party) != item.get(party + "_name"):
value = (
item.get(party)
+ "<br><br>"
+ "<span style='font-weight: normal'>"
+ item.get(party + "_name")
+ "</span>"
)
value = f"{item.get(party)}: {item.get(party + '_name')}"
else:
value = item.get(party)
else:

View File

@@ -86,7 +86,7 @@ def get_rate_as_at(date, from_currency, to_currency):
return rate
def convert_to_presentation_currency(gl_entries, currency_info):
def convert_to_presentation_currency(gl_entries, currency_info, filters=None):
"""
Take a list of GL Entries and change the 'debit' and 'credit' values to currencies
in `currency_info`.
@@ -99,6 +99,13 @@ def convert_to_presentation_currency(gl_entries, currency_info):
company_currency = currency_info["company_currency"]
account_currencies = list(set(entry["account_currency"] for entry in gl_entries))
exchange_gain_or_loss = False
if filters and isinstance(filters.get("account"), list):
account_filter = filters.get("account")
gain_loss_account = frappe.db.get_value("Company", filters.company, "exchange_gain_loss_account")
exchange_gain_or_loss = len(account_filter) == 1 and account_filter[0] == gain_loss_account
for entry in gl_entries:
debit = flt(entry["debit"])
@@ -107,7 +114,11 @@ def convert_to_presentation_currency(gl_entries, currency_info):
credit_in_account_currency = flt(entry["credit_in_account_currency"])
account_currency = entry["account_currency"]
if len(account_currencies) == 1 and account_currency == presentation_currency:
if (
len(account_currencies) == 1
and account_currency == presentation_currency
and not exchange_gain_or_loss
):
entry["debit"] = debit_in_account_currency
entry["credit"] = credit_in_account_currency
else:

View File

@@ -522,7 +522,8 @@ def reconcile_against_document(
skip_ref_details_update_for_pe=skip_ref_details_update_for_pe,
dimensions_dict=dimensions_dict,
)
if referenced_row.get("outstanding_amount"):
referenced_row.outstanding_amount -= flt(entry.allocated_amount)
doc.save(ignore_permissions=True)
# re-submit advance entry
doc = frappe.get_doc(entry.voucher_type, entry.voucher_no)

View File

@@ -659,7 +659,7 @@ class TestAssetDepreciationSchedule(IntegrationTestCase):
self.assertEqual(schedules, expected_depreciation_before_repair)
def test_daily_prorata_based_depreciation_schedule_after_cancelling_asset_repair_for(self):
def test_daily_prorata_based_depreciation_schedule_after_cancelling_asset_repair(self):
asset = create_asset(
item_code="Macbook Pro",
gross_purchase_amount=500,

View File

@@ -68,6 +68,7 @@
"fieldname": "completion_date",
"fieldtype": "Datetime",
"label": "Completion Date",
"mandatory_depends_on": "eval:doc.repair_status==\"Completed\"",
"no_copy": 1
},
{
@@ -260,7 +261,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-06-29 22:30:00.589597",
"modified": "2025-07-18 15:59:53.981224",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Repair",

View File

@@ -75,7 +75,7 @@ class AssetRepair(AccountsController):
)
def validate_dates(self):
if self.completion_date and (self.failure_date > self.completion_date):
if self.completion_date and (getdate(self.failure_date) > getdate(self.completion_date)):
frappe.throw(
_("Completion Date can not be before Failure Date. Please adjust the dates accordingly.")
)
@@ -292,7 +292,7 @@ class AssetRepair(AccountsController):
"voucher_type": self.doctype,
"voucher_no": self.name,
"cost_center": self.cost_center,
"posting_date": getdate(),
"posting_date": self.completion_date,
"company": self.company,
},
item=self,
@@ -309,7 +309,7 @@ class AssetRepair(AccountsController):
"voucher_type": self.doctype,
"voucher_no": self.name,
"cost_center": self.cost_center,
"posting_date": getdate(),
"posting_date": self.completion_date,
"against_voucher_type": "Purchase Invoice",
"company": self.company,
},
@@ -344,7 +344,7 @@ class AssetRepair(AccountsController):
"voucher_type": self.doctype,
"voucher_no": self.name,
"cost_center": self.cost_center,
"posting_date": getdate(),
"posting_date": self.completion_date,
"company": self.company,
},
item=self,
@@ -361,7 +361,7 @@ class AssetRepair(AccountsController):
"voucher_type": self.doctype,
"voucher_no": self.name,
"cost_center": self.cost_center,
"posting_date": getdate(),
"posting_date": self.completion_date,
"against_voucher_type": "Stock Entry",
"against_voucher": stock_entry.name,
"company": self.company,

View File

@@ -4,7 +4,7 @@ import unittest
import frappe
from frappe.tests import IntegrationTestCase
from frappe.utils import add_months, flt, get_first_day, nowdate, nowtime, today
from frappe.utils import add_days, add_months, flt, get_first_day, nowdate, nowtime, today
from erpnext.assets.doctype.asset.asset import (
get_asset_account,
@@ -388,6 +388,7 @@ def create_asset_repair(**args):
if args.submit:
asset_repair.repair_status = "Completed"
asset_repair.completion_date = add_days(args.failure_date, 1)
asset_repair.cost_center = frappe.db.get_value("Company", asset.company, "cost_center")
if args.stock_consumption:

View File

@@ -348,7 +348,7 @@ class BuyingController(SubcontractingController):
tax_accounts, total_valuation_amount, total_actual_tax_amount = self.get_tax_details()
for i, item in enumerate(self.get("items")):
if item.item_code and item.qty:
if item.item_code and (item.qty or item.get("rejected_qty")):
item_tax_amount, actual_tax_amount = 0.0, 0.0
if i == (last_item_idx - 1):
item_tax_amount = total_valuation_amount
@@ -387,7 +387,19 @@ class BuyingController(SubcontractingController):
if item.sales_incoming_rate: # for internal transfer
net_rate = item.qty * item.sales_incoming_rate
if (
not net_rate
and item.get("rejected_qty")
and frappe.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
)
):
net_rate = item.rejected_qty * item.net_rate
qty_in_stock_uom = flt(item.qty * item.conversion_factor)
if not qty_in_stock_uom and item.get("rejected_qty"):
qty_in_stock_uom = flt(item.rejected_qty * item.conversion_factor)
if self.get("is_old_subcontracting_flow"):
item.rm_supp_cost = self.get_supplied_items_cost(item.name, reset_outgoing_rate)
item.valuation_rate = (

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

@@ -23,6 +23,16 @@ frappe.ui.form.on("Job Card", {
};
});
frm.events.set_company_filters(frm, "source_warehouse");
frm.events.set_company_filters(frm, "wip_warehouse");
frm.set_query("source_warehouse", "items", () => {
return {
filters: {
company: frm.doc.company,
},
};
});
frm.set_indicator_formatter("sub_operation", function (doc) {
if (doc.status == "Pending") {
return "red";
@@ -32,6 +42,16 @@ frappe.ui.form.on("Job Card", {
});
},
set_company_filters(frm, fieldname) {
frm.set_query(fieldname, () => {
return {
filters: {
company: frm.doc.company,
},
};
});
},
make_fields_read_only(frm) {
if (frm.doc.docstatus === 1) {
frm.set_df_property("employee", "read_only", 1);

View File

@@ -47,6 +47,14 @@ frappe.ui.form.on("Production Plan", {
};
});
frm.set_query("sub_assembly_warehouse", function (doc) {
return {
filters: {
company: doc.company,
},
};
});
frm.set_query("material_request", "material_requests", function () {
return {
filters: {

View File

@@ -2965,56 +2965,45 @@ class TestWorkOrder(IntegrationTestCase):
fg_warehouse="_Test Warehouse 2 - _TC",
)
# Initial check
self.assertEqual(wo.operations[0].operation, "Test Operation A")
self.assertEqual(wo.operations[1].operation, "Test Operation B")
self.assertEqual(wo.operations[2].operation, "Test Operation C")
self.assertEqual(wo.operations[3].operation, "Test Operation D")
wo = frappe.copy_doc(wo)
wo.operations[3].sequence_id = 2
wo.operations[3].sequence_id = None
# Test 1 : If any one operation does not have sequence ID then error will be thrown
self.assertRaises(frappe.ValidationError, wo.submit)
for op in wo.operations:
op.sequence_id = None
wo.submit()
# Test 2 : Sort line items in child table based on sequence ID
self.assertEqual(wo.operations[0].operation, "Test Operation A")
self.assertEqual(wo.operations[1].operation, "Test Operation B")
self.assertEqual(wo.operations[2].operation, "Test Operation D")
self.assertEqual(wo.operations[3].operation, "Test Operation C")
# Test 2 : If none of the operations have sequence ID then they will be sequenced as per their idx
for op in wo.operations:
self.assertEqual(op.sequence_id, op.idx)
wo = frappe.copy_doc(wo)
wo.operations[3].sequence_id = 1
wo.submit()
wo.operations[0].sequence_id = 2
self.assertEqual(wo.operations[0].operation, "Test Operation A")
self.assertEqual(wo.operations[1].operation, "Test Operation C")
self.assertEqual(wo.operations[2].operation, "Test Operation B")
self.assertEqual(wo.operations[3].operation, "Test Operation D")
# Test 3 : Sequence IDs should not miss the correct sequence of numbers
self.assertRaises(frappe.ValidationError, wo.submit)
wo = frappe.copy_doc(wo)
wo.operations[0].sequence_id = 3
wo.submit()
wo.operations[1].sequence_id = 1
self.assertEqual(wo.operations[0].operation, "Test Operation C")
self.assertEqual(wo.operations[1].operation, "Test Operation B")
self.assertEqual(wo.operations[2].operation, "Test Operation D")
self.assertEqual(wo.operations[3].operation, "Test Operation A")
wo = frappe.copy_doc(wo)
wo.operations[1].sequence_id = 0
# Test 3 - Error should be thrown if any one operation does not have sequence id but others do
# Test 4 : Sequence IDs should be in the correct ascending order
self.assertRaises(frappe.ValidationError, wo.submit)
workstation = frappe.get_doc("Workstation", "Test Workstation A")
workstation.production_capacity = 4
workstation.save()
wo = frappe.copy_doc(wo)
wo.operations[0].sequence_id = 1
wo.operations[1].sequence_id = 2
wo.operations[2].sequence_id = 2
wo.operations[3].sequence_id = 3
wo.submit()
# Test 4 - If Sequence ID is same then planned start time for both operations should be same
self.assertEqual(wo.operations[1].planned_start_time, wo.operations[2].planned_start_time)
# Test 5 : If two operations have the same sequence ID then the next operation will start 10 mins after the longest previous operation ends
self.assertEqual(
wo.operations[3].planned_start_time, add_to_date(wo.operations[1].planned_end_time, minutes=10)
)
def make_stock_in_entries_and_get_batches(rm_item, source_warehouse, wip_warehouse):

View File

@@ -349,13 +349,18 @@ frappe.ui.form.on("Work Order", {
return operations_data;
},
},
function (data) {
function () {
const selected_rows = dialog.fields_dict["operations"].grid.get_selected_children();
if (selected_rows.length == 0) {
frappe.msgprint(__("Please select atleast one operation to create Job Card"));
return;
}
frappe.call({
method: "erpnext.manufacturing.doctype.work_order.work_order.make_job_card",
freeze: true,
args: {
work_order: frm.doc.name,
operations: data.operations,
operations: selected_rows,
},
callback: function () {
frm.reload_doc();
@@ -366,7 +371,7 @@ frappe.ui.form.on("Work Order", {
__("Create")
);
dialog.fields_dict["operations"].grid.wrapper.find(".grid-add-row").hide();
dialog.fields_dict["operations"].grid.grid_buttons.hide();
var pending_qty = 0;
frm.doc.operations.forEach((data) => {

View File

@@ -199,6 +199,7 @@ class WorkOrder(Document):
self.set_required_items(reset_only_qty=len(self.get("required_items")))
self.enable_auto_reserve_stock()
self.validate_operations_sequence()
def validate_dates(self):
if self.actual_start_date and self.actual_end_date:
@@ -229,6 +230,30 @@ class WorkOrder(Document):
if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"):
self.reserve_stock = 1
def validate_operations_sequence(self):
if all([not op.sequence_id for op in self.operations]):
for op in self.operations:
op.sequence_id = op.idx
else:
sequence_id = 1
for op in self.operations:
if op.idx == 1 and op.sequence_id != 1:
frappe.throw(
_("Row #1: Sequence ID must be 1 for Operation {0}.").format(
frappe.bold(op.operation)
)
)
elif op.sequence_id != sequence_id and op.sequence_id != sequence_id + 1:
frappe.throw(
_("Row #{0}: Sequence ID must be {1} or {2} for Operation {3}.").format(
op.idx,
frappe.bold(sequence_id),
frappe.bold(sequence_id + 1),
frappe.bold(op.operation),
)
)
sequence_id = op.sequence_id
def set_warehouses(self):
for row in self.required_items:
if not row.source_warehouse:
@@ -726,17 +751,6 @@ class WorkOrder(Document):
enable_capacity_planning = not cint(manufacturing_settings_doc.disable_capacity_planning)
plan_days = cint(manufacturing_settings_doc.capacity_planning_for_days) or 30
if all([op.sequence_id for op in self.operations]):
self.operations = sorted(self.operations, key=lambda op: op.sequence_id)
for idx, op in enumerate(self.operations):
op.idx = idx + 1
elif any([op.sequence_id for op in self.operations]):
frappe.throw(
_(
"Row #{0}: Incorrect Sequence ID. If any single operation has a Sequence ID then all other operations must have one too."
).format(next((op.idx for op in self.operations if not op.sequence_id), None))
)
for idx, row in enumerate(self.operations):
qty = self.qty
while qty > 0:

View File

@@ -202,6 +202,7 @@
"fieldname": "sequence_id",
"fieldtype": "Int",
"label": "Sequence ID",
"non_negative": 1,
"print_hide": 1
},
{
@@ -299,7 +300,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-04-09 16:21:47.110564",
"modified": "2025-05-15 15:10:06.885440",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Operation",

View File

@@ -427,4 +427,5 @@ erpnext.patches.v15_0.set_status_cancelled_on_cancelled_pos_opening_entry_and_po
erpnext.patches.v15_0.set_company_on_pos_inv_merge_log
erpnext.patches.v15_0.rename_price_list_to_buying_price_list
erpnext.patches.v15_0.repost_gl_entries_with_no_account_subcontracting
erpnext.patches.v15_0.patch_missing_buying_price_list_in_material_request
erpnext.patches.v15_0.remove_sales_partner_from_consolidated_sales_invoice

View File

@@ -0,0 +1,13 @@
import frappe
import frappe.defaults
def execute():
if frappe.db.has_column("Material Request", "buying_price_list") and (
default_buying_price_list := frappe.defaults.get_defaults().buying_price_list
):
docs = frappe.get_all(
"Material Request", filters={"buying_price_list": ["is", "not set"], "docstatus": 1}, pluck="name"
)
for doc in docs:
frappe.db.set_value("Material Request", doc, "buying_price_list", default_buying_price_list)

View File

@@ -787,7 +787,8 @@ class SalesOrder(SellingController):
if self.delivery_date:
for item in self.items:
item.delivery_date = self.delivery_date
if not item.delivery_date:
item.delivery_date = self.delivery_date
def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float:

View File

@@ -713,6 +713,10 @@ class PurchaseReceipt(BuyingController):
warehouse_with_no_account = []
for d in self.get("items"):
remarks = self.get("remarks") or _("Accounting Entry for {0}").format(
"Asset" if d.is_fixed_asset else "Stock"
)
if (
provisional_accounting_for_non_stock_items
and d.item_code not in stock_items
@@ -724,10 +728,6 @@ class PurchaseReceipt(BuyingController):
d, gl_entries, self.posting_date, d.get("provisional_expense_account")
)
elif flt(d.qty) and (flt(d.valuation_rate) or self.is_return):
remarks = self.get("remarks") or _("Accounting Entry for {0}").format(
"Asset" if d.is_fixed_asset else "Stock"
)
if not (
(erpnext.is_perpetual_inventory_enabled(self.company) and d.item_code in stock_items)
or (d.is_fixed_asset and not d.purchase_invoice)
@@ -772,7 +772,7 @@ class PurchaseReceipt(BuyingController):
make_amount_difference_entry(d)
make_sub_contracting_gl_entries(d)
make_divisional_loss_gl_entry(d, outgoing_amount)
elif (d.warehouse and d.warehouse not in warehouse_with_no_account) or (
elif (d.warehouse and d.qty and d.warehouse not in warehouse_with_no_account) or (
not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
and d.rejected_warehouse
and d.rejected_warehouse not in warehouse_with_no_account
@@ -785,10 +785,18 @@ class PurchaseReceipt(BuyingController):
if d.rejected_qty and frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
):
stock_asset_rbnb = (
self.get_company_default("asset_received_but_not_billed")
if d.is_fixed_asset
else self.get_company_default("stock_received_but_not_billed")
)
stock_value_diff = get_stock_value_difference(self.name, d.name, d.rejected_warehouse)
stock_asset_account_name = warehouse_account[d.rejected_warehouse]["account"]
make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name)
if not d.qty:
make_stock_received_but_not_billed_entry(d)
if warehouse_with_no_account:
frappe.msgprint(

View File

@@ -3693,7 +3693,7 @@ class TestPurchaseReceipt(IntegrationTestCase):
columns, data = execute(
filters=frappe._dict(
{"item_code": item_code, "warehouse": pr.items[0].warehouse, "company": pr.company}
{"item_code": [item_code], "warehouse": [pr.items[0].warehouse], "company": pr.company}
)
)
@@ -4286,6 +4286,47 @@ class TestPurchaseReceipt(IntegrationTestCase):
frappe.db.set_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials", 0)
def test_valuation_rate_for_rejected_materials_withoout_accepted_materials(self):
item = make_item("Test Item with Rej Material Valuation WO Accepted", {"is_stock_item": 1})
company = "_Test Company with perpetual inventory"
warehouse = create_warehouse(
"_Test In-ward Warehouse",
company="_Test Company with perpetual inventory",
)
rej_warehouse = create_warehouse(
"_Test Warehouse - Rejected Material",
company="_Test Company with perpetual inventory",
)
frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 1)
frappe.db.set_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials", 1)
pr = make_purchase_receipt(
item_code=item.name,
qty=0,
rate=100,
company=company,
warehouse=warehouse,
rejected_qty=5,
rejected_warehouse=rej_warehouse,
)
gl_entry = frappe.get_all(
"GL Entry", filters={"debit": (">", 0), "voucher_no": pr.name}, pluck="name"
)
stock_value_diff = frappe.db.get_value(
"Stock Ledger Entry",
{"warehouse": rej_warehouse, "voucher_no": pr.name},
"stock_value_difference",
)
self.assertTrue(gl_entry)
self.assertEqual(stock_value_diff, 500.00)
def test_no_valuation_rate_for_rejected_materials(self):
item = make_item("Test Item with Rej Material No Valuation", {"is_stock_item": 1})
company = "_Test Company with perpetual inventory"

View File

@@ -253,19 +253,35 @@ def get_warehouses_based_on_account(account, company=None):
# Will be use for frappe.qb
def apply_warehouse_filter(query, sle, filters):
if warehouse := filters.get("warehouse"):
warehouse_table = frappe.qb.DocType("Warehouse")
if not (warehouses := filters.get("warehouse")):
return query
lft, rgt = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"])
chilren_subquery = (
frappe.qb.from_(warehouse_table)
.select(warehouse_table.name)
.where(
(warehouse_table.lft >= lft)
& (warehouse_table.rgt <= rgt)
& (warehouse_table.name == sle.warehouse)
)
)
query = query.where(ExistsCriterion(chilren_subquery))
warehouse_table = frappe.qb.DocType("Warehouse")
if isinstance(warehouses, str):
warehouses = [warehouses]
warehouse_range = frappe.get_all(
"Warehouse",
filters={
"name": ("in", warehouses),
},
fields=["lft", "rgt"],
as_list=True,
)
child_query = frappe.qb.from_(warehouse_table).select(warehouse_table.name)
range_conditions = [
(warehouse_table.lft >= lft) & (warehouse_table.rgt <= rgt) for lft, rgt in warehouse_range
]
combined_condition = range_conditions[0]
for condition in range_conditions[1:]:
combined_condition = combined_condition | condition
child_query = child_query.where(combined_condition).where(warehouse_table.name == sle.warehouse)
query = query.where(ExistsCriterion(child_query))
return query

View File

@@ -36,38 +36,57 @@ frappe.query_reports["Stock Balance"] = {
},
{
fieldname: "item_code",
label: __("Item"),
fieldtype: "Link",
label: __("Items"),
fieldtype: "MultiSelectList",
width: "80",
options: "Item",
get_query: function () {
get_data: async function (txt) {
let item_group = frappe.query_report.get_filter_value("item_group");
return {
query: "erpnext.controllers.queries.item_query",
filters: {
...(item_group && { item_group }),
is_stock_item: 1,
},
let filters = {
...(item_group && { item_group }),
is_stock_item: 1,
};
let { message: data } = await frappe.call({
method: "erpnext.controllers.queries.item_query",
args: {
doctype: "Item",
txt: txt,
searchfield: "name",
start: 0,
page_len: 10,
filters: filters,
as_dict: 1,
},
});
data = data.map(({ name, description }) => {
return {
value: name,
description: description,
};
});
return data || [];
},
},
{
fieldname: "warehouse",
label: __("Warehouse"),
fieldtype: "Link",
label: __("Warehouses"),
fieldtype: "MultiSelectList",
width: "80",
options: "Warehouse",
get_query: () => {
get_data: (txt) => {
let warehouse_type = frappe.query_report.get_filter_value("warehouse_type");
let company = frappe.query_report.get_filter_value("company");
return {
filters: {
...(warehouse_type && { warehouse_type }),
...(company && { company }),
},
let filters = {
...(warehouse_type && { warehouse_type }),
...(company && { company }),
};
return frappe.db.get_link_options("Warehouse", txt, filters);
},
},
{

View File

@@ -8,7 +8,6 @@ from typing import Any, TypedDict
import frappe
from frappe import _
from frappe.query_builder import Order
from frappe.query_builder.functions import Coalesce
from frappe.utils import add_days, cint, date_diff, flt, getdate
from frappe.utils.nestedset import get_descendants_of
@@ -26,8 +25,8 @@ class StockBalanceFilter(TypedDict):
from_date: str
to_date: str
item_group: str | None
item: str | None
warehouse: str | None
item: list[str] | None
warehouse: list[str] | None
warehouse_type: str | None
include_uom: str | None # include extra info in converted UOM
show_stock_ageing_data: bool
@@ -361,6 +360,7 @@ class StockBalanceReport:
if self.filters.get("warehouse"):
query = apply_warehouse_filter(query, sle, self.filters)
elif warehouse_type := self.filters.get("warehouse_type"):
query = (
query.join(warehouse_table)
@@ -375,13 +375,11 @@ class StockBalanceReport:
children = get_descendants_of("Item Group", item_group, ignore_permissions=True)
query = query.where(item_table.item_group.isin([*children, item_group]))
for field in ["item_code", "brand"]:
if not self.filters.get(field):
continue
elif field == "item_code":
query = query.where(item_table.name == self.filters.get(field))
else:
query = query.where(item_table[field] == self.filters.get(field))
if item_codes := self.filters.get("item_code"):
query = query.where(item_table.name.isin(item_codes))
if brand := self.filters.get("brand"):
query = query.where(item_table.brand == brand)
return query

View File

@@ -23,7 +23,7 @@ class TestStockBalance(IntegrationTestCase):
self.filters = _dict(
{
"company": "_Test Company",
"item_code": self.item.name,
"item_code": [self.item.name],
"from_date": "2020-01-01",
"to_date": str(today()),
}
@@ -165,6 +165,6 @@ class TestStockBalance(IntegrationTestCase):
variant.save()
self.generate_stock_ledger(variant.name, [_dict(qty=5, rate=10)])
rows = stock_balance(self.filters.update({"show_variant_attributes": 1, "item_code": variant.name}))
rows = stock_balance(self.filters.update({"show_variant_attributes": 1, "item_code": [variant.name]}))
self.assertPartialDictEq(attributes, rows[0])
self.assertInvariants(rows)

View File

@@ -27,25 +27,44 @@ frappe.query_reports["Stock Ledger"] = {
},
{
fieldname: "warehouse",
label: __("Warehouse"),
fieldtype: "Link",
label: __("Warehouses"),
fieldtype: "MultiSelectList",
options: "Warehouse",
get_query: function () {
get_data: function (txt) {
const company = frappe.query_report.get_filter_value("company");
return {
filters: { company: company },
};
return frappe.db.get_link_options("Warehouse", txt, {
company: company,
});
},
},
{
fieldname: "item_code",
label: __("Item"),
fieldtype: "Link",
label: __("Items"),
fieldtype: "MultiSelectList",
options: "Item",
get_query: function () {
return {
query: "erpnext.controllers.queries.item_query",
};
get_data: async function (txt) {
let { message: data } = await frappe.call({
method: "erpnext.controllers.queries.item_query",
args: {
doctype: "Item",
txt: txt,
searchfield: "name",
start: 0,
page_len: 10,
filters: {},
as_dict: 1,
},
});
data = data.map(({ name, description }) => {
return {
value: name,
description: description,
};
});
return data || [];
},
},
{

View File

@@ -456,19 +456,23 @@ def get_items(filters):
query = frappe.qb.from_(item).select(item.name)
conditions = []
if item_code := filters.get("item_code"):
conditions.append(item.name == item_code)
if item_codes := filters.get("item_code"):
conditions.append(item.name.isin(item_codes))
else:
if brand := filters.get("brand"):
conditions.append(item.brand == brand)
if item_group := filters.get("item_group"):
if condition := get_item_group_condition(item_group, item):
conditions.append(condition)
if filters.get("item_group") and (
condition := get_item_group_condition(filters.get("item_group"), item)
):
conditions.append(condition)
items = []
if conditions:
for condition in conditions:
query = query.where(condition)
items = [r[0] for r in query.run()]
return items
@@ -505,6 +509,7 @@ def get_item_details(items, sl_entries, include_uom):
return item_details
# TODO: THIS IS NOT USED
def get_sle_conditions(filters):
conditions = []
if filters.get("warehouse"):
@@ -535,8 +540,8 @@ def get_opening_balance_from_batch(filters, columns, sl_entries):
}
for fields in ["item_code", "warehouse"]:
if filters.get(fields):
query_filters[fields] = filters.get(fields)
if value := filters.get(fields):
query_filters[fields] = ("in", value)
opening_data = frappe.get_all(
"Stock Ledger Entry",
@@ -567,8 +572,16 @@ def get_opening_balance_from_batch(filters, columns, sl_entries):
)
for field in ["item_code", "warehouse", "company"]:
if filters.get(field):
query = query.where(table[field] == filters.get(field))
value = filters.get(field)
if not value:
continue
if isinstance(value, list | tuple):
query = query.where(table[field].isin(value))
else:
query = query.where(table[field] == value)
bundle_data = query.run(as_dict=True)
@@ -623,13 +636,34 @@ def get_opening_balance(filters, columns, sl_entries):
return row
def get_warehouse_condition(warehouse):
warehouse_details = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"], as_dict=1)
if warehouse_details:
return f" exists (select name from `tabWarehouse` wh \
where wh.lft >= {warehouse_details.lft} and wh.rgt <= {warehouse_details.rgt} and warehouse = wh.name)"
def get_warehouse_condition(warehouses):
if not warehouses:
return ""
return ""
if isinstance(warehouses, str):
warehouses = [warehouses]
warehouse_range = frappe.get_all(
"Warehouse",
filters={
"name": ("in", warehouses),
},
fields=["lft", "rgt"],
as_list=True,
)
if not warehouse_range:
return ""
alias = "wh"
conditions = []
for lft, rgt in warehouse_range:
conditions.append(f"({alias}.lft >= {lft} and {alias}.rgt <= {rgt})")
conditions = " or ".join(conditions)
return f" exists (select name from `tabWarehouse` {alias} \
where ({conditions}) and warehouse = {alias}.name)"
def get_item_group_condition(item_group, item_table=None):

View File

@@ -17,7 +17,7 @@ class TestStockLedgerReeport(IntegrationTestCase):
company="_Test Company",
from_date=today(),
to_date=add_days(today(), 30),
item_code="_Test Stock Report Serial Item",
item_code=["_Test Stock Report Serial Item"],
)
def tearDown(self) -> None:

View File

@@ -18,8 +18,15 @@ batch = get_random("Batch")
REPORT_FILTER_TEST_CASES: list[tuple[ReportName, ReportFilters]] = [
("Stock Ledger", {"_optional": True}),
("Stock Ledger", {"batch_no": batch}),
("Stock Ledger", {"item_code": "_Test Item", "warehouse": "_Test Warehouse - _TC"}),
("Stock Balance", {"_optional": True}),
("Stock Ledger", {"item_code": ["_Test Item"], "warehouse": ["_Test Warehouse - _TC"]}),
(
"Stock Balance",
{
"item_code": ["_Test Item"],
"warehouse": ["_Test Warehouse - _TC"],
"item_group": "_Test Item Group",
},
),
("Stock Projected Qty", {"_optional": True}),
("Batch-Wise Balance History", {}),
("Itemwise Recommended Reorder Level", {"item_group": "All Item Groups"}),

View File

@@ -1683,8 +1683,20 @@ def get_stock_ledger_entries(
):
"""get stock ledger entries filtered by specific posting datetime conditions"""
conditions = f" and posting_datetime {operator} %(posting_datetime)s"
if previous_sle.get("warehouse"):
conditions += " and warehouse = %(warehouse)s"
if item_code := previous_sle.get("item_code"):
if isinstance(item_code, list | tuple):
conditions += " and item_code in %(item_code)s"
else:
conditions += " and item_code = %(item_code)s"
if warehouse := previous_sle.get("warehouse"):
if isinstance(warehouse, list | tuple):
conditions += " and warehouse in %(warehouse)s"
else:
conditions += " and warehouse = %(warehouse)s"
elif previous_sle.get("warehouse_condition"):
conditions += " and " + previous_sle.get("warehouse_condition")
@@ -1727,8 +1739,7 @@ def get_stock_ledger_entries(
"""
select *, posting_datetime as "timestamp"
from `tabStock Ledger Entry`
where item_code = %(item_code)s
and is_cancelled = 0
where is_cancelled = 0
{conditions}
order by posting_datetime {order}, creation {order}
{limit} {for_update}""".format(

View File

@@ -316,9 +316,12 @@ class TransactionBase(StatusUpdater):
setattr(item_obj, k, v)
def handle_internal_parties(self, item_obj: object, item_details: dict) -> None:
fetch_valuation_rate_for_internal_transaction = cint(
frappe.get_single_value("Accounts Settings", "fetch_valuation_rate_for_internal_transaction")
)
if (
self.get("is_internal_customer") or self.get("is_internal_supplier")
) and self.represents_company == self.company:
) and fetch_valuation_rate_for_internal_transaction:
args = frappe._dict(
{
"item_code": item_obj.item_code,