mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-06 15:07:55 +00:00
Compare commits
42 Commits
mergify/bp
...
coderabbit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6c8d5e038 | ||
|
|
a030ea6fde | ||
|
|
e5d25a7f04 | ||
|
|
40e86b6670 | ||
|
|
d0c9924c37 | ||
|
|
ede4faa152 | ||
|
|
05cf1dcab8 | ||
|
|
43a6dd5657 | ||
|
|
343ee9695b | ||
|
|
49e64f4e1c | ||
|
|
1e3db9f916 | ||
|
|
70ec977cb2 | ||
|
|
d6bbe43fa0 | ||
|
|
035b3cb61e | ||
|
|
97c36d1edc | ||
|
|
7170a1bd78 | ||
|
|
936f13eb20 | ||
|
|
ed51db3217 | ||
|
|
5bacb67d36 | ||
|
|
c919b1de38 | ||
|
|
46ab5e8e46 | ||
|
|
3960c01798 | ||
|
|
3c5071cefc | ||
|
|
6e4b90055f | ||
|
|
37ee560eae | ||
|
|
4b3000b071 | ||
|
|
ad6cb177e3 | ||
|
|
d256365f4a | ||
|
|
05fea7f66f | ||
|
|
7535931571 | ||
|
|
27915c9ce2 | ||
|
|
93b131f48a | ||
|
|
10d5463a40 | ||
|
|
017cc9d9f9 | ||
|
|
b691de0147 | ||
|
|
beabbb1fa2 | ||
|
|
65c3020d1b | ||
|
|
f003b3c378 | ||
|
|
a268316322 | ||
|
|
7532ab01d6 | ||
|
|
66fe1aa85d | ||
|
|
22e9cb4cf4 |
2
.github/workflows/generate-pot-file.yml
vendored
2
.github/workflows/generate-pot-file.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
branch: ["develop"]
|
||||
branch: ["develop", "version-16-hotfix"]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -569,6 +569,7 @@ accounting_dimension_doctypes = [
|
||||
"Payment Request",
|
||||
"Asset Movement Item",
|
||||
"Asset Depreciation Schedule",
|
||||
"Advance Taxes and Charges",
|
||||
]
|
||||
|
||||
get_matching_queries = (
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user