Merge pull request #53916 from frappe/version-15-hotfix

This commit is contained in:
diptanilsaha
2026-03-30 23:31:54 +05:30
committed by GitHub
30 changed files with 438 additions and 82 deletions

View File

@@ -131,6 +131,7 @@ def get_default_company_bank_account(company, party_type, party):
@frappe.whitelist()
def get_bank_account_details(bank_account):
frappe.has_permission("Bank Account", doc=bank_account, ptype="read", throw=True)
return frappe.get_cached_value(
"Bank Account", bank_account, ["account", "bank", "bank_account_no"], as_dict=1
)

View File

@@ -5,7 +5,7 @@
import frappe
from frappe import _, scrub
from frappe.model.document import Document
from frappe.utils import flt, nowdate
from frappe.utils import escape_html, flt, nowdate
from frappe.utils.background_jobs import enqueue, is_job_enqueued
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
@@ -84,6 +84,11 @@ class OpeningInvoiceCreationTool(Document):
)
prepare_invoice_summary(doctype, invoices)
invoices_summary_companies = list(invoices_summary.keys())
for company in invoices_summary_companies:
invoices_summary[escape_html(company)] = invoices_summary.pop(company)
return invoices_summary, max_count
def validate_company(self):

View File

@@ -612,12 +612,13 @@ class PurchaseInvoice(BuyingController):
frappe.db.set_value(self.doctype, self.name, "against_expense_account", self.against_expense_account)
def po_required(self):
if frappe.db.get_value("Buying Settings", None, "po_required") == "Yes":
if frappe.get_value(
if (
frappe.db.get_single_value("Buying Settings", "po_required") == "Yes"
and not self.is_internal_transfer()
and not frappe.db.get_value(
"Supplier", self.supplier, "allow_purchase_invoice_creation_without_purchase_order"
):
return
)
):
for d in self.get("items"):
if not d.purchase_order:
msg = _("Purchase Order Required for item {}").format(frappe.bold(d.item_code))

View File

