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

chore: release v15
This commit is contained in:
rohitwaghchaure 2023-11-28 21:53:16 +05:30 committed by GitHub
commit 4bfdab93ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
74 changed files with 915 additions and 623 deletions

View File

@ -355,7 +355,9 @@ def reconcile_vouchers(bank_transaction_name, vouchers):
vouchers = json.loads(vouchers) vouchers = json.loads(vouchers)
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name) transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
transaction.add_payment_entries(vouchers) transaction.add_payment_entries(vouchers)
return frappe.get_doc("Bank Transaction", bank_transaction_name) transaction.save()
return transaction
@frappe.whitelist() @frappe.whitelist()

View File

@ -13,6 +13,7 @@
"status", "status",
"bank_account", "bank_account",
"company", "company",
"amended_from",
"section_break_4", "section_break_4",
"deposit", "deposit",
"withdrawal", "withdrawal",
@ -25,10 +26,10 @@
"transaction_id", "transaction_id",
"transaction_type", "transaction_type",
"section_break_14", "section_break_14",
"column_break_oufv",
"payment_entries", "payment_entries",
"section_break_18", "section_break_18",
"allocated_amount", "allocated_amount",
"amended_from",
"column_break_17", "column_break_17",
"unallocated_amount", "unallocated_amount",
"party_section", "party_section",
@ -138,10 +139,12 @@
"fieldtype": "Section Break" "fieldtype": "Section Break"
}, },
{ {
"allow_on_submit": 1,
"fieldname": "allocated_amount", "fieldname": "allocated_amount",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Allocated Amount", "label": "Allocated Amount",
"options": "currency" "options": "currency",
"read_only": 1
}, },
{ {
"fieldname": "amended_from", "fieldname": "amended_from",
@ -157,10 +160,12 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"allow_on_submit": 1,
"fieldname": "unallocated_amount", "fieldname": "unallocated_amount",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Unallocated Amount", "label": "Unallocated Amount",
"options": "currency" "options": "currency",
"read_only": 1
}, },
{ {
"fieldname": "party_section", "fieldname": "party_section",
@ -225,11 +230,15 @@
"fieldname": "bank_party_account_number", "fieldname": "bank_party_account_number",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Party Account No. (Bank Statement)" "label": "Party Account No. (Bank Statement)"
},
{
"fieldname": "column_break_oufv",
"fieldtype": "Column Break"
} }
], ],
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2023-06-06 13:58:12.821411", "modified": "2023-11-18 18:32:47.203694",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Bank Transaction", "name": "Bank Transaction",

View File

@ -2,78 +2,73 @@
# For license information, please see license.txt # For license information, please see license.txt
import frappe import frappe
from frappe import _
from frappe.utils import flt from frappe.utils import flt
from erpnext.controllers.status_updater import StatusUpdater from erpnext.controllers.status_updater import StatusUpdater
class BankTransaction(StatusUpdater): class BankTransaction(StatusUpdater):
def after_insert(self): def before_validate(self):
self.unallocated_amount = abs(flt(self.withdrawal) - flt(self.deposit)) self.update_allocated_amount()
def on_submit(self): def validate(self):
self.clear_linked_payment_entries() self.validate_duplicate_references()
def validate_duplicate_references(self):
"""Make sure the same voucher is not allocated twice within the same Bank Transaction"""
if not self.payment_entries:
return
pe = []
for row in self.payment_entries:
reference = (row.payment_document, row.payment_entry)
if reference in pe:
frappe.throw(
_("{0} {1} is allocated twice in this Bank Transaction").format(
row.payment_document, row.payment_entry
)
)
pe.append(reference)
def update_allocated_amount(self):
self.allocated_amount = (
sum(p.allocated_amount for p in self.payment_entries) if self.payment_entries else 0.0
)
self.unallocated_amount = abs(flt(self.withdrawal) - flt(self.deposit)) - self.allocated_amount
def before_submit(self):
self.allocate_payment_entries()
self.set_status() self.set_status()
if frappe.db.get_single_value("Accounts Settings", "enable_party_matching"): if frappe.db.get_single_value("Accounts Settings", "enable_party_matching"):
self.auto_set_party() self.auto_set_party()
_saving_flag = False def before_update_after_submit(self):
self.validate_duplicate_references()
# nosemgrep: frappe-semgrep-rules.rules.frappe-modifying-but-not-comitting self.allocate_payment_entries()
def on_update_after_submit(self): self.update_allocated_amount()
"Run on save(). Avoid recursion caused by multiple saves"
if not self._saving_flag:
self._saving_flag = True
self.clear_linked_payment_entries()
self.update_allocations()
self._saving_flag = False
def on_cancel(self): def on_cancel(self):
self.clear_linked_payment_entries(for_cancel=True) for payment_entry in self.payment_entries:
self.set_status(update=True) self.clear_linked_payment_entry(payment_entry, for_cancel=True)
def update_allocations(self):
"The doctype does not allow modifications after submission, so write to the db direct"
if self.payment_entries:
allocated_amount = sum(p.allocated_amount for p in self.payment_entries)
else:
allocated_amount = 0.0
amount = abs(flt(self.withdrawal) - flt(self.deposit))
self.db_set("allocated_amount", flt(allocated_amount))
self.db_set("unallocated_amount", amount - flt(allocated_amount))
self.reload()
self.set_status(update=True) self.set_status(update=True)
def add_payment_entries(self, vouchers): def add_payment_entries(self, vouchers):
"Add the vouchers with zero allocation. Save() will perform the allocations and clearance" "Add the vouchers with zero allocation. Save() will perform the allocations and clearance"
if 0.0 >= self.unallocated_amount: if 0.0 >= self.unallocated_amount:
frappe.throw(frappe._("Bank Transaction {0} is already fully reconciled").format(self.name)) frappe.throw(_("Bank Transaction {0} is already fully reconciled").format(self.name))
added = False
for voucher in vouchers: for voucher in vouchers:
# Can't add same voucher twice self.append(
found = False "payment_entries",
for pe in self.payment_entries: {
if (
pe.payment_document == voucher["payment_doctype"]
and pe.payment_entry == voucher["payment_name"]
):
found = True
if not found:
pe = {
"payment_document": voucher["payment_doctype"], "payment_document": voucher["payment_doctype"],
"payment_entry": voucher["payment_name"], "payment_entry": voucher["payment_name"],
"allocated_amount": 0.0, # Temporary "allocated_amount": 0.0, # Temporary
} },
child = self.append("payment_entries", pe) )
added = True
# runs on_update_after_submit
if added:
self.save()
def allocate_payment_entries(self): def allocate_payment_entries(self):
"""Refactored from bank reconciliation tool. """Refactored from bank reconciliation tool.
@ -90,6 +85,7 @@ class BankTransaction(StatusUpdater):
- clear means: set the latest transaction date as clearance date - clear means: set the latest transaction date as clearance date
""" """
remaining_amount = self.unallocated_amount remaining_amount = self.unallocated_amount
to_remove = []
for payment_entry in self.payment_entries: for payment_entry in self.payment_entries:
if payment_entry.allocated_amount == 0.0: if payment_entry.allocated_amount == 0.0:
unallocated_amount, should_clear, latest_transaction = get_clearance_details( unallocated_amount, should_clear, latest_transaction = get_clearance_details(
@ -99,49 +95,39 @@ class BankTransaction(StatusUpdater):
if 0.0 == unallocated_amount: if 0.0 == unallocated_amount:
if should_clear: if should_clear:
latest_transaction.clear_linked_payment_entry(payment_entry) latest_transaction.clear_linked_payment_entry(payment_entry)
self.db_delete_payment_entry(payment_entry) to_remove.append(payment_entry)
elif remaining_amount <= 0.0: elif remaining_amount <= 0.0:
self.db_delete_payment_entry(payment_entry) to_remove.append(payment_entry)
elif 0.0 < unallocated_amount and unallocated_amount <= remaining_amount: elif 0.0 < unallocated_amount <= remaining_amount:
payment_entry.db_set("allocated_amount", unallocated_amount) payment_entry.allocated_amount = unallocated_amount
remaining_amount -= unallocated_amount remaining_amount -= unallocated_amount
if should_clear: if should_clear:
latest_transaction.clear_linked_payment_entry(payment_entry) latest_transaction.clear_linked_payment_entry(payment_entry)
elif 0.0 < unallocated_amount and unallocated_amount > remaining_amount: elif 0.0 < unallocated_amount:
payment_entry.db_set("allocated_amount", remaining_amount) payment_entry.allocated_amount = remaining_amount
remaining_amount = 0.0 remaining_amount = 0.0
elif 0.0 > unallocated_amount: elif 0.0 > unallocated_amount:
self.db_delete_payment_entry(payment_entry) frappe.throw(_("Voucher {0} is over-allocated by {1}").format(unallocated_amount))
frappe.throw(frappe._("Voucher {0} is over-allocated by {1}").format(unallocated_amount))
self.reload() for payment_entry in to_remove:
self.remove(to_remove)
def db_delete_payment_entry(self, payment_entry):
frappe.db.delete("Bank Transaction Payments", {"name": payment_entry.name})
@frappe.whitelist() @frappe.whitelist()
def remove_payment_entries(self): def remove_payment_entries(self):
for payment_entry in self.payment_entries: for payment_entry in self.payment_entries:
self.remove_payment_entry(payment_entry) self.remove_payment_entry(payment_entry)
# runs on_update_after_submit
self.save() self.save() # runs before_update_after_submit
def remove_payment_entry(self, payment_entry): def remove_payment_entry(self, payment_entry):
"Clear payment entry and clearance" "Clear payment entry and clearance"
self.clear_linked_payment_entry(payment_entry, for_cancel=True) self.clear_linked_payment_entry(payment_entry, for_cancel=True)
self.remove(payment_entry) self.remove(payment_entry)
def clear_linked_payment_entries(self, for_cancel=False):
if for_cancel:
for payment_entry in self.payment_entries:
self.clear_linked_payment_entry(payment_entry, for_cancel)
else:
self.allocate_payment_entries()
def clear_linked_payment_entry(self, payment_entry, for_cancel=False): def clear_linked_payment_entry(self, payment_entry, for_cancel=False):
clearance_date = None if for_cancel else self.date clearance_date = None if for_cancel else self.date
set_voucher_clearance( set_voucher_clearance(
@ -162,11 +148,10 @@ class BankTransaction(StatusUpdater):
deposit=self.deposit, deposit=self.deposit,
).match() ).match()
if result: if not result:
party_type, party = result return
frappe.db.set_value(
"Bank Transaction", self.name, field={"party_type": party_type, "party": party} self.party_type, self.party = result
)
@frappe.whitelist() @frappe.whitelist()
@ -198,9 +183,7 @@ def get_clearance_details(transaction, payment_entry):
if gle["gl_account"] == gl_bank_account: if gle["gl_account"] == gl_bank_account:
if gle["amount"] <= 0.0: if gle["amount"] <= 0.0:
frappe.throw( frappe.throw(
frappe._("Voucher {0} value is broken: {1}").format( _("Voucher {0} value is broken: {1}").format(payment_entry.payment_entry, gle["amount"])
payment_entry.payment_entry, gle["amount"]
)
) )
unmatched_gles -= 1 unmatched_gles -= 1
@ -221,7 +204,7 @@ def get_clearance_details(transaction, payment_entry):
def get_related_bank_gl_entries(doctype, docname): def get_related_bank_gl_entries(doctype, docname):
# nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql # nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
result = frappe.db.sql( return frappe.db.sql(
""" """
SELECT SELECT
ABS(gle.credit_in_account_currency - gle.debit_in_account_currency) AS amount, ABS(gle.credit_in_account_currency - gle.debit_in_account_currency) AS amount,
@ -239,7 +222,6 @@ def get_related_bank_gl_entries(doctype, docname):
dict(doctype=doctype, docname=docname), dict(doctype=doctype, docname=docname),
as_dict=True, as_dict=True,
) )
return result
def get_total_allocated_amount(doctype, docname): def get_total_allocated_amount(doctype, docname):
@ -365,6 +347,7 @@ def set_voucher_clearance(doctype, docname, clearance_date, self):
if clearance_date: if clearance_date:
vouchers = [{"payment_doctype": "Bank Transaction", "payment_name": self.name}] vouchers = [{"payment_doctype": "Bank Transaction", "payment_name": self.name}]
bt.add_payment_entries(vouchers) bt.add_payment_entries(vouchers)
bt.save()
else: else:
for pe in bt.payment_entries: for pe in bt.payment_entries:
if pe.payment_document == self.doctype and pe.payment_entry == self.name: if pe.payment_document == self.doctype and pe.payment_entry == self.name:

View File

@ -113,7 +113,7 @@ def generate_data_from_csv(file_doc, as_dict=False):
if as_dict: if as_dict:
data.append({frappe.scrub(header): row[index] for index, header in enumerate(headers)}) data.append({frappe.scrub(header): row[index] for index, header in enumerate(headers)})
else: else:
if not row[1]: if not row[1] and len(row) > 1:
row[1] = row[0] row[1] = row[0]
row[3] = row[2] row[3] = row[2]
data.append(row) data.append(row)

View File

@ -51,7 +51,7 @@ frappe.ui.form.on("Journal Entry", {
}, __('Make')); }, __('Make'));
} }
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm); erpnext.accounts.unreconcile_payment.add_unreconcile_btn(frm);
}, },
before_save: function(frm) { before_save: function(frm) {
if ((frm.doc.docstatus == 0) && (!frm.doc.is_system_generated)) { if ((frm.doc.docstatus == 0) && (!frm.doc.is_system_generated)) {

View File

@ -548,8 +548,16 @@
"icon": "fa fa-file-text", "icon": "fa fa-file-text",
"idx": 176, "idx": 176,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [
"modified": "2023-08-10 14:32:22.366895", {
"is_child_table": 1,
"link_doctype": "Bank Transaction Payments",
"link_fieldname": "payment_entry",
"parent_doctype": "Bank Transaction",
"table_fieldname": "payment_entries"
}
],
"modified": "2023-11-23 12:11:04.128015",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Journal Entry", "name": "Journal Entry",

View File

@ -508,7 +508,7 @@ class JournalEntry(AccountsController):
).format(d.reference_name, d.account) ).format(d.reference_name, d.account)
) )
else: else:
dr_or_cr = "debit" if d.credit > 0 else "credit" dr_or_cr = "debit" if flt(d.credit) > 0 else "credit"
valid = False valid = False
for jvd in against_entries: for jvd in against_entries:
if flt(jvd[dr_or_cr]) > 0: if flt(jvd[dr_or_cr]) > 0:
@ -868,7 +868,7 @@ class JournalEntry(AccountsController):
party_account_currency = d.account_currency party_account_currency = d.account_currency
elif frappe.get_cached_value("Account", d.account, "account_type") in ["Bank", "Cash"]: elif frappe.get_cached_value("Account", d.account, "account_type") in ["Bank", "Cash"]:
bank_amount += d.debit_in_account_currency or d.credit_in_account_currency bank_amount += flt(d.debit_in_account_currency) or flt(d.credit_in_account_currency)
bank_account_currency = d.account_currency bank_account_currency = d.account_currency
if party_type and pay_to_recd_from: if party_type and pay_to_recd_from:

