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

chore: release v15
This commit is contained in:
Deepesh Garg 2024-02-28 10:42:31 +05:30 committed by GitHub
commit 4291b9fa8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
85 changed files with 1293 additions and 512 deletions

View File

@ -149,7 +149,7 @@ frappe.ui.form.on('Payment Entry', {
}, },
refresh: function(frm) { refresh: function(frm) {
erpnext.hide_company(); erpnext.hide_company(frm);
frm.events.hide_unhide_fields(frm); frm.events.hide_unhide_fields(frm);
frm.events.set_dynamic_labels(frm); frm.events.set_dynamic_labels(frm);
frm.events.show_general_ledger(frm); frm.events.show_general_ledger(frm);

View File

@ -654,7 +654,7 @@
{ {
"depends_on": "eval:doc.use_serial_batch_fields === 1", "depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "serial_no", "fieldname": "serial_no",
"fieldtype": "Small Text", "fieldtype": "Text",
"hidden": 1, "hidden": 1,
"in_list_view": 1, "in_list_view": 1,
"label": "Serial No", "label": "Serial No",
@ -853,7 +853,7 @@
], ],
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-02-04 16:36:25.665743", "modified": "2024-02-25 15:50:17.140269",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "POS Invoice Item", "name": "POS Invoice Item",

View File

@ -72,7 +72,7 @@ class POSInvoiceItem(Document):
rate_with_margin: DF.Currency rate_with_margin: DF.Currency
sales_order: DF.Link | None sales_order: DF.Link | None
serial_and_batch_bundle: DF.Link | None serial_and_batch_bundle: DF.Link | None
serial_no: DF.SmallText | None serial_no: DF.Text | None
service_end_date: DF.Date | None service_end_date: DF.Date | None
service_start_date: DF.Date | None service_start_date: DF.Date | None
service_stop_date: DF.Date | None service_stop_date: DF.Date | None

View File

@ -22,6 +22,8 @@
"is_paid", "is_paid",
"is_return", "is_return",
"return_against", "return_against",
"update_billed_amount_in_purchase_order",
"update_billed_amount_in_purchase_receipt",
"apply_tds", "apply_tds",
"tax_withholding_category", "tax_withholding_category",
"amended_from", "amended_from",
@ -412,6 +414,20 @@
"read_only": 1, "read_only": 1,
"search_index": 1 "search_index": 1
}, },
{
"default": "0",
"depends_on": "eval: doc.is_return",
"fieldname": "update_billed_amount_in_purchase_order",
"fieldtype": "Check",
"label": "Update Billed Amount in Purchase Order"
},
{
"default": "1",
"depends_on": "eval: doc.is_return",
"fieldname": "update_billed_amount_in_purchase_receipt",
"fieldtype": "Check",
"label": "Update Billed Amount in Purchase Receipt"
},
{ {
"fieldname": "section_addresses", "fieldname": "section_addresses",
"fieldtype": "Section Break", "fieldtype": "Section Break",
@ -1612,7 +1628,7 @@
"idx": 204, "idx": 204,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2023-11-29 15:35:44.697496", "modified": "2024-02-25 11:20:28.366808",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Purchase Invoice", "name": "Purchase Invoice",
@ -1675,4 +1691,4 @@
"timeline_field": "supplier", "timeline_field": "supplier",
"title_field": "title", "title_field": "title",
"track_changes": 1 "track_changes": 1
} }

View File

@ -214,6 +214,8 @@ class PurchaseInvoice(BuyingController):
total_qty: DF.Float total_qty: DF.Float
total_taxes_and_charges: DF.Currency total_taxes_and_charges: DF.Currency
unrealized_profit_loss_account: DF.Link | None unrealized_profit_loss_account: DF.Link | None
update_billed_amount_in_purchase_order: DF.Check
update_billed_amount_in_purchase_receipt: DF.Check
update_stock: DF.Check update_stock: DF.Check
use_company_roundoff_cost_center: DF.Check use_company_roundoff_cost_center: DF.Check
use_transaction_date_exchange_rate: DF.Check use_transaction_date_exchange_rate: DF.Check
@ -679,6 +681,11 @@ class PurchaseInvoice(BuyingController):
super(PurchaseInvoice, self).on_submit() super(PurchaseInvoice, self).on_submit()
self.check_prev_docstatus() self.check_prev_docstatus()
if self.is_return and not self.update_billed_amount_in_purchase_order:
# NOTE status updating bypassed for is_return
self.status_updater = []
self.update_status_updater_args() self.update_status_updater_args()
self.update_prevdoc_status() self.update_prevdoc_status()
@ -1426,6 +1433,10 @@ class PurchaseInvoice(BuyingController):
self.check_on_hold_or_closed_status() self.check_on_hold_or_closed_status()
if self.is_return and not self.update_billed_amount_in_purchase_order:
# NOTE status updating bypassed for is_return
self.status_updater = []
self.update_status_updater_args() self.update_status_updater_args()
self.update_prevdoc_status() self.update_prevdoc_status()
@ -1520,6 +1531,9 @@ class PurchaseInvoice(BuyingController):
frappe.throw(_("Supplier Invoice No exists in Purchase Invoice {0}").format(pi)) frappe.throw(_("Supplier Invoice No exists in Purchase Invoice {0}").format(pi))
def update_billing_status_in_pr(self, update_modified=True): def update_billing_status_in_pr(self, update_modified=True):
if self.is_return and not self.update_billed_amount_in_purchase_receipt:
return
updated_pr = [] updated_pr = []
po_details = [] po_details = []

View File

@ -88,6 +88,7 @@ class RepostAccountingLedger(Document):
).append(gle.update({"old": True})) ).append(gle.update({"old": True}))
def generate_preview_data(self): def generate_preview_data(self):
frappe.flags.through_repost_accounting_ledger = True
self.gl_entries = [] self.gl_entries = []
self.get_existing_ledger_entries() self.get_existing_ledger_entries()
for x in self.vouchers: for x in self.vouchers:
@ -141,6 +142,7 @@ class RepostAccountingLedger(Document):
@frappe.whitelist() @frappe.whitelist()
def start_repost(account_repost_doc=str) -> None: def start_repost(account_repost_doc=str) -> None:
frappe.flags.through_repost_accounting_ledger = True
if account_repost_doc: if account_repost_doc:
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc) repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)

View File

@ -269,7 +269,7 @@ class SalesInvoice(SellingController):
super(SalesInvoice, self).validate() super(SalesInvoice, self).validate()
self.validate_auto_set_posting_time() self.validate_auto_set_posting_time()
if not self.is_pos: if not (self.is_pos or self.is_debit_note):
self.so_dn_required() self.so_dn_required()
self.set_tax_withholding() self.set_tax_withholding()
@ -446,7 +446,11 @@ class SalesInvoice(SellingController):
# Updating stock ledger should always be called after updating prevdoc status, # Updating stock ledger should always be called after updating prevdoc status,
# because updating reserved qty in bin depends upon updated delivered qty in SO # because updating reserved qty in bin depends upon updated delivered qty in SO
if self.update_stock == 1: if self.update_stock == 1:
self.make_bundle_using_old_serial_batch_fields() for table_name in ["items", "packed_items"]:
if not self.get(table_name):
continue
self.make_bundle_using_old_serial_batch_fields(table_name)
self.update_stock_ledger() self.update_stock_ledger()
# this sequence because outstanding may get -ve # this sequence because outstanding may get -ve
@ -1472,9 +1476,7 @@ class SalesInvoice(SellingController):
"credit_in_account_currency": payment_mode.base_amount "credit_in_account_currency": payment_mode.base_amount
if self.party_account_currency == self.company_currency if self.party_account_currency == self.company_currency
else payment_mode.amount, else payment_mode.amount,
"against_voucher": self.return_against "against_voucher": self.name,
if cint(self.is_return) and self.return_against
else self.name,
"against_voucher_type": self.doctype, "against_voucher_type": self.doctype,
"cost_center": self.cost_center, "cost_center": self.cost_center,
}, },

View File

@ -1088,6 +1088,44 @@ class TestSalesInvoice(FrappeTestCase):
self.assertEqual(pos.grand_total, 100.0) self.assertEqual(pos.grand_total, 100.0)
self.assertEqual(pos.write_off_amount, 10) self.assertEqual(pos.write_off_amount, 10)
def test_ledger_entries_of_return_pos_invoice(self):
make_pos_profile()
pos = create_sales_invoice(do_not_save=True)
pos.is_pos = 1
pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 100})
pos.save().submit()
self.assertEqual(pos.outstanding_amount, 0.0)
self.assertEqual(pos.status, "Paid")
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
pos_return = make_sales_return(pos.name)
pos_return.save().submit()
pos_return.reload()
pos.reload()
self.assertEqual(pos_return.is_return, 1)
self.assertEqual(pos_return.return_against, pos.name)
self.assertEqual(pos_return.outstanding_amount, 0.0)
self.assertEqual(pos_return.status, "Return")
self.assertEqual(pos.outstanding_amount, 0.0)
self.assertEqual(pos.status, "Credit Note Issued")
expected = (
("Cash - _TC", 0.0, 100.0, pos_return.name, None),
("Debtors - _TC", 0.0, 100.0, pos_return.name, pos_return.name),
("Debtors - _TC", 100.0, 0.0, pos_return.name, pos_return.name),
("Sales - _TC", 100.0, 0.0, pos_return.name, None),
)
res = frappe.db.get_all(
"GL Entry",
filters={"voucher_no": pos_return.name, "is_cancelled": 0},
fields=["account", "debit", "credit", "voucher_no", "against_voucher"],
order_by="account, debit, credit",
as_list=1,
)
self.assertEqual(expected, res)
def test_pos_with_no_gl_entry_for_change_amount(self): def test_pos_with_no_gl_entry_for_change_amount(self):
frappe.db.set_single_value("Accounts Settings", "post_change_gl_entries", 0) frappe.db.set_single_value("Accounts Settings", "post_change_gl_entries", 0)

View File

@ -625,7 +625,7 @@
{ {
"depends_on": "eval: doc.use_serial_batch_fields === 1 && parent.update_stock === 1", "depends_on": "eval: doc.use_serial_batch_fields === 1 && parent.update_stock === 1",
"fieldname": "serial_no", "fieldname": "serial_no",
"fieldtype": "Small Text", "fieldtype": "Text",
"label": "Serial No", "label": "Serial No",
"oldfieldname": "serial_no", "oldfieldname": "serial_no",
"oldfieldtype": "Small Text" "oldfieldtype": "Small Text"
@ -926,7 +926,7 @@
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-02-04 11:52:16.106541", "modified": "2024-02-25 15:56:44.828634",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Sales Invoice Item", "name": "Sales Invoice Item",

View File

@ -75,7 +75,7 @@ class SalesInvoiceItem(Document):
sales_invoice_item: DF.Data | None sales_invoice_item: DF.Data | None
sales_order: DF.Link | None sales_order: DF.Link | None
serial_and_batch_bundle: DF.Link | None serial_and_batch_bundle: DF.Link | None
serial_no: DF.SmallText | None serial_no: DF.Text | None
service_end_date: DF.Date | None service_end_date: DF.Date | None
service_start_date: DF.Date | None service_start_date: DF.Date | None
service_stop_date: DF.Date | None service_stop_date: DF.Date | None

View File

@ -8,6 +8,7 @@ from frappe.utils import today
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase): class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase):
@ -49,6 +50,16 @@ class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase):
) )
return pe return pe
def create_sales_order(self):
so = make_sales_order(
company=self.company,
customer=self.customer,
item=self.item,
rate=100,
transaction_date=today(),
)
return so
def test_01_unreconcile_invoice(self): def test_01_unreconcile_invoice(self):
si1 = self.create_sales_invoice() si1 = self.create_sales_invoice()
si2 = self.create_sales_invoice() si2 = self.create_sales_invoice()
@ -314,3 +325,41 @@ class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase):
), ),
1, 1,
) )
def test_05_unreconcile_order(self):
so = self.create_sales_order()
pe = self.create_payment_entry()
# Allocation payment against Sales Order
pe.paid_amount = 100
pe.append(
"references",
{"reference_doctype": so.doctype, "reference_name": so.name, "allocated_amount": 100},
)
pe.save().submit()
# Assert 'Advance Paid'
so.reload()
self.assertEqual(so.advance_paid, 100)
unreconcile = frappe.get_doc(
{
"doctype": "Unreconcile Payment",
"company": self.company,
"voucher_type": pe.doctype,
"voucher_no": pe.name,
}
)
unreconcile.add_references()
self.assertEqual(len(unreconcile.allocations), 1)
allocations = [x.reference_name for x in unreconcile.allocations]
self.assertEquals([so.name], allocations)
# unreconcile so
unreconcile.save().submit()
# Assert 'Advance Paid'
so.reload()
pe.reload()
self.assertEqual(so.advance_paid, 0)
self.assertEqual(len(pe.references), 0)
self.assertEqual(pe.unallocated_amount, 100)

View File

@ -82,6 +82,9 @@ class UnreconcilePayment(Document):
update_voucher_outstanding( update_voucher_outstanding(
alloc.reference_doctype, alloc.reference_name, alloc.account, alloc.party_type, alloc.party alloc.reference_doctype, alloc.reference_name, alloc.account, alloc.party_type, alloc.party
) )
if doc.doctype in frappe.get_hooks("advance_payment_doctypes"):
doc.set_total_advance_paid()
frappe.db.set_value("Unreconcile Payment Entries", alloc.name, "unlinked", True) frappe.db.set_value("Unreconcile Payment Entries", alloc.name, "unlinked", True)

View File

@ -975,7 +975,7 @@ class GrossProfitGenerator(object):
& (sle.is_cancelled == 0) & (sle.is_cancelled == 0)
) )
.orderby(sle.item_code) .orderby(sle.item_code)
.orderby(sle.warehouse, sle.posting_date, sle.posting_time, sle.creation, order=Order.desc) .orderby(sle.warehouse, sle.posting_datetime, sle.creation, order=Order.desc)
.run(as_dict=True) .run(as_dict=True)
) )

View File

@ -163,7 +163,7 @@ def get_entries(filters):
"""select """select
voucher_type, voucher_no, party_type, party, posting_date, debit, credit, remarks, against_voucher voucher_type, voucher_no, party_type, party, posting_date, debit, credit, remarks, against_voucher
from `tabGL Entry` from `tabGL Entry`
where company=%(company)s and voucher_type in ('Journal Entry', 'Payment Entry') {0} where company=%(company)s and voucher_type in ('Journal Entry', 'Payment Entry') and is_cancelled = 0 {0}
""".format( """.format(
get_conditions(filters) get_conditions(filters)
), ),

View File