@@ -48,6 +48,9 @@ class Deferred_Item:
Generate report data for output
"""
ret_data = frappe._dict({"name": self.item_name})
ret_data.service_start_date = self.service_start_date
ret_data.service_end_date = self.service_end_date
ret_data.amount = self.base_net_amount
for period in self.period_total:
ret_data[period.key] = period.total
ret_data.indent = 1
@@ -205,6 +208,9 @@ class Deferred_Invoice:
for item in self.uniq_items:
self.items.append(Deferred_Item(item, self, [x for x in items if x.item == item]))
# roll-up amount from all deferred items
self.amount_total = sum(item.base_net_amount for item in self.items)
def calculate_invoice_revenue_expense_for_period(self):
"""
calculate deferred revenue/expense for all items in invoice
@@ -232,7 +238,7 @@ class Deferred_Invoice:
generate report data for invoice, includes invoice total
"""
ret_data = []
inv_total = frappe._dict({"name": self.name})
inv_total = frappe._dict({"name": self.name, "amount": self.amount_total})
for x in self.period_total:
inv_total[x.key] = x.total
inv_total.indent = 0
@@ -386,6 +392,24 @@ class Deferred_Revenue_and_Expense_Report:
def get_columns(self):
columns = []
columns.append({"label": _("Name"), "fieldname": "name", "fieldtype": "Data", "read_only": 1})
columns.append(
{
"label": _("Service Start Date"),
"fieldname": "service_start_date",
"fieldtype": "Date",
"read_only": 1,
}
)
columns.append(
{
"label": _("Service End Date"),
"fieldname": "service_end_date",
"fieldtype": "Date",
"read_only": 1,
}
)
columns.append({"label": _("Amount"), "fieldname": "amount", "fieldtype": "Currency", "read_only": 1})
for period in self.period_list:
columns.append(
{
@@ -415,6 +439,8 @@ class Deferred_Revenue_and_Expense_Report:
elif self.filters.type == "Expense":
total_row = frappe._dict({"name": "Total Deferred Expense"})
total_row["amount"] = sum(inv.amount_total for inv in self.deferred_invoices)
for idx, period in enumerate(self.period_list, 0):
total_row[period.key] = self.period_total[idx].total
ret.append(total_row)

View File

@@ -826,18 +826,18 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
target.set_payment_schedule()
target.credit_to = get_party_account("Supplier", source.supplier, source.company)
def get_billed_qty(po_item_name):
from frappe.query_builder.functions import Sum
table = frappe.qb.DocType("Purchase Invoice Item")
query = (
frappe.qb.from_(table)
.select(Sum(table.qty).as_("qty"))
.where((table.docstatus == 1) & (table.po_detail == po_item_name))
)
return query.run(pluck="qty")[0] or 0
def update_item(obj, target, source_parent):
def get_billed_qty(po_item_name):
from frappe.query_builder.functions import Sum
table = frappe.qb.DocType("Purchase Invoice Item")
query = (
frappe.qb.from_(table)
.select(Sum(table.qty).as_("qty"))
.where((table.docstatus == 1) & (table.po_detail == po_item_name))
)
return query.run(pluck="qty")[0] or 0
billed_qty = flt(get_billed_qty(obj.name))
target.qty = flt(obj.qty) - billed_qty
@@ -877,7 +877,11 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
"wip_composite_asset": "wip_composite_asset",
},
"postprocess": update_item,
"condition": lambda doc: (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))
"condition": lambda doc: (
doc.base_amount == 0
or abs(doc.billed_amt) < abs(doc.amount)
or doc.qty > flt(get_billed_qty(doc.name))
)
and select_item(doc),
},
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},

View File

@@ -1346,6 +1346,35 @@ class TestPurchaseOrder(FrappeTestCase):
self.assertEqual(pi_2.status, "Paid")
self.assertEqual(po.status, "Completed")
@change_settings("Buying Settings", {"maintain_same_rate": 0})
def test_purchase_order_over_billing_missing_item(self):
item1 = make_item(
"_Test Item for Overbilling",
).name
item2 = make_item(
"_Test Item for Overbilling 2",
).name
po = create_purchase_order(qty=10, rate=1000, item_code=item1, do_not_save=1)
po.append("items", {"item_code": item2, "qty": 5, "rate": 20, "warehouse": "_Test Warehouse - _TC"})
po.taxes = []
po.insert()
po.submit()
pi1 = make_pi_from_po(po.name)
pi1.items[0].qty = 8
pi1.items[0].rate = 1250
pi1.remove(pi1.items[1])
pi1.insert()
pi1.submit()
self.assertEqual(pi1.grand_total, 10000.0)
self.assertTrue(len(pi1.items) == 1)
pi2 = make_pi_from_po(po.name)
self.assertEqual(len(pi2.items), 2)
def create_po_for_sc_testing():
from erpnext.controllers.tests.test_subcontracting_controller import (

View File

@@ -327,7 +327,7 @@ class BuyingController(SubcontractingController):
last_item_idx = d.idx
total_valuation_amount = sum(
flt(d.base_tax_amount_after_discount_amount)
flt(d.base_tax_amount_after_discount_amount) * (-1 if d.get("add_deduct_tax") == "Deduct" else 1)
for d in self.get("taxes")
if d.category in ["Valuation", "Valuation and Total"]
)

View File

@@ -992,3 +992,26 @@ def get_item_uom_query(doctype, txt, searchfield, start, page_len, filters):
limit_page_length=page_len,
as_list=1,
)
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_warehouse_address(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
table = frappe.qb.DocType(doctype)
child_table = frappe.qb.DocType("Dynamic Link")
query = (
frappe.qb.from_(table)
.inner_join(child_table)
.on((table.name == child_table.parent) & (child_table.parenttype == doctype))
.select(table.name)
.where(
(child_table.link_name == filters.get("warehouse"))
& (table.disabled == 0)
& (child_table.link_doctype == "Warehouse")
& (table.name.like(f"%{txt}%"))
)
.offset(start)
.limit(page_len)
)
return query.run(as_list=1)

View File

@@ -56,7 +56,7 @@
}
],
"links": [],
"modified": "2020-12-07 10:44:22.587047",
"modified": "2026-03-25 19:27:19.162421",
"modified_by": "Administrator",
"module": "CRM",
"name": "Contract Template",
@@ -75,43 +75,34 @@
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales Manager",
"share": 1,
"write": 1
"share": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Purchase Manager",
"share": 1,
"write": 1
"share": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "HR Manager",
"share": 1,
"write": 1
"share": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}
}

View File

@@ -204,8 +204,22 @@ def send_mail(entry, email_campaign):
# called from hooks on doc_event Email Unsubscribe
def unsubscribe_recipient(unsubscribe, method):
if unsubscribe.reference_doctype == "Email Campaign":
frappe.db.set_value("Email Campaign", unsubscribe.reference_name, "status", "Unsubscribed")
if unsubscribe.reference_doctype != "Email Campaign":
return
email_campaign = frappe.get_doc("Email Campaign", unsubscribe.reference_name)
if email_campaign.email_campaign_for == "Email Group":
if unsubscribe.email:
frappe.db.set_value(
"Email Group Member",
{"email_group": email_campaign.recipient, "email": unsubscribe.email},
"unsubscribed",
1,
)
else:
# For Lead or Contact
frappe.db.set_value("Email Campaign", email_campaign.name, "status", "Unsubscribed")
# called through hooks to update email campaign status daily

View File

@@ -767,12 +767,14 @@ class BOM(WebsiteGenerator):
hour_rate / flt(self.conversion_rate) if self.conversion_rate and hour_rate else hour_rate
)
if row.hour_rate and row.time_in_mins:
if row.hour_rate:
row.base_hour_rate = flt(row.hour_rate) * flt(self.conversion_rate)
row.operating_cost = flt(row.hour_rate) * flt(row.time_in_mins) / 60.0
row.base_operating_cost = flt(row.operating_cost) * flt(self.conversion_rate)
row.cost_per_unit = row.operating_cost / (row.batch_size or 1.0)
row.base_cost_per_unit = row.base_operating_cost / (row.batch_size or 1.0)
if row.time_in_mins:
row.operating_cost = flt(row.hour_rate) * flt(row.time_in_mins) / 60.0
row.base_operating_cost = flt(row.operating_cost) * flt(self.conversion_rate)
row.cost_per_unit = row.operating_cost / (row.batch_size or 1.0)
row.base_cost_per_unit = row.base_operating_cost / (row.batch_size or 1.0)
if update_hour_rate:
row.db_update()

View File

@@ -31,6 +31,34 @@ frappe.ui.form.on("Job Card", {
};
});
frm.set_query("operation", "time_logs", () => {
let operations = (frm.doc.sub_operations || []).map((d) => d.sub_operation);
return {
filters: {
name: ["in", operations],
},
};
});
frm.set_query("work_order", function () {
return {
filters: {
status: ["not in", ["Cancelled", "Closed", "Stopped"]],
},
};
});
frm.events.set_company_filters(frm, "target_warehouse");
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";

View File

@@ -1664,8 +1664,10 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
)
sales_order = data.get("sales_order")
qty_precision = frappe.get_precision("Material Request Plan Item", "quantity")
for item_code, details in item_details.items():
details.qty = flt(details.qty, qty_precision)
so_item_details.setdefault(sales_order, frappe._dict())
if item_code in so_item_details.get(sales_order, {}):
so_item_details[sales_order][item_code]["qty"] = so_item_details[sales_order][item_code].get(

View File

@@ -383,7 +383,7 @@ class WorkOrder(Document):
if self.docstatus == 0:
status = "Draft"
elif self.docstatus == 1:
if status != "Stopped":
if status not in ["Closed", "Stopped"]:
status = "Not Started"
if flt(self.material_transferred_for_manufacturing) > 0:
status = "In Process"

View File

@@ -1018,11 +1018,11 @@ class TestQuotation(FrappeTestCase):
def test_make_quotation_qar_to_inr(self):
quotation = make_quotation(
currency="QAR",
transaction_date="2026-06-04",
transaction_date="2026-01-01",
)
cache = frappe.cache()
key = "currency_exchange_rate_{}:{}:{}".format("2026-06-04", "QAR", "INR")
key = "currency_exchange_rate_{}:{}:{}".format("2026-01-01", "QAR", "INR")
value = cache.get(key)
expected_rate = flt(value) / 3.64

View File

@@ -281,8 +281,13 @@ erpnext.stock.move_item = function (item, source, target, actual_qty, rate, stoc
}
dialog.set_primary_action(__("Create Stock Entry"), function () {
if (source && (dialog.get_value("qty") == 0 || dialog.get_value("qty") > actual_qty)) {
frappe.msgprint(__("Quantity must be greater than zero, and less or equal to {0}", [actual_qty]));
if (flt(dialog.get_value("qty")) <= 0) {
frappe.msgprint(__("Quantity must be greater than zero"));
return;
}
if (source && dialog.get_value("qty") > actual_qty) {
frappe.msgprint(__("Quantity must be less than or equal to {0}", [actual_qty]));
return;
}

View File

@@ -1,6 +1,6 @@
import frappe
from frappe.model.db_query import DatabaseQuery
from frappe.utils import cint, flt
from frappe.utils import cint, escape_html, flt
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_sre_reserved_qty_for_items_and_warehouses as get_reserved_stock_details,
@@ -70,8 +70,10 @@ def get_data(
for item in items:
item.update(
{
"item_name": frappe.get_cached_value("Item", item.item_code, "item_name"),
"stock_uom": frappe.get_cached_value("Item", item.item_code, "stock_uom"),
"item_code": escape_html(item.item_code),
"item_name": escape_html(frappe.get_cached_value("Item", item.item_code, "item_name")),
"stock_uom": escape_html(frappe.get_cached_value("Item", item.item_code, "stock_uom")),
"warehouse": escape_html(item.warehouse),
"disable_quick_entry": frappe.get_cached_value("Item", item.item_code, "has_batch_no")
or frappe.get_cached_value("Item", item.item_code, "has_serial_no"),
"projected_qty": flt(item.projected_qty, precision),

View File

@@ -50,15 +50,15 @@
data-warehouse="{{ d.warehouse }}"
data-actual_qty="{{ d.actual_qty }}"
data-stock-uom="{{ d.stock_uom }}"
data-item="{{ escape(d.item_code) }}">{{ __("Move") }}</a>
data-item="{{ d.item_code }}">{{ __("Move") }}</button>
{% endif %}
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-add"
data-disable_quick_entry="{{ d.disable_quick_entry }}"
data-warehouse="{{ d.warehouse }}"
data-actual_qty="{{ d.actual_qty }}"
data-stock-uom="{{ d.stock_uom }}"
data-item="{{ escape(d.item_code) }}"
data-rate="{{ d.valuation_rate }}">{{ __("Add") }}</a>
data-item="{{ d.item_code }}"
data-rate="{{ d.valuation_rate }}">{{ __("Add") }}</button>
</div>
{% endif %}
</div>

View File

@@ -1,6 +1,6 @@
import frappe
from frappe.model.db_query import DatabaseQuery
from frappe.utils import flt, nowdate
from frappe.utils import escape_html, flt, nowdate
from erpnext.stock.utils import get_stock_balance
@@ -75,6 +75,9 @@ def get_warehouse_capacity_data(filters, start):
balance_qty = get_stock_balance(entry.item_code, entry.warehouse, nowdate()) or 0
entry.update(
{
"warehouse": escape_html(entry.warehouse),
"item_code": escape_html(entry.item_code),
"company": escape_html(entry.company),
"actual_qty": balance_qty,
"percent_occupied": flt((flt(balance_qty) / flt(entry.stock_capacity)) * 100, 0),
}

View File

@@ -10,6 +10,7 @@ from frappe.query_builder.custom import ConstantColumn
from frappe.utils import cint, flt
import erpnext
from erpnext import is_perpetual_inventory_enabled
from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
@@ -160,6 +161,9 @@ class LandedCostVoucher(Document):
)
def validate_expense_accounts(self):
if not is_perpetual_inventory_enabled(self.company):
return
for t in self.taxes:
company = frappe.get_cached_value("Account", t.expense_account, "company")

View File

@@ -175,6 +175,8 @@ class TestLandedCostVoucher(FrappeTestCase):
self.assertEqual(last_sle_after_landed_cost.stock_value - last_sle.stock_value, 50.0)
def test_lcv_validates_company(self):
from erpnext import is_perpetual_inventory_enabled
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import (
IncorrectCompanyValidationError,
)
@@ -182,6 +184,20 @@ class TestLandedCostVoucher(FrappeTestCase):
company_a = "_Test Company"
company_b = "_Test Company with perpetual inventory"
srbnb = create_account(
account_name="Stock Received But Not Billed",
account_type="Stock Received But Not Billed",
parent_account="Stock Liabilities - _TC",
company=company_a,
account_currency="INR",
)
epi = is_perpetual_inventory_enabled(company_a)
company_doc = frappe.get_doc("Company", company_a)
company_doc.enable_perpetual_inventory = 1
company_doc.stock_received_but_not_billed = srbnb
company_doc.save()
pr = make_purchase_receipt(
company=company_a,
warehouse="Stores - _TC",
@@ -207,6 +223,9 @@ class TestLandedCostVoucher(FrappeTestCase):
distribute_landed_cost_on_items(lcv)
lcv.submit()
frappe.db.set_value("Company", company_a, "enable_perpetual_inventory", epi)
frappe.local.enable_perpetual_inventory = {}
def test_landed_cost_voucher_for_zero_purchase_rate(self):
"Test impact of LCV on future stock balances."
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -505,8 +505,26 @@ class PickList(TransactionBase):
self.item_location_map = frappe._dict()
from_warehouses = [self.parent_warehouse] if self.parent_warehouse else []
if self.parent_warehouse:
from_warehouses.extend(get_descendants_of("Warehouse", self.parent_warehouse))
if self.work_order:
root_warehouse = frappe.db.get_value(
"Warehouse", {"company": self.company, "parent_warehouse": ["IS", "NOT SET"], "is_group": 1}
)
from_warehouses = [root_warehouse]
if from_warehouses:
from_warehouses.extend(get_descendants_of("Warehouse", from_warehouses[0]))
item_warehouse_dict = frappe._dict()
if self.work_order:
item_warehouse_list = frappe.get_all(
"Work Order Item",
filters={"parent": self.work_order},
fields=["item_code", "source_warehouse"],
)
if item_warehouse_list:
item_warehouse_dict = {item.item_code: item.source_warehouse for item in item_warehouse_list}
# Create replica before resetting, to handle empty table on update after submit.
locations_replica = self.get("locations")
@@ -524,6 +542,13 @@ class PickList(TransactionBase):
len_idx = len(self.get("locations")) or 0
for item_doc in items:
item_code = item_doc.item_code
priority_warehouses = []
if self.work_order and item_warehouse_dict.get(item_code):
source_warehouse = item_warehouse_dict.get(item_code)
priority_warehouses = [source_warehouse]
priority_warehouses.extend(get_descendants_of("Warehouse", source_warehouse))
from_warehouses = list(dict.fromkeys(priority_warehouses + from_warehouses))
self.item_location_map.setdefault(
item_code,
@@ -534,6 +559,7 @@ class PickList(TransactionBase):
self.company,
picked_item_details=picked_items_details.get(item_code),
consider_rejected_warehouses=self.consider_rejected_warehouses,
priority_warehouses=priority_warehouses,
),
)
@@ -959,6 +985,7 @@ def get_available_item_locations(
ignore_validation=False,
picked_item_details=None,
consider_rejected_warehouses=False,
priority_warehouses=None,
):
locations = []
@@ -999,7 +1026,7 @@ def get_available_item_locations(
locations = filter_locations_by_picked_materials(locations, picked_item_details)
if locations:
locations = get_locations_based_on_required_qty(locations, required_qty)
locations = get_locations_based_on_required_qty(locations, required_qty, priority_warehouses)
if not ignore_validation:
validate_picked_materials(item_code, required_qty, locations, picked_item_details)
@@ -1007,9 +1034,14 @@ def get_available_item_locations(
return locations
def get_locations_based_on_required_qty(locations, required_qty):
def get_locations_based_on_required_qty(locations, required_qty, priority_warehouses):
filtered_locations = []
if priority_warehouses:
priority_locations = [loc for loc in locations if loc.warehouse in priority_warehouses]
fallback_locations = [loc for loc in locations if loc.warehouse not in priority_warehouses]
locations = priority_locations + fallback_locations
for location in locations:
if location.qty >= required_qty:
location.qty = required_qty

View File

@@ -1041,6 +1041,53 @@ class TestPickList(FrappeTestCase):
pl = create_pick_list(so.name)
self.assertFalse(pl.locations)
def test_pick_list_warehouse_for_work_order(self):
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.doctype.work_order.work_order import create_pick_list, make_work_order
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
# Create Warehouses for Work Order
source_warehouse = create_warehouse("_Test WO Warehouse")
wip_warehouse = create_warehouse("_Test WIP Warehouse", company="_Test Company")
fg_warehouse = create_warehouse("_Test Finished Goods Warehouse", company="_Test Company")
# Create Finished Good Item
fg_item = make_item("Test Work Order Finished Good Item", properties={"is_stock_item": 1}).name
# Create Raw Material Item
rm_item = make_item("Test Work Order Raw Material Item", properties={"is_stock_item": 1}).name
# Create BOM
bom = make_bom(item=fg_item, rate=100, raw_materials=[rm_item])
# Create Inward entry for Raw Material
make_stock_entry(item=rm_item, to_warehouse=wip_warehouse, qty=10)
make_stock_entry(item=rm_item, to_warehouse=source_warehouse, qty=10)
# Create Work Order
wo = make_work_order(item=fg_item, qty=5, bom_no=bom.name, company="_Test Company")
wo.required_items[0].source_warehouse = source_warehouse
wo.fg_warehouse = fg_warehouse
wo.skip_transfer = True
wo.submit()
# Create Pick List
pl = create_pick_list(wo.name, for_qty=wo.qty)
# System prioritises the Source Warehouse
self.assertEqual(pl.locations[0].warehouse, source_warehouse)
self.assertEqual(pl.locations[0].item_code, rm_item)
self.assertEqual(pl.locations[0].qty, 5)
# Create Outward Entry from Source Warehouse
make_stock_entry(item=rm_item, from_warehouse=source_warehouse, qty=10)
pl.set_item_locations()
# System should pick other available warehouses
self.assertEqual(pl.locations[0].warehouse, wip_warehouse)
self.assertEqual(pl.locations[0].item_code, rm_item)
self.assertEqual(pl.locations[0].qty, 5)
def test_pick_list_validation_for_serial_no(self):
warehouse = "_Test Warehouse - _TC"
item = make_item(

View File

@@ -1217,6 +1217,65 @@ class TestPurchaseReceipt(FrappeTestCase):
pr.cancel()
def test_item_valuation_with_deduct_valuation_and_total_tax(self):
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
supplier_warehouse="Work In Progress - TCP1",
qty=5,
rate=100,
do_not_save=1,
)
pr.append(
"taxes",
{
"charge_type": "Actual",
"add_deduct_tax": "Deduct",
"account_head": "_Test Account Shipping Charges - TCP1",
"category": "Valuation and Total",
"cost_center": "Main - TCP1",
"description": "Valuation Discount",
"tax_amount": 20,
},
)
pr.insert()
self.assertAlmostEqual(pr.items[0].item_tax_amount, -20.0, places=2)
self.assertAlmostEqual(pr.items[0].valuation_rate, 96.0, places=2)
pr.delete()
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
supplier_warehouse="Work In Progress - TCP1",
qty=5,
rate=100,
do_not_save=1,
)
pr.append(
"taxes",
{
"charge_type": "On Net Total",
"add_deduct_tax": "Deduct",
"account_head": "_Test Account Shipping Charges - TCP1",
"category": "Valuation and Total",
"cost_center": "Main - TCP1",
"description": "Valuation Discount",
"rate": 10,
},
)
pr.insert()
self.assertAlmostEqual(pr.items[0].item_tax_amount, -50.0, places=2)
self.assertAlmostEqual(pr.items[0].valuation_rate, 90.0, places=2)
pr.delete()
def test_po_to_pi_and_po_to_pr_worflow_full(self):
"""Test following behaviour:
- Create PO

