Compare commits

..

42 Commits

Author SHA1 Message Date
coderabbitai[bot]
f6c8d5e038 📝 Add docstrings to fix/email-campaign-timeout
Docstrings generation was requested by @pratikb64.

* https://github.com/frappe/erpnext/pull/51994#issuecomment-3783742931

The following files were modified:

* `erpnext/crm/doctype/email_campaign/email_campaign.py`
2026-01-22 10:55:07 +00:00
Khushi Rawat
a030ea6fde Merge pull request #51756 from aerele/asset-repair
fix: disable asset repair when status is fully depreciated
2026-01-22 13:59:39 +05:30
Mihir Kandoi
e5d25a7f04 Merge pull request #51908 from ljain112/fix-inward-subcontracting 2026-01-22 10:34:44 +05:30
Mihir Kandoi
40e86b6670 Merge pull request #51929 from ljain112/fix-subcontracting-inward-rm-rate 2026-01-22 10:31:53 +05:30
Mihir Kandoi
d0c9924c37 Merge pull request #51966 from aerele/customer-group-filters 2026-01-22 10:26:22 +05:30
Mihir Kandoi
ede4faa152 Merge pull request #51967 from aerele/project-update-naming-series 2026-01-22 10:22:31 +05:30
Mihir Kandoi
05cf1dcab8 Merge pull request #51968 from mihir-kandoi/gh51965 2026-01-21 22:50:22 +05:30
Mihir Kandoi
43a6dd5657 Merge pull request #51964 from mihir-kandoi/dont-show-dn-btn-if-not-reqd 2026-01-21 22:39:08 +05:30
Mihir Kandoi
343ee9695b fix: rejected qty in PR doesn't consider conversion factor 2026-01-21 22:33:24 +05:30
ravibharathi656
49e64f4e1c fix(project): add missing counter to project update naming series 2026-01-21 19:55:33 +05:30
SowmyaArunachalam
1e3db9f916 fix(customer): add customer group filters 2026-01-21 17:37:37 +05:30
Mihir Kandoi
70ec977cb2 fix: create DN btn should not be shown if it cannot be created 2026-01-21 17:13:35 +05:30
Mihir Kandoi
d6bbe43fa0 Merge pull request #51958 from mihir-kandoi/force-serial-batch-stock-reco 2026-01-21 16:23:18 +05:30
Mihir Kandoi
035b3cb61e fix: tests 2026-01-21 15:58:46 +05:30
Mihir Kandoi
97c36d1edc Merge pull request #51947 from mihir-kandoi/st57849 2026-01-21 15:46:38 +05:30
Mihir Kandoi
7170a1bd78 fix: force user to enter batch or serial for serial/batch items 2026-01-21 15:30:55 +05:30
Diptanil Saha
936f13eb20 ci: generate pot files for version-16-hotfix (#51954) 2026-01-21 14:37:21 +05:30
Mihir Kandoi
ed51db3217 Merge pull request #51948 from mihir-kandoi/st57735 2026-01-21 13:05:43 +05:30
Mihir Kandoi
5bacb67d36 fix: warehouse permissions in MR incorrectly ignored 2026-01-21 12:50:50 +05:30
Mihir Kandoi
c919b1de38 fix: job cards should not be deleted on close of WO 2026-01-21 11:46:00 +05:30
Mihir Kandoi
46ab5e8e46 Merge pull request #51934 from mihir-kandoi/st57619 2026-01-20 20:46:38 +05:30
Mihir Kandoi
3960c01798 fix: validation message in stock reco row idx 2026-01-20 20:30:10 +05:30
rohitwaghchaure
3c5071cefc Merge pull request #51930 from frappe/revert-51920-fixed-do-reposting-for-lcv
Revert "perf: prevent duplicate reposting for the same item"
2026-01-20 19:48:35 +05:30
rohitwaghchaure
6e4b90055f Revert "perf: prevent duplicate reposting for the same item" 2026-01-20 19:30:42 +05:30
ljain112
37ee560eae fix: calculate weighted average rate for customer provided items in subcontracting inward order 2026-01-20 18:48:59 +05:30
Mihir Kandoi
4b3000b071 Merge pull request #51909 from mihir-kandoi/gh51906 2026-01-20 17:44:23 +05:30
rohitwaghchaure
ad6cb177e3 Merge pull request #51920 from rohitwaghchaure/fixed-do-reposting-for-lcv
perf: prevent duplicate reposting for the same item
2026-01-20 17:38:15 +05:30
ljain112
d256365f4a fix: throw if item order field is not set in subcontracting controller 2026-01-20 17:33:01 +05:30
Diptanil Saha
05fea7f66f Merge pull request #51887 from diptanilsaha/bank_ac 2026-01-20 17:21:51 +05:30
Rohit Waghchaure
7535931571 perf: prevent duplicate reposting for the same item 2026-01-20 17:13:16 +05:30
Mihir Kandoi
27915c9ce2 Merge pull request #51914 from mihir-kandoi/st57262 2026-01-20 16:44:43 +05:30
ruthra kumar
93b131f48a Merge pull request #51671 from nikkothari22/advance-taxes-dimensions
fix(accounts): add missing accounting dimensions in advance taxes and charges
2026-01-20 16:36:16 +05:30
Nikhil Kothari
10d5463a40 Merge branch 'develop' into advance-taxes-dimensions 2026-01-20 16:19:08 +05:30
Mihir Kandoi
017cc9d9f9 fix: continuous raw material consumption with bom validation 2026-01-20 16:18:06 +05:30
Mihir Kandoi
b691de0147 fix: allow creation of DN in SI for items not having DN reference 2026-01-20 15:08:37 +05:30
rohitwaghchaure
beabbb1fa2 Merge pull request #51900 from rohitwaghchaure/fixed-github-49961
fix: validation to check at-least one raw material for manufacture entry
2026-01-20 13:54:48 +05:30
rohitwaghchaure
65c3020d1b Merge pull request #51899 from rohitwaghchaure/fixed-github-51401
feat: option to import serial / batches using csv for outward entry
2026-01-20 13:37:35 +05:30
Rohit Waghchaure
f003b3c378 fix: validation to check at-least one raw material for manufacture entry 2026-01-20 13:36:46 +05:30
Rohit Waghchaure
a268316322 feat: option to import serial / batches using csv for outward entry 2026-01-20 13:10:40 +05:30
diptanilsaha
7532ab01d6 fix(bank_account): validation for is_company_account 2026-01-20 00:46:46 +05:30
SowmyaArunachalam
66fe1aa85d fix: disable asset repair when status is fully depreciated 2026-01-14 21:22:10 +05:30
Nikhil Kothari
22e9cb4cf4 fix(accounts): add missing accounting dimensions in advance taxes and charges 2026-01-12 00:42:52 +05:30
29 changed files with 326 additions and 137 deletions

View File

@@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
branch: ["develop"]
branch: ["develop", "version-16-hotfix"]
permissions:
contents: write

View File

@@ -42,8 +42,4 @@ frappe.ui.form.on("Bank Account", {
});
}
},
is_company_account: function (frm) {
frm.set_df_property("account", "reqd", frm.doc.is_company_account);
},
});