View File

@ -203,7 +203,8 @@
"fieldtype": "Select", "fieldtype": "Select",
"label": "Reference Type", "label": "Reference Type",
"no_copy": 1, "no_copy": 1,
"options": "\nSales Invoice\nPurchase Invoice\nJournal Entry\nSales Order\nPurchase Order\nExpense Claim\nAsset\nLoan\nPayroll Entry\nEmployee Advance\nExchange Rate Revaluation\nInvoice Discounting\nFees\nFull and Final Statement\nPayment Entry" "options": "\nSales Invoice\nPurchase Invoice\nJournal Entry\nSales Order\nPurchase Order\nExpense Claim\nAsset\nLoan\nPayroll Entry\nEmployee Advance\nExchange Rate Revaluation\nInvoice Discounting\nFees\nFull and Final Statement\nPayment Entry",
"search_index": 1
}, },
{ {
"fieldname": "reference_name", "fieldname": "reference_name",
@ -211,7 +212,8 @@
"in_list_view": 1, "in_list_view": 1,
"label": "Reference Name", "label": "Reference Name",
"no_copy": 1, "no_copy": 1,
"options": "reference_type" "options": "reference_type",
"search_index": 1
}, },
{ {
"depends_on": "eval:doc.reference_type&&!in_list(doc.reference_type, ['Expense Claim', 'Asset', 'Employee Loan', 'Employee Advance'])", "depends_on": "eval:doc.reference_type&&!in_list(doc.reference_type, ['Expense Claim', 'Asset', 'Employee Loan', 'Employee Advance'])",
@ -278,13 +280,14 @@
"fieldtype": "Data", "fieldtype": "Data",
"hidden": 1, "hidden": 1,
"label": "Reference Detail No", "label": "Reference Detail No",
"no_copy": 1 "no_copy": 1,
"search_index": 1
} }
], ],
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-06-16 14:11:13.507807", "modified": "2023-11-23 11:44:25.841187",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Journal Entry Account", "name": "Journal Entry Account",

View File

@ -9,7 +9,7 @@ erpnext.accounts.taxes.setup_tax_filters("Advance Taxes and Charges");
frappe.ui.form.on('Payment Entry', { frappe.ui.form.on('Payment Entry', {
onload: function(frm) { onload: function(frm) {
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting Ledger', 'Unreconcile Payments', 'Unreconcile Payment Entries']; frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting Ledger', 'Unreconcile Payment', 'Unreconcile Payment Entries'];
if(frm.doc.__islocal) { if(frm.doc.__islocal) {
if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null); if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null);
@ -160,7 +160,7 @@ frappe.ui.form.on('Payment Entry', {
}, __('Actions')); }, __('Actions'));
} }
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm); erpnext.accounts.unreconcile_payment.add_unreconcile_btn(frm);
}, },
validate_company: (frm) => { validate_company: (frm) => {

View File

@ -750,8 +750,16 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [
"modified": "2023-11-08 21:51:03.482709", {
"is_child_table": 1,
"link_doctype": "Bank Transaction Payments",
"link_fieldname": "payment_entry",
"parent_doctype": "Bank Transaction",
"table_fieldname": "payment_entries"
}
],
"modified": "2023-11-23 12:07:20.887885",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Payment Entry", "name": "Payment Entry",

View File

@ -148,7 +148,7 @@ class PaymentEntry(AccountsController):
"Repost Payment Ledger Items", "Repost Payment Ledger Items",
"Repost Accounting Ledger", "Repost Accounting Ledger",
"Repost Accounting Ledger Items", "Repost Accounting Ledger Items",
"Unreconcile Payments", "Unreconcile Payment",
"Unreconcile Payment Entries", "Unreconcile Payment Entries",
) )
super(PaymentEntry, self).on_cancel() super(PaymentEntry, self).on_cancel()

View File

@ -1171,6 +1171,7 @@ class TestPaymentReconciliation(FrappeTestCase):
# Should not raise frappe.exceptions.ValidationError: Payment Entry has been modified after you pulled it. Please pull it again. # Should not raise frappe.exceptions.ValidationError: Payment Entry has been modified after you pulled it. Please pull it again.
pr.reconcile() pr.reconcile()
def make_customer(customer_name, currency=None): def make_customer(customer_name, currency=None):
if not frappe.db.exists("Customer", customer_name): if not frappe.db.exists("Customer", customer_name):
customer = frappe.new_doc("Customer") customer = frappe.new_doc("Customer")

View File

@ -556,7 +556,7 @@ def get_stock_availability(item_code, warehouse):
return bin_qty - pos_sales_qty, is_stock_item return bin_qty - pos_sales_qty, is_stock_item
else: else:
is_stock_item = True is_stock_item = True
if frappe.db.exists("Product Bundle", item_code): if frappe.db.exists("Product Bundle", {"name": item_code, "disabled": 0}):
return get_bundle_availability(item_code, warehouse), is_stock_item return get_bundle_availability(item_code, warehouse), is_stock_item
else: else:
is_stock_item = False is_stock_item = False

View File

@ -17,11 +17,10 @@ class ProcessSubscription(Document):
def create_subscription_process( def create_subscription_process(
subscription: str | None, posting_date: Union[str, datetime.date] | None subscription: str | None = None, posting_date: Union[str, datetime.date] | None = None
): ):
"""Create a new Process Subscription document""" """Create a new Process Subscription document"""
doc = frappe.new_doc("Process Subscription") doc = frappe.new_doc("Process Subscription")
doc.subscription = subscription doc.subscription = subscription
doc.posting_date = getdate(posting_date) doc.posting_date = getdate(posting_date)
doc.insert(ignore_permissions=True)
doc.submit() doc.submit()

View File

@ -180,7 +180,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
} }
this.frm.set_df_property("tax_withholding_category", "hidden", doc.apply_tds ? 0 : 1); this.frm.set_df_property("tax_withholding_category", "hidden", doc.apply_tds ? 0 : 1);
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(me.frm); erpnext.accounts.unreconcile_payment.add_unreconcile_btn(me.frm);
} }
unblock_invoice() { unblock_invoice() {

View File

@ -13,6 +13,7 @@ from erpnext.accounts.deferred_revenue import validate_service_stop_date
from erpnext.accounts.doctype.gl_entry.gl_entry import update_outstanding_amt from erpnext.accounts.doctype.gl_entry.gl_entry import update_outstanding_amt
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import ( from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
validate_docs_for_deferred_accounting, validate_docs_for_deferred_accounting,
validate_docs_for_voucher_types,
) )
from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
check_if_return_invoice_linked_with_payment_entry, check_if_return_invoice_linked_with_payment_entry,
@ -491,6 +492,7 @@ class PurchaseInvoice(BuyingController):
def validate_for_repost(self): def validate_for_repost(self):
self.validate_write_off_account() self.validate_write_off_account()
self.validate_expense_account() self.validate_expense_account()
validate_docs_for_voucher_types(["Purchase Invoice"])
validate_docs_for_deferred_accounting([], [self.name]) validate_docs_for_deferred_accounting([], [self.name])
def on_submit(self): def on_submit(self):
@ -525,7 +527,11 @@ class PurchaseInvoice(BuyingController):
if self.update_stock == 1: if self.update_stock == 1:
self.repost_future_sle_and_gle() self.repost_future_sle_and_gle()
self.update_project() if (
frappe.db.get_single_value("Buying Settings", "project_update_frequency") == "Each Transaction"
):
self.update_project()
update_linked_doc(self.doctype, self.name, self.inter_company_invoice_reference) update_linked_doc(self.doctype, self.name, self.inter_company_invoice_reference)
self.update_advance_tax_references() self.update_advance_tax_references()
@ -1260,7 +1266,10 @@ class PurchaseInvoice(BuyingController):
if self.update_stock == 1: if self.update_stock == 1:
self.repost_future_sle_and_gle() self.repost_future_sle_and_gle()
self.update_project() if (
frappe.db.get_single_value("Buying Settings", "project_update_frequency") == "Each Transaction"
):
self.update_project()
self.db_set("status", "Cancelled") self.db_set("status", "Cancelled")
unlink_inter_company_doc(self.doctype, self.name, self.inter_company_invoice_reference) unlink_inter_company_doc(self.doctype, self.name, self.inter_company_invoice_reference)
@ -1279,13 +1288,21 @@ class PurchaseInvoice(BuyingController):
self.update_advance_tax_references(cancel=1) self.update_advance_tax_references(cancel=1)
def update_project(self): def update_project(self):
project_list = [] projects = frappe._dict()
for d in self.items: for d in self.items:
if d.project and d.project not in project_list: if d.project:
project = frappe.get_doc("Project", d.project) if self.docstatus == 1:
project.update_purchase_costing() projects[d.project] = projects.get(d.project, 0) + d.base_net_amount
project.db_update() elif self.docstatus == 2:
project_list.append(d.project) projects[d.project] = projects.get(d.project, 0) - d.base_net_amount
pj = frappe.qb.DocType("Project")
for proj, value in projects.items():
res = (
frappe.qb.from_(pj).select(pj.total_purchase_cost).where(pj.name == proj).for_update().run()
)
current_purchase_cost = res and res[0][0] or 0
frappe.db.set_value("Project", proj, "total_purchase_cost", current_purchase_cost + value)
def validate_supplier_invoice(self): def validate_supplier_invoice(self):
if self.bill_date: if self.bill_date:

View File

@ -498,6 +498,7 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"allow_on_submit": 1,
"fieldname": "project", "fieldname": "project",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Project", "label": "Project",
@ -505,6 +506,7 @@
"print_hide": 1 "print_hide": 1
}, },
{ {
"allow_on_submit": 1,
"default": ":Company", "default": ":Company",
"depends_on": "eval:!doc.is_fixed_asset", "depends_on": "eval:!doc.is_fixed_asset",
"fieldname": "cost_center", "fieldname": "cost_center",

View File

@ -10,12 +10,7 @@ from frappe.utils.data import comma_and
class RepostAccountingLedger(Document): class RepostAccountingLedger(Document):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(RepostAccountingLedger, self).__init__(*args, **kwargs) super(RepostAccountingLedger, self).__init__(*args, **kwargs)
self._allowed_types = [ self._allowed_types = get_allowed_types_from_settings()
x.document_type
for x in frappe.db.get_all(
"Repost Allowed Types", filters={"allowed": True}, fields=["distinct(document_type)"]
)
]
def validate(self): def validate(self):
self.validate_vouchers() self.validate_vouchers()
@ -56,15 +51,7 @@ class RepostAccountingLedger(Document):
def validate_vouchers(self): def validate_vouchers(self):
if self.vouchers: if self.vouchers:
# Validate voucher types validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
voucher_types = set([x.voucher_type for x in self.vouchers])
if disallowed_types := voucher_types.difference(self._allowed_types):
frappe.throw(
_("{0} types are not allowed. Only {1} are.").format(
frappe.bold(comma_and(list(disallowed_types))),
frappe.bold(comma_and(list(self._allowed_types))),
)
)
def get_existing_ledger_entries(self): def get_existing_ledger_entries(self):
vouchers = [x.voucher_no for x in self.vouchers] vouchers = [x.voucher_no for x in self.vouchers]
@ -168,6 +155,15 @@ def start_repost(account_repost_doc=str) -> None:
frappe.db.commit() frappe.db.commit()
def get_allowed_types_from_settings():
return [
x.document_type
for x in frappe.db.get_all(
"Repost Allowed Types", filters={"allowed": True}, fields=["distinct(document_type)"]
)
]
def validate_docs_for_deferred_accounting(sales_docs, purchase_docs): def validate_docs_for_deferred_accounting(sales_docs, purchase_docs):
docs_with_deferred_revenue = frappe.db.get_all( docs_with_deferred_revenue = frappe.db.get_all(
"Sales Invoice Item", "Sales Invoice Item",
@ -191,6 +187,25 @@ def validate_docs_for_deferred_accounting(sales_docs, purchase_docs):
) )
def validate_docs_for_voucher_types(doc_voucher_types):
allowed_types = get_allowed_types_from_settings()
# Validate voucher types
voucher_types = set(doc_voucher_types)
if disallowed_types := voucher_types.difference(allowed_types):
message = "are" if len(disallowed_types) > 1 else "is"
frappe.throw(
_("{0} {1} not allowed to be reposted. Modify {2} to enable reposting.").format(
frappe.bold(comma_and(list(disallowed_types))),
message,
frappe.bold(
frappe.utils.get_link_to_form(
"Repost Accounting Ledger Settings", "Repost Accounting Ledger Settings"
)
),
)
)
@frappe.whitelist() @frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs @frappe.validate_and_sanitize_search_inputs
def get_repost_allowed_types(doctype, txt, searchfield, start, page_len, filters): def get_repost_allowed_types(doctype, txt, searchfield, start, page_len, filters):

View File

@ -37,7 +37,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
super.onload(); super.onload();
this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log', this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log',
'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger", "Repost Accounting Ledger", "Unreconcile Payments", "Unreconcile Payment Entries"]; 'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger", "Repost Accounting Ledger", "Unreconcile Payment", "Unreconcile Payment Entries"];
if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) { if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) {
// show debit_to in print format // show debit_to in print format
@ -184,7 +184,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
} }
} }
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(me.frm); erpnext.accounts.unreconcile_payment.add_unreconcile_btn(me.frm);
} }
make_maintenance_schedule() { make_maintenance_schedule() {

View File

@ -1615,7 +1615,8 @@
"hide_seconds": 1, "hide_seconds": 1,
"label": "Inter Company Invoice Reference", "label": "Inter Company Invoice Reference",
"options": "Purchase Invoice", "options": "Purchase Invoice",
"read_only": 1 "read_only": 1,
"search_index": 1
}, },
{ {
"fieldname": "customer_group", "fieldname": "customer_group",
@ -2173,7 +2174,7 @@
"link_fieldname": "consolidated_invoice" "link_fieldname": "consolidated_invoice"
} }
], ],
"modified": "2023-11-20 11:51:43.555197", "modified": "2023-11-23 16:56:29.679499",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Sales Invoice", "name": "Sales Invoice",