View File

@@ -14,19 +14,19 @@
"fields": [
{
"fieldname": "length",
"fieldtype": "Int",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Length (cm)"
},
{
"fieldname": "width",
"fieldtype": "Int",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Width (cm)"
},
{
"fieldname": "height",
"fieldtype": "Int",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Height (cm)"
},
@@ -49,7 +49,7 @@
],
"istable": 1,
"links": [],
"modified": "2024-03-06 16:48:57.355757",
"modified": "2026-03-29 00:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Shipment Parcel",
@@ -60,4 +60,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -15,21 +15,21 @@
"fields": [
{
"fieldname": "length",
"fieldtype": "Int",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Length (cm)",
"reqd": 1
},
{
"fieldname": "width",
"fieldtype": "Int",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Width (cm)",
"reqd": 1
},
{
"fieldname": "height",
"fieldtype": "Int",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Height (cm)",
"reqd": 1
@@ -52,7 +52,7 @@
}
],
"links": [],
"modified": "2020-09-28 12:51:00.320421",
"modified": "2026-03-29 00:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Shipment Parcel Template",
@@ -75,4 +75,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}
}

View File

@@ -38,18 +38,18 @@ frappe.ui.form.on("Stock Entry", {
frm.set_query("source_warehouse_address", function () {
return {
query: "erpnext.controllers.queries.get_warehouse_address",
filters: {
link_doctype: "Warehouse",
link_name: frm.doc.from_warehouse,
warehouse: frm.doc.from_warehouse,
},
};
});
frm.set_query("target_warehouse_address", function () {
return {
query: "erpnext.controllers.queries.get_warehouse_address",
filters: {
link_doctype: "Warehouse",
link_name: frm.doc.to_warehouse,
warehouse: frm.doc.to_warehouse,
},
};
});

View File

@@ -7,6 +7,7 @@ from operator import itemgetter
import frappe
from frappe import _
from frappe.query_builder.functions import Count
from frappe.utils import cint, date_diff, flt, get_datetime
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
@@ -238,9 +239,9 @@ class FIFOSlots:
Returns dict of the foll.g structure:
Key = Item A / (Item A, Warehouse A)
Key: {
'details' -> Dict: ** item details **,
'fifo_queue' -> List: ** list of lists containing entries/slots for existing stock,
consumed/updated and maintained via FIFO. **
'details' -> Dict: ** item details **,
'fifo_queue' -> List: ** list of lists containing entries/slots for existing stock,
consumed/updated and maintained via FIFO. **
}
"""
from erpnext.stock.serial_batch_bundle import get_serial_nos_from_bundle
@@ -251,16 +252,33 @@ class FIFOSlots:
if stock_ledger_entries is None:
bundle_wise_serial_nos = self.__get_bundle_wise_serial_nos()
# prepare single sle voucher detail lookup
self.prepare_stock_reco_voucher_wise_count()
with frappe.db.unbuffered_cursor():
if stock_ledger_entries is None:
stock_ledger_entries = self.__get_stock_ledger_entries()
for d in stock_ledger_entries:
key, fifo_queue, transferred_item_key = self.__init_key_stores(d)
prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0)
if d.voucher_type == "Stock Reconciliation":
if d.voucher_type == "Stock Reconciliation" and (
not d.batch_no or d.serial_no or d.serial_and_batch_bundle
):
if d.voucher_detail_no in self.stock_reco_voucher_wise_count:
# for legacy recon with single sle has qty_after_transaction and stock_value_difference without outward entry
# for exisitng handle emptying the existing queue and details.
d.stock_value_difference = flt(d.qty_after_transaction * d.valuation_rate)
d.actual_qty = d.qty_after_transaction
self.item_details[key]["qty_after_transaction"] = 0
self.item_details[key]["total_qty"] = 0
fifo_queue.clear()
else:
d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty)
elif d.voucher_type == "Stock Reconciliation":
# get difference in qty shift as actual qty
prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0)
d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty)
serial_nos = get_serial_nos(d.serial_no) if d.serial_no else []
@@ -278,6 +296,14 @@ class FIFOSlots:
self.__update_balances(d, key)
# handle serial nos misconsumption
if d.has_serial_no:
qty_after = cint(self.item_details[key]["qty_after_transaction"])
if qty_after <= 0:
fifo_queue.clear()
elif len(fifo_queue) > qty_after:
fifo_queue[:] = fifo_queue[:qty_after]
# Note that stock_ledger_entries is an iterator, you can not reuse it like a list
del stock_ledger_entries
@@ -404,7 +430,6 @@ class FIFOSlots:
def __update_balances(self, row: dict, key: tuple | str):
self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction
if "total_qty" not in self.item_details[key]:
self.item_details[key]["total_qty"] = row.actual_qty
else:
@@ -460,6 +485,7 @@ class FIFOSlots:
sle.posting_date,
sle.voucher_type,
sle.voucher_no,
sle.voucher_detail_no,
sle.serial_no,
sle.batch_no,
sle.qty_after_transaction,
@@ -555,3 +581,36 @@ class FIFOSlots:
warehouse_results = [x[0] for x in warehouse_results]
return sle_query.where(sle.warehouse.isin(warehouse_results))
def prepare_stock_reco_voucher_wise_count(self):
self.stock_reco_voucher_wise_count = frappe._dict()
doctype = frappe.qb.DocType("Stock Ledger Entry")
item = frappe.qb.DocType("Item")
query = (
frappe.qb.from_(doctype)
.inner_join(item)
.on(doctype.item_code == item.name)
.select(doctype.voucher_detail_no, Count(doctype.name).as_("count"))
.where(
(doctype.voucher_type == "Stock Reconciliation")
& (doctype.docstatus < 2)
& (doctype.is_cancelled == 0)
)
.groupby(doctype.voucher_detail_no)
)
data = query.run(as_dict=True)
if not data:
return
for row in data:
if row.count != 1:
continue
sr_item = frappe.db.get_value(
"Stock Reconciliation Item", row.voucher_detail_no, ["current_qty", "qty"], as_dict=True
)
if sr_item.qty and sr_item.current_qty:
self.stock_reco_voucher_wise_count[row.voucher_detail_no] = sr_item.current_qty

View File

@@ -140,7 +140,7 @@
<div class="col-sm-12">
{% for attachment in attachments %}
<p class="small">
<a href="{{ attachment.file_url }}" target="blank"> {{ attachment.file_name }} </a>
<a href="{{ attachment.file_url|e }}" target="blank"> {{ attachment.file_name|e }} </a>
</p>
{% endfor %}
</div>

View File

@@ -82,11 +82,11 @@
<div class="project-attachments">
{% for attachment in doc.attachments %}
<div class="attachment">
<a class="no-decoration attachment-link" href="{{ attachment.file_url }}" target="blank">
<a class="no-decoration attachment-link" href="{{ attachment.file_url|e }}" target="blank">
<div class="row">
<div class="col-xs-9">
<span class="indicator red file-name">
{{ attachment.file_name }}</span>
{{ attachment.file_name|e }}</span>
</div>
<div class="col-xs-3">
<span class="pull-right file-size">{{ attachment.file_size }}</span>
@@ -101,8 +101,8 @@
</div>
<script>
{ % include "frappe/public/js/frappe/provide.js" % }
{ % include "frappe/public/js/frappe/form/formatters.js" % }
{% include "frappe/public/js/frappe/provide.js" %}
{% include "frappe/public/js/frappe/form/formatters.js" %}
</script>
{% endblock %}