@ -242,7 +242,7 @@ def get_columns(filters):
"width": 120, "width": 120,
}, },
{ {
"label": _("Tax Amount"), "label": _("TDS Amount") if filters.get("party_type") == "Supplier" else _("TCS Amount"),
"fieldname": "tax_amount", "fieldname": "tax_amount",
"fieldtype": "Float", "fieldtype": "Float",
"width": 120, "width": 120,

View File

@ -970,46 +970,6 @@ def get_currency_precision():
return precision return precision
def get_stock_rbnb_difference(posting_date, company):
stock_items = frappe.db.sql_list(
"""select distinct item_code
from `tabStock Ledger Entry` where company=%s""",
company,
)
pr_valuation_amount = frappe.db.sql(
"""
select sum(pr_item.valuation_rate * pr_item.qty * pr_item.conversion_factor)
from `tabPurchase Receipt Item` pr_item, `tabPurchase Receipt` pr
where pr.name = pr_item.parent and pr.docstatus=1 and pr.company=%s
and pr.posting_date <= %s and pr_item.item_code in (%s)"""
% ("%s", "%s", ", ".join(["%s"] * len(stock_items))),
tuple([company, posting_date] + stock_items),
)[0][0]
pi_valuation_amount = frappe.db.sql(
"""
select sum(pi_item.valuation_rate * pi_item.qty * pi_item.conversion_factor)
from `tabPurchase Invoice Item` pi_item, `tabPurchase Invoice` pi
where pi.name = pi_item.parent and pi.docstatus=1 and pi.company=%s
and pi.posting_date <= %s and pi_item.item_code in (%s)"""
% ("%s", "%s", ", ".join(["%s"] * len(stock_items))),
tuple([company, posting_date] + stock_items),
)[0][0]
# Balance should be
stock_rbnb = flt(pr_valuation_amount, 2) - flt(pi_valuation_amount, 2)
# Balance as per system
stock_rbnb_account = "Stock Received But Not Billed - " + frappe.get_cached_value(
"Company", company, "abbr"
)
sys_bal = get_balance_on(stock_rbnb_account, posting_date, in_account_currency=False)
# Amount should be credited
return flt(stock_rbnb) + flt(sys_bal)
def get_held_invoices(party_type, party): def get_held_invoices(party_type, party):
""" """
Returns a list of names Purchase Invoices for the given party that are on hold Returns a list of names Purchase Invoices for the given party that are on hold
@ -1412,8 +1372,7 @@ def sort_stock_vouchers_by_posting_date(
.select(sle.voucher_type, sle.voucher_no, sle.posting_date, sle.posting_time, sle.creation) .select(sle.voucher_type, sle.voucher_no, sle.posting_date, sle.posting_time, sle.creation)
.where((sle.is_cancelled == 0) & (sle.voucher_no.isin(voucher_nos))) .where((sle.is_cancelled == 0) & (sle.voucher_no.isin(voucher_nos)))
.groupby(sle.voucher_type, sle.voucher_no) .groupby(sle.voucher_type, sle.voucher_no)
.orderby(sle.posting_date) .orderby(sle.posting_datetime)
.orderby(sle.posting_time)
.orderby(sle.creation) .orderby(sle.creation)
).run(as_dict=True) ).run(as_dict=True)
sorted_vouchers = [(sle.voucher_type, sle.voucher_no) for sle in sles] sorted_vouchers = [(sle.voucher_type, sle.voucher_no) for sle in sles]

View File

@ -107,7 +107,7 @@
{ {
"depends_on": "eval:doc.use_serial_batch_fields === 1", "depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "serial_no", "fieldname": "serial_no",
"fieldtype": "Small Text", "fieldtype": "Text",
"hidden": 1, "hidden": 1,
"label": "Serial No", "label": "Serial No",
"print_hide": 1 "print_hide": 1
@ -178,7 +178,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-02-04 16:41:09.239762", "modified": "2024-02-25 15:57:35.007501",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Assets", "module": "Assets",
"name": "Asset Capitalization Stock Item", "name": "Asset Capitalization Stock Item",

View File

@ -24,7 +24,7 @@ class AssetCapitalizationStockItem(Document):
parentfield: DF.Data parentfield: DF.Data
parenttype: DF.Data parenttype: DF.Data
serial_and_batch_bundle: DF.Link | None serial_and_batch_bundle: DF.Link | None
serial_no: DF.SmallText | None serial_no: DF.Text | None
stock_qty: DF.Float stock_qty: DF.Float
stock_uom: DF.Link stock_uom: DF.Link
use_serial_batch_fields: DF.Check use_serial_batch_fields: DF.Check

View File

@ -1037,6 +1037,38 @@ class TestPurchaseOrder(FrappeTestCase):
self.assertTrue(frappe.db.get_value("Subcontracting Order", {"purchase_order": po.name})) self.assertTrue(frappe.db.get_value("Subcontracting Order", {"purchase_order": po.name}))
def test_po_billed_amount_against_return_entry(self):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_debit_note
# Create a Purchase Order and Fully Bill it
po = create_purchase_order()
pi = make_pi_from_po(po.name)
pi.insert()
pi.submit()
# Debit Note - 50% Qty & enable updating PO billed amount
pi_return = make_debit_note(pi.name)
pi_return.items[0].qty = -5
pi_return.update_billed_amount_in_purchase_order = 1
pi_return.submit()
# Check if the billed amount reduced
po.reload()
self.assertEqual(po.per_billed, 50)
pi_return.reload()
pi_return.cancel()
# Debit Note - 50% Qty & disable updating PO billed amount
pi_return = make_debit_note(pi.name)
pi_return.items[0].qty = -5
pi_return.update_billed_amount_in_purchase_order = 0
pi_return.submit()
# Check if the billed amount stayed the same
po.reload()
self.assertEqual(po.per_billed, 100)
def prepare_data_for_internal_transfer(): def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier

View File

@ -205,10 +205,30 @@ class RequestforQuotation(BuyingController):
contact.save(ignore_permissions=True) contact.save(ignore_permissions=True)
if rfq_supplier.supplier:
self.update_user_in_supplier(rfq_supplier.supplier, user.name)
if not rfq_supplier.contact: if not rfq_supplier.contact:
# return contact to later update, RFQ supplier row's contact # return contact to later update, RFQ supplier row's contact
return contact.name return contact.name
def update_user_in_supplier(self, supplier, user):
"""Update user in Supplier."""
if not frappe.db.exists("Portal User", {"parent": supplier, "user": user}):
supplier_doc = frappe.get_doc("Supplier", supplier)
supplier_doc.append(
"portal_users",
{
"user": user,
},
)
supplier_doc.flags.ignore_validate = True
supplier_doc.flags.ignore_mandatory = True
supplier_doc.flags.ignore_permissions = True
supplier_doc.save()
def create_user(self, rfq_supplier, link): def create_user(self, rfq_supplier, link):
user = frappe.get_doc( user = frappe.get_doc(
{ {
@ -245,6 +265,10 @@ class RequestforQuotation(BuyingController):
"user_fullname": full_name, "user_fullname": full_name,
} }
) )
if not self.email_template:
return
email_template = frappe.get_doc("Email Template", self.email_template) email_template = frappe.get_doc("Email Template", self.email_template)
message = frappe.render_template(email_template.response_, doc_args) message = frappe.render_template(email_template.response_, doc_args)
subject = frappe.render_template(email_template.subject, doc_args) subject = frappe.render_template(email_template.subject, doc_args)

View File

@ -138,6 +138,33 @@ class TestRequestforQuotation(FrappeTestCase):
get_pdf(rfq.name, rfq.get("suppliers")[0].supplier) get_pdf(rfq.name, rfq.get("suppliers")[0].supplier)
self.assertEqual(frappe.local.response.type, "pdf") self.assertEqual(frappe.local.response.type, "pdf")
def test_portal_user_with_new_supplier(self):
supplier_doc = frappe.get_doc(
{
"doctype": "Supplier",
"supplier_name": "Test Supplier for RFQ",
"supplier_group": "_Test Supplier Group",
}
).insert()
self.assertFalse(supplier_doc.portal_users)
rfq = make_request_for_quotation(
supplier_data=[
{
"supplier": supplier_doc.name,
"supplier_name": supplier_doc.supplier_name,
"email_id": "123_testrfquser@example.com",
}
],
do_not_submit=True,
)
for rfq_supplier in rfq.suppliers:
rfq.update_supplier_contact(rfq_supplier, rfq.get_link())
supplier_doc.reload()
self.assertTrue(supplier_doc.portal_users[0].user)
def make_request_for_quotation(**args) -> "RequestforQuotation": def make_request_for_quotation(**args) -> "RequestforQuotation":
""" """

View File

@ -216,7 +216,8 @@ class AccountsController(TransactionBase):
) )
) )
if self.get("is_return") and self.get("return_against"): if self.get("is_return") and self.get("return_against") and not self.get("is_pos"):
# if self.get("is_return") and self.get("return_against"):
document_type = "Credit Note" if self.doctype == "Sales Invoice" else "Debit Note" document_type = "Credit Note" if self.doctype == "Sales Invoice" else "Debit Note"
frappe.msgprint( frappe.msgprint(
_( _(
@ -345,6 +346,12 @@ class AccountsController(TransactionBase):
ple = frappe.qb.DocType("Payment Ledger Entry") ple = frappe.qb.DocType("Payment Ledger Entry")
frappe.qb.from_(ple).delete().where( frappe.qb.from_(ple).delete().where(
(ple.voucher_type == self.doctype) & (ple.voucher_no == self.name) (ple.voucher_type == self.doctype) & (ple.voucher_no == self.name)
| (
(ple.against_voucher_type == self.doctype)
& (ple.against_voucher_no == self.name)
& ple.delinked
== 1
)
).run() ).run()
frappe.db.sql( frappe.db.sql(
"delete from `tabGL Entry` where voucher_type=%s and voucher_no=%s", (self.doctype, self.name) "delete from `tabGL Entry` where voucher_type=%s and voucher_no=%s", (self.doctype, self.name)

View File

@ -28,7 +28,8 @@ class SellingController(StockController):
def validate(self): def validate(self):
super(SellingController, self).validate() super(SellingController, self).validate()
self.validate_items() self.validate_items()
self.validate_max_discount() if not self.get("is_debit_note"):
self.validate_max_discount()
self.validate_selling_price() self.validate_selling_price()
self.set_qty_as_per_stock_uom() self.set_qty_as_per_stock_uom()
self.set_po_nos(for_validate=True) self.set_po_nos(for_validate=True)
@ -706,6 +707,9 @@ def set_default_income_account_for_item(obj):
def get_serial_and_batch_bundle(child, parent): def get_serial_and_batch_bundle(child, parent):
from erpnext.stock.serial_batch_bundle import SerialBatchCreation from erpnext.stock.serial_batch_bundle import SerialBatchCreation
if child.get("use_serial_batch_fields"):
return
if not frappe.db.get_single_value( if not frappe.db.get_single_value(
"Stock Settings", "auto_create_serial_and_batch_bundle_for_outward" "Stock Settings", "auto_create_serial_and_batch_bundle_for_outward"
): ):

View File

@ -158,7 +158,7 @@ class StockController(AccountsController):
# remove extra whitespace and store one serial no on each line # remove extra whitespace and store one serial no on each line
row.serial_no = clean_serial_no_string(row.serial_no) row.serial_no = clean_serial_no_string(row.serial_no)
def make_bundle_using_old_serial_batch_fields(self): def make_bundle_using_old_serial_batch_fields(self, table_name=None):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.serial_batch_bundle import SerialBatchCreation from erpnext.stock.serial_batch_bundle import SerialBatchCreation
@ -169,7 +169,9 @@ class StockController(AccountsController):
if frappe.flags.in_test and frappe.flags.use_serial_and_batch_fields: if frappe.flags.in_test and frappe.flags.use_serial_and_batch_fields:
return return
table_name = "items" if not table_name:
table_name = "items"
if self.doctype == "Asset Capitalization": if self.doctype == "Asset Capitalization":
table_name = "stock_items" table_name = "stock_items"
@ -192,6 +194,12 @@ class StockController(AccountsController):
qty = row.qty qty = row.qty
type_of_transaction = "Inward" type_of_transaction = "Inward"
warehouse = row.warehouse warehouse = row.warehouse
elif table_name == "packed_items":
qty = row.qty
warehouse = row.warehouse
type_of_transaction = "Outward"
if self.is_return:
type_of_transaction = "Inward"
else: else:
qty = row.stock_qty if self.doctype != "Stock Entry" else row.transfer_qty qty = row.stock_qty if self.doctype != "Stock Entry" else row.transfer_qty
type_of_transaction = get_type_of_transaction(self, row) type_of_transaction = get_type_of_transaction(self, row)

View File

@ -1020,7 +1020,7 @@ def get_itemised_tax_breakup_data(doc):
for item_code, taxes in itemised_tax.items(): for item_code, taxes in itemised_tax.items():
itemised_tax_data.append( itemised_tax_data.append(
frappe._dict( frappe._dict(
{"item": item_code, "taxable_amount": itemised_taxable_amount.get(item_code), **taxes} {"item": item_code, "taxable_amount": itemised_taxable_amount.get(item_code, 0), **taxes}
) )
) )

View File

@ -41,7 +41,9 @@ class SalesPipelineAnalytics(object):
month_list = self.get_month_list() month_list = self.get_month_list()
for month in month_list: for month in month_list:
self.columns.append({"fieldname": month, "fieldtype": based_on, "label": month, "width": 200}) self.columns.append(
{"fieldname": month, "fieldtype": based_on, "label": _(month), "width": 200}
)
elif self.filters.get("range") == "Quarterly": elif self.filters.get("range") == "Quarterly":
for quarter in range(1, 5): for quarter in range(1, 5):
@ -156,7 +158,7 @@ class SalesPipelineAnalytics(object):
for column in self.columns: for column in self.columns:
if column["fieldname"] != "opportunity_owner" and column["fieldname"] != "sales_stage": if column["fieldname"] != "opportunity_owner" and column["fieldname"] != "sales_stage":
labels.append(column["fieldname"]) labels.append(_(column["fieldname"]))
self.chart = {"data": {"labels": labels, "datasets": datasets}, "type": "line"} self.chart = {"data": {"labels": labels, "datasets": datasets}, "type": "line"}

View File

@ -10,7 +10,6 @@ from frappe.model.document import Document
from frappe.utils import add_months, formatdate, getdate, sbool, today from frappe.utils import add_months, formatdate, getdate, sbool, today
from plaid.errors import ItemError from plaid.errors import ItemError
from erpnext.accounts.doctype.journal_entry.journal_entry import get_default_bank_cash_account
from erpnext.erpnext_integrations.doctype.plaid_settings.plaid_connector import PlaidConnector from erpnext.erpnext_integrations.doctype.plaid_settings.plaid_connector import PlaidConnector
@ -90,9 +89,15 @@ def add_bank_accounts(response, bank, company):
bank = json.loads(bank) bank = json.loads(bank)
result = [] result = []
default_gl_account = get_default_bank_cash_account(company, "Bank") parent_gl_account = frappe.db.get_all(
if not default_gl_account: "Account", {"company": company, "account_type": "Bank", "is_group": 1, "disabled": 0}
frappe.throw(_("Please setup a default bank account for company {0}").format(company)) )
if not parent_gl_account:
frappe.throw(
_(
"Please setup and enable a group account with the Account Type - {0} for the company {1}"
).format(frappe.bold("Bank"), company)
)
for account in response["accounts"]: for account in response["accounts"]:
acc_type = frappe.db.get_value("Bank Account Type", account["type"]) acc_type = frappe.db.get_value("Bank Account Type", account["type"])
@ -108,11 +113,22 @@ def add_bank_accounts(response, bank, company):
if not existing_bank_account: if not existing_bank_account:
try: try:
gl_account = frappe.get_doc(
{
"doctype": "Account",
"account_name": account["name"] + " - " + response["institution"]["name"],
"parent_account": parent_gl_account[0].name,
"account_type": "Bank",
"company": company,
}
)
gl_account.insert(ignore_if_duplicate=True)
new_account = frappe.get_doc( new_account = frappe.get_doc(
{ {
"doctype": "Bank Account", "doctype": "Bank Account",
"bank": bank["bank_name"], "bank": bank["bank_name"],
"account": default_gl_account.account, "account": gl_account.name,
"account_name": account["name"], "account_name": account["name"],
"account_type": account.get("type", ""), "account_type": account.get("type", ""),
"account_subtype": account.get("subtype", ""), "account_subtype": account.get("subtype", ""),

View File

@ -7,7 +7,6 @@ import unittest
import frappe import frappe
from frappe.utils.response import json_handler from frappe.utils.response import json_handler
from erpnext.accounts.doctype.journal_entry.journal_entry import get_default_bank_cash_account
from erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings import ( from erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings import (
add_account_subtype, add_account_subtype,
add_account_type, add_account_type,
@ -72,14 +71,6 @@ class TestPlaidSettings(unittest.TestCase):
bank = json.dumps(frappe.get_doc("Bank", "Citi").as_dict(), default=json_handler) bank = json.dumps(frappe.get_doc("Bank", "Citi").as_dict(), default=json_handler)
company = frappe.db.get_single_value("Global Defaults", "default_company") company = frappe.db.get_single_value("Global Defaults", "default_company")
if frappe.db.get_value("Company", company, "default_bank_account") is None:
frappe.db.set_value(
"Company",
company,
"default_bank_account",
get_default_bank_cash_account(company, "Cash").get("account"),
)
add_bank_accounts(bank_accounts, bank, company) add_bank_accounts(bank_accounts, bank, company)
transactions = { transactions = {

View File

@ -18,7 +18,7 @@ frappe.ui.form.on('Blanket Order', {
}, },
refresh: function(frm) { refresh: function(frm) {
erpnext.hide_company(); erpnext.hide_company(frm);
if (frm.doc.customer && frm.doc.docstatus === 1 && frm.doc.to_date > frappe.datetime.get_today()) { if (frm.doc.customer && frm.doc.docstatus === 1 && frm.doc.to_date > frappe.datetime.get_today()) {
frm.add_custom_button(__("Sales Order"), function() { frm.add_custom_button(__("Sales Order"), function() {
frappe.model.open_mapped_doc({ frappe.model.open_mapped_doc({

View File

@ -1071,8 +1071,7 @@ def get_valuation_rate(data):
frappe.qb.from_(sle) frappe.qb.from_(sle)
.select(sle.valuation_rate) .select(sle.valuation_rate)
.where((sle.item_code == item_code) & (sle.valuation_rate > 0) & (sle.is_cancelled == 0)) .where((sle.item_code == item_code) & (sle.valuation_rate > 0) & (sle.is_cancelled == 0))
.orderby(sle.posting_date, order=frappe.qb.desc) .orderby(sle.posting_datetime, order=frappe.qb.desc)
.orderby(sle.posting_time, order=frappe.qb.desc)
.orderby(sle.creation, order=frappe.qb.desc) .orderby(sle.creation, order=frappe.qb.desc)
.limit(1) .limit(1)
).run(as_dict=True) ).run(as_dict=True)

View File

@ -239,12 +239,12 @@ class JobCard(Document):
for row in self.sub_operations: for row in self.sub_operations:
self.total_completed_qty += row.completed_qty self.total_completed_qty += row.completed_qty
def get_overlap_for(self, args, check_next_available_slot=False): def get_overlap_for(self, args):
time_logs = [] time_logs = []
time_logs.extend(self.get_time_logs(args, "Job Card Time Log", check_next_available_slot)) time_logs.extend(self.get_time_logs(args, "Job Card Time Log"))
time_logs.extend(self.get_time_logs(args, "Job Card Scheduled Time", check_next_available_slot)) time_logs.extend(self.get_time_logs(args, "Job Card Scheduled Time"))
if not time_logs: if not time_logs:
return {} return {}
@ -269,7 +269,7 @@ class JobCard(Document):
self.workstation = workstation_time.get("workstation") self.workstation = workstation_time.get("workstation")
return workstation_time return workstation_time
return time_logs[-1] return time_logs[0]
def has_overlap(self, production_capacity, time_logs): def has_overlap(self, production_capacity, time_logs):
overlap = False overlap = False
@ -308,7 +308,7 @@ class JobCard(Document):
return True return True
return overlap return overlap
def get_time_logs(self, args, doctype, check_next_available_slot=False): def get_time_logs(self, args, doctype):
jc = frappe.qb.DocType("Job Card") jc = frappe.qb.DocType("Job Card")
jctl = frappe.qb.DocType(doctype) jctl = frappe.qb.DocType(doctype)
@ -318,9 +318,6 @@ class JobCard(Document):
((jctl.from_time >= args.from_time) & (jctl.to_time <= args.to_time)), ((jctl.from_time >= args.from_time) & (jctl.to_time <= args.to_time)),
] ]
if check_next_available_slot:
time_conditions.append(((jctl.from_time >= args.from_time) & (jctl.to_time >= args.to_time)))
query = ( query = (
frappe.qb.from_(jctl) frappe.qb.from_(jctl)
.from_(jc) .from_(jc)
@ -395,18 +392,28 @@ class JobCard(Document):
def validate_overlap_for_workstation(self, args, row): def validate_overlap_for_workstation(self, args, row):
# get the last record based on the to time from the job card # get the last record based on the to time from the job card
data = self.get_overlap_for(args, check_next_available_slot=True) data = self.get_overlap_for(args)
if not self.workstation: if not self.workstation:
workstations = get_workstations(self.workstation_type) workstations = get_workstations(self.workstation_type)
if workstations: if workstations:
# Get the first workstation # Get the first workstation
self.workstation = workstations[0] self.workstation = workstations[0]
if not data:
row.planned_start_time = args.from_time
return
if data: if data:
if data.get("planned_start_time"): if data.get("planned_start_time"):
row.planned_start_time = get_datetime(data.planned_start_time) args.planned_start_time = get_datetime(data.planned_start_time)
else: else:
row.planned_start_time = get_datetime(data.to_time + get_mins_between_operations()) args.planned_start_time = get_datetime(data.to_time + get_mins_between_operations())
args.from_time = args.planned_start_time
args.to_time = add_to_date(args.planned_start_time, minutes=row.remaining_time_in_mins)
self.validate_overlap_for_workstation(args, row)
def check_workstation_time(self, row): def check_workstation_time(self, row):
workstation_doc = frappe.get_cached_doc("Workstation", self.workstation) workstation_doc = frappe.get_cached_doc("Workstation", self.workstation)

View File

@ -518,6 +518,12 @@ frappe.ui.form.on("Production Plan Sales Order", {
} }
}); });
frappe.ui.form.on("Production Plan Sub Assembly Item", {
fg_warehouse(frm, cdt, cdn) {
erpnext.utils.copy_value_in_all_rows(frm.doc, cdt, cdn, "sub_assembly_items", "fg_warehouse");
},
})
frappe.tour['Production Plan'] = [ frappe.tour['Production Plan'] = [
{ {
fieldname: "get_items_from", fieldname: "get_items_from",

View File

@ -421,9 +421,11 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"description": "When a parent warehouse is chosen, the system conducts stock checks against the associated child warehouses",
"fieldname": "sub_assembly_warehouse", "fieldname": "sub_assembly_warehouse",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Sub Assembly Warehouse", "label": "Sub Assembly Warehouse",
"mandatory_depends_on": "eval:doc.skip_available_sub_assembly_item === 1",
"options": "Warehouse" "options": "Warehouse"
}, },
{ {
@ -437,7 +439,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2024-02-11 15:42:47.642481", "modified": "2024-02-27 13:34:20.692211",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Production Plan", "name": "Production Plan",

View File

@ -894,8 +894,8 @@ class ProductionPlan(Document):
sub_assembly_items_store = [] # temporary store to process all subassembly items sub_assembly_items_store = [] # temporary store to process all subassembly items
for row in self.po_items: for row in self.po_items:
if self.skip_available_sub_assembly_item and not row.warehouse: if self.skip_available_sub_assembly_item and not self.sub_assembly_warehouse:
frappe.throw(_("Row #{0}: Please select the FG Warehouse in Assembly Items").format(row.idx)) frappe.throw(_("Row #{0}: Please select the Sub Assembly Warehouse").format(row.idx))
if not row.item_code: if not row.item_code:
frappe.throw(_("Row #{0}: Please select Item Code in Assembly Items").format(row.idx)) frappe.throw(_("Row #{0}: Please select Item Code in Assembly Items").format(row.idx))
@ -905,15 +905,24 @@ class ProductionPlan(Document):
bom_data = [] bom_data = []
warehouse = ( warehouse = (self.sub_assembly_warehouse) if self.skip_available_sub_assembly_item else None
(self.sub_assembly_warehouse or row.warehouse)
if self.skip_available_sub_assembly_item
else None
)
get_sub_assembly_items(row.bom_no, bom_data, row.planned_qty, self.company, warehouse=warehouse) get_sub_assembly_items(row.bom_no, bom_data, row.planned_qty, self.company, warehouse=warehouse)
self.set_sub_assembly_items_based_on_level(row, bom_data, manufacturing_type) self.set_sub_assembly_items_based_on_level(row, bom_data, manufacturing_type)
sub_assembly_items_store.extend(bom_data) sub_assembly_items_store.extend(bom_data)
if not sub_assembly_items_store and self.skip_available_sub_assembly_item:
message = (
_(
"As there are sufficient Sub Assembly Items, Work Order is not required for Warehouse {0}."
).format(self.sub_assembly_warehouse)
+ "<br><br>"
)
message += _(
"If you still want to proceed, please disable 'Skip Available Sub Assembly Items' checkbox."
)
frappe.msgprint(message, title=_("Note"))
if self.combine_sub_items: if self.combine_sub_items:
# Combine subassembly items # Combine subassembly items
sub_assembly_items_store = self.combine_subassembly_items(sub_assembly_items_store) sub_assembly_items_store = self.combine_subassembly_items(sub_assembly_items_store)
@ -926,15 +935,19 @@ class ProductionPlan(Document):
def set_sub_assembly_items_based_on_level(self, row, bom_data, manufacturing_type=None): def set_sub_assembly_items_based_on_level(self, row, bom_data, manufacturing_type=None):
"Modify bom_data, set additional details." "Modify bom_data, set additional details."
is_group_warehouse = frappe.db.get_value("Warehouse", self.sub_assembly_warehouse, "is_group")
for data in bom_data: for data in bom_data:
data.qty = data.stock_qty data.qty = data.stock_qty
data.production_plan_item = row.name data.production_plan_item = row.name
data.fg_warehouse = self.sub_assembly_warehouse or row.warehouse
data.schedule_date = row.planned_start_date data.schedule_date = row.planned_start_date
data.type_of_manufacturing = manufacturing_type or ( data.type_of_manufacturing = manufacturing_type or (
"Subcontract" if data.is_sub_contracted_item else "In House" "Subcontract" if data.is_sub_contracted_item else "In House"
) )
if not is_group_warehouse:
data.fg_warehouse = self.sub_assembly_warehouse
def set_default_supplier_for_subcontracting_order(self): def set_default_supplier_for_subcontracting_order(self):
items = [ items = [
d.production_item for d in self.sub_assembly_items if d.type_of_manufacturing == "Subcontract" d.production_item for d in self.sub_assembly_items if d.type_of_manufacturing == "Subcontract"
@ -1478,7 +1491,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
so_item_details = frappe._dict() so_item_details = frappe._dict()
sub_assembly_items = {} sub_assembly_items = {}
if doc.get("skip_available_sub_assembly_item"): if doc.get("skip_available_sub_assembly_item") and doc.get("sub_assembly_items"):
for d in doc.get("sub_assembly_items"): for d in doc.get("sub_assembly_items"):
sub_assembly_items.setdefault((d.get("production_item"), d.get("bom_no")), d.get("qty")) sub_assembly_items.setdefault((d.get("production_item"), d.get("bom_no")), d.get("qty"))
@ -1507,19 +1520,17 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
frappe.throw(_("For row {0}: Enter Planned Qty").format(data.get("idx"))) frappe.throw(_("For row {0}: Enter Planned Qty").format(data.get("idx")))
if bom_no: if bom_no:
if ( if data.get("include_exploded_items") and doc.get("skip_available_sub_assembly_item"):
data.get("include_exploded_items") item_details = {}
and doc.get("sub_assembly_items") if doc.get("sub_assembly_items"):
and doc.get("skip_available_sub_assembly_item") item_details = get_raw_materials_of_sub_assembly_items(
): item_details,
item_details = get_raw_materials_of_sub_assembly_items( company,
item_details, bom_no,
company, include_non_stock_items,
bom_no, sub_assembly_items,
include_non_stock_items, planned_qty=planned_qty,
sub_assembly_items, )
planned_qty=planned_qty,
)
elif data.get("include_exploded_items") and include_subcontracted_items: elif data.get("include_exploded_items") and include_subcontracted_items:
# fetch exploded items from BOM # fetch exploded items from BOM
@ -1692,34 +1703,37 @@ def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, company, warehouse=
stock_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty) stock_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty)
if warehouse: if warehouse:
bin_dict = get_bin_details(d, company, for_warehouse=warehouse) bin_details = get_bin_details(d, company, for_warehouse=warehouse)
if bin_dict and bin_dict[0].projected_qty > 0: for _bin_dict in bin_details:
if bin_dict[0].projected_qty > stock_qty: if _bin_dict.projected_qty > 0:
continue if _bin_dict.projected_qty > stock_qty:
else: stock_qty = 0
stock_qty = stock_qty - bin_dict[0].projected_qty continue
else:
stock_qty = stock_qty - _bin_dict.projected_qty
bom_data.append( if stock_qty > 0:
frappe._dict( bom_data.append(
{ frappe._dict(
"parent_item_code": parent_item_code, {
"description": d.description, "parent_item_code": parent_item_code,
"production_item": d.item_code, "description": d.description,
"item_name": d.item_name, "production_item": d.item_code,
"stock_uom": d.stock_uom, "item_name": d.item_name,
"uom": d.stock_uom, "stock_uom": d.stock_uom,
"bom_no": d.value, "uom": d.stock_uom,
"is_sub_contracted_item": d.is_sub_contracted_item, "bom_no": d.value,
"bom_level": indent, "is_sub_contracted_item": d.is_sub_contracted_item,
"indent": indent, "bom_level": indent,
"stock_qty": stock_qty, "indent": indent,
} "stock_qty": stock_qty,
}
)
) )
)
if d.value: if d.value:
get_sub_assembly_items(d.value, bom_data, stock_qty, company, warehouse, indent=indent + 1) get_sub_assembly_items(d.value, bom_data, stock_qty, company, warehouse, indent=indent + 1)
def set_default_warehouses(row, default_warehouses): def set_default_warehouses(row, default_warehouses):

View File

@ -1205,6 +1205,7 @@ class TestProductionPlan(FrappeTestCase):
ignore_existing_ordered_qty=1, ignore_existing_ordered_qty=1,
do_not_submit=1, do_not_submit=1,
skip_available_sub_assembly_item=1, skip_available_sub_assembly_item=1,
sub_assembly_warehouse="_Test Warehouse - _TC",
warehouse="_Test Warehouse - _TC", warehouse="_Test Warehouse - _TC",
) )
@ -1232,6 +1233,35 @@ class TestProductionPlan(FrappeTestCase):
if row.item_code == "SubAssembly2 For SUB Test": if row.item_code == "SubAssembly2 For SUB Test":
self.assertEqual(row.quantity, 10) self.assertEqual(row.quantity, 10)
def test_sub_assembly_and_their_raw_materials_exists(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
bom_tree = {
"FG1 For SUB Test": {
"SAB1 For SUB Test": {"CP1 For SUB Test": {}},
"SAB2 For SUB Test": {},
}
}
parent_bom = create_nested_bom(bom_tree, prefix="")
for item in ["SAB1 For SUB Test", "SAB2 For SUB Test"]:
make_stock_entry(item_code=item, qty=10, rate=100, target="_Test Warehouse - _TC")
plan = create_production_plan(
item_code=parent_bom.item,
planned_qty=10,
ignore_existing_ordered_qty=1,
do_not_submit=1,
skip_available_sub_assembly_item=1,
warehouse="_Test Warehouse - _TC",
)
items = get_items_for_material_requests(
plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}]
)
self.assertFalse(items)
def test_transfer_and_purchase_mrp_for_purchase_uom(self): def test_transfer_and_purchase_mrp_for_purchase_uom(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
@ -1309,6 +1339,7 @@ class TestProductionPlan(FrappeTestCase):
ignore_existing_ordered_qty=1, ignore_existing_ordered_qty=1,
do_not_submit=1, do_not_submit=1,
skip_available_sub_assembly_item=1, skip_available_sub_assembly_item=1,
sub_assembly_warehouse="_Test Warehouse - _TC",
warehouse="_Test Warehouse - _TC", warehouse="_Test Warehouse - _TC",
) )
@ -1561,6 +1592,48 @@ class TestProductionPlan(FrappeTestCase):
for row in work_orders: for row in work_orders:
self.assertEqual(row.qty, wo_qty[row.name]) self.assertEqual(row.qty, wo_qty[row.name])
def test_parent_warehouse_for_sub_assembly_items(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
parent_warehouse = "_Test Warehouse Group - _TC"
sub_warehouse = create_warehouse("Sub Warehouse", company="_Test Company")
fg_item = make_item(properties={"is_stock_item": 1}).name
sf_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1}).name
bom_tree = {fg_item: {sf_item: {rm_item: {}}}}
create_nested_bom(bom_tree, prefix="")
pln = create_production_plan(
item_code=fg_item,
planned_qty=10,
warehouse="_Test Warehouse - _TC",
sub_assembly_warehouse=parent_warehouse,
skip_available_sub_assembly_item=1,
do_not_submit=1,
skip_getting_mr_items=1,
)
pln.get_sub_assembly_items()
for row in pln.sub_assembly_items:
self.assertFalse(row.fg_warehouse)
self.assertEqual(row.production_item, sf_item)
self.assertEqual(row.qty, 10.0)
make_stock_entry(item_code=sf_item, qty=5, target=sub_warehouse, rate=100)
pln.sub_assembly_items = []
pln.get_sub_assembly_items()
self.assertEqual(pln.sub_assembly_warehouse, parent_warehouse)
for row in pln.sub_assembly_items:
self.assertFalse(row.fg_warehouse)
self.assertEqual(row.production_item, sf_item)
self.assertEqual(row.qty, 5.0)
def create_production_plan(**args): def create_production_plan(**args):
""" """

View File

@ -11,6 +11,7 @@
"bom_no", "bom_no",
"column_break_6", "column_break_6",
"planned_qty", "planned_qty",
"stock_uom",
"warehouse", "warehouse",
"planned_start_date", "planned_start_date",
"section_break_9", "section_break_9",
@ -18,7 +19,6 @@
"ordered_qty", "ordered_qty",
"column_break_17", "column_break_17",
"description", "description",
"stock_uom",
"produced_qty", "produced_qty",
"reference_section", "reference_section",
"sales_order", "sales_order",
@ -65,6 +65,7 @@
"width": "100px" "width": "100px"
}, },
{ {
"columns": 1,
"fieldname": "planned_qty", "fieldname": "planned_qty",
"fieldtype": "Float", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
@ -80,6 +81,7 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"columns": 2,
"fieldname": "warehouse", "fieldname": "warehouse",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
@ -141,8 +143,10 @@
"width": "200px" "width": "200px"
}, },
{ {
"columns": 1,
"fieldname": "stock_uom", "fieldname": "stock_uom",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1,
"label": "UOM", "label": "UOM",
"oldfieldname": "stock_uom", "oldfieldname": "stock_uom",
"oldfieldtype": "Data", "oldfieldtype": "Data",
@ -216,7 +220,7 @@
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2022-11-25 14:15:40.061514", "modified": "2024-02-27 13:24:43.571844",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Production Plan Item", "name": "Production Plan Item",

View File

@ -101,7 +101,6 @@
"columns": 1, "columns": 1,
"fieldname": "bom_level", "fieldname": "bom_level",
"fieldtype": "Int", "fieldtype": "Int",
"in_list_view": 1,
"label": "Level (BOM)", "label": "Level (BOM)",
"read_only": 1 "read_only": 1
}, },
@ -149,8 +148,10 @@
"label": "Indent" "label": "Indent"
}, },
{ {
"columns": 2,
"fieldname": "fg_warehouse", "fieldname": "fg_warehouse",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1,
"label": "Target Warehouse", "label": "Target Warehouse",
"options": "Warehouse" "options": "Warehouse"
}, },
@ -170,6 +171,7 @@
"options": "Supplier" "options": "Supplier"
}, },
{ {
"columns": 1,
"fieldname": "schedule_date", "fieldname": "schedule_date",
"fieldtype": "Datetime", "fieldtype": "Datetime",
"in_list_view": 1, "in_list_view": 1,
@ -207,7 +209,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-11-03 13:33:42.959387", "modified": "2024-02-27 13:45:17.422435",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Production Plan Sub Assembly Item", "name": "Production Plan Sub Assembly Item",

View File

@ -1821,6 +1821,113 @@ class TestWorkOrder(FrappeTestCase):
valuation_rate = sum([item.valuation_rate * item.transfer_qty for item in mce.items]) / 10 valuation_rate = sum([item.valuation_rate * item.transfer_qty for item in mce.items]) / 10
self.assertEqual(me.items[0].valuation_rate, valuation_rate) self.assertEqual(me.items[0].valuation_rate, valuation_rate)
def test_capcity_planning_for_workstation(self):
frappe.db.set_single_value(
"Manufacturing Settings",
{
"disable_capacity_planning": 0,
"capacity_planning_for_days": 1,
"mins_between_operations": 10,
},
)
properties = {"is_stock_item": 1, "valuation_rate": 100}
fg_item = make_item("Test FG Item For Capacity Planning", properties).name
rm_item = make_item("Test RM Item For Capacity Planning", properties).name
workstation = "Test Workstation For Capacity Planning"
if not frappe.db.exists("Workstation", workstation):
make_workstation(workstation=workstation, production_capacity=1)
operation = "Test Operation For Capacity Planning"
if not frappe.db.exists("Operation", operation):
make_operation(operation=operation, workstation=workstation)
bom_doc = make_bom(
item=fg_item,
source_warehouse="Stores - _TC",
raw_materials=[rm_item],
with_operations=1,
do_not_submit=True,
)
bom_doc.append(
"operations",
{"operation": operation, "time_in_mins": 1420, "hour_rate": 100, "workstation": workstation},
)
bom_doc.submit()
# 1st Work Order,
# Capacity to run parallel the operation 'Test Operation For Capacity Planning' is 2
wo_doc = make_wo_order_test_record(
production_item=fg_item, qty=1, planned_start_date="2024-02-25 00:00:00", do_not_submit=1
)
wo_doc.submit()
job_cards = frappe.get_all(
"Job Card",
filters={"work_order": wo_doc.name},
)
self.assertEqual(len(job_cards), 1)
# 2nd Work Order,
wo_doc = make_wo_order_test_record(
production_item=fg_item, qty=1, planned_start_date="2024-02-25 00:00:00", do_not_submit=1
)
wo_doc.submit()
job_cards = frappe.get_all(
"Job Card",
filters={"work_order": wo_doc.name},
)
self.assertEqual(len(job_cards), 1)
# 3rd Work Order, capacity is full
wo_doc = make_wo_order_test_record(
production_item=fg_item, qty=1, planned_start_date="2024-02-25 00:00:00", do_not_submit=1
)
self.assertRaises(CapacityError, wo_doc.submit)
frappe.db.set_single_value(
"Manufacturing Settings", {"disable_capacity_planning": 1, "mins_between_operations": 0}
)
def make_operation(**kwargs):
kwargs = frappe._dict(kwargs)
operation_doc = frappe.get_doc(
{
"doctype": "Operation",
"name": kwargs.operation,
"workstation": kwargs.workstation,
}
)
operation_doc.insert()
return operation_doc
def make_workstation(**kwargs):
kwargs = frappe._dict(kwargs)
workstation_doc = frappe.get_doc(
{
"doctype": "Workstation",
"workstation_name": kwargs.workstation,
"workstation_type": kwargs.workstation_type,
"production_capacity": kwargs.production_capacity or 0,
"hour_rate": kwargs.hour_rate or 100,
}
)
workstation_doc.insert()
return workstation_doc
def prepare_boms_for_sub_assembly_test(): def prepare_boms_for_sub_assembly_test():
if not frappe.db.exists("BOM", {"item": "Test Final SF Item 1"}): if not frappe.db.exists("BOM", {"item": "Test Final SF Item 1"}):

View File

@ -242,8 +242,12 @@ class WorkOrder(Document):
def calculate_operating_cost(self): def calculate_operating_cost(self):
self.planned_operating_cost, self.actual_operating_cost = 0.0, 0.0 self.planned_operating_cost, self.actual_operating_cost = 0.0, 0.0
for d in self.get("operations"): for d in self.get("operations"):
d.planned_operating_cost = flt(d.hour_rate) * (flt(d.time_in_mins) / 60.0) d.planned_operating_cost = flt(
d.actual_operating_cost = flt(d.hour_rate) * (flt(d.actual_operation_time) / 60.0) flt(d.hour_rate) * (flt(d.time_in_mins) / 60.0), d.precision("planned_operating_cost")
)
d.actual_operating_cost = flt(
flt(d.hour_rate) * (flt(d.actual_operation_time) / 60.0), d.precision("actual_operating_cost")
)
self.planned_operating_cost += flt(d.planned_operating_cost) self.planned_operating_cost += flt(d.planned_operating_cost)
self.actual_operating_cost += flt(d.actual_operating_cost) self.actual_operating_cost += flt(d.actual_operating_cost)
@ -588,7 +592,6 @@ class WorkOrder(Document):
def prepare_data_for_job_card(self, row, index, plan_days, enable_capacity_planning): def prepare_data_for_job_card(self, row, index, plan_days, enable_capacity_planning):
self.set_operation_start_end_time(index, row) self.set_operation_start_end_time(index, row)
original_start_time = row.planned_start_time
job_card_doc = create_job_card( job_card_doc = create_job_card(
self, row, auto_create=True, enable_capacity_planning=enable_capacity_planning self, row, auto_create=True, enable_capacity_planning=enable_capacity_planning
) )
@ -597,11 +600,15 @@ class WorkOrder(Document):
row.planned_start_time = job_card_doc.scheduled_time_logs[-1].from_time row.planned_start_time = job_card_doc.scheduled_time_logs[-1].from_time
row.planned_end_time = job_card_doc.scheduled_time_logs[-1].to_time row.planned_end_time = job_card_doc.scheduled_time_logs[-1].to_time
if date_diff(row.planned_start_time, original_start_time) > plan_days: if date_diff(row.planned_end_time, self.planned_start_date) > plan_days:
frappe.message_log.pop() frappe.message_log.pop()
frappe.throw( frappe.throw(
_("Unable to find the time slot in the next {0} days for the operation {1}.").format( _(
plan_days, row.operation "Unable to find the time slot in the next {0} days for the operation {1}. Please increase the 'Capacity Planning For (Days)' in the {2}."
).format(
plan_days,
row.operation,
get_link_to_form("Manufacturing Settings", "Manufacturing Settings"),
), ),
CapacityError, CapacityError,
) )

View File

@ -1,25 +1,28 @@
{ {
"add_total_row": 0, "add_total_row": 0,
"apply_user_permissions": 1, "columns": [],
"creation": "2013-08-12 12:44:27", "creation": "2013-08-12 12:44:27",
"disabled": 0, "disabled": 0,
"docstatus": 0, "docstatus": 0,
"doctype": "Report", "doctype": "Report",
"idx": 3, "filters": [],
"is_standard": "Yes", "idx": 3,
"modified": "2018-02-13 04:58:51.549413", "is_standard": "Yes",
"modified_by": "Administrator", "letterhead": null,
"module": "Manufacturing", "modified": "2024-02-21 14:35:14.301848",
"name": "Completed Work Orders", "modified_by": "Administrator",
"owner": "Administrator", "module": "Manufacturing",
"query": "SELECT\n `tabWork Order`.name as \"Work Order:Link/Work Order:200\",\n `tabWork Order`.creation as \"Date:Date:120\",\n `tabWork Order`.production_item as \"Item:Link/Item:150\",\n `tabWork Order`.qty as \"To Produce:Int:100\",\n `tabWork Order`.produced_qty as \"Produced:Int:100\",\n `tabWork Order`.company as \"Company:Link/Company:\"\nFROM\n `tabWork Order`\nWHERE\n `tabWork Order`.docstatus=1\n AND ifnull(`tabWork Order`.produced_qty,0) = `tabWork Order`.qty", "name": "Completed Work Orders",
"ref_doctype": "Work Order", "owner": "Administrator",
"report_name": "Completed Work Orders", "prepared_report": 0,
"report_type": "Query Report", "query": "SELECT\n `tabWork Order`.name as \"Work Order:Link/Work Order:200\",\n `tabWork Order`.creation as \"Date:Date:120\",\n `tabWork Order`.production_item as \"Item:Link/Item:150\",\n `tabWork Order`.qty as \"To Produce:Int:100\",\n `tabWork Order`.produced_qty as \"Produced:Int:100\",\n `tabWork Order`.company as \"Company:Link/Company:\"\nFROM\n `tabWork Order`\nWHERE\n `tabWork Order`.docstatus=1\n AND ifnull(`tabWork Order`.produced_qty,0) >= `tabWork Order`.qty",
"ref_doctype": "Work Order",
"report_name": "Completed Work Orders",
"report_type": "Query Report",
"roles": [ "roles": [
{ {
"role": "Manufacturing User" "role": "Manufacturing User"
}, },
{ {
"role": "Stock User" "role": "Stock User"
} }

View File

@ -58,7 +58,7 @@ def get_data(filters):
query_filters["creation"] = ("between", [filters.get("from_date"), filters.get("to_date")]) query_filters["creation"] = ("between", [filters.get("from_date"), filters.get("to_date")])
data = frappe.get_all( data = frappe.get_all(
"Work Order", fields=fields, filters=query_filters, order_by="planned_start_date asc", debug=1 "Work Order", fields=fields, filters=query_filters, order_by="planned_start_date asc"
) )
res = [] res = []

View File

@ -264,6 +264,7 @@ execute:frappe.rename_doc("Report", "TDS Payable Monthly", "Tax Withholding Deta
[post_model_sync] [post_model_sync]
execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings') execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings')
erpnext.patches.v14_0.update_posting_datetime_and_dropped_indexes #22-02-2024
erpnext.patches.v14_0.rename_ongoing_status_in_sla_documents erpnext.patches.v14_0.rename_ongoing_status_in_sla_documents
erpnext.patches.v14_0.delete_shopify_doctypes erpnext.patches.v14_0.delete_shopify_doctypes
erpnext.patches.v14_0.delete_healthcare_doctypes erpnext.patches.v14_0.delete_healthcare_doctypes
@ -351,6 +352,7 @@ erpnext.patches.v14_0.create_accounting_dimensions_in_supplier_quotation
erpnext.patches.v14_0.update_zero_asset_quantity_field erpnext.patches.v14_0.update_zero_asset_quantity_field
execute:frappe.db.set_single_value("Buying Settings", "project_update_frequency", "Each Transaction") execute:frappe.db.set_single_value("Buying Settings", "project_update_frequency", "Each Transaction")
erpnext.patches.v14_0.update_total_asset_cost_field erpnext.patches.v14_0.update_total_asset_cost_field
erpnext.patches.v14_0.create_accounting_dimensions_in_reconciliation_tool
# below migration patch should always run last # below migration patch should always run last
erpnext.patches.v14_0.migrate_gl_to_payment_ledger erpnext.patches.v14_0.migrate_gl_to_payment_ledger
erpnext.stock.doctype.delivery_note.patches.drop_unused_return_against_index # 2023-12-20 erpnext.stock.doctype.delivery_note.patches.drop_unused_return_against_index # 2023-12-20

View File

@ -0,0 +1,8 @@
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
create_accounting_dimensions_for_doctype,
)
def execute():
create_accounting_dimensions_for_doctype(doctype="Payment Reconciliation")
create_accounting_dimensions_for_doctype(doctype="Payment Reconciliation Allocation")

View File

@ -0,0 +1,19 @@
import frappe
def execute():
frappe.db.sql(
"""
UPDATE `tabStock Ledger Entry`
SET posting_datetime = DATE_FORMAT(timestamp(posting_date, posting_time), '%Y-%m-%d %H:%i:%s')
"""
)
drop_indexes()
def drop_indexes():
if not frappe.db.has_index("tabStock Ledger Entry", "posting_sort_index"):
return
frappe.db.sql_ddl("ALTER TABLE `tabStock Ledger Entry` DROP INDEX `posting_sort_index`")

View File

@ -83,7 +83,7 @@ class Timesheet(Document):
def set_status(self): def set_status(self):
self.status = {"0": "Draft", "1": "Submitted", "2": "Cancelled"}[str(self.docstatus or 0)] self.status = {"0": "Draft", "1": "Submitted", "2": "Cancelled"}[str(self.docstatus or 0)]
if self.per_billed == 100: if flt(self.per_billed, self.precision("per_billed")) >= 100.0:
self.status = "Billed" self.status = "Billed"
if self.sales_invoice: if self.sales_invoice:

View File

@ -106,7 +106,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
frappe.ui.form.on(this.frm.doctype + " Item", { frappe.ui.form.on(this.frm.doctype + " Item", {
items_add: function(frm, cdt, cdn) { items_add: function(frm, cdt, cdn) {
debugger
var item = frappe.get_doc(cdt, cdn); var item = frappe.get_doc(cdt, cdn);
if (!item.warehouse && frm.doc.set_warehouse) { if (!item.warehouse && frm.doc.set_warehouse) {
item.warehouse = frm.doc.set_warehouse; item.warehouse = frm.doc.set_warehouse;
@ -166,7 +165,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
} }
if(this.frm.fields_dict["items"]) { if(this.frm.fields_dict["items"]) {
this["items_remove"] = this.calculate_net_weight; this["items_remove"] = this.process_item_removal;
} }
if(this.frm.fields_dict["recurring_print_format"]) { if(this.frm.fields_dict["recurring_print_format"]) {
@ -398,7 +397,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
refresh() { refresh() {
erpnext.toggle_naming_series(); erpnext.toggle_naming_series();
erpnext.hide_company(); erpnext.hide_company(this.frm);
this.set_dynamic_labels(); this.set_dynamic_labels();
this.setup_sms(); this.setup_sms();
this.setup_quality_inspection(); this.setup_quality_inspection();
@ -1288,6 +1287,11 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
} }
} }
process_item_removal() {
this.frm.trigger("calculate_taxes_and_totals");
this.frm.trigger("calculate_net_weight");
}
calculate_net_weight(){ calculate_net_weight(){
/* Calculate Total Net Weight then further applied shipping rule to calculate shipping charges.*/ /* Calculate Total Net Weight then further applied shipping rule to calculate shipping charges.*/
var me = this; var me = this;

View File

@ -26,21 +26,24 @@ $.extend(erpnext, {
} }
}, },
hide_company: function() { hide_company: function(frm) {
if(cur_frm.fields_dict.company) { if(frm?.fields_dict.company) {
var companies = Object.keys(locals[":Company"] || {}); var companies = Object.keys(locals[":Company"] || {});
if(companies.length === 1) { if(companies.length === 1) {
if(!cur_frm.doc.company) cur_frm.set_value("company", companies[0]); if(!frm.doc.company) frm.set_value("company", companies[0]);
cur_frm.toggle_display("company", false); frm.toggle_display("company", false);
} else if(erpnext.last_selected_company) { } else if(erpnext.last_selected_company) {
if(!cur_frm.doc.company) cur_frm.set_value("company", erpnext.last_selected_company); if(!frm.doc.company) frm.set_value("company", erpnext.last_selected_company);
} }
} }
}, },
is_perpetual_inventory_enabled: function(company) { is_perpetual_inventory_enabled: function(company) {
if(company) { if(company) {
return frappe.get_doc(":Company", company).enable_perpetual_inventory let company_local = locals[":Company"] && locals[":Company"][company];
if(company_local) {
return cint(company_local.enable_perpetual_inventory);
}
} }
}, },

View File

@ -297,11 +297,35 @@ class TestCustomer(FrappeTestCase):
if credit_limit > outstanding_amt: if credit_limit > outstanding_amt:
set_credit_limit("_Test Customer", "_Test Company", credit_limit) set_credit_limit("_Test Customer", "_Test Company", credit_limit)
# Makes Sales invoice from Sales Order def test_customer_credit_limit_after_submit(self):
so.save(ignore_permissions=True) from erpnext.controllers.accounts_controller import update_child_qty_rate
si = make_sales_invoice(so.name) from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
si.save(ignore_permissions=True)
self.assertRaises(frappe.ValidationError, make_sales_order) outstanding_amt = self.get_customer_outstanding_amount()
credit_limit = get_credit_limit("_Test Customer", "_Test Company")
if outstanding_amt <= 0.0:
item_qty = int((abs(outstanding_amt) + 200) / 100)
make_sales_order(qty=item_qty)
if credit_limit <= 0.0:
set_credit_limit("_Test Customer", "_Test Company", outstanding_amt + 100)
so = make_sales_order(rate=100, qty=1)
# Update qty in submitted Sales Order to trigger Credit Limit validation
fields = ["name", "item_code", "delivery_date", "conversion_factor", "qty", "rate", "uom", "idx"]
modified_item = frappe._dict()
for x in fields:
modified_item[x] = so.items[0].get(x)
modified_item["docname"] = so.items[0].name
modified_item["qty"] = 2
self.assertRaises(
frappe.ValidationError,
update_child_qty_rate,
so.doctype,
frappe.json.dumps([modified_item]),
so.name,
)
def test_customer_credit_limit_on_change(self): def test_customer_credit_limit_on_change(self):
outstanding_amt = self.get_customer_outstanding_amount() outstanding_amt = self.get_customer_outstanding_amount()

View File

@ -513,6 +513,9 @@ class SalesOrder(SellingController):
def on_update(self): def on_update(self):
pass pass
def on_update_after_submit(self):
self.check_credit_limit()
def before_update_after_submit(self): def before_update_after_submit(self):
self.validate_po() self.validate_po()
self.validate_drop_ship() self.validate_drop_ship()

View File

@ -206,42 +206,36 @@ def prepare_data(
def get_actual_data(filters, sales_users_or_territory_data, date_field, sales_field): def get_actual_data(filters, sales_users_or_territory_data, date_field, sales_field):
fiscal_year = get_fiscal_year(fiscal_year=filters.get("fiscal_year"), as_dict=1) fiscal_year = get_fiscal_year(fiscal_year=filters.get("fiscal_year"), as_dict=1)
dates = [fiscal_year.year_start_date, fiscal_year.year_end_date]
select_field = "`tab{0}`.{1}".format(filters.get("doctype"), sales_field) parent_doc = frappe.qb.DocType(filters.get("doctype"))
child_table = "`tab{0}`".format(filters.get("doctype") + " Item") child_doc = frappe.qb.DocType(filters.get("doctype") + " Item")
sales_team = frappe.qb.DocType("Sales Team")
query = (
frappe.qb.from_(parent_doc)
.inner_join(child_doc)
.on(child_doc.parent == parent_doc.name)
.inner_join(sales_team)
.on(sales_team.parent == parent_doc.name)
.select(
child_doc.item_group,
(child_doc.stock_qty * sales_team.allocated_percentage / 100).as_("stock_qty"),
(child_doc.base_net_amount * sales_team.allocated_percentage / 100).as_("base_net_amount"),
sales_team.sales_person,
parent_doc[date_field],
)
.where(
(parent_doc.docstatus == 1)
& (parent_doc[date_field].between(fiscal_year.year_start_date, fiscal_year.year_end_date))
)
)
if sales_field == "sales_person": if sales_field == "sales_person":
select_field = "`tabSales Team`.sales_person" query = query.where(sales_team.sales_person.isin(sales_users_or_territory_data))
child_table = "`tab{0}`, `tabSales Team`".format(filters.get("doctype") + " Item")
cond = """`tabSales Team`.parent = `tab{0}`.name and
`tabSales Team`.sales_person in ({1}) """.format(
filters.get("doctype"), ",".join(["%s"] * len(sales_users_or_territory_data))
)
else: else:
cond = "`tab{0}`.{1} in ({2})".format( query = query.where(parent_doc[sales_field].isin(sales_users_or_territory_data))
filters.get("doctype"), sales_field, ",".join(["%s"] * len(sales_users_or_territory_data))
)
return frappe.db.sql( return query.run(as_dict=True)
""" SELECT `tab{child_doc}`.item_group,
`tab{child_doc}`.stock_qty, `tab{child_doc}`.base_net_amount,
{select_field}, `tab{parent_doc}`.{date_field}
FROM `tab{parent_doc}`, {child_table}
WHERE
`tab{child_doc}`.parent = `tab{parent_doc}`.name
and `tab{parent_doc}`.docstatus = 1 and {cond}
and `tab{parent_doc}`.{date_field} between %s and %s""".format(
cond=cond,
date_field=date_field,
select_field=select_field,
child_table=child_table,
parent_doc=filters.get("doctype"),
child_doc=filters.get("doctype") + " Item",
),
tuple(sales_users_or_territory_data + dates),
as_dict=1,
)
def get_parents_data(filters, partner_doctype): def get_parents_data(filters, partner_doctype):

View File

@ -0,0 +1,84 @@
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import flt, nowdate
from erpnext.accounts.utils import get_fiscal_year
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.selling.report.sales_person_target_variance_based_on_item_group.sales_person_target_variance_based_on_item_group import (
execute,
)
class TestSalesPersonTargetVarianceBasedOnItemGroup(FrappeTestCase):
def setUp(self):
self.fiscal_year = get_fiscal_year(nowdate())[0]
def tearDown(self):
frappe.db.rollback()
def test_achieved_target_and_variance(self):
# Create a Target Distribution
distribution = frappe.new_doc("Monthly Distribution")
distribution.distribution_id = "Target Report Distribution"
distribution.fiscal_year = self.fiscal_year
distribution.get_months()
distribution.insert()
# Create sales people with targets
person_1 = create_sales_person_with_target("Sales Person 1", self.fiscal_year, distribution.name)
person_2 = create_sales_person_with_target("Sales Person 2", self.fiscal_year, distribution.name)
# Create a Sales Order with 50-50 contribution
so = make_sales_order(
rate=1000,
qty=20,
do_not_submit=True,
)
so.set(
"sales_team",
[
{
"sales_person": person_1.name,
"allocated_percentage": 50,
"allocated_amount": 10000,
},
{
"sales_person": person_2.name,
"allocated_percentage": 50,
"allocated_amount": 10000,
},
],
)
so.submit()
# Check Achieved Target and Variance
result = execute(
frappe._dict(
{
"fiscal_year": self.fiscal_year,
"doctype": "Sales Order",
"period": "Yearly",
"target_on": "Quantity",
}
)
)[1]
row = frappe._dict(result[0])
self.assertSequenceEqual(
[flt(value, 2) for value in (row.total_target, row.total_achieved, row.total_variance)],
[50, 10, -40],
)
def create_sales_person_with_target(sales_person_name, fiscal_year, distribution_id):
sales_person = frappe.new_doc("Sales Person")
sales_person.sales_person_name = sales_person_name
sales_person.append(
"targets",
{
"fiscal_year": fiscal_year,
"target_qty": 50,
"target_amount": 30000,
"distribution_id": distribution_id,
},
)
return sales_person.insert()

View File

@ -36,6 +36,7 @@ def execute(filters=None):
d.base_net_amount, d.base_net_amount,
d.sales_person, d.sales_person,
d.allocated_percentage, d.allocated_percentage,
(d.stock_qty * d.allocated_percentage / 100),
d.contribution_amt, d.contribution_amt,
company_currency, company_currency,
] ]
@ -103,7 +104,7 @@ def get_columns(filters):
"fieldtype": "Link", "fieldtype": "Link",
"width": 140, "width": 140,
}, },
{"label": _("Qty"), "fieldname": "qty", "fieldtype": "Float", "width": 140}, {"label": _("SO Total Qty"), "fieldname": "qty", "fieldtype": "Float", "width": 140},
{ {
"label": _("Amount"), "label": _("Amount"),
"options": "currency", "options": "currency",
@ -119,6 +120,12 @@ def get_columns(filters):
"width": 140, "width": 140,
}, },
{"label": _("Contribution %"), "fieldname": "contribution", "fieldtype": "Float", "width": 140}, {"label": _("Contribution %"), "fieldname": "contribution", "fieldtype": "Float", "width": 140},
{
"label": _("Contribution Qty"),
"fieldname": "contribution_qty",
"fieldtype": "Float",
"width": 140,
},
{ {
"label": _("Contribution Amount"), "label": _("Contribution Amount"),
"options": "currency", "options": "currency",

View File

@ -95,7 +95,17 @@ def create_demo_record(doctype):
def make_transactions(company): def make_transactions(company):
frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 1) frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 1)
start_date = get_fiscal_year(date=getdate())[1] from erpnext.accounts.utils import FiscalYearError
try:
start_date = get_fiscal_year(date=getdate())[1]
except FiscalYearError:
# User might have setup fiscal year for previous or upcoming years
active_fiscal_years = frappe.db.get_all("Fiscal Year", filters={"disabled": 0}, as_list=1)
if active_fiscal_years:
start_date = frappe.db.get_value("Fiscal Year", active_fiscal_years[0][0], "year_start_date")
else:
frappe.throw(_("There are no active Fiscal Years for which Demo Data can be generated."))
for doctype in frappe.get_hooks("demo_transaction_doctypes"): for doctype in frappe.get_hooks("demo_transaction_doctypes"):
data = read_data_file_using_hooks(doctype) data = read_data_file_using_hooks(doctype)
@ -159,6 +169,7 @@ def convert_order_to_invoices():
if i % 2 != 0: if i % 2 != 0:
payment = get_payment_entry(invoice.doctype, invoice.name) payment = get_payment_entry(invoice.doctype, invoice.name)
payment.posting_date = order.transaction_date
payment.reference_no = invoice.name payment.reference_no = invoice.name
payment.submit() payment.submit()

View File

@ -399,7 +399,12 @@ class DeliveryNote(SellingController):
elif self.issue_credit_note: elif self.issue_credit_note:
self.make_return_invoice() self.make_return_invoice()
self.make_bundle_using_old_serial_batch_fields() for table_name in ["items", "packed_items"]:
if not self.get(table_name):
continue
self.make_bundle_using_old_serial_batch_fields(table_name)
# Updating stock ledger should always be called after updating prevdoc status, # Updating stock ledger should always be called after updating prevdoc status,
# because updating reserved qty in bin depends upon updated delivered qty in SO # because updating reserved qty in bin depends upon updated delivered qty in SO
self.update_stock_ledger() self.update_stock_ledger()

View File

@ -1067,6 +1067,8 @@ class TestDeliveryNote(FrappeTestCase):
self.assertEqual(si2.items[1].qty, 1) self.assertEqual(si2.items[1].qty, 1)
def test_delivery_note_bundle_with_batched_item(self): def test_delivery_note_bundle_with_batched_item(self):
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 0)
batched_bundle = make_item("_Test Batched bundle", {"is_stock_item": 0}) batched_bundle = make_item("_Test Batched bundle", {"is_stock_item": 0})
batched_item = make_item( batched_item = make_item(
"_Test Batched Item", "_Test Batched Item",
@ -1088,6 +1090,8 @@ class TestDeliveryNote(FrappeTestCase):
batch_no = get_batch_from_bundle(dn.packed_items[0].serial_and_batch_bundle) batch_no = get_batch_from_bundle(dn.packed_items[0].serial_and_batch_bundle)
self.assertTrue(batch_no) self.assertTrue(batch_no)
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1)
def test_payment_terms_are_fetched_when_creating_sales_invoice(self): def test_payment_terms_are_fetched_when_creating_sales_invoice(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import ( from erpnext.accounts.doctype.payment_entry.test_payment_entry import (
create_payment_terms_template, create_payment_terms_template,
@ -1540,6 +1544,53 @@ class TestDeliveryNote(FrappeTestCase):
self.assertEqual(so.items[0].rate, rate) self.assertEqual(so.items[0].rate, rate)
self.assertEqual(dn.items[0].rate, so.items[0].rate) self.assertEqual(dn.items[0].rate, so.items[0].rate)
def test_use_serial_batch_fields_for_packed_items(self):
bundle_item = make_item("Test _Packed Product Bundle Item ", {"is_stock_item": 0})
serial_item = make_item(
"Test _Packed Serial Item ",
{"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "SN-TESTSERIAL-.#####"},
)
batch_item = make_item(
"Test _Packed Batch Item ",
{
"is_stock_item": 1,
"has_batch_no": 1,
"batch_no_series": "BATCH-TESTSERIAL-.#####",
"create_new_batch": 1,
},
)
make_product_bundle(parent=bundle_item.name, items=[serial_item.name, batch_item.name])
item_details = {}
for item in [serial_item, batch_item]:
se = make_stock_entry(
item_code=item.name, target="_Test Warehouse - _TC", qty=5, basic_rate=100
)
item_details[item.name] = se.items[0].serial_and_batch_bundle
dn = create_delivery_note(item_code=bundle_item.name, qty=1, do_not_submit=True)
serial_no = ""
for row in dn.packed_items:
row.use_serial_batch_fields = 1
if row.item_code == serial_item.name:
serial_and_batch_bundle = item_details[serial_item.name]
row.serial_no = get_serial_nos_from_bundle(serial_and_batch_bundle)[3]
serial_no = row.serial_no
else:
serial_and_batch_bundle = item_details[batch_item.name]
row.batch_no = get_batch_from_bundle(serial_and_batch_bundle)
dn.submit()
dn.load_from_db()
for row in dn.packed_items:
self.assertTrue(row.serial_no or row.batch_no)
self.assertTrue(row.serial_and_batch_bundle)
if row.serial_no:
self.assertEqual(row.serial_no, serial_no)
def create_delivery_note(**args): def create_delivery_note(**args):
dn = frappe.new_doc("Delivery Note") dn = frappe.new_doc("Delivery Note")

View File

@ -1,187 +1,77 @@
{ {
"allow_copy": 0, "actions": [],
"allow_import": 0, "creation": "2013-02-22 01:28:02",
"allow_rename": 0, "doctype": "DocType",
"beta": 0, "document_type": "Document",
"creation": "2013-02-22 01:28:02", "editable_grid": 1,
"custom": 0, "engine": "InnoDB",
"docstatus": 0, "field_order": [
"doctype": "DocType", "receipt_document_type",
"document_type": "Document", "receipt_document",
"editable_grid": 1, "supplier",
"col_break1",
"posting_date",
"grand_total"
],
"fields": [ "fields": [
{ {
"allow_on_submit": 0, "fieldname": "receipt_document_type",
"bold": 0, "fieldtype": "Select",
"collapsible": 0, "in_list_view": 1,
"fieldname": "receipt_document_type", "label": "Receipt Document Type",
"fieldtype": "Select", "options": "\nPurchase Invoice\nPurchase Receipt",
"hidden": 0, "reqd": 1
"ignore_user_permissions": 0, },
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 1,
"label": "Receipt Document Type",
"length": 0,
"no_copy": 0,
"options": "\nPurchase Invoice\nPurchase Receipt",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_on_submit": 0, "fieldname": "receipt_document",
"bold": 0, "fieldtype": "Dynamic Link",
"collapsible": 0, "in_list_view": 1,
"fieldname": "receipt_document", "label": "Receipt Document",
"fieldtype": "Dynamic Link", "oldfieldname": "purchase_receipt_no",
"hidden": 0, "oldfieldtype": "Link",
"ignore_user_permissions": 0, "options": "receipt_document_type",
"ignore_xss_filter": 0, "print_width": "220px",
"in_filter": 0, "reqd": 1,
"in_list_view": 1,
"label": "Receipt Document",
"length": 0,
"no_copy": 0,
"oldfieldname": "purchase_receipt_no",
"oldfieldtype": "Link",
"options": "receipt_document_type",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "220px",
"read_only": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"unique": 0,
"width": "220px" "width": "220px"
}, },
{ {
"allow_on_submit": 0, "fieldname": "supplier",
"bold": 0, "fieldtype": "Link",
"collapsible": 0, "in_list_view": 1,
"fieldname": "supplier", "label": "Supplier",
"fieldtype": "Link", "options": "Supplier",
"hidden": 0, "read_only": 1
"ignore_user_permissions": 0, },
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 1,
"label": "Supplier",
"length": 0,
"no_copy": 0,
"options": "Supplier",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_on_submit": 0, "fieldname": "col_break1",
"bold": 0, "fieldtype": "Column Break",
"collapsible": 0,
"fieldname": "col_break1",
"fieldtype": "Column Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 0,
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0,
"width": "50%" "width": "50%"
}, },
{ {
"allow_on_submit": 0, "fieldname": "posting_date",
"bold": 0, "fieldtype": "Date",
"collapsible": 0, "label": "Posting Date",
"fieldname": "posting_date", "read_only": 1
"fieldtype": "Date", },
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 0,
"label": "Posting Date",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_on_submit": 0, "fieldname": "grand_total",
"bold": 0, "fieldtype": "Currency",
"collapsible": 0, "in_list_view": 1,
"fieldname": "grand_total", "label": "Grand Total",
"fieldtype": "Currency", "options": "Company:company:default_currency",
"hidden": 0, "read_only": 1
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 1,
"label": "Grand Total",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
} }
], ],
"hide_heading": 0, "idx": 1,
"hide_toolbar": 0, "istable": 1,
"idx": 1, "links": [],
"image_view": 0, "modified": "2024-02-26 18:41:06.281750",
"in_create": 0, "modified_by": "Administrator",
"module": "Stock",
"is_submittable": 0, "name": "Landed Cost Purchase Receipt",
"issingle": 0, "owner": "Administrator",
"istable": 1, "permissions": [],
"max_attachments": 0, "sort_field": "modified",
"modified": "2020-09-18 17:26:09.703215", "sort_order": "ASC",
"modified_by": "Administrator", "states": []
"module": "Stock",
"name": "Landed Cost Purchase Receipt",
"owner": "Administrator",
"permissions": [],
"quick_entry": 0,
"read_only": 0,
"read_only_onload": 0,
"sort_order": "ASC",
"track_seen": 0
} }

View File

@ -199,6 +199,7 @@ frappe.ui.form.on('Material Request', {
get_item_data: function(frm, item, overwrite_warehouse=false) { get_item_data: function(frm, item, overwrite_warehouse=false) {
if (item && !item.item_code) { return; } if (item && !item.item_code) { return; }
frappe.call({ frappe.call({
method: "erpnext.stock.get_item_details.get_item_details", method: "erpnext.stock.get_item_details.get_item_details",
args: { args: {
@ -225,20 +226,22 @@ frappe.ui.form.on('Material Request', {
}, },
callback: function(r) { callback: function(r) {
const d = item; const d = item;
const qty_fields = ['actual_qty', 'projected_qty', 'min_order_qty']; const allow_to_change_fields = ['actual_qty', 'projected_qty', 'min_order_qty', 'item_name', 'description', 'stock_uom', 'uom', 'conversion_factor', 'stock_qty'];
if(!r.exc) { if(!r.exc) {
$.each(r.message, function(key, value) { $.each(r.message, function(key, value) {
if(!d[key] || qty_fields.includes(key)) { if(!d[key] || allow_to_change_fields.includes(key)) {
d[key] = value; d[key] = value;
} }
}); });
if (d.price_list_rate != r.message.price_list_rate) { if (d.price_list_rate != r.message.price_list_rate) {
d.rate = 0.0;
d.price_list_rate = r.message.price_list_rate; d.price_list_rate = r.message.price_list_rate;
frappe.model.set_value(d.doctype, d.name, "rate", d.price_list_rate); frappe.model.set_value(d.doctype, d.name, "rate", d.price_list_rate);
} }
refresh_field("items");
} }
} }
}); });
@ -435,7 +438,7 @@ frappe.ui.form.on("Material Request Item", {
frm.events.get_item_data(frm, item, false); frm.events.get_item_data(frm, item, false);
}, },
rate: function(frm, doctype, name) { rate(frm, doctype, name) {
const item = locals[doctype][name]; const item = locals[doctype][name];
item.amount = flt(item.qty) * flt(item.rate); item.amount = flt(item.qty) * flt(item.rate);
frappe.model.set_value(doctype, name, "amount", item.amount); frappe.model.set_value(doctype, name, "amount", item.amount);

View File

@ -198,12 +198,14 @@
"fieldname": "rate", "fieldname": "rate",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Rate", "label": "Rate",
"options": "Company:company:default_currency",
"print_hide": 1 "print_hide": 1
}, },
{ {
"fieldname": "amount", "fieldname": "amount",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Amount", "label": "Amount",
"options": "Company:company:default_currency",
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1
}, },
@ -489,7 +491,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-02-08 16:30:56.137858", "modified": "2024-02-26 18:30:03.684872",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Material Request Item", "name": "Material Request Item",

View File

@ -227,6 +227,9 @@ def update_packed_item_stock_data(main_item_row, pi_row, packing_item, item_data
bin = get_packed_item_bin_qty(packing_item.item_code, pi_row.warehouse) bin = get_packed_item_bin_qty(packing_item.item_code, pi_row.warehouse)
pi_row.actual_qty = flt(bin.get("actual_qty")) pi_row.actual_qty = flt(bin.get("actual_qty"))
pi_row.projected_qty = flt(bin.get("projected_qty")) pi_row.projected_qty = flt(bin.get("projected_qty"))
pi_row.use_serial_batch_fields = frappe.db.get_single_value(
"Stock Settings", "use_serial_batch_fields"
)
def update_packed_item_price_data(pi_row, item_data, doc): def update_packed_item_price_data(pi_row, item_data, doc):

View File

@ -2200,6 +2200,10 @@ class TestPurchaseReceipt(FrappeTestCase):
create_stock_reconciliation, create_stock_reconciliation,
) )
frappe.db.set_single_value(
"Stock Settings", "do_not_update_serial_batch_on_creation_of_auto_bundle", 0
)
item_code = make_item( item_code = make_item(
"_Test Use Serial Fields Item Serial Item", "_Test Use Serial Fields Item Serial Item",
properties={"has_serial_no": 1, "serial_no_series": "SNU-TSFISI-.#####"}, properties={"has_serial_no": 1, "serial_no_series": "SNU-TSFISI-.#####"},
@ -2280,6 +2284,99 @@ class TestPurchaseReceipt(FrappeTestCase):
serial_no_status = frappe.db.get_value("Serial No", sn, "status") serial_no_status = frappe.db.get_value("Serial No", sn, "status")
self.assertTrue(serial_no_status != "Active") self.assertTrue(serial_no_status != "Active")
frappe.db.set_single_value(
"Stock Settings", "do_not_update_serial_batch_on_creation_of_auto_bundle", 1
)
def test_sle_qty_after_transaction(self):
item = make_item(
"_Test Item Qty After Transaction",
properties={"is_stock_item": 1, "valuation_method": "FIFO"},
).name
posting_date = today()
posting_time = nowtime()
# Step 1: Create Purchase Receipt
pr = make_purchase_receipt(
item_code=item,
qty=1,
rate=100,
posting_date=posting_date,
posting_time=posting_time,
do_not_save=1,
)
for i in range(9):
pr.append(
"items",
{
"item_code": item,
"qty": 1,
"rate": 100,
"warehouse": pr.items[0].warehouse,
"cost_center": pr.items[0].cost_center,
"expense_account": pr.items[0].expense_account,
"uom": pr.items[0].uom,
"stock_uom": pr.items[0].stock_uom,
"conversion_factor": pr.items[0].conversion_factor,
},
)
self.assertEqual(len(pr.items), 10)
pr.save()
pr.submit()
data = frappe.get_all(
"Stock Ledger Entry",
fields=["qty_after_transaction", "creation", "posting_datetime"],
filters={"voucher_no": pr.name, "is_cancelled": 0},
order_by="creation",
)
for index, d in enumerate(data):
self.assertEqual(d.qty_after_transaction, 1 + index)
# Step 2: Create Purchase Receipt
pr = make_purchase_receipt(
item_code=item,
qty=1,
rate=100,
posting_date=posting_date,
posting_time=posting_time,
do_not_save=1,
)
for i in range(9):
pr.append(
"items",
{
"item_code": item,
"qty": 1,
"rate": 100,
"warehouse": pr.items[0].warehouse,
"cost_center": pr.items[0].cost_center,
"expense_account": pr.items[0].expense_account,
"uom": pr.items[0].uom,
"stock_uom": pr.items[0].stock_uom,
"conversion_factor": pr.items[0].conversion_factor,
},
)
self.assertEqual(len(pr.items), 10)
pr.save()
pr.submit()
data = frappe.get_all(
"Stock Ledger Entry",
fields=["qty_after_transaction", "creation", "posting_datetime"],
filters={"voucher_no": pr.name, "is_cancelled": 0},
order_by="creation",
)
for index, d in enumerate(data):
self.assertEqual(d.qty_after_transaction, 11 + index)
def test_auto_set_batch_based_on_bundle(self): def test_auto_set_batch_based_on_bundle(self):
item_code = make_item( item_code = make_item(
"_Test Auto Set Batch Based on Bundle", "_Test Auto Set Batch Based on Bundle",
@ -2290,7 +2387,9 @@ class TestPurchaseReceipt(FrappeTestCase):
}, },
).name ).name
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1) frappe.db.set_single_value(
"Stock Settings", "do_not_update_serial_batch_on_creation_of_auto_bundle", 0
)
pr = make_purchase_receipt( pr = make_purchase_receipt(
item_code=item_code, item_code=item_code,
@ -2302,7 +2401,44 @@ class TestPurchaseReceipt(FrappeTestCase):
batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle) batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle)
self.assertEqual(pr.items[0].batch_no, batch_no) self.assertEqual(pr.items[0].batch_no, batch_no)
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 0) frappe.db.set_single_value(
"Stock Settings", "do_not_update_serial_batch_on_creation_of_auto_bundle", 1
)
def test_pr_billed_amount_against_return_entry(self):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_debit_note
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
make_purchase_invoice as make_pi_from_pr,
)
# Create a Purchase Receipt and Fully Bill it
pr = make_purchase_receipt(qty=10)
pi = make_pi_from_pr(pr.name)
pi.insert()
pi.submit()
# Debit Note - 50% Qty & enable updating PR billed amount
pi_return = make_debit_note(pi.name)
pi_return.items[0].qty = -5
pi_return.update_billed_amount_in_purchase_receipt = 1
pi_return.submit()
# Check if the billed amount reduced
pr.reload()
self.assertEqual(pr.per_billed, 50)
pi_return.reload()
pi_return.cancel()
# Debit Note - 50% Qty & disable updating PR billed amount
pi_return = make_debit_note(pi.name)
pi_return.items[0].qty = -5
pi_return.update_billed_amount_in_purchase_receipt = 0
pi_return.submit()
# Check if the billed amount stayed the same
pr.reload()
self.assertEqual(pr.per_billed, 100)
def prepare_data_for_internal_transfer(): def prepare_data_for_internal_transfer():

View File

@ -272,6 +272,7 @@ def on_doctype_update():
def repost(doc): def repost(doc):
try: try:
frappe.flags.through_repost_item_valuation = True
if not frappe.db.exists("Repost Item Valuation", doc.name): if not frappe.db.exists("Repost Item Valuation", doc.name):
return return

View File

@ -257,9 +257,9 @@ class SerialandBatchBundle(Document):
if sn_obj.batch_avg_rate.get(d.batch_no): if sn_obj.batch_avg_rate.get(d.batch_no):
d.incoming_rate = abs(sn_obj.batch_avg_rate.get(d.batch_no)) d.incoming_rate = abs(sn_obj.batch_avg_rate.get(d.batch_no))
available_qty = flt(sn_obj.available_qty.get(d.batch_no)) available_qty = flt(sn_obj.available_qty.get(d.batch_no), d.precision("qty"))
if self.docstatus == 1: if self.docstatus == 1:
available_qty += flt(d.qty) available_qty += flt(d.qty, d.precision("qty"))
if not allow_negative_stock: if not allow_negative_stock:
self.validate_negative_batch(d.batch_no, available_qty) self.validate_negative_batch(d.batch_no, available_qty)

View File

@ -5,7 +5,7 @@ import json
import frappe import frappe
from frappe.tests.utils import FrappeTestCase, change_settings from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, add_to_date, flt, nowdate, nowtime, today from frappe.utils import flt, nowtime, today
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
@ -191,6 +191,7 @@ class TestSerialandBatchBundle(FrappeTestCase):
doc.flags.ignore_links = True doc.flags.ignore_links = True
doc.flags.ignore_validate = True doc.flags.ignore_validate = True
doc.submit() doc.submit()
doc.reload()
bundle_doc = make_serial_batch_bundle( bundle_doc = make_serial_batch_bundle(
{ {

View File

@ -68,7 +68,7 @@
{ {
"fieldname": "incoming_rate", "fieldname": "incoming_rate",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Incoming Rate", "label": "Valuation Rate",
"no_copy": 1, "no_copy": 1,
"read_only": 1, "read_only": 1,
"read_only_depends_on": "eval:parent.type_of_transaction == \"Outward\"" "read_only_depends_on": "eval:parent.type_of_transaction == \"Outward\""
@ -76,6 +76,7 @@
{ {
"fieldname": "outgoing_rate", "fieldname": "outgoing_rate",
"fieldtype": "Float", "fieldtype": "Float",
"hidden": 1,
"label": "Outgoing Rate", "label": "Outgoing Rate",
"no_copy": 1, "no_copy": 1,
"read_only": 1 "read_only": 1
@ -95,6 +96,7 @@
"default": "0", "default": "0",
"fieldname": "is_outward", "fieldname": "is_outward",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 1,
"label": "Is Outward", "label": "Is Outward",
"read_only": 1 "read_only": 1
}, },
@ -120,7 +122,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-12-10 19:47:48.227772", "modified": "2024-02-23 12:44:18.054270",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Serial and Batch Entry", "name": "Serial and Batch Entry",

View File

@ -943,7 +943,7 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
if (this.frm.doc.docstatus===1 && erpnext.is_perpetual_inventory_enabled(this.frm.doc.company)) { if (this.frm.doc.docstatus===1 && erpnext.is_perpetual_inventory_enabled(this.frm.doc.company)) {
this.show_general_ledger(); this.show_general_ledger();
} }
erpnext.hide_company(); erpnext.hide_company(this.frm);
erpnext.utils.add_item(this.frm); erpnext.utils.add_item(this.frm);
} }

View File

@ -1028,7 +1028,7 @@ class StockEntry(StockController):
already_picked_serial_nos = [] already_picked_serial_nos = []
for row in self.items: for row in self.items:
if row.use_serial_batch_fields and (row.serial_no or row.batch_no): if row.use_serial_batch_fields:
continue continue
if not row.s_warehouse: if not row.s_warehouse:
@ -1897,6 +1897,7 @@ class StockEntry(StockController):
return return
id = create_serial_and_batch_bundle( id = create_serial_and_batch_bundle(
self,
row, row,
frappe._dict( frappe._dict(
{ {
@ -2167,7 +2168,7 @@ class StockEntry(StockController):
"to_warehouse": "", "to_warehouse": "",
"qty": qty, "qty": qty,
"item_name": item.item_name, "item_name": item.item_name,
"serial_and_batch_bundle": create_serial_and_batch_bundle(row, item, "Outward"), "serial_and_batch_bundle": create_serial_and_batch_bundle(self, row, item, "Outward"),
"description": item.description, "description": item.description,
"stock_uom": item.stock_uom, "stock_uom": item.stock_uom,
"expense_account": item.expense_account, "expense_account": item.expense_account,
@ -2545,6 +2546,7 @@ class StockEntry(StockController):
row = frappe._dict({"serial_nos": serial_nos[0 : cint(d.qty)]}) row = frappe._dict({"serial_nos": serial_nos[0 : cint(d.qty)]})
id = create_serial_and_batch_bundle( id = create_serial_and_batch_bundle(
self,
row, row,
frappe._dict( frappe._dict(
{ {
@ -3068,7 +3070,7 @@ def get_stock_entry_data(work_order):
return data return data
def create_serial_and_batch_bundle(row, child, type_of_transaction=None): def create_serial_and_batch_bundle(parent_doc, row, child, type_of_transaction=None):
item_details = frappe.get_cached_value( item_details = frappe.get_cached_value(
"Item", child.item_code, ["has_serial_no", "has_batch_no"], as_dict=1 "Item", child.item_code, ["has_serial_no", "has_batch_no"], as_dict=1
) )
@ -3086,6 +3088,8 @@ def create_serial_and_batch_bundle(row, child, type_of_transaction=None):
"item_code": child.item_code, "item_code": child.item_code,
"warehouse": child.warehouse, "warehouse": child.warehouse,
"type_of_transaction": type_of_transaction, "type_of_transaction": type_of_transaction,
"posting_date": parent_doc.posting_date,
"posting_time": parent_doc.posting_time,
} }
) )

View File

@ -1602,24 +1602,22 @@ class TestStockEntry(FrappeTestCase):
item_code = "Test Negative Item - 001" item_code = "Test Negative Item - 001"
item_doc = create_item(item_code=item_code, is_stock_item=1, valuation_rate=10) item_doc = create_item(item_code=item_code, is_stock_item=1, valuation_rate=10)
make_stock_entry( se1 = make_stock_entry(
item_code=item_code, item_code=item_code,
posting_date=add_days(today(), -3), posting_date=add_days(today(), -3),
posting_time="00:00:00", posting_time="00:00:00",
purpose="Material Receipt", target="_Test Warehouse - _TC",
qty=10, qty=10,
to_warehouse="_Test Warehouse - _TC", to_warehouse="_Test Warehouse - _TC",
do_not_save=True,
) )
make_stock_entry( se2 = make_stock_entry(
item_code=item_code, item_code=item_code,
posting_date=today(), posting_date=today(),
posting_time="00:00:00", posting_time="00:00:00",
purpose="Material Receipt", source="_Test Warehouse - _TC",
qty=8, qty=8,
from_warehouse="_Test Warehouse - _TC", from_warehouse="_Test Warehouse - _TC",
do_not_save=True,
) )
sr_doc = create_stock_reconciliation( sr_doc = create_stock_reconciliation(

View File

@ -294,7 +294,7 @@
{ {
"depends_on": "eval:doc.use_serial_batch_fields === 1", "depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "serial_no", "fieldname": "serial_no",
"fieldtype": "Small Text", "fieldtype": "Text",
"label": "Serial No", "label": "Serial No",
"no_copy": 1, "no_copy": 1,
"oldfieldname": "serial_no", "oldfieldname": "serial_no",
@ -610,7 +610,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-02-04 16:16:47.606270", "modified": "2024-02-25 15:58:40.982582",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Entry Detail", "name": "Stock Entry Detail",

View File

@ -54,7 +54,7 @@ class StockEntryDetail(Document):
sample_quantity: DF.Int sample_quantity: DF.Int
sco_rm_detail: DF.Data | None sco_rm_detail: DF.Data | None
serial_and_batch_bundle: DF.Link | None serial_and_batch_bundle: DF.Link | None
serial_no: DF.SmallText | None serial_no: DF.Text | None
set_basic_rate_manually: DF.Check set_basic_rate_manually: DF.Check
ste_detail: DF.Data | None ste_detail: DF.Data | None
stock_uom: DF.Link stock_uom: DF.Link

View File

@ -11,6 +11,7 @@
"warehouse", "warehouse",
"posting_date", "posting_date",
"posting_time", "posting_time",
"posting_datetime",
"is_adjustment_entry", "is_adjustment_entry",
"auto_created_serial_and_batch_bundle", "auto_created_serial_and_batch_bundle",
"column_break_6", "column_break_6",
@ -100,7 +101,6 @@
"oldfieldtype": "Date", "oldfieldtype": "Date",
"print_width": "100px", "print_width": "100px",
"read_only": 1, "read_only": 1,
"search_index": 1,
"width": "100px" "width": "100px"
}, },
{ {
@ -253,7 +253,6 @@
"options": "Company", "options": "Company",
"print_width": "150px", "print_width": "150px",
"read_only": 1, "read_only": 1,
"search_index": 1,
"width": "150px" "width": "150px"
}, },
{ {
@ -348,6 +347,11 @@
"fieldname": "auto_created_serial_and_batch_bundle", "fieldname": "auto_created_serial_and_batch_bundle",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Auto Created Serial and Batch Bundle" "label": "Auto Created Serial and Batch Bundle"
},
{
"fieldname": "posting_datetime",
"fieldtype": "Datetime",
"label": "Posting Datetime"
} }
], ],
"hide_toolbar": 1, "hide_toolbar": 1,
@ -356,7 +360,7 @@
"in_create": 1, "in_create": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2023-11-14 16:47:39.791967", "modified": "2024-02-07 09:18:13.999231",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Ledger Entry", "name": "Stock Ledger Entry",

View File

@ -51,6 +51,7 @@ class StockLedgerEntry(Document):
item_code: DF.Link | None item_code: DF.Link | None
outgoing_rate: DF.Currency outgoing_rate: DF.Currency
posting_date: DF.Date | None posting_date: DF.Date | None
posting_datetime: DF.Datetime | None
posting_time: DF.Time | None posting_time: DF.Time | None
project: DF.Link | None project: DF.Link | None
qty_after_transaction: DF.Float qty_after_transaction: DF.Float
@ -92,6 +93,12 @@ class StockLedgerEntry(Document):
self.validate_with_last_transaction_posting_time() self.validate_with_last_transaction_posting_time()
self.validate_inventory_dimension_negative_stock() self.validate_inventory_dimension_negative_stock()
def set_posting_datetime(self):
from erpnext.stock.utils import get_combine_datetime
self.posting_datetime = get_combine_datetime(self.posting_date, self.posting_time)
self.db_set("posting_datetime", self.posting_datetime)
def validate_inventory_dimension_negative_stock(self): def validate_inventory_dimension_negative_stock(self):
if self.is_cancelled: if self.is_cancelled:
return return
@ -162,6 +169,7 @@ class StockLedgerEntry(Document):
return inv_dimension_dict return inv_dimension_dict
def on_submit(self): def on_submit(self):
self.set_posting_datetime()
self.check_stock_frozen_date() self.check_stock_frozen_date()
# Added to handle few test cases where serial_and_batch_bundles are not required # Added to handle few test cases where serial_and_batch_bundles are not required
@ -330,9 +338,7 @@ class StockLedgerEntry(Document):
def on_doctype_update(): def on_doctype_update():
frappe.db.add_index(
"Stock Ledger Entry", fields=["posting_date", "posting_time"], index_name="posting_sort_index"
)
frappe.db.add_index("Stock Ledger Entry", ["voucher_no", "voucher_type"]) frappe.db.add_index("Stock Ledger Entry", ["voucher_no", "voucher_type"])
frappe.db.add_index("Stock Ledger Entry", ["batch_no", "item_code", "warehouse"]) frappe.db.add_index("Stock Ledger Entry", ["batch_no", "item_code", "warehouse"])
frappe.db.add_index("Stock Ledger Entry", ["warehouse", "item_code"], "item_warehouse") frappe.db.add_index("Stock Ledger Entry", ["warehouse", "item_code"], "item_warehouse")
frappe.db.add_index("Stock Ledger Entry", ["posting_datetime", "creation"])

View File

@ -2,6 +2,7 @@
# See license.txt # See license.txt
import json import json
import time
from uuid import uuid4 from uuid import uuid4
import frappe import frappe
@ -1077,7 +1078,7 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin):
frappe.qb.from_(sle) frappe.qb.from_(sle)
.select("qty_after_transaction") .select("qty_after_transaction")
.where((sle.item_code == item) & (sle.warehouse == warehouse) & (sle.is_cancelled == 0)) .where((sle.item_code == item) & (sle.warehouse == warehouse) & (sle.is_cancelled == 0))
.orderby(CombineDatetime(sle.posting_date, sle.posting_time)) .orderby(sle.posting_datetime)
.orderby(sle.creation) .orderby(sle.creation)
).run(pluck=True) ).run(pluck=True)
@ -1154,6 +1155,89 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin):
except Exception as e: except Exception as e:
self.fail("Double processing of qty for clashing timestamp.") self.fail("Double processing of qty for clashing timestamp.")
def test_previous_sle_with_clashed_timestamp(self):
item = make_item().name
warehouse = "_Test Warehouse - _TC"
reciept1 = make_stock_entry(
item_code=item,
to_warehouse=warehouse,
qty=100,
rate=10,
posting_date="2021-01-01",
posting_time="02:00:00",
)
time.sleep(3)
reciept2 = make_stock_entry(
item_code=item,
to_warehouse=warehouse,
qty=5,
posting_date="2021-01-01",
rate=10,
posting_time="02:00:00.1234",
)
sle = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": reciept1.name},
fields=["qty_after_transaction", "actual_qty"],
)
self.assertEqual(sle[0].qty_after_transaction, 100)
self.assertEqual(sle[0].actual_qty, 100)
sle = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": reciept2.name},
fields=["qty_after_transaction", "actual_qty"],
)
self.assertEqual(sle[0].qty_after_transaction, 105)
self.assertEqual(sle[0].actual_qty, 5)
def test_backdated_sle_with_same_timestamp(self):
item = make_item().name
warehouse = "_Test Warehouse - _TC"
reciept1 = make_stock_entry(
item_code=item,
to_warehouse=warehouse,
qty=5,
posting_date="2021-01-01",
rate=10,
posting_time="02:00:00.1234",
)
time.sleep(3)
# backdated entry with same timestamp but different ms part
reciept2 = make_stock_entry(
item_code=item,
to_warehouse=warehouse,
qty=100,
rate=10,
posting_date="2021-01-01",
posting_time="02:00:00",
)
sle = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": reciept1.name},
fields=["qty_after_transaction", "actual_qty"],
)
self.assertEqual(sle[0].qty_after_transaction, 5)
self.assertEqual(sle[0].actual_qty, 5)
sle = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": reciept2.name},
fields=["qty_after_transaction", "actual_qty"],
)
self.assertEqual(sle[0].qty_after_transaction, 105)
self.assertEqual(sle[0].actual_qty, 100)
@change_settings("System Settings", {"float_precision": 3, "currency_precision": 2}) @change_settings("System Settings", {"float_precision": 3, "currency_precision": 2})
def test_transfer_invariants(self): def test_transfer_invariants(self):
"""Extact stock value should be transferred.""" """Extact stock value should be transferred."""

View File

@ -906,8 +906,13 @@ class StockReconciliation(StockController):
def has_negative_stock_allowed(self): def has_negative_stock_allowed(self):
allow_negative_stock = cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) allow_negative_stock = cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
if allow_negative_stock:
return True
if all(d.serial_and_batch_bundle and flt(d.qty) == flt(d.current_qty) for d in self.items): if any(
((d.serial_and_batch_bundle or d.batch_no) and flt(d.qty) == flt(d.current_qty))
for d in self.items
):
allow_negative_stock = True allow_negative_stock = True
return allow_negative_stock return allow_negative_stock

View File

@ -7,7 +7,7 @@ import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder.functions import Sum from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt from frappe.utils import cint, flt, nowdate, nowtime
from erpnext.stock.utils import get_or_make_bin, get_stock_balance from erpnext.stock.utils import get_or_make_bin, get_stock_balance
@ -866,6 +866,8 @@ def get_ssb_bundle_for_voucher(sre: dict) -> object:
bundle = frappe.new_doc("Serial and Batch Bundle") bundle = frappe.new_doc("Serial and Batch Bundle")
bundle.type_of_transaction = "Outward" bundle.type_of_transaction = "Outward"
bundle.voucher_type = "Delivery Note" bundle.voucher_type = "Delivery Note"
bundle.posting_date = nowdate()
bundle.posting_time = nowtime()
for field in ("item_code", "warehouse", "has_serial_no", "has_batch_no"): for field in ("item_code", "warehouse", "has_serial_no", "has_batch_no"):
setattr(bundle, field, sre[field]) setattr(bundle, field, sre[field])

View File

@ -51,6 +51,7 @@
"use_naming_series", "use_naming_series",
"naming_series_prefix", "naming_series_prefix",
"use_serial_batch_fields", "use_serial_batch_fields",
"do_not_update_serial_batch_on_creation_of_auto_bundle",
"stock_planning_tab", "stock_planning_tab",
"auto_material_request", "auto_material_request",
"auto_indent", "auto_indent",
@ -424,9 +425,18 @@
}, },
{ {
"default": "1", "default": "1",
"description": "On submission of the stock transaction, system will auto create the Serial and Batch Bundle based on the Serial No / Batch fields.",
"fieldname": "use_serial_batch_fields", "fieldname": "use_serial_batch_fields",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Use Serial / Batch Fields" "label": "Use Serial / Batch Fields"
},
{
"default": "1",
"depends_on": "use_serial_batch_fields",
"description": "If enabled, do not update serial / batch values in the stock transactions on creation of auto Serial \n / Batch Bundle. ",
"fieldname": "do_not_update_serial_batch_on_creation_of_auto_bundle",
"fieldtype": "Check",
"label": "Do Not Update Serial / Batch on Creation of Auto Bundle"
} }
], ],
"icon": "icon-cog", "icon": "icon-cog",
@ -434,7 +444,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2024-02-04 12:01:31.931864", "modified": "2024-02-25 16:32:01.084453",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Settings", "name": "Stock Settings",

View File

@ -39,6 +39,7 @@ class StockSettings(Document):
clean_description_html: DF.Check clean_description_html: DF.Check
default_warehouse: DF.Link | None default_warehouse: DF.Link | None
disable_serial_no_and_batch_selector: DF.Check disable_serial_no_and_batch_selector: DF.Check
do_not_update_serial_batch_on_creation_of_auto_bundle: DF.Check
enable_stock_reservation: DF.Check enable_stock_reservation: DF.Check
item_group: DF.Link | None item_group: DF.Link | None
item_naming_by: DF.Literal["Item Code", "Naming Series"] item_naming_by: DF.Literal["Item Code", "Naming Series"]

View File

@ -5,7 +5,7 @@
import frappe import frappe
from frappe import _ from frappe import _
from frappe.query_builder import Field from frappe.query_builder import Field
from frappe.query_builder.functions import CombineDatetime, Min from frappe.query_builder.functions import Min
from frappe.utils import add_days, getdate, today from frappe.utils import add_days, getdate, today
import erpnext import erpnext
@ -75,7 +75,7 @@ def get_data(report_filters):
& (sle.company == report_filters.company) & (sle.company == report_filters.company)
& (sle.is_cancelled == 0) & (sle.is_cancelled == 0)
) )
.orderby(CombineDatetime(sle.posting_date, sle.posting_time), sle.creation) .orderby(sle.posting_datetime, sle.creation)
).run(as_dict=True) ).run(as_dict=True)
for d in data: for d in data:

View File

@ -213,13 +213,11 @@ def get_stock_ledger_entries(filters, items):
query = ( query = (
frappe.qb.from_(sle) frappe.qb.from_(sle)
.force_index("posting_sort_index")
.left_join(sle2) .left_join(sle2)
.on( .on(
(sle.item_code == sle2.item_code) (sle.item_code == sle2.item_code)
& (sle.warehouse == sle2.warehouse) & (sle.warehouse == sle2.warehouse)
& (sle.posting_date < sle2.posting_date) & (sle.posting_datetime < sle2.posting_datetime)
& (sle.posting_time < sle2.posting_time)
& (sle.name < sle2.name) & (sle.name < sle2.name)
) )
.select(sle.item_code, sle.warehouse, sle.qty_after_transaction, sle.company) .select(sle.item_code, sle.warehouse, sle.qty_after_transaction, sle.company)

View File

@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional, TypedDict
import frappe import frappe
from frappe import _ from frappe import _
from frappe.query_builder import Order from frappe.query_builder import Order
from frappe.query_builder.functions import Coalesce, CombineDatetime from frappe.query_builder.functions import Coalesce
from frappe.utils import add_days, cint, date_diff, flt, getdate from frappe.utils import add_days, cint, date_diff, flt, getdate
from frappe.utils.nestedset import get_descendants_of from frappe.utils.nestedset import get_descendants_of
@ -300,7 +300,7 @@ class StockBalanceReport(object):
item_table.item_name, item_table.item_name,
) )
.where((sle.docstatus < 2) & (sle.is_cancelled == 0)) .where((sle.docstatus < 2) & (sle.is_cancelled == 0))
.orderby(CombineDatetime(sle.posting_date, sle.posting_time)) .orderby(sle.posting_datetime)
.orderby(sle.creation) .orderby(sle.creation)
.orderby(sle.actual_qty) .orderby(sle.actual_qty)
) )

View File

@ -345,7 +345,7 @@ def get_stock_ledger_entries(filters, items):
frappe.qb.from_(sle) frappe.qb.from_(sle)
.select( .select(
sle.item_code, sle.item_code,
CombineDatetime(sle.posting_date, sle.posting_time).as_("date"), sle.posting_datetime.as_("date"),
sle.warehouse, sle.warehouse,
sle.posting_date, sle.posting_date,
sle.posting_time, sle.posting_time,

View File

@ -142,9 +142,11 @@ class SerialBatchBundle:
"serial_and_batch_bundle": sn_doc.name, "serial_and_batch_bundle": sn_doc.name,
} }
if frappe.db.get_single_value("Stock Settings", "use_serial_batch_fields"): if not frappe.db.get_single_value(
"Stock Settings", "do_not_update_serial_batch_on_creation_of_auto_bundle"
):
if sn_doc.has_serial_no: if sn_doc.has_serial_no:
values_to_update["serial_no"] = "\n".join(cstr(d.serial_no) for d in sn_doc.entries) values_to_update["serial_no"] = ",".join(cstr(d.serial_no) for d in sn_doc.entries)
elif sn_doc.has_batch_no and len(sn_doc.entries) == 1: elif sn_doc.has_batch_no and len(sn_doc.entries) == 1:
values_to_update["batch_no"] = sn_doc.entries[0].batch_no values_to_update["batch_no"] = sn_doc.entries[0].batch_no

View File

@ -9,7 +9,7 @@ from typing import Optional, Set, Tuple
import frappe import frappe
from frappe import _, scrub from frappe import _, scrub
from frappe.model.meta import get_field_precision from frappe.model.meta import get_field_precision
from frappe.query_builder.functions import CombineDatetime, Sum from frappe.query_builder.functions import Sum
from frappe.utils import ( from frappe.utils import (
cint, cint,
cstr, cstr,
@ -33,6 +33,7 @@ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry impor
get_sre_reserved_serial_nos_details, get_sre_reserved_serial_nos_details,
) )
from erpnext.stock.utils import ( from erpnext.stock.utils import (
get_combine_datetime,
get_incoming_outgoing_rate_for_cancel, get_incoming_outgoing_rate_for_cancel,
get_incoming_rate, get_incoming_rate,
get_or_make_bin, get_or_make_bin,
@ -95,6 +96,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
sle_doc = make_entry(sle, allow_negative_stock, via_landed_cost_voucher) sle_doc = make_entry(sle, allow_negative_stock, via_landed_cost_voucher)
args = sle_doc.as_dict() args = sle_doc.as_dict()
args["posting_datetime"] = get_combine_datetime(args.posting_date, args.posting_time)
if sle.get("voucher_type") == "Stock Reconciliation": if sle.get("voucher_type") == "Stock Reconciliation":
# preserve previous_qty_after_transaction for qty reposting # preserve previous_qty_after_transaction for qty reposting
@ -616,12 +618,14 @@ class update_entries_after(object):
self.process_sle(sle) self.process_sle(sle)
def get_sle_against_current_voucher(self): def get_sle_against_current_voucher(self):
self.args["time_format"] = "%H:%i:%s" self.args["posting_datetime"] = get_combine_datetime(
self.args.posting_date, self.args.posting_time
)
return frappe.db.sql( return frappe.db.sql(
""" """
select select
*, timestamp(posting_date, posting_time) as "timestamp" *, posting_datetime as "timestamp"
from from
`tabStock Ledger Entry` `tabStock Ledger Entry`
where where
@ -629,8 +633,7 @@ class update_entries_after(object):
and warehouse = %(warehouse)s and warehouse = %(warehouse)s
and is_cancelled = 0 and is_cancelled = 0
and ( and (
posting_date = %(posting_date)s and posting_datetime = %(posting_datetime)s
time_format(posting_time, %(time_format)s) = time_format(%(posting_time)s, %(time_format)s)
) )
order by order by
creation ASC creation ASC
@ -1399,11 +1402,11 @@ class update_entries_after(object):
def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_voucher=False): def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_voucher=False):
"""get stock ledger entries filtered by specific posting datetime conditions""" """get stock ledger entries filtered by specific posting datetime conditions"""
args["time_format"] = "%H:%i:%s"
if not args.get("posting_date"): if not args.get("posting_date"):
args["posting_date"] = "1900-01-01" args["posting_datetime"] = "1900-01-01 00:00:00"
if not args.get("posting_time"):
args["posting_time"] = "00:00" if not args.get("posting_datetime"):
args["posting_datetime"] = get_combine_datetime(args["posting_date"], args["posting_time"])
voucher_condition = "" voucher_condition = ""
if exclude_current_voucher: if exclude_current_voucher:
@ -1412,23 +1415,20 @@ def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_vouc
sle = frappe.db.sql( sle = frappe.db.sql(
""" """
select *, timestamp(posting_date, posting_time) as "timestamp" select *, posting_datetime as "timestamp"
from `tabStock Ledger Entry` from `tabStock Ledger Entry`
where item_code = %(item_code)s where item_code = %(item_code)s
and warehouse = %(warehouse)s and warehouse = %(warehouse)s
and is_cancelled = 0 and is_cancelled = 0
{voucher_condition} {voucher_condition}
and ( and (
posting_date < %(posting_date)s or posting_datetime {operator} %(posting_datetime)s
(
posting_date = %(posting_date)s and
time_format(posting_time, %(time_format)s) {operator} time_format(%(posting_time)s, %(time_format)s)
)
) )
order by timestamp(posting_date, posting_time) desc, creation desc order by posting_datetime desc, creation desc
limit 1 limit 1
for update""".format( for update""".format(
operator=operator, voucher_condition=voucher_condition operator=operator,
voucher_condition=voucher_condition,
), ),
args, args,
as_dict=1, as_dict=1,
@ -1469,9 +1469,7 @@ def get_stock_ledger_entries(
extra_cond=None, extra_cond=None,
): ):
"""get stock ledger entries filtered by specific posting datetime conditions""" """get stock ledger entries filtered by specific posting datetime conditions"""
conditions = " and timestamp(posting_date, posting_time) {0} timestamp(%(posting_date)s, %(posting_time)s)".format( conditions = " and posting_datetime {0} %(posting_datetime)s".format(operator)
operator
)
if previous_sle.get("warehouse"): if previous_sle.get("warehouse"):
conditions += " and warehouse = %(warehouse)s" conditions += " and warehouse = %(warehouse)s"
elif previous_sle.get("warehouse_condition"): elif previous_sle.get("warehouse_condition"):
@ -1497,9 +1495,11 @@ def get_stock_ledger_entries(
) )
if not previous_sle.get("posting_date"): if not previous_sle.get("posting_date"):
previous_sle["posting_date"] = "1900-01-01" previous_sle["posting_datetime"] = "1900-01-01 00:00:00"
if not previous_sle.get("posting_time"): else:
previous_sle["posting_time"] = "00:00" previous_sle["posting_datetime"] = get_combine_datetime(
previous_sle["posting_date"], previous_sle["posting_time"]
)
if operator in (">", "<=") and previous_sle.get("name"): if operator in (">", "<=") and previous_sle.get("name"):
conditions += " and name!=%(name)s" conditions += " and name!=%(name)s"
@ -1509,12 +1509,12 @@ def get_stock_ledger_entries(
return frappe.db.sql( return frappe.db.sql(
""" """
select *, timestamp(posting_date, posting_time) as "timestamp" select *, posting_datetime as "timestamp"
from `tabStock Ledger Entry` from `tabStock Ledger Entry`
where item_code = %%(item_code)s where item_code = %%(item_code)s
and is_cancelled = 0 and is_cancelled = 0
%(conditions)s %(conditions)s
order by timestamp(posting_date, posting_time) %(order)s, creation %(order)s order by posting_datetime %(order)s, creation %(order)s
%(limit)s %(for_update)s""" %(limit)s %(for_update)s"""
% { % {
"conditions": conditions, "conditions": conditions,
@ -1540,7 +1540,7 @@ def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None):
"posting_date", "posting_date",
"posting_time", "posting_time",
"voucher_detail_no", "voucher_detail_no",
"timestamp(posting_date, posting_time) as timestamp", "posting_datetime as timestamp",
], ],
as_dict=1, as_dict=1,
) )
@ -1552,13 +1552,10 @@ def get_batch_incoming_rate(
sle = frappe.qb.DocType("Stock Ledger Entry") sle = frappe.qb.DocType("Stock Ledger Entry")
timestamp_condition = CombineDatetime(sle.posting_date, sle.posting_time) < CombineDatetime( timestamp_condition = sle.posting_datetime < get_combine_datetime(posting_date, posting_time)
posting_date, posting_time
)
if creation: if creation:
timestamp_condition |= ( timestamp_condition |= (
CombineDatetime(sle.posting_date, sle.posting_time) sle.posting_datetime == get_combine_datetime(posting_date, posting_time)
== CombineDatetime(posting_date, posting_time)
) & (sle.creation < creation) ) & (sle.creation < creation)
batch_details = ( batch_details = (
@ -1639,7 +1636,7 @@ def get_valuation_rate(
AND valuation_rate >= 0 AND valuation_rate >= 0
AND is_cancelled = 0 AND is_cancelled = 0
AND NOT (voucher_no = %s AND voucher_type = %s) AND NOT (voucher_no = %s AND voucher_type = %s)
order by posting_date desc, posting_time desc, name desc limit 1""", order by posting_datetime desc, name desc limit 1""",
(item_code, warehouse, voucher_no, voucher_type), (item_code, warehouse, voucher_no, voucher_type),
): ):
return flt(last_valuation_rate[0][0]) return flt(last_valuation_rate[0][0])
@ -1698,7 +1695,7 @@ def update_qty_in_future_sle(args, allow_negative_stock=False):
datetime_limit_condition = "" datetime_limit_condition = ""
qty_shift = args.actual_qty qty_shift = args.actual_qty
args["time_format"] = "%H:%i:%s" args["posting_datetime"] = get_combine_datetime(args["posting_date"], args["posting_time"])
# find difference/shift in qty caused by stock reconciliation # find difference/shift in qty caused by stock reconciliation
if args.voucher_type == "Stock Reconciliation": if args.voucher_type == "Stock Reconciliation":
@ -1708,8 +1705,6 @@ def update_qty_in_future_sle(args, allow_negative_stock=False):
next_stock_reco_detail = get_next_stock_reco(args) next_stock_reco_detail = get_next_stock_reco(args)
if next_stock_reco_detail: if next_stock_reco_detail:
detail = next_stock_reco_detail[0] detail = next_stock_reco_detail[0]
# add condition to update SLEs before this date & time
datetime_limit_condition = get_datetime_limit_condition(detail) datetime_limit_condition = get_datetime_limit_condition(detail)
frappe.db.sql( frappe.db.sql(
@ -1722,13 +1717,9 @@ def update_qty_in_future_sle(args, allow_negative_stock=False):
and voucher_no != %(voucher_no)s and voucher_no != %(voucher_no)s
and is_cancelled = 0 and is_cancelled = 0
and ( and (
posting_date > %(posting_date)s or posting_datetime > %(posting_datetime)s
(
posting_date = %(posting_date)s and
time_format(posting_time, %(time_format)s) > time_format(%(posting_time)s, %(time_format)s)
)
) )
{datetime_limit_condition} {datetime_limit_condition}
""", """,
args, args,
) )
@ -1785,20 +1776,11 @@ def get_next_stock_reco(kwargs):
& (sle.voucher_no != kwargs.get("voucher_no")) & (sle.voucher_no != kwargs.get("voucher_no"))
& (sle.is_cancelled == 0) & (sle.is_cancelled == 0)
& ( & (
( sle.posting_datetime
CombineDatetime(sle.posting_date, sle.posting_time) >= get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time"))
> CombineDatetime(kwargs.get("posting_date"), kwargs.get("posting_time"))
)
| (
(
CombineDatetime(sle.posting_date, sle.posting_time)
== CombineDatetime(kwargs.get("posting_date"), kwargs.get("posting_time"))
)
& (sle.creation > kwargs.get("creation"))
)
) )
) )
.orderby(CombineDatetime(sle.posting_date, sle.posting_time)) .orderby(sle.posting_datetime)
.orderby(sle.creation) .orderby(sle.creation)
.limit(1) .limit(1)
) )
@ -1810,11 +1792,13 @@ def get_next_stock_reco(kwargs):
def get_datetime_limit_condition(detail): def get_datetime_limit_condition(detail):
posting_datetime = get_combine_datetime(detail.posting_date, detail.posting_time)
return f""" return f"""
and and
(timestamp(posting_date, posting_time) < timestamp('{detail.posting_date}', '{detail.posting_time}') (posting_datetime < '{posting_datetime}'
or ( or (
timestamp(posting_date, posting_time) = timestamp('{detail.posting_date}', '{detail.posting_time}') posting_datetime = '{posting_datetime}'
and creation < '{detail.creation}' and creation < '{detail.creation}'
) )
)""" )"""
@ -1888,10 +1872,10 @@ def get_future_sle_with_negative_qty(args):
item_code = %(item_code)s item_code = %(item_code)s
and warehouse = %(warehouse)s and warehouse = %(warehouse)s
and voucher_no != %(voucher_no)s and voucher_no != %(voucher_no)s
and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s) and posting_datetime >= %(posting_datetime)s
and is_cancelled = 0 and is_cancelled = 0
and qty_after_transaction < 0 and qty_after_transaction < 0
order by timestamp(posting_date, posting_time) asc order by posting_datetime asc
limit 1 limit 1
""", """,
args, args,
@ -1904,20 +1888,20 @@ def get_future_sle_with_negative_batch_qty(args):
""" """
with batch_ledger as ( with batch_ledger as (
select select
posting_date, posting_time, voucher_type, voucher_no, posting_date, posting_time, posting_datetime, voucher_type, voucher_no,
sum(actual_qty) over (order by posting_date, posting_time, creation) as cumulative_total sum(actual_qty) over (order by posting_datetime, creation) as cumulative_total
from `tabStock Ledger Entry` from `tabStock Ledger Entry`
where where
item_code = %(item_code)s item_code = %(item_code)s
and warehouse = %(warehouse)s and warehouse = %(warehouse)s
and batch_no=%(batch_no)s and batch_no=%(batch_no)s
and is_cancelled = 0 and is_cancelled = 0
order by posting_date, posting_time, creation order by posting_datetime, creation
) )
select * from batch_ledger select * from batch_ledger
where where
cumulative_total < 0.0 cumulative_total < 0.0
and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s) and posting_datetime >= %(posting_datetime)s
limit 1 limit 1
""", """,
args, args,
@ -2059,6 +2043,7 @@ def is_internal_transfer(sle):
def get_stock_value_difference(item_code, warehouse, posting_date, posting_time, voucher_no=None): def get_stock_value_difference(item_code, warehouse, posting_date, posting_time, voucher_no=None):
table = frappe.qb.DocType("Stock Ledger Entry") table = frappe.qb.DocType("Stock Ledger Entry")
posting_datetime = get_combine_datetime(posting_date, posting_time)
query = ( query = (
frappe.qb.from_(table) frappe.qb.from_(table)
@ -2067,10 +2052,7 @@ def get_stock_value_difference(item_code, warehouse, posting_date, posting_time,
(table.is_cancelled == 0) (table.is_cancelled == 0)
& (table.item_code == item_code) & (table.item_code == item_code)
& (table.warehouse == warehouse) & (table.warehouse == warehouse)
& ( & (table.posting_datetime <= posting_datetime)
(table.posting_date < posting_date)
| ((table.posting_date == posting_date) & (table.posting_time <= posting_time))
)
) )
) )

View File

@ -8,7 +8,7 @@ from typing import Dict, Optional
import frappe import frappe
from frappe import _ from frappe import _
from frappe.query_builder.functions import CombineDatetime, IfNull, Sum from frappe.query_builder.functions import CombineDatetime, IfNull, Sum
from frappe.utils import cstr, flt, get_link_to_form, nowdate, nowtime from frappe.utils import cstr, flt, get_link_to_form, get_time, getdate, nowdate, nowtime
import erpnext import erpnext
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
@ -657,3 +657,18 @@ def _update_item_info(scan_result: Dict[str, Optional[str]]) -> Dict[str, Option
): ):
scan_result.update(item_info) scan_result.update(item_info)
return scan_result return scan_result
def get_combine_datetime(posting_date, posting_time):
import datetime
if isinstance(posting_date, str):
posting_date = getdate(posting_date)
if isinstance(posting_time, str):
posting_time = get_time(posting_time)
if isinstance(posting_time, datetime.timedelta):
posting_time = (datetime.datetime.min + posting_time).time()
return datetime.datetime.combine(posting_date, posting_time).replace(microsecond=0)

View File

@ -643,10 +643,6 @@ class TestSubcontractingReceipt(FrappeTestCase):
) )
scr = make_subcontracting_receipt(sco.name) scr = make_subcontracting_receipt(sco.name)
scr.save() scr.save()
for row in scr.supplied_items:
self.assertNotEqual(row.rate, 300.00)
self.assertFalse(row.serial_and_batch_bundle)
scr.submit() scr.submit()
scr.reload() scr.reload()

View File

@ -39,3 +39,6 @@ force_grid_wrap = 0
use_parentheses = true use_parentheses = true
ensure_newline_before_comments = true ensure_newline_before_comments = true
indent = "\t" indent = "\t"
[tool.bench.frappe-dependencies]
frappe = ">=15.10.0,<16.0.0"