mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-12 19:35:09 +00:00
Merge pull request #53916 from frappe/version-15-hotfix
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user