View File

@ -17,6 +17,7 @@ from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
) )
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import ( from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
validate_docs_for_deferred_accounting, validate_docs_for_deferred_accounting,
validate_docs_for_voucher_types,
) )
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import (
get_party_tax_withholding_details, get_party_tax_withholding_details,
@ -172,6 +173,7 @@ class SalesInvoice(SellingController):
self.validate_write_off_account() self.validate_write_off_account()
self.validate_account_for_change_amount() self.validate_account_for_change_amount()
self.validate_income_account() self.validate_income_account()
validate_docs_for_voucher_types(["Sales Invoice"])
validate_docs_for_deferred_accounting([self.name], []) validate_docs_for_deferred_accounting([self.name], [])
def validate_fixed_asset(self): def validate_fixed_asset(self):
@ -395,7 +397,7 @@ class SalesInvoice(SellingController):
"Repost Payment Ledger Items", "Repost Payment Ledger Items",
"Repost Accounting Ledger", "Repost Accounting Ledger",
"Repost Accounting Ledger Items", "Repost Accounting Ledger Items",
"Unreconcile Payments", "Unreconcile Payment",
"Unreconcile Payment Entries", "Unreconcile Payment Entries",
"Payment Ledger Entry", "Payment Ledger Entry",
"Serial and Batch Bundle", "Serial and Batch Bundle",

View File

@ -676,7 +676,7 @@ def get_prorata_factor(
def process_all( def process_all(
subscription: str | None, posting_date: Optional["DateTimeLikeObject"] = None subscription: str | None = None, posting_date: Optional["DateTimeLikeObject"] = None
) -> None: ) -> None:
""" """
Task to updates the status of all `Subscription` apart from those that are cancelled Task to updates the status of all `Subscription` apart from those that are cancelled

View File

@ -10,7 +10,7 @@ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sal
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
class TestUnreconcilePayments(AccountsTestMixin, FrappeTestCase): class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase):
def setUp(self): def setUp(self):
self.create_company() self.create_company()
self.create_customer() self.create_customer()
@ -73,7 +73,7 @@ class TestUnreconcilePayments(AccountsTestMixin, FrappeTestCase):
unreconcile = frappe.get_doc( unreconcile = frappe.get_doc(
{ {
"doctype": "Unreconcile Payments", "doctype": "Unreconcile Payment",
"company": self.company, "company": self.company,
"voucher_type": pe.doctype, "voucher_type": pe.doctype,
"voucher_no": pe.name, "voucher_no": pe.name,
@ -138,7 +138,7 @@ class TestUnreconcilePayments(AccountsTestMixin, FrappeTestCase):
unreconcile = frappe.get_doc( unreconcile = frappe.get_doc(
{ {
"doctype": "Unreconcile Payments", "doctype": "Unreconcile Payment",
"company": self.company, "company": self.company,
"voucher_type": pe2.doctype, "voucher_type": pe2.doctype,
"voucher_no": pe2.name, "voucher_no": pe2.name,
@ -196,7 +196,7 @@ class TestUnreconcilePayments(AccountsTestMixin, FrappeTestCase):
unreconcile = frappe.get_doc( unreconcile = frappe.get_doc(
{ {
"doctype": "Unreconcile Payments", "doctype": "Unreconcile Payment",
"company": self.company, "company": self.company,
"voucher_type": pe.doctype, "voucher_type": pe.doctype,
"voucher_no": pe.name, "voucher_no": pe.name,
@ -281,7 +281,7 @@ class TestUnreconcilePayments(AccountsTestMixin, FrappeTestCase):
unreconcile = frappe.get_doc( unreconcile = frappe.get_doc(
{ {
"doctype": "Unreconcile Payments", "doctype": "Unreconcile Payment",
"company": self.company, "company": self.company,
"voucher_type": pe2.doctype, "voucher_type": pe2.doctype,
"voucher_no": pe2.name, "voucher_no": pe2.name,

View File

@ -1,7 +1,7 @@
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors // Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt // For license information, please see license.txt
frappe.ui.form.on("Unreconcile Payments", { frappe.ui.form.on("Unreconcile Payment", {
refresh(frm) { refresh(frm) {
frm.set_query("voucher_type", function() { frm.set_query("voucher_type", function() {
return { return {

View File

@ -21,7 +21,7 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Amended From", "label": "Amended From",
"no_copy": 1, "no_copy": 1,
"options": "Unreconcile Payments", "options": "Unreconcile Payment",
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1
}, },
@ -61,7 +61,7 @@
"modified": "2023-08-28 17:42:50.261377", "modified": "2023-08-28 17:42:50.261377",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Unreconcile Payments", "name": "Unreconcile Payment",
"naming_rule": "Expression", "naming_rule": "Expression",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
@ -90,4 +90,4 @@
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@ -15,7 +15,7 @@ from erpnext.accounts.utils import (
) )
class UnreconcilePayments(Document): class UnreconcilePayment(Document):
def validate(self): def validate(self):
self.supported_types = ["Payment Entry", "Journal Entry"] self.supported_types = ["Payment Entry", "Journal Entry"]
if not self.voucher_type in self.supported_types: if not self.voucher_type in self.supported_types:
@ -142,7 +142,7 @@ def create_unreconcile_doc_for_selection(selections=None):
selections = frappe.json.loads(selections) selections = frappe.json.loads(selections)
# assuming each row is a unique voucher # assuming each row is a unique voucher
for row in selections: for row in selections:
unrecon = frappe.new_doc("Unreconcile Payments") unrecon = frappe.new_doc("Unreconcile Payment")
unrecon.company = row.get("company") unrecon.company = row.get("company")
unrecon.voucher_type = row.get("voucher_type") unrecon.voucher_type = row.get("voucher_type")
unrecon.voucher_no = row.get("voucher_no") unrecon.voucher_no = row.get("voucher_no")

View File

@ -281,8 +281,8 @@ class ReceivablePayableReport(object):
must_consider = False must_consider = False
if self.filters.get("for_revaluation_journals"): if self.filters.get("for_revaluation_journals"):
if (abs(row.outstanding) > 1.0 / 10**self.currency_precision) or ( if (abs(row.outstanding) > 0.0 / 10**self.currency_precision) or (
(abs(row.outstanding_in_account_currency) > 1.0 / 10**self.currency_precision) (abs(row.outstanding_in_account_currency) > 0.0 / 10**self.currency_precision)
): ):
must_consider = True must_consider = True
else: else:

View File

@ -1,7 +1,7 @@
import frappe import frappe
from frappe import _ from frappe import _
from erpnext.accounts.report.tds_payable_monthly.tds_payable_monthly import ( from erpnext.accounts.report.tax_withholding_details.tax_withholding_details import (
get_result, get_result,
get_tds_docs, get_tds_docs,
) )

View File

@ -183,6 +183,7 @@ def get_balance_on(
cost_center=None, cost_center=None,
ignore_account_permission=False, ignore_account_permission=False,
account_type=None, account_type=None,
start_date=None,
): ):
if not account and frappe.form_dict.get("account"): if not account and frappe.form_dict.get("account"):
account = frappe.form_dict.get("account") account = frappe.form_dict.get("account")
@ -196,6 +197,8 @@ def get_balance_on(
cost_center = frappe.form_dict.get("cost_center") cost_center = frappe.form_dict.get("cost_center")
cond = ["is_cancelled=0"] cond = ["is_cancelled=0"]
if start_date:
cond.append("posting_date >= %s" % frappe.db.escape(cstr(start_date)))
if date: if date:
cond.append("posting_date <= %s" % frappe.db.escape(cstr(date))) cond.append("posting_date <= %s" % frappe.db.escape(cstr(date)))
else: else:
@ -1826,6 +1829,28 @@ class QueryPaymentLedger(object):
Table("outstanding").amount_in_account_currency >= self.max_outstanding Table("outstanding").amount_in_account_currency >= self.max_outstanding
) )
if self.limit and self.get_invoices:
outstanding_vouchers = (
qb.from_(ple)
.select(
ple.against_voucher_no.as_("voucher_no"),
Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
)
.where(ple.delinked == 0)
.where(Criterion.all(filter_on_against_voucher_no))
.where(Criterion.all(self.common_filter))
.groupby(ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
.orderby(ple.posting_date, ple.voucher_no)
.having(qb.Field("amount_in_account_currency") > 0)
.limit(self.limit)
.run()
)
if outstanding_vouchers:
filter_on_voucher_no.append(ple.voucher_no.isin([x[0] for x in outstanding_vouchers]))
filter_on_against_voucher_no.append(
ple.against_voucher_no.isin([x[0] for x in outstanding_vouchers])
)
# build query for voucher amount # build query for voucher amount
query_voucher_amount = ( query_voucher_amount = (
qb.from_(ple) qb.from_(ple)

View File

@ -509,6 +509,9 @@ def restore_asset(asset_name):
def depreciate_asset(asset_doc, date, notes): def depreciate_asset(asset_doc, date, notes):
if not asset_doc.calculate_depreciation:
return
asset_doc.flags.ignore_validate_update_after_submit = True asset_doc.flags.ignore_validate_update_after_submit = True
make_new_active_asset_depr_schedules_and_cancel_current_ones( make_new_active_asset_depr_schedules_and_cancel_current_ones(
@ -521,6 +524,9 @@ def depreciate_asset(asset_doc, date, notes):
def reset_depreciation_schedule(asset_doc, date, notes): def reset_depreciation_schedule(asset_doc, date, notes):
if not asset_doc.calculate_depreciation:
return
asset_doc.flags.ignore_validate_update_after_submit = True asset_doc.flags.ignore_validate_update_after_submit = True
make_new_active_asset_depr_schedules_and_cancel_current_ones( make_new_active_asset_depr_schedules_and_cancel_current_ones(

View File

@ -17,6 +17,7 @@
"po_required", "po_required",
"pr_required", "pr_required",
"blanket_order_allowance", "blanket_order_allowance",
"project_update_frequency",
"column_break_12", "column_break_12",
"maintain_same_rate", "maintain_same_rate",
"set_landed_cost_based_on_purchase_invoice_rate", "set_landed_cost_based_on_purchase_invoice_rate",
@ -172,6 +173,14 @@
"fieldname": "blanket_order_allowance", "fieldname": "blanket_order_allowance",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Blanket Order Allowance (%)" "label": "Blanket Order Allowance (%)"
},
{
"default": "Each Transaction",
"description": "How often should Project be updated of Total Purchase Cost ?",
"fieldname": "project_update_frequency",
"fieldtype": "Select",
"label": "Update frequency of Project",
"options": "Each Transaction\nManual"
} }
], ],
"icon": "fa fa-cog", "icon": "fa fa-cog",
@ -179,7 +188,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2023-10-25 14:03:32.520418", "modified": "2023-11-24 10:55:51.287327",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Buying Settings", "name": "Buying Settings",

View File

@ -16,7 +16,7 @@ from erpnext.buying.doctype.purchase_order.purchase_order import (
make_purchase_invoice as make_pi_from_po, make_purchase_invoice as make_pi_from_po,
) )
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
from erpnext.controllers.accounts_controller import update_child_qty_rate from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate
from erpnext.manufacturing.doctype.blanket_order.test_blanket_order import make_blanket_order from erpnext.manufacturing.doctype.blanket_order.test_blanket_order import make_blanket_order
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.material_request.material_request import make_purchase_order from erpnext.stock.doctype.material_request.material_request import make_purchase_order
@ -27,6 +27,21 @@ from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
class TestPurchaseOrder(FrappeTestCase): class TestPurchaseOrder(FrappeTestCase):
def test_purchase_order_qty(self):
po = create_purchase_order(qty=1, do_not_save=True)
po.append(
"items",
{
"item_code": "_Test Item",
"qty": -1,
"rate": 10,
},
)
self.assertRaises(frappe.NonNegativeError, po.save)
po.items[1].qty = 0
self.assertRaises(InvalidQtyError, po.save)
def test_make_purchase_receipt(self): def test_make_purchase_receipt(self):
po = create_purchase_order(do_not_submit=True) po = create_purchase_order(do_not_submit=True)
self.assertRaises(frappe.ValidationError, make_purchase_receipt, po.name) self.assertRaises(frappe.ValidationError, make_purchase_receipt, po.name)

View File

@ -214,6 +214,7 @@
"fieldtype": "Float", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Quantity", "label": "Quantity",
"non_negative": 1,
"oldfieldname": "qty", "oldfieldname": "qty",
"oldfieldtype": "Currency", "oldfieldtype": "Currency",
"print_width": "60px", "print_width": "60px",
@ -917,7 +918,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-11-14 18:34:27.267382", "modified": "2023-11-24 13:24:41.298416",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Purchase Order Item", "name": "Purchase Order Item",

View File

@ -165,16 +165,17 @@ class Supplier(TransactionBase):
@frappe.validate_and_sanitize_search_inputs @frappe.validate_and_sanitize_search_inputs
def get_supplier_primary_contact(doctype, txt, searchfield, start, page_len, filters): def get_supplier_primary_contact(doctype, txt, searchfield, start, page_len, filters):
supplier = filters.get("supplier") supplier = filters.get("supplier")
return frappe.db.sql( contact = frappe.qb.DocType("Contact")
""" dynamic_link = frappe.qb.DocType("Dynamic Link")
SELECT
`tabContact`.name from `tabContact`, return (
`tabDynamic Link` frappe.qb.from_(contact)
WHERE .join(dynamic_link)
`tabContact`.name = `tabDynamic Link`.parent .on(contact.name == dynamic_link.parent)
and `tabDynamic Link`.link_name = %(supplier)s .select(contact.name, contact.email_id)
and `tabDynamic Link`.link_doctype = 'Supplier' .where(
and `tabContact`.name like %(txt)s (dynamic_link.link_name == supplier)
""", & (dynamic_link.link_doctype == "Supplier")
{"supplier": supplier, "txt": "%%%s%%" % txt}, & (contact.name.like("%{0}%".format(txt)))
) )
).run(as_dict=False)

View File

@ -71,6 +71,10 @@ class AccountMissingError(frappe.ValidationError):
pass pass
class InvalidQtyError(frappe.ValidationError):
pass
force_item_fields = ( force_item_fields = (
"item_group", "item_group",
"brand", "brand",
@ -239,7 +243,7 @@ class AccountsController(TransactionBase):
references_map.setdefault(x.parent, []).append(x.name) references_map.setdefault(x.parent, []).append(x.name)
for doc, rows in references_map.items(): for doc, rows in references_map.items():
unreconcile_doc = frappe.get_doc("Unreconcile Payments", doc) unreconcile_doc = frappe.get_doc("Unreconcile Payment", doc)
for row in rows: for row in rows:
unreconcile_doc.remove(unreconcile_doc.get("allocations", {"name": row})[0]) unreconcile_doc.remove(unreconcile_doc.get("allocations", {"name": row})[0])
@ -248,9 +252,9 @@ class AccountsController(TransactionBase):
unreconcile_doc.save(ignore_permissions=True) unreconcile_doc.save(ignore_permissions=True)
# delete docs upon parent doc deletion # delete docs upon parent doc deletion
unreconcile_docs = frappe.db.get_all("Unreconcile Payments", filters={"voucher_no": self.name}) unreconcile_docs = frappe.db.get_all("Unreconcile Payment", filters={"voucher_no": self.name})
for x in unreconcile_docs: for x in unreconcile_docs:
_doc = frappe.get_doc("Unreconcile Payments", x.name) _doc = frappe.get_doc("Unreconcile Payment", x.name)
if _doc.docstatus == 1: if _doc.docstatus == 1:
_doc.cancel() _doc.cancel()
_doc.delete() _doc.delete()
@ -910,10 +914,16 @@ class AccountsController(TransactionBase):
return flt(args.get(field, 0) / self.get("conversion_rate", 1)) return flt(args.get(field, 0) / self.get("conversion_rate", 1))
def validate_qty_is_not_zero(self): def validate_qty_is_not_zero(self):
if self.doctype != "Purchase Receipt": if self.doctype == "Purchase Receipt":
for item in self.items: return
if not item.qty:
frappe.throw(_("Item quantity can not be zero")) for item in self.items:
if not flt(item.qty):
frappe.throw(
msg=_("Row #{0}: Item quantity cannot be zero").format(item.idx),
title=_("Invalid Quantity"),
exc=InvalidQtyError,
)
def validate_account_currency(self, account, account_currency=None): def validate_account_currency(self, account, account_currency=None):
valid_currency = [self.company_currency] valid_currency = [self.company_currency]
@ -3139,16 +3149,19 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
conv_fac_precision = child_item.precision("conversion_factor") or 2 conv_fac_precision = child_item.precision("conversion_factor") or 2
qty_precision = child_item.precision("qty") or 2 qty_precision = child_item.precision("qty") or 2
if flt(child_item.billed_amt, rate_precision) > flt( # Amount cannot be lesser than billed amount, except for negative amounts
flt(d.get("rate"), rate_precision) * flt(d.get("qty"), qty_precision), rate_precision row_rate = flt(d.get("rate"), rate_precision)
): amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt(
row_rate * flt(d.get("qty"), qty_precision), rate_precision
)
if amount_below_billed_amt and row_rate > 0.0:
frappe.throw( frappe.throw(
_("Row #{0}: Cannot set Rate if amount is greater than billed amount for Item {1}.").format( _("Row #{0}: Cannot set Rate if amount is greater than billed amount for Item {1}.").format(
child_item.idx, child_item.item_code child_item.idx, child_item.item_code
) )
) )
else: else:
child_item.rate = flt(d.get("rate"), rate_precision) child_item.rate = row_rate
if d.get("conversion_factor"): if d.get("conversion_factor"):
if child_item.stock_uom == child_item.uom: if child_item.stock_uom == child_item.uom:

View File

@ -350,11 +350,12 @@ class SellingController(StockController):
return il return il
def has_product_bundle(self, item_code): def has_product_bundle(self, item_code):
return frappe.db.sql( product_bundle = frappe.qb.DocType("Product Bundle")
"""select name from `tabProduct Bundle` return (
where new_item_code=%s and docstatus != 2""", frappe.qb.from_(product_bundle)
item_code, .select(product_bundle.name)
) .where((product_bundle.new_item_code == item_code) & (product_bundle.disabled == 0))
).run()
def get_already_delivered_qty(self, current_docname, so, so_detail): def get_already_delivered_qty(self, current_docname, so, so_detail):
delivered_via_dn = frappe.db.sql( delivered_via_dn = frappe.db.sql(

View File

@ -29,8 +29,16 @@
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [
"modified": "2021-10-21 12:43:59.106807", {
"is_child_table": 1,
"link_doctype": "Competitor Detail",
"link_fieldname": "competitor",
"parent_doctype": "Quotation",
"table_fieldname": "competitors"
}
],
"modified": "2023-11-23 19:33:54.284279",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "CRM", "module": "CRM",
"name": "Competitor", "name": "Competitor",
@ -64,5 +72,6 @@
"quick_entry": 1, "quick_entry": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@ -34,6 +34,15 @@ class Lead(SellingController, CRMNote):
def before_insert(self): def before_insert(self):
self.contact_doc = None self.contact_doc = None
if frappe.db.get_single_value("CRM Settings", "auto_creation_of_contact"): if frappe.db.get_single_value("CRM Settings", "auto_creation_of_contact"):
if self.source == "Existing Customer" and self.customer:
contact = frappe.db.get_value(
"Dynamic Link",
{"link_doctype": "Customer", "link_name": self.customer},
"parent",
)
if contact:
self.contact_doc = frappe.get_doc("Contact", contact)
return
self.contact_doc = self.create_contact() self.contact_doc = self.create_contact()
def after_insert(self): def after_insert(self):

View File

@ -419,7 +419,6 @@ scheduler_events = {
"erpnext.projects.doctype.project.project.collect_project_status", "erpnext.projects.doctype.project.project.collect_project_status",
], ],
"hourly_long": [ "hourly_long": [
"erpnext.accounts.doctype.process_subscription.process_subscription.create_subscription_process",
"erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries", "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries",
"erpnext.utilities.bulk_transaction.retry", "erpnext.utilities.bulk_transaction.retry",
], ],
@ -450,6 +449,7 @@ scheduler_events = {
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly", "erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly",
], ],
"daily_long": [ "daily_long": [
"erpnext.accounts.doctype.process_subscription.process_subscription.create_subscription_process",
"erpnext.setup.doctype.email_digest.email_digest.send", "erpnext.setup.doctype.email_digest.email_digest.send",
"erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.auto_update_latest_price_in_all_boms", "erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.auto_update_latest_price_in_all_boms",
"erpnext.crm.utils.open_leads_opportunities_based_on_todays_event", "erpnext.crm.utils.open_leads_opportunities_based_on_todays_event",

View File

@ -185,7 +185,8 @@ class JobCard(Document):
# override capacity for employee # override capacity for employee
production_capacity = 1 production_capacity = 1
if time_logs and production_capacity > len(time_logs): overlap_count = self.get_overlap_count(time_logs)
if time_logs and production_capacity > overlap_count:
return {} return {}
if self.workstation_type and time_logs: if self.workstation_type and time_logs:
@ -195,6 +196,37 @@ class JobCard(Document):
return time_logs[-1] return time_logs[-1]
@staticmethod
def get_overlap_count(time_logs):
count = 1
# Check overlap exists or not between the overlapping time logs with the current Job Card
for idx, row in enumerate(time_logs):
next_idx = idx
if idx + 1 < len(time_logs):
next_idx = idx + 1
next_row = time_logs[next_idx]
if row.name == next_row.name:
continue
if (
(
get_datetime(next_row.from_time) >= get_datetime(row.from_time)
and get_datetime(next_row.from_time) <= get_datetime(row.to_time)
)
or (
get_datetime(next_row.to_time) >= get_datetime(row.from_time)
and get_datetime(next_row.to_time) <= get_datetime(row.to_time)
)
or (
get_datetime(next_row.from_time) <= get_datetime(row.from_time)
and get_datetime(next_row.to_time) >= get_datetime(row.to_time)
)
):
count += 1
return count
def get_time_logs(self, args, doctype, check_next_available_slot=False): def get_time_logs(self, args, doctype, check_next_available_slot=False):
jc = frappe.qb.DocType("Job Card") jc = frappe.qb.DocType("Job Card")
jctl = frappe.qb.DocType(doctype) jctl = frappe.qb.DocType(doctype)
@ -211,7 +243,14 @@ class JobCard(Document):
query = ( query = (
frappe.qb.from_(jctl) frappe.qb.from_(jctl)
.from_(jc) .from_(jc)
.select(jc.name.as_("name"), jctl.from_time, jctl.to_time, jc.workstation, jc.workstation_type) .select(
jc.name.as_("name"),
jctl.name.as_("row_name"),
jctl.from_time,
jctl.to_time,
jc.workstation,
jc.workstation_type,
)
.where( .where(
(jctl.parent == jc.name) (jctl.parent == jc.name)
& (Criterion.any(time_conditions)) & (Criterion.any(time_conditions))

View File

@ -920,6 +920,20 @@ class TestWorkOrder(FrappeTestCase):
"Test RM Item 2 for Scrap Item Test", "Test RM Item 2 for Scrap Item Test",
] ]
from_time = add_days(now(), -1)
job_cards = frappe.get_all(
"Job Card Time Log",
fields=["distinct parent as name", "docstatus"],
filters={"from_time": (">", from_time)},
order_by="creation asc",
)
for job_card in job_cards:
if job_card.docstatus == 1:
frappe.get_doc("Job Card", job_card.name).cancel()
frappe.delete_doc("Job Card Time Log", job_card.name)
company = "_Test Company with perpetual inventory" company = "_Test Company with perpetual inventory"
for item_code in items: for item_code in items:
create_item( create_item(

View File

@ -351,5 +351,6 @@ erpnext.patches.v15_0.rename_depreciation_amount_based_on_num_days_in_month_to_d
erpnext.patches.v15_0.set_reserved_stock_in_bin erpnext.patches.v15_0.set_reserved_stock_in_bin
erpnext.patches.v14_0.create_accounting_dimensions_in_supplier_quotation 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")
# 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

View File

@ -21,6 +21,9 @@ def execute():
params = set({x.casefold(): x for x in params}.values()) params = set({x.casefold(): x for x in params}.values())
for parameter in params: for parameter in params:
if frappe.db.exists("Quality Inspection Parameter", parameter):
continue
frappe.get_doc( frappe.get_doc(
{"doctype": "Quality Inspection Parameter", "parameter": parameter, "description": parameter} {"doctype": "Quality Inspection Parameter", "parameter": parameter, "description": parameter}
).insert(ignore_permissions=True) ).insert(ignore_permissions=True)

View File

@ -68,6 +68,10 @@ frappe.ui.form.on("Project", {
frm.events.create_duplicate(frm); frm.events.create_duplicate(frm);
}, __("Actions")); }, __("Actions"));
frm.add_custom_button(__('Update Total Purchase Cost'), () => {
frm.events.update_total_purchase_cost(frm);
}, __("Actions"));
frm.trigger("set_project_status_button"); frm.trigger("set_project_status_button");
@ -92,6 +96,22 @@ frappe.ui.form.on("Project", {
}, },
update_total_purchase_cost: function(frm) {
frappe.call({
method: "erpnext.projects.doctype.project.project.recalculate_project_total_purchase_cost",
args: {project: frm.doc.name},
freeze: true,
freeze_message: __('Recalculating Purchase Cost against this Project...'),
callback: function(r) {
if (r && !r.exc) {
frappe.msgprint(__('Total Purchase Cost has been updated'));
frm.refresh();
}
}
});
},
set_project_status_button: function(frm) { set_project_status_button: function(frm) {
frm.add_custom_button(__('Set Project Status'), () => { frm.add_custom_button(__('Set Project Status'), () => {
let d = new frappe.ui.Dialog({ let d = new frappe.ui.Dialog({

View File

@ -4,11 +4,11 @@
import frappe import frappe
from email_reply_parser import EmailReplyParser from email_reply_parser import EmailReplyParser
from frappe import _ from frappe import _, qb
from frappe.desk.reportview import get_match_cond from frappe.desk.reportview import get_match_cond
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder import Interval from frappe.query_builder import Interval
from frappe.query_builder.functions import Count, CurDate, Date, UnixTimestamp from frappe.query_builder.functions import Count, CurDate, Date, Sum, UnixTimestamp
from frappe.utils import add_days, flt, get_datetime, get_time, get_url, nowtime, today from frappe.utils import add_days, flt, get_datetime, get_time, get_url, nowtime, today
from frappe.utils.user import is_website_user from frappe.utils.user import is_website_user
@ -249,12 +249,7 @@ class Project(Document):
self.per_gross_margin = (self.gross_margin / flt(self.total_billed_amount)) * 100 self.per_gross_margin = (self.gross_margin / flt(self.total_billed_amount)) * 100
def update_purchase_costing(self): def update_purchase_costing(self):
total_purchase_cost = frappe.db.sql( total_purchase_cost = calculate_total_purchase_cost(self.name)
"""select sum(base_net_amount)
from `tabPurchase Invoice Item` where project = %s and docstatus=1""",
self.name,
)
self.total_purchase_cost = total_purchase_cost and total_purchase_cost[0][0] or 0 self.total_purchase_cost = total_purchase_cost and total_purchase_cost[0][0] or 0
def update_sales_amount(self): def update_sales_amount(self):
@ -695,3 +690,29 @@ def get_holiday_list(company=None):
def get_users_email(doc): def get_users_email(doc):
return [d.email for d in doc.users if frappe.db.get_value("User", d.user, "enabled")] return [d.email for d in doc.users if frappe.db.get_value("User", d.user, "enabled")]
def calculate_total_purchase_cost(project: str | None = None):
if project:
pitem = qb.DocType("Purchase Invoice Item")
frappe.qb.DocType("Purchase Invoice Item")
total_purchase_cost = (
qb.from_(pitem)
.select(Sum(pitem.base_net_amount))
.where((pitem.project == project) & (pitem.docstatus == 1))
.run(as_list=True)
)
return total_purchase_cost
return None
@frappe.whitelist()
def recalculate_project_total_purchase_cost(project: str | None = None):
if project:
total_purchase_cost = calculate_total_purchase_cost(project)
frappe.db.set_value(
"Project",
project,
"total_purchase_cost",
(total_purchase_cost and total_purchase_cost[0][0] or 0),
)

View File

@ -1,6 +1,6 @@
frappe.provide('erpnext.accounts'); frappe.provide('erpnext.accounts');
erpnext.accounts.unreconcile_payments = { erpnext.accounts.unreconcile_payment = {
add_unreconcile_btn(frm) { add_unreconcile_btn(frm) {
if (frm.doc.docstatus == 1) { if (frm.doc.docstatus == 1) {
if(((frm.doc.doctype == "Journal Entry") && (frm.doc.voucher_type != "Journal Entry")) if(((frm.doc.doctype == "Journal Entry") && (frm.doc.voucher_type != "Journal Entry"))
@ -10,7 +10,7 @@ erpnext.accounts.unreconcile_payments = {
} }
frappe.call({ frappe.call({
"method": "erpnext.accounts.doctype.unreconcile_payments.unreconcile_payments.doc_has_references", "method": "erpnext.accounts.doctype.unreconcile_payment.unreconcile_payment.doc_has_references",
"args": { "args": {
"doctype": frm.doc.doctype, "doctype": frm.doc.doctype,
"docname": frm.doc.name "docname": frm.doc.name
@ -18,7 +18,7 @@ erpnext.accounts.unreconcile_payments = {
callback: function(r) { callback: function(r) {
if (r.message) { if (r.message) {
frm.add_custom_button(__("UnReconcile"), function() { frm.add_custom_button(__("UnReconcile"), function() {
erpnext.accounts.unreconcile_payments.build_unreconcile_dialog(frm); erpnext.accounts.unreconcile_payment.build_unreconcile_dialog(frm);
}, __('Actions')); }, __('Actions'));
} }
} }
@ -74,7 +74,7 @@ erpnext.accounts.unreconcile_payments = {
// get linked payments // get linked payments
frappe.call({ frappe.call({
"method": "erpnext.accounts.doctype.unreconcile_payments.unreconcile_payments.get_linked_payments_for_doc", "method": "erpnext.accounts.doctype.unreconcile_payment.unreconcile_payment.get_linked_payments_for_doc",
"args": { "args": {
"company": frm.doc.company, "company": frm.doc.company,
"doctype": frm.doc.doctype, "doctype": frm.doc.doctype,
@ -96,8 +96,8 @@ erpnext.accounts.unreconcile_payments = {
let selected_allocations = values.allocations.filter(x=>x.__checked); let selected_allocations = values.allocations.filter(x=>x.__checked);
if (selected_allocations.length > 0) { if (selected_allocations.length > 0) {
let selection_map = erpnext.accounts.unreconcile_payments.build_selection_map(frm, selected_allocations); let selection_map = erpnext.accounts.unreconcile_payment.build_selection_map(frm, selected_allocations);
erpnext.accounts.unreconcile_payments.create_unreconcile_docs(selection_map); erpnext.accounts.unreconcile_payment.create_unreconcile_docs(selection_map);
d.hide(); d.hide();
} else { } else {
@ -115,7 +115,7 @@ erpnext.accounts.unreconcile_payments = {
create_unreconcile_docs(selection_map) { create_unreconcile_docs(selection_map) {
frappe.call({ frappe.call({
"method": "erpnext.accounts.doctype.unreconcile_payments.unreconcile_payments.create_unreconcile_doc_for_selection", "method": "erpnext.accounts.doctype.unreconcile_payment.unreconcile_payment.create_unreconcile_doc_for_selection",
"args": { "args": {
"selections": selection_map "selections": selection_map
}, },

View File

@ -663,7 +663,7 @@ def make_contact(args, is_primary_contact=1):
"company_name": args.get(party_name_key), "company_name": args.get(party_name_key),
} }
) )
contact = frappe.get_doc(values) contact = frappe.get_doc(values)
if args.get("email_id"): if args.get("email_id"):

View File

@ -1,315 +1,119 @@
{ {
"allow_copy": 0, "actions": [],
"allow_guest_to_view": 0, "allow_import": 1,
"allow_import": 1, "creation": "2013-06-20 11:53:21",
"allow_rename": 0, "description": "Aggregate group of **Items** into another **Item**. This is useful if you are bundling a certain **Items** into a package and you maintain stock of the packed **Items** and not the aggregate **Item**. \n\nThe package **Item** will have \"Is Stock Item\" as \"No\" and \"Is Sales Item\" as \"Yes\".\n\nFor Example: If you are selling Laptops and Backpacks separately and have a special price if the customer buys both, then the Laptop + Backpack will be a new Product Bundle Item.\n\nNote: BOM = Bill of Materials",
"beta": 0, "doctype": "DocType",
"creation": "2013-06-20 11:53:21", "engine": "InnoDB",
"custom": 0, "field_order": [
"description": "Aggregate group of **Items** into another **Item**. This is useful if you are bundling a certain **Items** into a package and you maintain stock of the packed **Items** and not the aggregate **Item**. \n\nThe package **Item** will have \"Is Stock Item\" as \"No\" and \"Is Sales Item\" as \"Yes\".\n\nFor Example: If you are selling Laptops and Backpacks separately and have a special price if the customer buys both, then the Laptop + Backpack will be a new Product Bundle Item.\n\nNote: BOM = Bill of Materials", "basic_section",
"docstatus": 0, "new_item_code",
"doctype": "DocType", "description",
"document_type": "", "column_break_eonk",
"editable_grid": 0, "disabled",
"item_section",
"items",
"section_break_4",
"about"
],
"fields": [ "fields": [
{ {
"allow_bulk_edit": 0, "fieldname": "basic_section",
"allow_on_submit": 0, "fieldtype": "Section Break"
"bold": 0, },
"collapsible": 0,
"columns": 0,
"fieldname": "basic_section",
"fieldtype": "Section Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_bulk_edit": 0, "fieldname": "new_item_code",
"allow_on_submit": 0, "fieldtype": "Link",
"bold": 0, "in_global_search": 1,
"collapsible": 0, "in_list_view": 1,
"columns": 0, "label": "Parent Item",
"description": "", "no_copy": 1,
"fieldname": "new_item_code", "oldfieldname": "new_item_code",
"fieldtype": "Link", "oldfieldtype": "Data",
"hidden": 0, "options": "Item",
"ignore_user_permissions": 0, "reqd": 1
"ignore_xss_filter": 0, },
"in_filter": 0,
"in_global_search": 1,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Parent Item",
"length": 0,
"no_copy": 1,
"oldfieldname": "new_item_code",
"oldfieldtype": "Data",
"options": "Item",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_bulk_edit": 0, "fieldname": "description",
"allow_on_submit": 0, "fieldtype": "Data",
"bold": 0, "in_list_view": 1,
"collapsible": 0, "label": "Description"
"columns": 0, },
"fieldname": "description",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Description",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_bulk_edit": 0, "description": "List items that form the package.",
"allow_on_submit": 0, "fieldname": "item_section",
"bold": 0, "fieldtype": "Section Break",
"collapsible": 0, "label": "Items"
"columns": 0, },
"description": "List items that form the package.",
"fieldname": "item_section",
"fieldtype": "Section Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Items",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_bulk_edit": 0, "fieldname": "items",
"allow_on_submit": 0, "fieldtype": "Table",
"bold": 0, "label": "Items",
"collapsible": 0, "oldfieldname": "sales_bom_items",
"columns": 0, "oldfieldtype": "Table",
"fieldname": "items", "options": "Product Bundle Item",
"fieldtype": "Table", "reqd": 1
"hidden": 0, },
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Items",
"length": 0,
"no_copy": 0,
"oldfieldname": "sales_bom_items",
"oldfieldtype": "Table",
"options": "Product Bundle Item",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_bulk_edit": 0, "fieldname": "section_break_4",
"allow_on_submit": 0, "fieldtype": "Section Break"
"bold": 0, },
"collapsible": 0,
"columns": 0,
"fieldname": "section_break_4",
"fieldtype": "Section Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_bulk_edit": 0, "fieldname": "about",
"allow_on_submit": 0, "fieldtype": "HTML",
"bold": 0, "options": "<h3>About Product Bundle</h3>\n\n<p>Aggregate group of <b>Items</b> into another <b>Item</b>. This is useful if you are bundling a certain <b>Items</b> into a package and you maintain stock of the packed <b>Items</b> and not the aggregate <b>Item</b>.</p>\n<p>The package <b>Item</b> will have <code>Is Stock Item</code> as <b>No</b> and <code>Is Sales Item</code> as <b>Yes</b>.</p>\n<h4>Example:</h4>\n<p>If you are selling Laptops and Backpacks separately and have a special price if the customer buys both, then the Laptop + Backpack will be a new Product Bundle Item.</p>"
"collapsible": 0, },
"columns": 0, {
"fieldname": "about", "default": "0",
"fieldtype": "HTML", "fieldname": "disabled",
"hidden": 0, "fieldtype": "Check",
"ignore_user_permissions": 0, "label": "Disabled"
"ignore_xss_filter": 0, },
"in_filter": 0, {
"in_global_search": 0, "fieldname": "column_break_eonk",
"in_list_view": 0, "fieldtype": "Column Break"
"in_standard_filter": 0,
"label": "",
"length": 0,
"no_copy": 0,
"options": "<h3>About Product Bundle</h3>\n\n<p>Aggregate group of <b>Items</b> into another <b>Item</b>. This is useful if you are bundling a certain <b>Items</b> into a package and you maintain stock of the packed <b>Items</b> and not the aggregate <b>Item</b>.</p>\n<p>The package <b>Item</b> will have <code>Is Stock Item</code> as <b>No</b> and <code>Is Sales Item</code> as <b>Yes</b>.</p>\n<h4>Example:</h4>\n<p>If you are selling Laptops and Backpacks separately and have a special price if the customer buys both, then the Laptop + Backpack will be a new Product Bundle Item.</p>",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
} }
], ],
"has_web_view": 0, "icon": "fa fa-sitemap",
"hide_heading": 0, "idx": 1,
"hide_toolbar": 0, "links": [],
"icon": "fa fa-sitemap", "modified": "2023-11-22 15:20:46.805114",
"idx": 1, "modified_by": "Administrator",
"image_view": 0, "module": "Selling",
"in_create": 0, "name": "Product Bundle",
"is_submittable": 0, "owner": "Administrator",
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2020-09-18 17:26:09.703215",
"modified_by": "Administrator",
"module": "Selling",
"name": "Product Bundle",
"owner": "Administrator",
"permissions": [ "permissions": [
{ {
"amend": 0, "create": 1,
"apply_user_permissions": 0, "delete": 1,
"cancel": 0, "email": 1,
"create": 1, "print": 1,
"delete": 1, "read": 1,
"email": 1, "report": 1,
"export": 0, "role": "Stock Manager",
"if_owner": 0, "share": 1,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "Stock Manager",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"write": 1 "write": 1
}, },
{ {
"amend": 0, "email": 1,
"apply_user_permissions": 0, "print": 1,
"cancel": 0, "read": 1,
"create": 0, "report": 1,
"delete": 0, "role": "Stock User"
"email": 1, },
"export": 0,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "Stock User",
"set_user_permissions": 0,
"share": 0,
"submit": 0,
"write": 0
},
{ {
"amend": 0, "create": 1,
"apply_user_permissions": 0, "delete": 1,
"cancel": 0, "email": 1,
"create": 1, "print": 1,
"delete": 1, "read": 1,
"email": 1, "report": 1,
"export": 0, "role": "Sales User",
"if_owner": 0, "share": 1,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"write": 1 "write": 1
} }
], ],
"quick_entry": 0, "sort_field": "modified",
"read_only": 0, "sort_order": "ASC",
"read_only_onload": 0, "states": []
"show_name_in_global_search": 0,
"sort_order": "ASC",
"track_changes": 0,
"track_seen": 0
} }

View File

@ -59,10 +59,12 @@ class ProductBundle(Document):
"""Validates, main Item is not a stock item""" """Validates, main Item is not a stock item"""
if frappe.db.get_value("Item", self.new_item_code, "is_stock_item"): if frappe.db.get_value("Item", self.new_item_code, "is_stock_item"):
frappe.throw(_("Parent Item {0} must not be a Stock Item").format(self.new_item_code)) frappe.throw(_("Parent Item {0} must not be a Stock Item").format(self.new_item_code))
if frappe.db.get_value("Item", self.new_item_code, "is_fixed_asset"):
frappe.throw(_("Parent Item {0} must not be a Fixed Asset").format(self.new_item_code))
def validate_child_items(self): def validate_child_items(self):
for item in self.items: for item in self.items:
if frappe.db.exists("Product Bundle", item.item_code): if frappe.db.exists("Product Bundle", {"name": item.item_code, "disabled": 0}):
frappe.throw( frappe.throw(
_( _(
"Row #{0}: Child Item should not be a Product Bundle. Please remove Item {1} and Save" "Row #{0}: Child Item should not be a Product Bundle. Please remove Item {1} and Save"
@ -73,12 +75,20 @@ class ProductBundle(Document):
@frappe.whitelist() @frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs @frappe.validate_and_sanitize_search_inputs
def get_new_item_code(doctype, txt, searchfield, start, page_len, filters): def get_new_item_code(doctype, txt, searchfield, start, page_len, filters):
from erpnext.controllers.queries import get_match_cond product_bundles = frappe.db.get_list("Product Bundle", {"disabled": 0}, pluck="name")
return frappe.db.sql( item = frappe.qb.DocType("Item")
"""select name, item_name, description from tabItem query = (
where is_stock_item=0 and name not in (select name from `tabProduct Bundle`) frappe.qb.from_(item)
and %s like %s %s limit %s offset %s""" .select(item.item_code, item.item_name)
% (searchfield, "%s", get_match_cond(doctype), "%s", "%s"), .where(
("%%%s%%" % txt, page_len, start), (item.is_stock_item == 0) & (item.is_fixed_asset == 0) & (item[searchfield].like(f"%{txt}%"))
)
.limit(page_len)
.offset(start)
) )
if product_bundles:
query = query.where(item.name.notin(product_bundles))
return query.run()

View File

@ -214,13 +214,12 @@ frappe.ui.form.on("Sales Order", {
label: __("Items to Reserve"), label: __("Items to Reserve"),
allow_bulk_edit: false, allow_bulk_edit: false,
cannot_add_rows: true, cannot_add_rows: true,
cannot_delete_rows: true,
data: [], data: [],
fields: [ fields: [
{ {
fieldname: "name", fieldname: "sales_order_item",
fieldtype: "Data", fieldtype: "Data",
label: __("Name"), label: __("Sales Order Item"),
reqd: 1, reqd: 1,
read_only: 1, read_only: 1,
}, },
@ -260,7 +259,7 @@ frappe.ui.form.on("Sales Order", {
], ],
primary_action_label: __("Reserve Stock"), primary_action_label: __("Reserve Stock"),
primary_action: () => { primary_action: () => {
var data = {items: dialog.fields_dict.items.grid.get_selected_children()}; var data = {items: dialog.fields_dict.items.grid.data};
if (data.items && data.items.length > 0) { if (data.items && data.items.length > 0) {
frappe.call({ frappe.call({
@ -278,9 +277,6 @@ frappe.ui.form.on("Sales Order", {
} }
}); });
} }
else {
frappe.msgprint(__("Please select items to reserve."));
}
dialog.hide(); dialog.hide();
}, },
@ -292,7 +288,7 @@ frappe.ui.form.on("Sales Order", {
if (unreserved_qty > 0) { if (unreserved_qty > 0) {
dialog.fields_dict.items.df.data.push({ dialog.fields_dict.items.df.data.push({
'name': item.name, 'sales_order_item': item.name,
'item_code': item.item_code, 'item_code': item.item_code,
'warehouse': item.warehouse, 'warehouse': item.warehouse,
'qty_to_reserve': (unreserved_qty / flt(item.conversion_factor)) 'qty_to_reserve': (unreserved_qty / flt(item.conversion_factor))
@ -308,7 +304,7 @@ frappe.ui.form.on("Sales Order", {
cancel_stock_reservation_entries(frm) { cancel_stock_reservation_entries(frm) {
const dialog = new frappe.ui.Dialog({ const dialog = new frappe.ui.Dialog({
title: __("Stock Unreservation"), title: __("Stock Unreservation"),
size: "large", size: "extra-large",
fields: [ fields: [
{ {
fieldname: "sr_entries", fieldname: "sr_entries",
@ -316,14 +312,13 @@ frappe.ui.form.on("Sales Order", {
label: __("Reserved Stock"), label: __("Reserved Stock"),
allow_bulk_edit: false, allow_bulk_edit: false,
cannot_add_rows: true, cannot_add_rows: true,
cannot_delete_rows: true,
in_place_edit: true, in_place_edit: true,
data: [], data: [],
fields: [ fields: [
{ {
fieldname: "name", fieldname: "sre",
fieldtype: "Link", fieldtype: "Link",
label: __("SRE"), label: __("Stock Reservation Entry"),
options: "Stock Reservation Entry", options: "Stock Reservation Entry",
reqd: 1, reqd: 1,
read_only: 1, read_only: 1,
@ -360,14 +355,14 @@ frappe.ui.form.on("Sales Order", {
], ],
primary_action_label: __("Unreserve Stock"), primary_action_label: __("Unreserve Stock"),
primary_action: () => { primary_action: () => {
var data = {sr_entries: dialog.fields_dict.sr_entries.grid.get_selected_children()}; var data = {sr_entries: dialog.fields_dict.sr_entries.grid.data};
if (data.sr_entries && data.sr_entries.length > 0) { if (data.sr_entries && data.sr_entries.length > 0) {
frappe.call({ frappe.call({
doc: frm.doc, doc: frm.doc,
method: "cancel_stock_reservation_entries", method: "cancel_stock_reservation_entries",
args: { args: {
sre_list: data.sr_entries, sre_list: data.sr_entries.map(item => item.sre),
}, },
freeze: true, freeze: true,
freeze_message: __('Unreserving Stock...'), freeze_message: __('Unreserving Stock...'),
@ -377,9 +372,6 @@ frappe.ui.form.on("Sales Order", {
} }
}); });
} }
else {
frappe.msgprint(__("Please select items to unreserve."));
}
dialog.hide(); dialog.hide();
}, },
@ -396,7 +388,7 @@ frappe.ui.form.on("Sales Order", {
r.message.forEach(sre => { r.message.forEach(sre => {
if (flt(sre.reserved_qty) > flt(sre.delivered_qty)) { if (flt(sre.reserved_qty) > flt(sre.delivered_qty)) {
dialog.fields_dict.sr_entries.df.data.push({ dialog.fields_dict.sr_entries.df.data.push({
'name': sre.name, 'sre': sre.name,
'item_code': sre.item_code, 'item_code': sre.item_code,
'warehouse': sre.warehouse, 'warehouse': sre.warehouse,
'qty': (flt(sre.reserved_qty) - flt(sre.delivered_qty)) 'qty': (flt(sre.reserved_qty) - flt(sre.delivered_qty))

View File

@ -688,7 +688,9 @@ def make_material_request(source_name, target_doc=None):
"Sales Order Item": { "Sales Order Item": {
"doctype": "Material Request Item", "doctype": "Material Request Item",
"field_map": {"name": "sales_order_item", "parent": "sales_order"}, "field_map": {"name": "sales_order_item", "parent": "sales_order"},
"condition": lambda item: not frappe.db.exists("Product Bundle", item.item_code) "condition": lambda item: not frappe.db.exists(
"Product Bundle", {"name": item.item_code, "disabled": 0}
)
and get_remaining_qty(item) > 0, and get_remaining_qty(item) > 0,
"postprocess": update_item, "postprocess": update_item,
}, },
@ -1309,7 +1311,7 @@ def set_delivery_date(items, sales_order):
def is_product_bundle(item_code): def is_product_bundle(item_code):
return frappe.db.exists("Product Bundle", item_code) return frappe.db.exists("Product Bundle", {"name": item_code, "disabled": 0})
@frappe.whitelist() @frappe.whitelist()
@ -1521,7 +1523,7 @@ def get_work_order_items(sales_order, for_raw_material_request=0):
product_bundle_parents = [ product_bundle_parents = [
pb.new_item_code pb.new_item_code
for pb in frappe.get_all( for pb in frappe.get_all(
"Product Bundle", {"new_item_code": ["in", item_codes]}, ["new_item_code"] "Product Bundle", {"new_item_code": ["in", item_codes], "disabled": 0}, ["new_item_code"]
) )
] ]

View File

@ -51,6 +51,35 @@ class TestSalesOrder(FrappeTestCase):
def tearDown(self): def tearDown(self):
frappe.set_user("Administrator") frappe.set_user("Administrator")
def test_sales_order_with_negative_rate(self):
"""
Test if negative rate is allowed in Sales Order via doc submission and update items
"""
so = make_sales_order(qty=1, rate=100, do_not_save=True)
so.append("items", {"item_code": "_Test Item", "qty": 1, "rate": -10})
so.save()
so.submit()
first_item = so.get("items")[0]
second_item = so.get("items")[1]
trans_item = json.dumps(
[
{
"item_code": first_item.item_code,
"rate": first_item.rate,
"qty": first_item.qty,
"docname": first_item.name,
},
{
"item_code": second_item.item_code,
"rate": -20,
"qty": second_item.qty,
"docname": second_item.name,
},
]
)
update_child_qty_rate("Sales Order", trans_item, so.name)
def test_make_material_request(self): def test_make_material_request(self):
so = make_sales_order(do_not_submit=True) so = make_sales_order(do_not_submit=True)

View File

@ -200,6 +200,7 @@
"fieldtype": "Float", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Quantity", "label": "Quantity",
"non_negative": 1,
"oldfieldname": "qty", "oldfieldname": "qty",
"oldfieldtype": "Currency", "oldfieldtype": "Currency",
"print_width": "100px", "print_width": "100px",
@ -895,7 +896,7 @@
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-11-14 18:37:12.787893", "modified": "2023-11-24 13:24:55.756320",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Sales Order Item", "name": "Sales Order Item",

View File

@ -0,0 +1,40 @@
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.query_reports["Lost Quotations"] = {
filters: [
{
fieldname: "company",
label: __("Company"),
fieldtype: "Link",
options: "Company",
default: frappe.defaults.get_user_default("Company"),
},
{
label: "Timespan",
fieldtype: "Select",
fieldname: "timespan",
options: [
"Last Week",
"Last Month",
"Last Quarter",
"Last 6 months",
"Last Year",
"This Week",
"This Month",
"This Quarter",
"This Year",
],
default: "This Year",
reqd: 1,
},
{
fieldname: "group_by",
label: __("Group By"),
fieldtype: "Select",
options: ["Lost Reason", "Competitor"],
default: "Lost Reason",
reqd: 1,
},
],
};

View File

@ -0,0 +1,30 @@
{
"add_total_row": 0,
"columns": [],
"creation": "2023-11-23 18:00:19.141922",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"letter_head": null,
"letterhead": null,
"modified": "2023-11-23 19:27:28.854108",
"modified_by": "Administrator",
"module": "Selling",
"name": "Lost Quotations",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Quotation",
"report_name": "Lost Quotations",
"report_type": "Script Report",
"roles": [
{
"role": "Sales User"
},
{
"role": "Sales Manager"
}
]
}

View File

@ -0,0 +1,98 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from typing import Literal
import frappe
from frappe import _
from frappe.model.docstatus import DocStatus
from frappe.query_builder.functions import Coalesce, Count, Round, Sum
from frappe.utils.data import get_timespan_date_range
def execute(filters=None):
columns = get_columns(filters.get("group_by"))
from_date, to_date = get_timespan_date_range(filters.get("timespan").lower())
data = get_data(filters.get("company"), from_date, to_date, filters.get("group_by"))
return columns, data
def get_columns(group_by: Literal["Lost Reason", "Competitor"]):
return [
{
"fieldname": "lost_reason" if group_by == "Lost Reason" else "competitor",
"label": _("Lost Reason") if group_by == "Lost Reason" else _("Competitor"),
"fieldtype": "Link",
"options": "Quotation Lost Reason" if group_by == "Lost Reason" else "Competitor",
"width": 200,
},
{
"filedname": "lost_quotations",
"label": _("Lost Quotations"),
"fieldtype": "Int",
"width": 150,
},
{
"filedname": "lost_quotations_pct",
"label": _("Lost Quotations %"),
"fieldtype": "Percent",
"width": 200,
},
{
"fieldname": "lost_value",
"label": _("Lost Value"),
"fieldtype": "Currency",
"width": 150,
},
{
"filedname": "lost_value_pct",
"label": _("Lost Value %"),
"fieldtype": "Percent",
"width": 200,
},
]
def get_data(
company: str, from_date: str, to_date: str, group_by: Literal["Lost Reason", "Competitor"]
):
"""Return quotation value grouped by lost reason or competitor"""
if group_by == "Lost Reason":
fieldname = "lost_reason"
dimension = frappe.qb.DocType("Quotation Lost Reason Detail")
elif group_by == "Competitor":
fieldname = "competitor"
dimension = frappe.qb.DocType("Competitor Detail")
else:
frappe.throw(_("Invalid Group By"))
q = frappe.qb.DocType("Quotation")
lost_quotation_condition = (
(q.status == "Lost")
& (q.docstatus == DocStatus.submitted())
& (q.transaction_date >= from_date)
& (q.transaction_date <= to_date)
& (q.company == company)
)
from_lost_quotations = frappe.qb.from_(q).where(lost_quotation_condition)
total_quotations = from_lost_quotations.select(Count(q.name))
total_value = from_lost_quotations.select(Sum(q.base_net_total))
query = (
frappe.qb.from_(q)
.select(
Coalesce(dimension[fieldname], _("Not Specified")),
Count(q.name).distinct(),
Round((Count(q.name).distinct() / total_quotations * 100), 2),
Sum(q.base_net_total),
Round((Sum(q.base_net_total) / total_value * 100), 2),
)
.left_join(dimension)
.on(dimension.parent == q.name)
.where(lost_quotation_condition)
.groupby(dimension[fieldname])
)
return query.run()

View File

@ -382,9 +382,10 @@ class EmailDigest(Document):
"""Get income to date""" """Get income to date"""
balance = 0.0 balance = 0.0
count = 0 count = 0
fy_start_date = get_fiscal_year(self.future_to_date)[1]
for account in self.get_root_type_accounts(root_type): for account in self.get_root_type_accounts(root_type):
balance += get_balance_on(account, date=self.future_to_date) balance += get_balance_on(account, date=self.future_to_date, start_date=fy_start_date)
count += get_count_on(account, fieldname, date=self.future_to_date) count += get_count_on(account, fieldname, date=self.future_to_date)
if fieldname == "income": if fieldname == "income":

View File

@ -1,83 +1,58 @@
{ {
"allow_copy": 0, "actions": [],
"allow_import": 1, "allow_import": 1,
"allow_rename": 0, "autoname": "field:order_lost_reason",
"autoname": "field:order_lost_reason", "creation": "2013-01-10 16:34:24",
"beta": 0, "doctype": "DocType",
"creation": "2013-01-10 16:34:24", "document_type": "Setup",
"custom": 0, "engine": "InnoDB",
"docstatus": 0, "field_order": [
"doctype": "DocType", "order_lost_reason"
"document_type": "Setup", ],
"editable_grid": 0,
"fields": [ "fields": [
{ {
"allow_on_submit": 0, "fieldname": "order_lost_reason",
"bold": 0, "fieldtype": "Data",
"collapsible": 0, "in_list_view": 1,
"fieldname": "order_lost_reason", "label": "Quotation Lost Reason",
"fieldtype": "Data", "oldfieldname": "order_lost_reason",
"hidden": 0, "oldfieldtype": "Data",
"ignore_user_permissions": 0, "reqd": 1,
"ignore_xss_filter": 0, "unique": 1
"in_filter": 0,
"in_list_view": 1,
"label": "Quotation Lost Reason",
"length": 0,
"no_copy": 0,
"oldfieldname": "order_lost_reason",
"oldfieldtype": "Data",
"permlevel": 0,
"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
} }
], ],
"hide_heading": 0, "icon": "fa fa-flag",
"hide_toolbar": 0, "idx": 1,
"icon": "fa fa-flag", "links": [
"idx": 1, {
"image_view": 0, "is_child_table": 1,
"in_create": 0, "link_doctype": "Quotation Lost Reason Detail",
"link_fieldname": "lost_reason",
"is_submittable": 0, "parent_doctype": "Quotation",
"issingle": 0, "table_fieldname": "lost_reasons"
"istable": 0, }
"max_attachments": 0, ],
"modified": "2016-07-25 05:24:25.533953", "modified": "2023-11-23 19:31:02.743353",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Setup", "module": "Setup",
"name": "Quotation Lost Reason", "name": "Quotation Lost Reason",
"owner": "Administrator", "naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [ "permissions": [
{ {
"amend": 0, "create": 1,
"apply_user_permissions": 0, "delete": 1,
"cancel": 0, "email": 1,
"create": 1, "print": 1,
"delete": 1, "read": 1,
"email": 1, "report": 1,
"export": 0, "role": "Sales Master Manager",
"if_owner": 0, "share": 1,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales Master Manager",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"write": 1 "write": 1
} }
], ],
"quick_entry": 1, "quick_entry": 1,
"read_only": 0, "sort_field": "modified",
"read_only_onload": 0, "sort_order": "DESC",
"track_seen": 0 "states": []
} }

View File

@ -615,7 +615,7 @@ class DeliveryNote(SellingController):
items_list = [item.item_code for item in self.items] items_list = [item.item_code for item in self.items]
return frappe.db.get_all( return frappe.db.get_all(
"Product Bundle", "Product Bundle",
filters={"new_item_code": ["in", items_list]}, filters={"new_item_code": ["in", items_list], "disabled": 0},
pluck="name", pluck="name",
) )
@ -938,7 +938,7 @@ def make_packing_slip(source_name, target_doc=None):
}, },
"postprocess": update_item, "postprocess": update_item,
"condition": lambda item: ( "condition": lambda item: (
not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code}) not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code, "disabled": 0})
and flt(item.packed_qty) < flt(item.qty) and flt(item.packed_qty) < flt(item.qty)
), ),
}, },

View File

@ -1247,6 +1247,25 @@ class TestDeliveryNote(FrappeTestCase):
dn.reload() dn.reload()
self.assertFalse(dn.items[0].target_warehouse) self.assertFalse(dn.items[0].target_warehouse)
def test_serial_no_status(self):
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
item = make_item(
"Test Serial Item For Status",
{"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "TESTSERIAL.#####"},
)
item_code = item.name
pi = make_purchase_receipt(qty=1, item_code=item.name)
pi.reload()
serial_no = get_serial_nos_from_bundle(pi.items[0].serial_and_batch_bundle)
self.assertEqual(frappe.db.get_value("Serial No", serial_no, "status"), "Active")
dn = create_delivery_note(qty=1, item_code=item_code, serial_no=serial_no)
dn.reload()
self.assertEqual(frappe.db.get_value("Serial No", serial_no, "status"), "Delivered")
def create_delivery_note(**args): def create_delivery_note(**args):
dn = frappe.new_doc("Delivery Note") dn = frappe.new_doc("Delivery Note")

View File

@ -512,8 +512,12 @@ class Item(Document):
def validate_duplicate_product_bundles_before_merge(self, old_name, new_name): def validate_duplicate_product_bundles_before_merge(self, old_name, new_name):
"Block merge if both old and new items have product bundles." "Block merge if both old and new items have product bundles."
old_bundle = frappe.get_value("Product Bundle", filters={"new_item_code": old_name}) old_bundle = frappe.get_value(
new_bundle = frappe.get_value("Product Bundle", filters={"new_item_code": new_name}) "Product Bundle", filters={"new_item_code": old_name, "disabled": 0}
)
new_bundle = frappe.get_value(
"Product Bundle", filters={"new_item_code": new_name, "disabled": 0}
)
if old_bundle and new_bundle: if old_bundle and new_bundle:
bundle_link = get_link_to_form("Product Bundle", old_bundle) bundle_link = get_link_to_form("Product Bundle", old_bundle)

View File

@ -55,7 +55,7 @@ def make_packing_list(doc):
def is_product_bundle(item_code: str) -> bool: def is_product_bundle(item_code: str) -> bool:
return bool(frappe.db.exists("Product Bundle", {"new_item_code": item_code})) return bool(frappe.db.exists("Product Bundle", {"new_item_code": item_code, "disabled": 0}))
def get_indexed_packed_items_table(doc): def get_indexed_packed_items_table(doc):
@ -111,7 +111,7 @@ def get_product_bundle_items(item_code):
product_bundle_item.uom, product_bundle_item.uom,
product_bundle_item.description, product_bundle_item.description,
) )
.where(product_bundle.new_item_code == item_code) .where((product_bundle.new_item_code == item_code) & (product_bundle.disabled == 0))
.orderby(product_bundle_item.idx) .orderby(product_bundle_item.idx)
) )
return query.run(as_dict=True) return query.run(as_dict=True)

View File

@ -233,7 +233,7 @@ class PickList(Document):
for location in self.locations: for location in self.locations:
if location.warehouse and location.sales_order and location.sales_order_item: if location.warehouse and location.sales_order and location.sales_order_item:
item_details = { item_details = {
"name": location.sales_order_item, "sales_order_item": location.sales_order_item,
"item_code": location.item_code, "item_code": location.item_code,
"warehouse": location.warehouse, "warehouse": location.warehouse,
"qty_to_reserve": (flt(location.picked_qty) - flt(location.stock_reserved_qty)), "qty_to_reserve": (flt(location.picked_qty) - flt(location.stock_reserved_qty)),
@ -368,7 +368,9 @@ class PickList(Document):
frappe.throw("Row #{0}: Item Code is Mandatory".format(item.idx)) frappe.throw("Row #{0}: Item Code is Mandatory".format(item.idx))
if not cint( if not cint(
frappe.get_cached_value("Item", item.item_code, "is_stock_item") frappe.get_cached_value("Item", item.item_code, "is_stock_item")
) and not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code}): ) and not frappe.db.exists(
"Product Bundle", {"new_item_code": item.item_code, "disabled": 0}
):
continue continue
item_code = item.item_code item_code = item.item_code
reference = item.sales_order_item or item.material_request_item reference = item.sales_order_item or item.material_request_item
@ -507,7 +509,9 @@ class PickList(Document):
# bundle_item_code: Dict[component, qty] # bundle_item_code: Dict[component, qty]
product_bundle_qty_map = {} product_bundle_qty_map = {}
for bundle_item_code in bundles: for bundle_item_code in bundles:
bundle = frappe.get_last_doc("Product Bundle", {"new_item_code": bundle_item_code}) bundle = frappe.get_last_doc(
"Product Bundle", {"new_item_code": bundle_item_code, "disabled": 0}
)
product_bundle_qty_map[bundle_item_code] = {item.item_code: item.qty for item in bundle.items} product_bundle_qty_map[bundle_item_code] = {item.item_code: item.qty for item in bundle.items}
return product_bundle_qty_map return product_bundle_qty_map

View File

@ -781,7 +781,7 @@ class PurchaseReceipt(BuyingController):
for item in self.items: for item in self.items:
if item.sales_order and item.sales_order_item: if item.sales_order and item.sales_order_item:
item_details = { item_details = {
"name": item.sales_order_item, "sales_order_item": item.sales_order_item,
"item_code": item.item_code, "item_code": item.item_code,
"warehouse": item.warehouse, "warehouse": item.warehouse,
"qty_to_reserve": item.stock_qty, "qty_to_reserve": item.stock_qty,

View File

@ -18,3 +18,22 @@ cur_frm.cscript.onload = function() {
frappe.ui.form.on("Serial No", "refresh", function(frm) { frappe.ui.form.on("Serial No", "refresh", function(frm) {
frm.toggle_enable("item_code", frm.doc.__islocal); frm.toggle_enable("item_code", frm.doc.__islocal);
}); });
frappe.ui.form.on("Serial No", {
refresh(frm) {
frm.trigger("view_ledgers")
},
view_ledgers(frm) {
frm.add_custom_button(__("View Ledgers"), () => {
frappe.route_options = {
"item_code": frm.doc.item_code,
"serial_no": frm.doc.name,
"posting_date": frappe.datetime.now_date(),
"posting_time": frappe.datetime.now_time()
};
frappe.set_route("query-report", "Serial No Ledger");
}).addClass('btn-primary');
}
})

View File

@ -269,7 +269,7 @@
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Status", "label": "Status",
"options": "\nActive\nInactive\nExpired", "options": "\nActive\nInactive\nDelivered\nExpired",
"read_only": 1 "read_only": 1
}, },
{ {
@ -280,7 +280,7 @@
"icon": "fa fa-barcode", "icon": "fa fa-barcode",
"idx": 1, "idx": 1,
"links": [], "links": [],
"modified": "2023-04-16 15:58:46.139887", "modified": "2023-11-28 15:37:59.489945",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Serial No", "name": "Serial No",

View File

@ -869,7 +869,7 @@ def create_stock_reservation_entries_for_so_items(
items = [] items = []
if items_details: if items_details:
for item in items_details: for item in items_details:
so_item = frappe.get_doc("Sales Order Item", item.get("name")) so_item = frappe.get_doc("Sales Order Item", item.get("sales_order_item"))
so_item.warehouse = item.get("warehouse") so_item.warehouse = item.get("warehouse")
so_item.qty_to_reserve = ( so_item.qty_to_reserve = (
flt(item.get("qty_to_reserve")) flt(item.get("qty_to_reserve"))
@ -1053,12 +1053,14 @@ def cancel_stock_reservation_entries(
from_voucher_type: Literal["Pick List", "Purchase Receipt"] = None, from_voucher_type: Literal["Pick List", "Purchase Receipt"] = None,
from_voucher_no: str = None, from_voucher_no: str = None,
from_voucher_detail_no: str = None, from_voucher_detail_no: str = None,
sre_list: list[dict] = None, sre_list: list = None,
notify: bool = True, notify: bool = True,
) -> None: ) -> None:
"""Cancel Stock Reservation Entries.""" """Cancel Stock Reservation Entries."""
if not sre_list: if not sre_list:
sre_list = {}
if voucher_type and voucher_no: if voucher_type and voucher_no:
sre_list = get_stock_reservation_entries_for_voucher( sre_list = get_stock_reservation_entries_for_voucher(
voucher_type, voucher_no, voucher_detail_no, fields=["name"] voucher_type, voucher_no, voucher_detail_no, fields=["name"]
@ -1082,9 +1084,11 @@ def cancel_stock_reservation_entries(
sre_list = query.run(as_dict=True) sre_list = query.run(as_dict=True)
sre_list = [d.name for d in sre_list]
if sre_list: if sre_list:
for sre in sre_list: for sre in sre_list:
frappe.get_doc("Stock Reservation Entry", sre["name"]).cancel() frappe.get_doc("Stock Reservation Entry", sre).cancel()
if notify: if notify:
msg = _("Stock Reservation Entries Cancelled") msg = _("Stock Reservation Entries Cancelled")

View File

@ -149,7 +149,7 @@ def remove_standard_fields(details):
def set_valuation_rate(out, args): def set_valuation_rate(out, args):
if frappe.db.exists("Product Bundle", args.item_code, cache=True): if frappe.db.exists("Product Bundle", {"name": args.item_code, "disabled": 0}, cache=True):
valuation_rate = 0.0 valuation_rate = 0.0
bundled_items = frappe.get_doc("Product Bundle", args.item_code) bundled_items = frappe.get_doc("Product Bundle", args.item_code)

View File

@ -36,21 +36,27 @@ def get_columns(filters):
"fieldtype": "Link", "fieldtype": "Link",
"fieldname": "company", "fieldname": "company",
"options": "Company", "options": "Company",
"width": 150, "width": 120,
}, },
{ {
"label": _("Warehouse"), "label": _("Warehouse"),
"fieldtype": "Link", "fieldtype": "Link",
"fieldname": "warehouse", "fieldname": "warehouse",
"options": "Warehouse", "options": "Warehouse",
"width": 150, "width": 120,
},
{
"label": _("Status"),
"fieldtype": "Data",
"fieldname": "status",
"width": 120,
}, },
{ {
"label": _("Serial No"), "label": _("Serial No"),
"fieldtype": "Link", "fieldtype": "Link",
"fieldname": "serial_no", "fieldname": "serial_no",
"options": "Serial No", "options": "Serial No",
"width": 150, "width": 130,
}, },
{ {
"label": _("Valuation Rate"), "label": _("Valuation Rate"),
@ -58,6 +64,12 @@ def get_columns(filters):
"fieldname": "valuation_rate", "fieldname": "valuation_rate",
"width": 150, "width": 150,
}, },
{
"label": _("Qty"),
"fieldtype": "Float",
"fieldname": "qty",
"width": 150,
},
] ]
return columns return columns
@ -83,12 +95,16 @@ def get_data(filters):
"posting_time": row.posting_time, "posting_time": row.posting_time,
"voucher_type": row.voucher_type, "voucher_type": row.voucher_type,
"voucher_no": row.voucher_no, "voucher_no": row.voucher_no,
"status": "Active" if row.actual_qty > 0 else "Delivered",
"company": row.company, "company": row.company,
"warehouse": row.warehouse, "warehouse": row.warehouse,
"qty": 1 if row.actual_qty > 0 else -1,
} }
) )
serial_nos = bundle_wise_serial_nos.get(row.serial_and_batch_bundle, []) serial_nos = [{"serial_no": row.serial_no, "valuation_rate": row.valuation_rate}]
if row.serial_and_batch_bundle:
serial_nos = bundle_wise_serial_nos.get(row.serial_and_batch_bundle, [])
for index, bundle_data in enumerate(serial_nos): for index, bundle_data in enumerate(serial_nos):
if index == 0: if index == 0:

View File

@ -166,4 +166,4 @@ def create_reposting_entries(rows, company):
if entries: if entries:
entries = ", ".join(entries) entries = ", ".join(entries)
frappe.msgprint(_(f"Reposting entries created: {entries}")) frappe.msgprint(_("Reposting entries created: {0}").format(entries))

View File

@ -255,11 +255,15 @@ class SerialBatchBundle:
if not serial_nos: if not serial_nos:
return return
status = "Inactive"
if self.sle.actual_qty < 0:
status = "Delivered"
sn_table = frappe.qb.DocType("Serial No") sn_table = frappe.qb.DocType("Serial No")
( (
frappe.qb.update(sn_table) frappe.qb.update(sn_table)
.set(sn_table.warehouse, warehouse) .set(sn_table.warehouse, warehouse)
.set(sn_table.status, "Active" if warehouse else "Inactive") .set(sn_table.status, "Active" if warehouse else status)
.where(sn_table.name.isin(serial_nos)) .where(sn_table.name.isin(serial_nos))
).run() ).run()

View File

@ -98,6 +98,7 @@ class TransactionBase(StatusUpdater):
"Selling Settings", "None", ["maintain_same_rate_action", "role_to_override_stop_action"] "Selling Settings", "None", ["maintain_same_rate_action", "role_to_override_stop_action"]
) )
stop_actions = []
for ref_dt, ref_dn_field, ref_link_field in ref_details: for ref_dt, ref_dn_field, ref_link_field in ref_details:
reference_names = [d.get(ref_link_field) for d in self.get("items") if d.get(ref_link_field)] reference_names = [d.get(ref_link_field) for d in self.get("items") if d.get(ref_link_field)]
reference_details = self.get_reference_details(reference_names, ref_dt + " Item") reference_details = self.get_reference_details(reference_names, ref_dt + " Item")
@ -108,7 +109,7 @@ class TransactionBase(StatusUpdater):
if abs(flt(d.rate - ref_rate, d.precision("rate"))) >= 0.01: if abs(flt(d.rate - ref_rate, d.precision("rate"))) >= 0.01:
if action == "Stop": if action == "Stop":
if role_allowed_to_override not in frappe.get_roles(): if role_allowed_to_override not in frappe.get_roles():
frappe.throw( stop_actions.append(
_("Row #{0}: Rate must be same as {1}: {2} ({3} / {4})").format( _("Row #{0}: Rate must be same as {1}: {2} ({3} / {4})").format(
d.idx, ref_dt, d.get(ref_dn_field), d.rate, ref_rate d.idx, ref_dt, d.get(ref_dn_field), d.rate, ref_rate
) )
@ -121,6 +122,8 @@ class TransactionBase(StatusUpdater):
title=_("Warning"), title=_("Warning"),
indicator="orange", indicator="orange",
) )
if stop_actions:
frappe.throw(stop_actions, as_list=True)
def get_reference_details(self, reference_names, reference_doctype): def get_reference_details(self, reference_names, reference_doctype):
return frappe._dict( return frappe._dict(