View File

@@ -52,6 +52,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Company Account",
"mandatory_depends_on": "is_company_account",
"options": "Account"
},
{
@@ -98,6 +99,7 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Company",
"mandatory_depends_on": "is_company_account",
"options": "Company"
},
{
@@ -252,7 +254,7 @@
"link_fieldname": "default_bank_account"
}
],
"modified": "2025-08-29 12:32:01.081687",
"modified": "2026-01-20 00:46:16.633364",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Account",

View File

@@ -51,25 +51,29 @@ class BankAccount(Document):
delete_contact_and_address("Bank Account", self.name)
def validate(self):
self.validate_company()
self.validate_account()
self.validate_is_company_account()
self.update_default_bank_account()
def validate_account(self):
if self.account:
if accounts := frappe.db.get_all(
"Bank Account", filters={"account": self.account, "name": ["!=", self.name]}, as_list=1
):
frappe.throw(
_("'{0}' account is already used by {1}. Use another account.").format(
frappe.bold(self.account),
frappe.bold(comma_and([get_link_to_form(self.doctype, x[0]) for x in accounts])),
)
)
def validate_is_company_account(self):
if self.is_company_account:
if not self.company:
frappe.throw(_("Company is mandatory for company account"))
def validate_company(self):
if self.is_company_account and not self.company:
frappe.throw(_("Company is mandatory for company account"))
if not self.account:
frappe.throw(_("Company Account is mandatory"))
self.validate_account()
def validate_account(self):
if accounts := frappe.db.get_all(
"Bank Account", filters={"account": self.account, "name": ["!=", self.name]}, as_list=1
):
frappe.throw(
_("'{0}' account is already used by {1}. Use another account.").format(
frappe.bold(self.account),
frappe.bold(comma_and([get_link_to_form(self.doctype, x[0]) for x in accounts])),
)
)
def update_default_bank_account(self):
if self.is_default and not self.disabled:

View File

@@ -115,18 +115,21 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
}
if (cint(doc.update_stock) != 1) {
// show Make Delivery Note button only if Sales Invoice is not created from Delivery Note
var from_delivery_note = false;
from_delivery_note = this.frm.doc.items.some(function (item) {
return item.delivery_note ? true : false;
});
if (!from_delivery_note && !is_delivered_by_supplier) {
this.frm.add_custom_button(
__("Delivery"),
this.frm.cscript["Make Delivery Note"],
__("Create")
if (!is_delivered_by_supplier) {
const should_create_delivery_note = doc.items.some(
(item) =>
item.qty - item.delivered_qty > 0 &&
!item.scio_detail &&
!item.dn_detail &&
!item.delivered_by_supplier
);
if (should_create_delivery_note) {
this.frm.add_custom_button(
__("Delivery Note"),
this.frm.cscript["Make Delivery Note"],
__("Create")
);
}
}
}

View File

@@ -2470,7 +2470,10 @@ def make_delivery_note(source_name, target_doc=None):
"cost_center": "cost_center",
},
"postprocess": update_item,
"condition": lambda doc: doc.delivered_by_supplier != 1 and not doc.scio_detail,
"condition": lambda doc: doc.delivered_by_supplier != 1
and not doc.scio_detail
and not doc.dn_detail
and doc.qty - doc.delivered_qty > 0,
},
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
"Sales Team": {

View File

@@ -116,14 +116,6 @@ frappe.ui.form.on("Asset", {
__("Manage")
);
frm.add_custom_button(
__("Repair Asset"),
function () {
frm.trigger("create_asset_repair");
},
__("Manage")
);
frm.add_custom_button(
__("Split Asset"),
function () {
@@ -155,6 +147,14 @@ frappe.ui.form.on("Asset", {
},
__("Manage")
);
frm.add_custom_button(
__("Repair Asset"),
function () {
frm.trigger("create_asset_repair");
},
__("Manage")
);
}
if (!frm.doc.calculate_depreciation) {

View File

@@ -552,7 +552,7 @@ class StockController(AccountsController):
if is_rejected:
serial_nos = row.get("rejected_serial_no")
type_of_transaction = "Inward" if not self.is_return else "Outward"
qty = row.get("rejected_qty")
qty = row.get("rejected_qty") * row.get("conversion_factor", 1.0)
warehouse = row.get("rejected_warehouse")
if (

View File

@@ -166,29 +166,46 @@ class SubcontractingController(StockController):
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
)
if self.doctype != "Subcontracting Receipt" and item.qty > flt(
get_pending_subcontracted_quantity(
self.doctype,
self.purchase_order if self.doctype == "Subcontracting Order" else self.sales_order,
).get(
item.purchase_order_item
if self.doctype == "Subcontracting Order"
else item.sales_order_item
)
/ item.subcontracting_conversion_factor,
frappe.get_precision(
if self.doctype != "Subcontracting Receipt":
order_item_doctype = (
"Purchase Order Item"
if self.doctype == "Subcontracting Order"
else "Sales Order Item",
"qty",
),
):
frappe.throw(
_(
"Row {0}: Item {1}'s quantity cannot be higher than the available quantity."
).format(item.idx, item.item_name)
else "Sales Order Item"
)
order_name = (
self.purchase_order if self.doctype == "Subcontracting Order" else self.sales_order
)
order_item_field = frappe.scrub(order_item_doctype)
if not item.get(order_item_field):
frappe.throw(
_("Row {0}: Item {1} must be linked to a {2}.").format(
item.idx, item.item_name, order_item_doctype
)
)
pending_qty = flt(
flt(
get_pending_subcontracted_quantity(
order_item_doctype,
order_name,
).get(item.get(order_item_field))
)
/ item.subcontracting_conversion_factor,
frappe.get_precision(
order_item_doctype,
"qty",
),
)
if item.qty > pending_qty:
frappe.throw(
_(
"Row {0}: Item {1}'s quantity cannot be higher than the available quantity."
).format(item.idx, item.item_name)
)
if self.doctype != "Subcontracting Inward Order":
item.amount = item.qty * item.rate
@@ -1332,9 +1349,7 @@ def get_item_details(items):
def get_pending_subcontracted_quantity(doctype, name):
table = frappe.qb.DocType(
"Purchase Order Item" if doctype == "Subcontracting Order" else "Sales Order Item"
)
table = frappe.qb.DocType(doctype)
query = (
frappe.qb.from_(table)
.select(table.name, table.stock_qty, table.subcontracted_qty)

View File

@@ -720,6 +720,7 @@ class SubcontractingInwardController:
item.db_set("scio_detail", scio_rm.name)
if data:
precision = self.precision("customer_provided_item_cost", "items")
result = frappe.get_all(
"Subcontracting Inward Order Received Item",
filters={
@@ -734,10 +735,17 @@ class SubcontractingInwardController:
table = frappe.qb.DocType("Subcontracting Inward Order Received Item")
case_expr_qty, case_expr_rate = Case(), Case()
for d in result:
d.received_qty += (
data[d.name].transfer_qty if self._action == "submit" else -data[d.name].transfer_qty
current_qty = flt(data[d.name].transfer_qty) * (1 if self._action == "submit" else -1)
current_rate = flt(data[d.name].rate)
# Calculate weighted average rate
old_total = d.rate * d.received_qty
current_total = current_rate * current_qty
d.received_qty = d.received_qty + current_qty
d.rate = (
flt((old_total + current_total) / d.received_qty, precision) if d.received_qty else 0.0
)
d.rate += data[d.name].rate if self._action == "submit" else -data[d.name].rate
if not d.required_qty and not d.received_qty:
deleted_docs.append(d.name)

View File

@@ -100,6 +100,16 @@ def send_email_to_leads_or_contacts():
def send_mail(entry, email_campaign):
"""
Send the rendered email template to the recipients defined by the campaign, creating one communication per batch.
Parameters:
entry (dict): Campaign schedule entry containing at least the `email_template` reference and `send_after_days`.
email_campaign (Document): Email Campaign document that provides recipient selection, sender, campaign name, start/end dates and related context.
Returns:
comm (Document or None): The last created communication Document for the final recipient batch, or `None` if no recipients were found.
"""
recipient_list = []
if email_campaign.email_campaign_for == "Email Group":
for member in frappe.db.get_list(
@@ -116,19 +126,29 @@ def send_mail(entry, email_campaign):
email_template = frappe.get_doc("Email Template", entry.get("email_template"))
sender = frappe.db.get_value("User", email_campaign.get("sender"), "email")
context = {"doc": frappe.get_doc(email_campaign.email_campaign_for, email_campaign.recipient)}
# send mail and link communication to document
comm = make(
doctype="Email Campaign",
name=email_campaign.name,
subject=frappe.render_template(email_template.get("subject"), context),
content=frappe.render_template(email_template.response_, context),
sender=sender,
bcc=recipient_list,
communication_medium="Email",
sent_or_received="Sent",
send_email=True,
email_template=email_template.name,
)
subject = frappe.render_template(email_template.get("subject"), context)
content = frappe.render_template(email_template.response_, context)
# Batch recipients to avoid timeout when processing large email groups
BATCH_SIZE = 100
comm = None
for i in range(0, len(recipient_list), BATCH_SIZE):
batch = recipient_list[i : i + BATCH_SIZE]
comm = make(
doctype="Email Campaign",
name=email_campaign.name,
subject=subject,
content=content,
sender=sender,
bcc=batch,
communication_medium="Email",
sent_or_received="Sent",
send_email=True,
email_template=email_template.name,
)
return comm
@@ -143,4 +163,4 @@ def set_email_campaign_status():
email_campaigns = frappe.get_all("Email Campaign", filters={"status": ("!=", "Unsubscribed")})
for entry in email_campaigns:
email_campaign = frappe.get_doc("Email Campaign", entry.name)
email_campaign.update_status()
email_campaign.update_status()

View File

@@ -569,6 +569,7 @@ accounting_dimension_doctypes = [
"Payment Request",
"Asset Movement Item",
"Asset Depreciation Schedule",
"Advance Taxes and Charges",
]
get_matching_queries = (

View File

@@ -829,7 +829,7 @@ erpnext.work_order = {
}
}
if (counter > 0) {
var consumption_btn = frm.add_custom_button(
frm.add_custom_button(
__("Material Consumption"),
function () {
const backflush_raw_materials_based_on =

View File

@@ -770,6 +770,7 @@ class WorkOrder(Document):
self.db_set("status", "Cancelled")
self.on_close_or_cancel()
self.delete_job_card()
def on_close_or_cancel(self):
if self.production_plan and frappe.db.exists(
@@ -779,7 +780,6 @@ class WorkOrder(Document):
else:
self.update_work_order_qty_in_so()
self.delete_job_card()
self.update_completed_qty_in_material_request()
self.update_planned_qty()
self.update_ordered_qty()

View File

@@ -457,4 +457,5 @@ erpnext.patches.v16_0.migrate_tax_withholding_data
erpnext.patches.v16_0.update_corrected_cancelled_status
erpnext.patches.v16_0.fix_barcode_typo
erpnext.patches.v16_0.set_post_change_gl_entries_on_pos_settings
erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges
execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Opening & Closing")

View File

@@ -0,0 +1,7 @@
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
create_accounting_dimensions_for_doctype,
)
def execute():
create_accounting_dimensions_for_doctype(doctype="Advance Taxes and Charges")

View File

@@ -603,7 +603,7 @@ def send_project_update_email_to_users(project):
"sent": 0,
"date": today(),
"time": nowtime(),
"naming_series": "UPDATE-.project.-.YY.MM.DD.-",
"naming_series": "UPDATE-.project.-.YY.MM.DD.-.####",
}
).insert()

View File

@@ -163,7 +163,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
}
if (this.item?.type_of_transaction === "Outward") {
fields = [...this.get_filter_fields(), ...fields];
fields = [...this.get_filter_fields(), ...fields, ...this.get_attach_field()];
} else {
fields = [...fields, ...this.get_attach_field()];
}
@@ -195,7 +195,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
}
let fields = [];
if (this.item.has_serial_no) {
if (this.item.has_serial_no && this.item?.type_of_transaction !== "Outward") {
fields.push({
fieldtype: "Check",
label: __("Enter Manually"),
@@ -217,7 +217,8 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
label: __("Import Using CSV file"),
fieldname: "import_using_csv_file",
depends_on: "eval:doc.enter_manually !== 1",
default: !this.item.has_serial_no ? 1 : 0,
default: !this.item.has_serial_no || this.item?.type_of_transaction === "Outward" ? 1 : 0,
hidden: this.item?.type_of_transaction === "Outward",
change() {
if (me.dialog.get_value("import_using_csv_file")) {
me.dialog.set_value("enter_manually", 0);
@@ -246,7 +247,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
},
];
if (this.item?.has_serial_no) {
if (this.item?.has_serial_no && this.item?.type_of_transaction !== "Outward") {
fields = [
...fields,
{
@@ -267,7 +268,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
];
}
if (this.item?.has_serial_no) {
if (this.item?.has_serial_no && this.item?.type_of_transaction !== "Outward") {
fields = [
...fields,
{

View File

@@ -183,6 +183,7 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Customer Group",
"link_filters": "[[\"Customer Group\", \"is_group\", \"=\", 0]]",
"oldfieldname": "customer_group",
"oldfieldtype": "Link",
"options": "Customer Group",
@@ -625,7 +626,7 @@
"link_fieldname": "party"
}
],
"modified": "2026-01-16 15:56:05.967663",
"modified": "2026-01-21 17:23:42.151114",
"modified_by": "Administrator",
"module": "Selling",
"name": "Customer",

View File

@@ -61,6 +61,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Default Customer Group",
"link_filters": "[[\"Customer Group\", \"is_group\", \"=\", 0]]",
"options": "Customer Group"
},
{
@@ -297,7 +298,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-01-02 18:17:05.734945",
"modified": "2026-01-21 17:28:37.027837",
"modified_by": "Administrator",
"module": "Selling",
"name": "Selling Settings",

View File

@@ -90,19 +90,12 @@ frappe.query_reports["Sales Analytics"] = {
label: __("Curves"),
fieldtype: "Select",
options: [
<<<<<<< HEAD
{ value: "select", label: __("Select") },
=======
>>>>>>> c2995f6800 (fix(sales analytics): add curve filter)
{ value: "all", label: __("All") },
{ value: "non-zeros", label: __("Non-Zeros") },
{ value: "total", label: __("Total Only") },
],
<<<<<<< HEAD
default: "select",
=======
default: "all",
>>>>>>> c2995f6800 (fix(sales analytics): add curve filter)
reqd: 1,
},
{

View File

@@ -529,7 +529,6 @@ class Analytics:
labels = [d.get("label") for d in self.columns[1 : length - 1]]
datasets = []
<<<<<<< HEAD
if self.filters.curves != "select":
for curve in self.data:
data = {
@@ -553,29 +552,6 @@ class Analytics:
datasets[0]["name"] = _("Total")
else:
datasets.append(data)
=======
for curve in self.data:
data = {
"name": curve.get("entity_name", curve["entity"]),
"values": [curve.get(scrub(label), 0) for label in labels],
}
if self.filters.curves == "non-zeros" and not sum(data["values"]):
continue
elif self.filters.curves == "total" and "indent" in curve:
if curve["indent"] == 0:
datasets.append(data)
elif self.filters.curves == "total":
if datasets:
a = [
data["values"][idx] + datasets[0]["values"][idx] for idx in range(len(data["values"]))
]
datasets[0]["values"] = a
else:
datasets.append(data)
datasets[0]["name"] = _("Total")
else:
datasets.append(data)
>>>>>>> c2995f6800 (fix(sales analytics): add curve filter)
self.chart = {"data": {"labels": labels, "datasets": datasets}, "type": "line"}

View File

@@ -282,7 +282,6 @@
{
"fieldname": "set_warehouse",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"in_list_view": 1,
"label": "Set Target Warehouse",
"options": "Warehouse"
@@ -378,7 +377,7 @@
"idx": 70,
"is_submittable": 1,
"links": [],
"modified": "2026-01-10 15:34:59.000603",
"modified": "2026-01-21 12:48:40.792323",
"modified_by": "Administrator",
"module": "Stock",
"name": "Material Request",

View File

@@ -278,9 +278,30 @@ class StockEntry(StockController, SubcontractingInwardController):
self.validate_closed_subcontracting_order()
self.validate_subcontract_order()
self.validate_raw_materials_exists()
super().validate_subcontracting_inward()
def validate_raw_materials_exists(self):
if self.purpose not in ["Manufacture", "Repack", "Disassemble"]:
return
if frappe.db.get_single_value("Manufacturing Settings", "material_consumption"):
return
raw_materials = []
for row in self.items:
if row.s_warehouse:
raw_materials.append(row.item_code)
if not raw_materials:
frappe.throw(
_(
"At least one raw material item must be present in the stock entry for the type {0}"
).format(bold(self.purpose)),
title=_("Raw Materials Missing"),
)
def set_serial_batch_for_disassembly(self):
if self.purpose != "Disassemble":
return
@@ -938,7 +959,9 @@ class StockEntry(StockController, SubcontractingInwardController):
if matched_item := self.get_matched_items(item_code):
if flt(details.get("qty"), precision) != flt(matched_item.qty, precision):
frappe.throw(
_("For the item {0}, the quantity should be {1} according to the BOM {2}.").format(
_(
"For the item {0}, the consumed quantity should be {1} according to the BOM {2}."
).format(
frappe.bold(item_code),
flt(details.get("qty")),
get_link_to_form("BOM", self.bom_no),
@@ -1003,12 +1026,37 @@ class StockEntry(StockController, SubcontractingInwardController):
)
def get_matched_items(self, item_code):
for row in self.items:
items = [item for item in self.items if item.s_warehouse]
for row in items or self.get_consumed_items():
if row.item_code == item_code or row.original_item == item_code:
return row
return {}
def get_consumed_items(self):
"""Get all raw materials consumed through consumption entries"""
parent = frappe.qb.DocType("Stock Entry")
child = frappe.qb.DocType("Stock Entry Detail")
query = (
frappe.qb.from_(parent)
.join(child)
.on(parent.name == child.parent)
.select(
child.item_code,
Sum(child.qty).as_("qty"),
child.original_item,
)
.where(
(parent.docstatus == 1)
& (parent.purpose == "Material Consumption for Manufacture")
& (parent.work_order == self.work_order)
)
.groupby(child.item_code, child.original_item)
)
return query.run(as_dict=True)
@frappe.whitelist()
def get_stock_and_rate(self):
"""

View File

@@ -2361,6 +2361,51 @@ class TestStockEntry(IntegrationTestCase):
self.assertEqual(target_sabb.entries[0].batch_no, batch)
self.assertEqual([entry.serial_no for entry in target_sabb.entries], serial_nos[:2])
@IntegrationTestCase.change_settings("Manufacturing Settings", {"material_consumption": 0})
def test_raw_material_missing_validation(self):
stock_entry = make_stock_entry(
item_code="_Test Item",
qty=1,
target="_Test Warehouse - _TC",
do_not_save=True,
)
stock_entry.purpose = "Manufacture"
stock_entry.stock_entry_type = "Manufacture"
stock_entry.items[0].is_finished_item = 1
self.assertRaises(
frappe.ValidationError,
stock_entry.save,
)
@IntegrationTestCase.change_settings(
"Manufacturing Settings",
{
"material_consumption": 1,
"backflush_raw_materials_based_on": "BOM",
"validate_components_quantities_per_bom": 1,
},
)
def test_validation_as_per_bom_with_continuous_raw_material_consumption(self):
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as _make_stock_entry
from erpnext.manufacturing.doctype.work_order.work_order import make_work_order
fg_item = make_item("_Mobiles", properties={"is_stock_item": 1}).name
rm_item1 = make_item("_Battery", properties={"is_stock_item": 1}).name
warehouse = "Stores - WP"
bom_no = make_bom(item=fg_item, raw_materials=[rm_item1]).name
make_stock_entry(item_code=rm_item1, target=warehouse, qty=5, rate=10, purpose="Material Receipt")
work_order = make_work_order(bom_no, fg_item, 5)
work_order.skip_transfer = 1
work_order.fg_warehouse = warehouse
work_order.submit()
frappe.get_doc(_make_stock_entry(work_order.name, "Material Consumption for Manufacture", 5)).submit()
frappe.get_doc(_make_stock_entry(work_order.name, "Manufacture", 5)).submit()
def make_serialized_item(self, **args):
args = frappe._dict(args)

View File

@@ -74,6 +74,7 @@ class StockReconciliation(StockController):
self.validate_duplicate_serial_and_batch_bundle("items")
self.remove_items_with_no_change()
self.validate_data()
self.change_row_indexes()
self.validate_expense_account()
self.validate_customer_provided_item()
self.set_zero_value_for_customer_provided_items()
@@ -555,8 +556,7 @@ class StockReconciliation(StockController):
elif len(items) != len(self.items):
self.items = items
for i, item in enumerate(self.items):
item.idx = i + 1
self.change_idx = True
frappe.msgprint(_("Removed items with no change in quantity or value."))
def calculate_difference_amount(self, item, item_dict):
@@ -573,14 +573,14 @@ class StockReconciliation(StockController):
def validate_data(self):
def _get_msg(row_num, msg):
return _("Row # {0}:").format(row_num + 1) + " " + msg
return _("Row #{0}:").format(row_num) + " " + msg
self.validation_messages = []
item_warehouse_combinations = []
default_currency = frappe.db.get_default("currency")
for row_num, row in enumerate(self.items):
for row in self.items:
# find duplicates
key = [row.item_code, row.warehouse]
for field in ["serial_no", "batch_no"]:
@@ -593,7 +593,7 @@ class StockReconciliation(StockController):
if key in item_warehouse_combinations:
self.validation_messages.append(
_get_msg(row_num, _("Same item and warehouse combination already entered."))
_get_msg(row.idx, _("Same item and warehouse combination already entered."))
)
else:
item_warehouse_combinations.append(key)
@@ -603,7 +603,7 @@ class StockReconciliation(StockController):
if row.serial_no and not row.qty:
self.validation_messages.append(
_get_msg(
row_num,
row.idx,
f"Quantity should not be zero for the {bold(row.item_code)} since serial nos are specified",
)
)
@@ -611,17 +611,17 @@ class StockReconciliation(StockController):
# if both not specified
if row.qty in ["", None] and row.valuation_rate in ["", None]:
self.validation_messages.append(
_get_msg(row_num, _("Please specify either Quantity or Valuation Rate or both"))
_get_msg(row.idx, _("Please specify either Quantity or Valuation Rate or both"))
)
# do not allow negative quantity
if flt(row.qty) < 0:
self.validation_messages.append(_get_msg(row_num, _("Negative Quantity is not allowed")))
self.validation_messages.append(_get_msg(row.idx, _("Negative Quantity is not allowed")))
# do not allow negative valuation
if flt(row.valuation_rate) < 0:
self.validation_messages.append(
_get_msg(row_num, _("Negative Valuation Rate is not allowed"))
_get_msg(row.idx, _("Negative Valuation Rate is not allowed"))
)
if row.qty and row.valuation_rate in ["", None]:
@@ -653,6 +653,11 @@ class StockReconciliation(StockController):
raise frappe.ValidationError(self.validation_messages)
def change_row_indexes(self):
if getattr(self, "change_idx", False):
for i, item in enumerate(self.items):
item.idx = i + 1
def validate_item(self, item_code, row):
from erpnext.stock.doctype.item.item import (
validate_cancelled_item,
@@ -660,6 +665,16 @@ class StockReconciliation(StockController):
validate_is_stock_item,
)
def validate_serial_batch_items():
has_batch_no, has_serial_no = frappe.get_value(
"Item", item_code, ["has_batch_no", "has_serial_no"]
)
if row.use_serial_batch_fields and self.purpose == "Stock Reconciliation":
if has_batch_no and not row.batch_no:
raise frappe.ValidationError(_("Please enter Batch No"))
if has_serial_no and not row.serial_no:
raise frappe.ValidationError(_("Please enter Serial No"))
# using try except to catch all validation msgs and display together
try:
@@ -668,12 +683,13 @@ class StockReconciliation(StockController):
# end of life and stock item
validate_end_of_life(item_code, item.end_of_life, item.disabled)
validate_is_stock_item(item_code, item.is_stock_item)
validate_serial_batch_items()
# docstatus should be < 2
validate_cancelled_item(item_code, item.docstatus)
except Exception as e:
self.validation_messages.append(_("Row #") + " " + ("%d: " % (row.idx)) + cstr(e))
self.validation_messages.append(_("Row #") + ("%d: " % (row.idx)) + cstr(e))
def validate_reserved_stock(self) -> None:
"""Raises an exception if there is any reserved stock for the items in the Stock Reconciliation."""

View File

@@ -1449,6 +1449,7 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin):
qty=10,
rate=100,
use_serial_batch_fields=1,
purpose="Opening Stock",
)
sr.reload()
@@ -1591,6 +1592,7 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin):
qty=10,
rate=80,
use_serial_batch_fields=1,
purpose="Opening Stock",
)
batch_no = get_batch_from_bundle(reco.items[0].serial_and_batch_bundle)
@@ -1675,6 +1677,7 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin):
qty=10,
rate=100,
use_serial_batch_fields=1,
purpose="Opening Stock",
)
sr.reload()

View File

@@ -633,6 +633,9 @@ class SerialNoValuation(DeprecatedSerialNoValuation):
self.old_serial_nos = []
serial_nos = self.get_serial_nos()
if not serial_nos:
return
result = self.get_serial_no_wise_incoming_rate(serial_nos)
for serial_no in serial_nos:
incoming_rate = result.get(serial_no)

View File

@@ -51,6 +51,49 @@ class IntegrationTestSubcontractingInwardOrder(IntegrationTestCase):
for item in rm_in.get("items"):
self.assertEqual(item.customer_provided_item_cost, 15)
def test_customer_provided_item_cost_with_multiple_receipts(self):
"""
Validate that rate is calculated correctly (Weighted Average) when multiple receipts
occur for the same SCIO Received Item.
"""
so, scio = create_so_scio()
rm_item = "Basic RM"
# Receipt 1: 5 Qty @ Unit Cost 10
rm_in_1 = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward())
rm_in_1.items = [item for item in rm_in_1.items if item.item_code == rm_item]
rm_in_1.items[0].qty = 5
rm_in_1.items[0].basic_rate = 10
rm_in_1.items[0].transfer_qty = 5
rm_in_1.submit()
scio.reload()
received_item = next(item for item in scio.received_items if item.rm_item_code == rm_item)
self.assertEqual(received_item.rate, 10)
# Receipt 2: 5 Qty @ Unit Cost 20
rm_in_2 = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward())
rm_in_2.items = [item for item in rm_in_2.items if item.item_code == rm_item]
rm_in_2.items[0].qty = 5
rm_in_2.items[0].basic_rate = 20
rm_in_2.items[0].transfer_qty = 5
rm_in_2.save()
rm_in_2.submit()
# Check 2: Rate should be Weighted Average
# (5 * 10 + 5 * 20) / 10 = 150 / 10 = 15
scio.reload()
received_item = next(item for item in scio.received_items if item.rm_item_code == rm_item)
self.assertEqual(received_item.rate, 15)
# Cancel Receipt 2: Rate should revert to original
# (15 * 10 - 20 * 5) / 5 = 50 / 5 = 10
rm_in_2.cancel()
scio.reload()
received_item = next(item for item in scio.received_items if item.rm_item_code == rm_item)
self.assertEqual(received_item.received_qty, 5)
self.assertEqual(received_item.rate, 10)
def test_add_extra_customer_provided_item(self):
so, scio = create_so_scio()