mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-17 05:45:11 +00:00
Merge pull request #40147 from frappe/version-14-hotfix
chore: release v14
This commit is contained in:
2
.github/workflows/semantic-commits.yml
vendored
2
.github/workflows/semantic-commits.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 14
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
|
||||
- name: Check commit titles
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"is_paid",
|
||||
"is_return",
|
||||
"return_against",
|
||||
"update_billed_amount_in_purchase_order",
|
||||
"update_billed_amount_in_purchase_receipt",
|
||||
"apply_tds",
|
||||
"tax_withholding_category",
|
||||
"amended_from",
|
||||
@@ -410,6 +412,20 @@
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: doc.is_return",
|
||||
"fieldname": "update_billed_amount_in_purchase_order",
|
||||
"fieldtype": "Check",
|
||||
"label": "Update Billed Amount in Purchase Order"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"depends_on": "eval: doc.is_return",
|
||||
"fieldname": "update_billed_amount_in_purchase_receipt",
|
||||
"fieldtype": "Check",
|
||||
"label": "Update Billed Amount in Purchase Receipt"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_addresses",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -1594,7 +1610,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-11-03 15:47:30.319200",
|
||||
"modified": "2024-02-25 11:20:28.366808",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -514,6 +514,11 @@ class PurchaseInvoice(BuyingController):
|
||||
super(PurchaseInvoice, self).on_submit()
|
||||
|
||||
self.check_prev_docstatus()
|
||||
|
||||
if self.is_return and not self.update_billed_amount_in_purchase_order:
|
||||
# NOTE status updating bypassed for is_return
|
||||
self.status_updater = []
|
||||
|
||||
self.update_status_updater_args()
|
||||
self.update_prevdoc_status()
|
||||
|
||||
@@ -1264,6 +1269,10 @@ class PurchaseInvoice(BuyingController):
|
||||
|
||||
self.check_on_hold_or_closed_status()
|
||||
|
||||
if self.is_return and not self.update_billed_amount_in_purchase_order:
|
||||
# NOTE status updating bypassed for is_return
|
||||
self.status_updater = []
|
||||
|
||||
self.update_status_updater_args()
|
||||
self.update_prevdoc_status()
|
||||
|
||||
@@ -1357,6 +1366,9 @@ class PurchaseInvoice(BuyingController):
|
||||
frappe.throw(_("Supplier Invoice No exists in Purchase Invoice {0}").format(pi))
|
||||
|
||||
def update_billing_status_in_pr(self, update_modified=True):
|
||||
if self.is_return and not self.update_billed_amount_in_purchase_receipt:
|
||||
return
|
||||
|
||||
updated_pr = []
|
||||
po_details = []
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ class RepostAccountingLedger(Document):
|
||||
).append(gle.update({"old": True}))
|
||||
|
||||
def generate_preview_data(self):
|
||||
frappe.flags.through_repost_accounting_ledger = True
|
||||
self.gl_entries = []
|
||||
self.get_existing_ledger_entries()
|
||||
for x in self.vouchers:
|
||||
@@ -123,6 +124,7 @@ class RepostAccountingLedger(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def start_repost(account_repost_doc=str) -> None:
|
||||
frappe.flags.through_repost_accounting_ledger = True
|
||||
if account_repost_doc:
|
||||
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ class SalesInvoice(SellingController):
|
||||
super(SalesInvoice, self).validate()
|
||||
self.validate_auto_set_posting_time()
|
||||
|
||||
if not self.is_pos:
|
||||
if not (self.is_pos or self.is_debit_note):
|
||||
self.so_dn_required()
|
||||
|
||||
self.set_tax_withholding()
|
||||
@@ -1293,9 +1293,7 @@ class SalesInvoice(SellingController):
|
||||
"credit_in_account_currency": payment_mode.base_amount
|
||||
if self.party_account_currency == self.company_currency
|
||||
else payment_mode.amount,
|
||||
"against_voucher": self.return_against
|
||||
if cint(self.is_return) and self.return_against
|
||||
else self.name,
|
||||
"against_voucher": self.name,
|
||||
"against_voucher_type": self.doctype,
|
||||
"cost_center": self.cost_center,
|
||||
},
|
||||
|
||||
@@ -1081,6 +1081,44 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
self.assertEqual(pos.grand_total, 100.0)
|
||||
self.assertEqual(pos.write_off_amount, 10)
|
||||
|
||||
def test_ledger_entries_of_return_pos_invoice(self):
|
||||
make_pos_profile()
|
||||
|
||||
pos = create_sales_invoice(do_not_save=True)
|
||||
pos.is_pos = 1
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 100})
|
||||
pos.save().submit()
|
||||
self.assertEqual(pos.outstanding_amount, 0.0)
|
||||
self.assertEqual(pos.status, "Paid")
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
|
||||
|
||||
pos_return = make_sales_return(pos.name)
|
||||
pos_return.save().submit()
|
||||
pos_return.reload()
|
||||
pos.reload()
|
||||
self.assertEqual(pos_return.is_return, 1)
|
||||
self.assertEqual(pos_return.return_against, pos.name)
|
||||
self.assertEqual(pos_return.outstanding_amount, 0.0)
|
||||
self.assertEqual(pos_return.status, "Return")
|
||||
self.assertEqual(pos.outstanding_amount, 0.0)
|
||||
self.assertEqual(pos.status, "Credit Note Issued")
|
||||
|
||||
expected = (
|
||||
("Cash - _TC", 0.0, 100.0, pos_return.name, None),
|
||||
("Debtors - _TC", 0.0, 100.0, pos_return.name, pos_return.name),
|
||||
("Debtors - _TC", 100.0, 0.0, pos_return.name, pos_return.name),
|
||||
("Sales - _TC", 100.0, 0.0, pos_return.name, None),
|
||||
)
|
||||
res = frappe.db.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": pos_return.name, "is_cancelled": 0},
|
||||
fields=["account", "debit", "credit", "voucher_no", "against_voucher"],
|
||||
order_by="account, debit, credit",
|
||||
as_list=1,
|
||||
)
|
||||
self.assertEqual(expected, res)
|
||||
|
||||
def test_pos_with_no_gl_entry_for_change_amount(self):
|
||||
frappe.db.set_value("Accounts Settings", None, "post_change_gl_entries", 0)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from frappe.utils import today
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
|
||||
class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase):
|
||||
@@ -49,6 +50,16 @@ class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase):
|
||||
)
|
||||
return pe
|
||||
|
||||
def create_sales_order(self):
|
||||
so = make_sales_order(
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
item=self.item,
|
||||
rate=100,
|
||||
transaction_date=today(),
|
||||
)
|
||||
return so
|
||||
|
||||
def test_01_unreconcile_invoice(self):
|
||||
si1 = self.create_sales_invoice()
|
||||
si2 = self.create_sales_invoice()
|
||||
@@ -314,3 +325,41 @@ class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase):
|
||||
),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_05_unreconcile_order(self):
|
||||
so = self.create_sales_order()
|
||||
|
||||
pe = self.create_payment_entry()
|
||||
# Allocation payment against Sales Order
|
||||
pe.paid_amount = 100
|
||||
pe.append(
|
||||
"references",
|
||||
{"reference_doctype": so.doctype, "reference_name": so.name, "allocated_amount": 100},
|
||||
)
|
||||
pe.save().submit()
|
||||
|
||||
# Assert 'Advance Paid'
|
||||
so.reload()
|
||||
self.assertEqual(so.advance_paid, 100)
|
||||
|
||||
unreconcile = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Unreconcile Payment",
|
||||
"company": self.company,
|
||||
"voucher_type": pe.doctype,
|
||||
"voucher_no": pe.name,
|
||||
}
|
||||
)
|
||||
unreconcile.add_references()
|
||||
self.assertEqual(len(unreconcile.allocations), 1)
|
||||
allocations = [x.reference_name for x in unreconcile.allocations]
|
||||
self.assertEquals([so.name], allocations)
|
||||
# unreconcile so
|
||||
unreconcile.save().submit()
|
||||
|
||||
# Assert 'Advance Paid'
|
||||
so.reload()
|
||||
pe.reload()
|
||||
self.assertEqual(so.advance_paid, 0)
|
||||
self.assertEqual(len(pe.references), 0)
|
||||
self.assertEqual(pe.unallocated_amount, 100)
|
||||
|
||||
@@ -63,6 +63,9 @@ class UnreconcilePayment(Document):
|
||||
update_voucher_outstanding(
|
||||
alloc.reference_doctype, alloc.reference_name, alloc.account, alloc.party_type, alloc.party
|
||||
)
|
||||
if doc.doctype in frappe.get_hooks("advance_payment_doctypes"):
|
||||
doc.set_total_advance_paid()
|
||||
|
||||
frappe.db.set_value("Unreconcile Payment Entries", alloc.name, "unlinked", True)
|
||||
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ def get_entries(filters):
|
||||
"""select
|
||||
voucher_type, voucher_no, party_type, party, posting_date, debit, credit, remarks, against_voucher
|
||||
from `tabGL Entry`
|
||||
where company=%(company)s and voucher_type in ('Journal Entry', 'Payment Entry') {0}
|
||||
where company=%(company)s and voucher_type in ('Journal Entry', 'Payment Entry') and is_cancelled = 0 {0}
|
||||
""".format(
|
||||
get_conditions(filters)
|
||||
),
|
||||
|
||||
@@ -242,7 +242,7 @@ def get_columns(filters):
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
"label": _("Tax Amount"),
|
||||
"label": _("TDS Amount") if filters.get("party_type") == "Supplier" else _("TCS Amount"),
|
||||
"fieldname": "tax_amount",
|
||||
"fieldtype": "Float",
|
||||
"width": 120,
|
||||
|
||||
@@ -940,6 +940,38 @@ class TestPurchaseOrder(FrappeTestCase):
|
||||
|
||||
self.assertRaises(frappe.ValidationError, po.save)
|
||||
|
||||
def test_po_billed_amount_against_return_entry(self):
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_debit_note
|
||||
|
||||
# Create a Purchase Order and Fully Bill it
|
||||
po = create_purchase_order()
|
||||
pi = make_pi_from_po(po.name)
|
||||
pi.insert()
|
||||
pi.submit()
|
||||
|
||||
# Debit Note - 50% Qty & enable updating PO billed amount
|
||||
pi_return = make_debit_note(pi.name)
|
||||
pi_return.items[0].qty = -5
|
||||
pi_return.update_billed_amount_in_purchase_order = 1
|
||||
pi_return.submit()
|
||||
|
||||
# Check if the billed amount reduced
|
||||
po.reload()
|
||||
self.assertEqual(po.per_billed, 50)
|
||||
|
||||
pi_return.reload()
|
||||
pi_return.cancel()
|
||||
|
||||
# Debit Note - 50% Qty & disable updating PO billed amount
|
||||
pi_return = make_debit_note(pi.name)
|
||||
pi_return.items[0].qty = -5
|
||||
pi_return.update_billed_amount_in_purchase_order = 0
|
||||
pi_return.submit()
|
||||
|
||||
# Check if the billed amount stayed the same
|
||||
po.reload()
|
||||
self.assertEqual(po.per_billed, 100)
|
||||
|
||||
|
||||
def prepare_data_for_internal_transfer():
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
|
||||
|
||||
@@ -201,7 +201,8 @@ class AccountsController(TransactionBase):
|
||||
)
|
||||
)
|
||||
|
||||
if self.get("is_return") and self.get("return_against"):
|
||||
if self.get("is_return") and self.get("return_against") and not self.get("is_pos"):
|
||||
# if self.get("is_return") and self.get("return_against"):
|
||||
document_type = "Credit Note" if self.doctype == "Sales Invoice" else "Debit Note"
|
||||
frappe.msgprint(
|
||||
_(
|
||||
@@ -324,6 +325,12 @@ class AccountsController(TransactionBase):
|
||||
ple = frappe.qb.DocType("Payment Ledger Entry")
|
||||
frappe.qb.from_(ple).delete().where(
|
||||
(ple.voucher_type == self.doctype) & (ple.voucher_no == self.name)
|
||||
| (
|
||||
(ple.against_voucher_type == self.doctype)
|
||||
& (ple.against_voucher_no == self.name)
|
||||
& ple.delinked
|
||||
== 1
|
||||
)
|
||||
).run()
|
||||
frappe.db.sql(
|
||||
"delete from `tabGL Entry` where voucher_type=%s and voucher_no=%s", (self.doctype, self.name)
|
||||
|
||||
@@ -811,7 +811,8 @@ class BuyingController(SubcontractingController):
|
||||
if self.doctype == "Purchase Invoice" and not self.get("update_stock"):
|
||||
return
|
||||
|
||||
frappe.db.sql("delete from `tabAsset Movement` where reference_name=%s", self.name)
|
||||
asset_movement = frappe.db.get_value("Asset Movement", {"reference_name": self.name}, "name")
|
||||
frappe.delete_doc("Asset Movement", asset_movement, force=1)
|
||||
|
||||
def validate_schedule_date(self):
|
||||
if not self.get("items"):
|
||||
|
||||
@@ -31,7 +31,8 @@ class SellingController(StockController):
|
||||
def validate(self):
|
||||
super(SellingController, self).validate()
|
||||
self.validate_items()
|
||||
self.validate_max_discount()
|
||||
if not self.get("is_debit_note"):
|
||||
self.validate_max_discount()
|
||||
self.validate_selling_price()
|
||||
self.set_qty_as_per_stock_uom()
|
||||
self.set_po_nos(for_validate=True)
|
||||
|
||||
@@ -1005,7 +1005,7 @@ def get_itemised_tax_breakup_data(doc):
|
||||
for item_code, taxes in itemised_tax.items():
|
||||
itemised_tax_data.append(
|
||||
frappe._dict(
|
||||
{"item": item_code, "taxable_amount": itemised_taxable_amount.get(item_code), **taxes}
|
||||
{"item": item_code, "taxable_amount": itemised_taxable_amount.get(item_code, 0), **taxes}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -41,7 +41,9 @@ class SalesPipelineAnalytics(object):
|
||||
month_list = self.get_month_list()
|
||||
|
||||
for month in month_list:
|
||||
self.columns.append({"fieldname": month, "fieldtype": based_on, "label": month, "width": 200})
|
||||
self.columns.append(
|
||||
{"fieldname": month, "fieldtype": based_on, "label": _(month), "width": 200}
|
||||
)
|
||||
|
||||
elif self.filters.get("range") == "Quarterly":
|
||||
for quarter in range(1, 5):
|
||||
@@ -156,7 +158,7 @@ class SalesPipelineAnalytics(object):
|
||||
|
||||
for column in self.columns:
|
||||
if column["fieldname"] != "opportunity_owner" and column["fieldname"] != "sales_stage":
|
||||
labels.append(column["fieldname"])
|
||||
labels.append(_(column["fieldname"]))
|
||||
|
||||
self.chart = {"data": {"labels": labels, "datasets": datasets}, "type": "line"}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from frappe.model.document import Document
|
||||
from frappe.utils import add_months, formatdate, getdate, sbool, today
|
||||
from plaid.errors import ItemError
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_default_bank_cash_account
|
||||
from erpnext.erpnext_integrations.doctype.plaid_settings.plaid_connector import PlaidConnector
|
||||
|
||||
|
||||
@@ -74,9 +73,15 @@ def add_bank_accounts(response, bank, company):
|
||||
bank = json.loads(bank)
|
||||
result = []
|
||||
|
||||
default_gl_account = get_default_bank_cash_account(company, "Bank")
|
||||
if not default_gl_account:
|
||||
frappe.throw(_("Please setup a default bank account for company {0}").format(company))
|
||||
parent_gl_account = frappe.db.get_all(
|
||||
"Account", {"company": company, "account_type": "Bank", "is_group": 1, "disabled": 0}
|
||||
)
|
||||
if not parent_gl_account:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Please setup and enable a group account with the Account Type - {0} for the company {1}"
|
||||
).format(frappe.bold("Bank"), company)
|
||||
)
|
||||
|
||||
for account in response["accounts"]:
|
||||
acc_type = frappe.db.get_value("Bank Account Type", account["type"])
|
||||
@@ -92,11 +97,22 @@ def add_bank_accounts(response, bank, company):
|
||||
|
||||
if not existing_bank_account:
|
||||
try:
|
||||
gl_account = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Account",
|
||||
"account_name": account["name"] + " - " + response["institution"]["name"],
|
||||
"parent_account": parent_gl_account[0].name,
|
||||
"account_type": "Bank",
|
||||
"company": company,
|
||||
}
|
||||
)
|
||||
gl_account.insert(ignore_if_duplicate=True)
|
||||
|
||||
new_account = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Bank Account",
|
||||
"bank": bank["bank_name"],
|
||||
"account": default_gl_account.account,
|
||||
"account": gl_account.name,
|
||||
"account_name": account["name"],
|
||||
"account_type": account.get("type", ""),
|
||||
"account_subtype": account.get("subtype", ""),
|
||||
|
||||
@@ -7,7 +7,6 @@ import unittest
|
||||
import frappe
|
||||
from frappe.utils.response import json_handler
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_default_bank_cash_account
|
||||
from erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings import (
|
||||
add_account_subtype,
|
||||
add_account_type,
|
||||
@@ -43,7 +42,7 @@ class TestPlaidSettings(unittest.TestCase):
|
||||
add_account_subtype("loan")
|
||||
self.assertEqual(frappe.get_doc("Bank Account Subtype", "loan").name, "loan")
|
||||
|
||||
def test_default_bank_account(self):
|
||||
def test_parent_bank_account_validation(self):
|
||||
if not frappe.db.exists("Bank", "Citi"):
|
||||
frappe.get_doc({"doctype": "Bank", "bank_name": "Citi"}).insert()
|
||||
|
||||
@@ -71,12 +70,19 @@ class TestPlaidSettings(unittest.TestCase):
|
||||
|
||||
bank = json.dumps(frappe.get_doc("Bank", "Citi").as_dict(), default=json_handler)
|
||||
company = frappe.db.get_single_value("Global Defaults", "default_company")
|
||||
frappe.db.set_value("Company", company, "default_bank_account", None)
|
||||
group_bank_account = frappe.db.get_all(
|
||||
"Account", {"company": company, "account_type": "Bank", "is_group": 1}, pluck="name"
|
||||
)
|
||||
if group_bank_account:
|
||||
frappe.db.set_value("Account", group_bank_account[0], "disabled", 1)
|
||||
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, add_bank_accounts, response=bank_accounts, bank=bank, company=company
|
||||
)
|
||||
|
||||
if group_bank_account:
|
||||
frappe.db.set_value("Account", group_bank_account[0], "disabled", 0)
|
||||
|
||||
def test_new_transaction(self):
|
||||
if not frappe.db.exists("Bank", "Citi"):
|
||||
frappe.get_doc({"doctype": "Bank", "bank_name": "Citi"}).insert()
|
||||
@@ -106,14 +112,6 @@ class TestPlaidSettings(unittest.TestCase):
|
||||
bank = json.dumps(frappe.get_doc("Bank", "Citi").as_dict(), default=json_handler)
|
||||
company = frappe.db.get_single_value("Global Defaults", "default_company")
|
||||
|
||||
if frappe.db.get_value("Company", company, "default_bank_account") is None:
|
||||
frappe.db.set_value(
|
||||
"Company",
|
||||
company,
|
||||
"default_bank_account",
|
||||
get_default_bank_cash_account(company, "Cash").get("account"),
|
||||
)
|
||||
|
||||
add_bank_accounts(bank_accounts, bank, company)
|
||||
|
||||
transactions = {
|
||||
|
||||
@@ -163,7 +163,7 @@ class JobCard(Document):
|
||||
for row in self.sub_operations:
|
||||
self.total_completed_qty += row.completed_qty
|
||||
|
||||
def get_overlap_for(self, args, check_next_available_slot=False):
|
||||
def get_overlap_for(self, args):
|
||||
production_capacity = 1
|
||||
|
||||
jc = frappe.qb.DocType("Job Card")
|
||||
@@ -175,9 +175,6 @@ class JobCard(Document):
|
||||
((jctl.from_time >= args.from_time) & (jctl.to_time <= args.to_time)),
|
||||
]
|
||||
|
||||
if check_next_available_slot:
|
||||
time_conditions.append(((jctl.from_time >= args.from_time) & (jctl.to_time >= args.to_time)))
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(jctl)
|
||||
.from_(jc)
|
||||
@@ -279,13 +276,29 @@ class JobCard(Document):
|
||||
self.check_workstation_time(row)
|
||||
|
||||
def validate_overlap_for_workstation(self, args, row):
|
||||
if args.get("to_time") and get_datetime(args.to_time) < get_datetime(args.from_time):
|
||||
args.to_time = add_to_date(row.planned_start_time, minutes=row.remaining_time_in_mins)
|
||||
|
||||
# get the last record based on the to time from the job card
|
||||
data = self.get_overlap_for(args, check_next_available_slot=True)
|
||||
data = self.get_overlap_for(args)
|
||||
|
||||
if not data:
|
||||
row.planned_start_time = args.from_time
|
||||
return
|
||||
|
||||
if data:
|
||||
if not self.workstation:
|
||||
self.workstation = data.workstation
|
||||
|
||||
row.planned_start_time = get_datetime(data.to_time + get_mins_between_operations())
|
||||
if data.get("planned_start_time"):
|
||||
args.planned_start_time = get_datetime(data.planned_start_time)
|
||||
else:
|
||||
args.planned_start_time = get_datetime(data.to_time + get_mins_between_operations())
|
||||
|
||||
args.from_time = args.planned_start_time
|
||||
args.to_time = add_to_date(args.planned_start_time, minutes=row.remaining_time_in_mins)
|
||||
|
||||
self.validate_overlap_for_workstation(args, row)
|
||||
|
||||
def check_workstation_time(self, row):
|
||||
workstation_doc = frappe.get_cached_doc("Workstation", self.workstation)
|
||||
|
||||
@@ -518,6 +518,12 @@ frappe.ui.form.on("Production Plan Sales Order", {
|
||||
}
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Production Plan Sub Assembly Item", {
|
||||
fg_warehouse(frm, cdt, cdn) {
|
||||
erpnext.utils.copy_value_in_all_rows(frm.doc, cdt, cdn, "sub_assembly_items", "fg_warehouse");
|
||||
},
|
||||
})
|
||||
|
||||
frappe.tour['Production Plan'] = [
|
||||
{
|
||||
fieldname: "get_items_from",
|
||||
|
||||
@@ -421,9 +421,11 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"description": "When a parent warehouse is chosen, the system conducts stock checks against the associated child warehouses",
|
||||
"fieldname": "sub_assembly_warehouse",
|
||||
"fieldtype": "Link",
|
||||
"label": "Sub Assembly Warehouse",
|
||||
"mandatory_depends_on": "eval:doc.skip_available_sub_assembly_item === 1",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
@@ -437,7 +439,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-02-11 15:42:47.642481",
|
||||
"modified": "2024-02-27 13:34:20.692211",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Production Plan",
|
||||
|
||||
@@ -817,8 +817,8 @@ class ProductionPlan(Document):
|
||||
sub_assembly_items_store = [] # temporary store to process all subassembly items
|
||||
|
||||
for row in self.po_items:
|
||||
if self.skip_available_sub_assembly_item and not row.warehouse:
|
||||
frappe.throw(_("Row #{0}: Please select the FG Warehouse in Assembly Items").format(row.idx))
|
||||
if self.skip_available_sub_assembly_item and not self.sub_assembly_warehouse:
|
||||
frappe.throw(_("Row #{0}: Please select the Sub Assembly Warehouse").format(row.idx))
|
||||
|
||||
if not row.item_code:
|
||||
frappe.throw(_("Row #{0}: Please select Item Code in Assembly Items").format(row.idx))
|
||||
@@ -828,15 +828,24 @@ class ProductionPlan(Document):
|
||||
|
||||
bom_data = []
|
||||
|
||||
warehouse = (
|
||||
(self.sub_assembly_warehouse or row.warehouse)
|
||||
if self.skip_available_sub_assembly_item
|
||||
else None
|
||||
)
|
||||
warehouse = (self.sub_assembly_warehouse) if self.skip_available_sub_assembly_item else None
|
||||
get_sub_assembly_items(row.bom_no, bom_data, row.planned_qty, self.company, warehouse=warehouse)
|
||||
self.set_sub_assembly_items_based_on_level(row, bom_data, manufacturing_type)
|
||||
sub_assembly_items_store.extend(bom_data)
|
||||
|
||||
if not sub_assembly_items_store and self.skip_available_sub_assembly_item:
|
||||
message = (
|
||||
_(
|
||||
"As there are sufficient Sub Assembly Items, Work Order is not required for Warehouse {0}."
|
||||
).format(self.sub_assembly_warehouse)
|
||||
+ "<br><br>"
|
||||
)
|
||||
message += _(
|
||||
"If you still want to proceed, please disable 'Skip Available Sub Assembly Items' checkbox."
|
||||
)
|
||||
|
||||
frappe.msgprint(message, title=_("Note"))
|
||||
|
||||
if self.combine_sub_items:
|
||||
# Combine subassembly items
|
||||
sub_assembly_items_store = self.combine_subassembly_items(sub_assembly_items_store)
|
||||
@@ -849,15 +858,19 @@ class ProductionPlan(Document):
|
||||
|
||||
def set_sub_assembly_items_based_on_level(self, row, bom_data, manufacturing_type=None):
|
||||
"Modify bom_data, set additional details."
|
||||
is_group_warehouse = frappe.db.get_value("Warehouse", self.sub_assembly_warehouse, "is_group")
|
||||
|
||||
for data in bom_data:
|
||||
data.qty = data.stock_qty
|
||||
data.production_plan_item = row.name
|
||||
data.fg_warehouse = self.sub_assembly_warehouse or row.warehouse
|
||||
data.schedule_date = row.planned_start_date
|
||||
data.type_of_manufacturing = manufacturing_type or (
|
||||
"Subcontract" if data.is_sub_contracted_item else "In House"
|
||||
)
|
||||
|
||||
if not is_group_warehouse:
|
||||
data.fg_warehouse = self.sub_assembly_warehouse
|
||||
|
||||
def set_default_supplier_for_subcontracting_order(self):
|
||||
items = [
|
||||
d.production_item for d in self.sub_assembly_items if d.type_of_manufacturing == "Subcontract"
|
||||
@@ -1401,7 +1414,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
||||
so_item_details = frappe._dict()
|
||||
|
||||
sub_assembly_items = {}
|
||||
if doc.get("skip_available_sub_assembly_item"):
|
||||
if doc.get("skip_available_sub_assembly_item") and doc.get("sub_assembly_items"):
|
||||
for d in doc.get("sub_assembly_items"):
|
||||
sub_assembly_items.setdefault((d.get("production_item"), d.get("bom_no")), d.get("qty"))
|
||||
|
||||
@@ -1430,19 +1443,17 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
||||
frappe.throw(_("For row {0}: Enter Planned Qty").format(data.get("idx")))
|
||||
|
||||
if bom_no:
|
||||
if (
|
||||
data.get("include_exploded_items")
|
||||
and doc.get("sub_assembly_items")
|
||||
and doc.get("skip_available_sub_assembly_item")
|
||||
):
|
||||
item_details = get_raw_materials_of_sub_assembly_items(
|
||||
item_details,
|
||||
company,
|
||||
bom_no,
|
||||
include_non_stock_items,
|
||||
sub_assembly_items,
|
||||
planned_qty=planned_qty,
|
||||
)
|
||||
if data.get("include_exploded_items") and doc.get("skip_available_sub_assembly_item"):
|
||||
item_details = {}
|
||||
if doc.get("sub_assembly_items"):
|
||||
item_details = get_raw_materials_of_sub_assembly_items(
|
||||
item_details,
|
||||
company,
|
||||
bom_no,
|
||||
include_non_stock_items,
|
||||
sub_assembly_items,
|
||||
planned_qty=planned_qty,
|
||||
)
|
||||
|
||||
elif data.get("include_exploded_items") and include_subcontracted_items:
|
||||
# fetch exploded items from BOM
|
||||
@@ -1615,34 +1626,37 @@ def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, company, warehouse=
|
||||
stock_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty)
|
||||
|
||||
if warehouse:
|
||||
bin_dict = get_bin_details(d, company, for_warehouse=warehouse)
|
||||
bin_details = get_bin_details(d, company, for_warehouse=warehouse)
|
||||
|
||||
if bin_dict and bin_dict[0].projected_qty > 0:
|
||||
if bin_dict[0].projected_qty > stock_qty:
|
||||
continue
|
||||
else:
|
||||
stock_qty = stock_qty - bin_dict[0].projected_qty
|
||||
for _bin_dict in bin_details:
|
||||
if _bin_dict.projected_qty > 0:
|
||||
if _bin_dict.projected_qty > stock_qty:
|
||||
stock_qty = 0
|
||||
continue
|
||||
else:
|
||||
stock_qty = stock_qty - _bin_dict.projected_qty
|
||||
|
||||
bom_data.append(
|
||||
frappe._dict(
|
||||
{
|
||||
"parent_item_code": parent_item_code,
|
||||
"description": d.description,
|
||||
"production_item": d.item_code,
|
||||
"item_name": d.item_name,
|
||||
"stock_uom": d.stock_uom,
|
||||
"uom": d.stock_uom,
|
||||
"bom_no": d.value,
|
||||
"is_sub_contracted_item": d.is_sub_contracted_item,
|
||||
"bom_level": indent,
|
||||
"indent": indent,
|
||||
"stock_qty": stock_qty,
|
||||
}
|
||||
if stock_qty > 0:
|
||||
bom_data.append(
|
||||
frappe._dict(
|
||||
{
|
||||
"parent_item_code": parent_item_code,
|
||||
"description": d.description,
|
||||
"production_item": d.item_code,
|
||||
"item_name": d.item_name,
|
||||
"stock_uom": d.stock_uom,
|
||||
"uom": d.stock_uom,
|
||||
"bom_no": d.value,
|
||||
"is_sub_contracted_item": d.is_sub_contracted_item,
|
||||
"bom_level": indent,
|
||||
"indent": indent,
|
||||
"stock_qty": stock_qty,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if d.value:
|
||||
get_sub_assembly_items(d.value, bom_data, stock_qty, company, warehouse, indent=indent + 1)
|
||||
if d.value:
|
||||
get_sub_assembly_items(d.value, bom_data, stock_qty, company, warehouse, indent=indent + 1)
|
||||
|
||||
|
||||
def set_default_warehouses(row, default_warehouses):
|
||||
|
||||
@@ -1194,6 +1194,7 @@ class TestProductionPlan(FrappeTestCase):
|
||||
ignore_existing_ordered_qty=1,
|
||||
do_not_submit=1,
|
||||
skip_available_sub_assembly_item=1,
|
||||
sub_assembly_warehouse="_Test Warehouse - _TC",
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
)
|
||||
|
||||
@@ -1221,6 +1222,35 @@ class TestProductionPlan(FrappeTestCase):
|
||||
if row.item_code == "SubAssembly2 For SUB Test":
|
||||
self.assertEqual(row.quantity, 10)
|
||||
|
||||
def test_sub_assembly_and_their_raw_materials_exists(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
|
||||
bom_tree = {
|
||||
"FG1 For SUB Test": {
|
||||
"SAB1 For SUB Test": {"CP1 For SUB Test": {}},
|
||||
"SAB2 For SUB Test": {},
|
||||
}
|
||||
}
|
||||
|
||||
parent_bom = create_nested_bom(bom_tree, prefix="")
|
||||
for item in ["SAB1 For SUB Test", "SAB2 For SUB Test"]:
|
||||
make_stock_entry(item_code=item, qty=10, rate=100, target="_Test Warehouse - _TC")
|
||||
|
||||
plan = create_production_plan(
|
||||
item_code=parent_bom.item,
|
||||
planned_qty=10,
|
||||
ignore_existing_ordered_qty=1,
|
||||
do_not_submit=1,
|
||||
skip_available_sub_assembly_item=1,
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
)
|
||||
|
||||
items = get_items_for_material_requests(
|
||||
plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}]
|
||||
)
|
||||
|
||||
self.assertFalse(items)
|
||||
|
||||
def test_transfer_and_purchase_mrp_for_purchase_uom(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
@@ -1298,6 +1328,7 @@ class TestProductionPlan(FrappeTestCase):
|
||||
ignore_existing_ordered_qty=1,
|
||||
do_not_submit=1,
|
||||
skip_available_sub_assembly_item=1,
|
||||
sub_assembly_warehouse="_Test Warehouse - _TC",
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
)
|
||||
|
||||
@@ -1550,6 +1581,48 @@ class TestProductionPlan(FrappeTestCase):
|
||||
for row in work_orders:
|
||||
self.assertEqual(row.qty, wo_qty[row.name])
|
||||
|
||||
def test_parent_warehouse_for_sub_assembly_items(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
parent_warehouse = "_Test Warehouse Group - _TC"
|
||||
sub_warehouse = create_warehouse("Sub Warehouse", company="_Test Company")
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
sf_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
bom_tree = {fg_item: {sf_item: {rm_item: {}}}}
|
||||
create_nested_bom(bom_tree, prefix="")
|
||||
|
||||
pln = create_production_plan(
|
||||
item_code=fg_item,
|
||||
planned_qty=10,
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
sub_assembly_warehouse=parent_warehouse,
|
||||
skip_available_sub_assembly_item=1,
|
||||
do_not_submit=1,
|
||||
skip_getting_mr_items=1,
|
||||
)
|
||||
|
||||
pln.get_sub_assembly_items()
|
||||
|
||||
for row in pln.sub_assembly_items:
|
||||
self.assertFalse(row.fg_warehouse)
|
||||
self.assertEqual(row.production_item, sf_item)
|
||||
self.assertEqual(row.qty, 10.0)
|
||||
|
||||
make_stock_entry(item_code=sf_item, qty=5, target=sub_warehouse, rate=100)
|
||||
|
||||
pln.sub_assembly_items = []
|
||||
pln.get_sub_assembly_items()
|
||||
|
||||
self.assertEqual(pln.sub_assembly_warehouse, parent_warehouse)
|
||||
for row in pln.sub_assembly_items:
|
||||
self.assertFalse(row.fg_warehouse)
|
||||
self.assertEqual(row.production_item, sf_item)
|
||||
self.assertEqual(row.qty, 5.0)
|
||||
|
||||
|
||||
def create_production_plan(**args):
|
||||
"""
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"bom_no",
|
||||
"column_break_6",
|
||||
"planned_qty",
|
||||
"stock_uom",
|
||||
"warehouse",
|
||||
"planned_start_date",
|
||||
"section_break_9",
|
||||
@@ -18,7 +19,6 @@
|
||||
"ordered_qty",
|
||||
"column_break_17",
|
||||
"description",
|
||||
"stock_uom",
|
||||
"produced_qty",
|
||||
"reference_section",
|
||||
"sales_order",
|
||||
@@ -65,6 +65,7 @@
|
||||
"width": "100px"
|
||||
},
|
||||
{
|
||||
"columns": 1,
|
||||
"fieldname": "planned_qty",
|
||||
"fieldtype": "Float",
|
||||
"in_list_view": 1,
|
||||
@@ -80,6 +81,7 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"columns": 2,
|
||||
"fieldname": "warehouse",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
@@ -141,8 +143,10 @@
|
||||
"width": "200px"
|
||||
},
|
||||
{
|
||||
"columns": 1,
|
||||
"fieldname": "stock_uom",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "UOM",
|
||||
"oldfieldname": "stock_uom",
|
||||
"oldfieldtype": "Data",
|
||||
@@ -216,7 +220,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2022-11-25 14:15:40.061514",
|
||||
"modified": "2024-02-27 13:24:43.571844",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Production Plan Item",
|
||||
|
||||
@@ -101,7 +101,6 @@
|
||||
"columns": 1,
|
||||
"fieldname": "bom_level",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Level (BOM)",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -149,8 +148,10 @@
|
||||
"label": "Indent"
|
||||
},
|
||||
{
|
||||
"columns": 2,
|
||||
"fieldname": "fg_warehouse",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Target Warehouse",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
@@ -170,6 +171,7 @@
|
||||
"options": "Supplier"
|
||||
},
|
||||
{
|
||||
"columns": 1,
|
||||
"fieldname": "schedule_date",
|
||||
"fieldtype": "Datetime",
|
||||
"in_list_view": 1,
|
||||
@@ -207,7 +209,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-11-03 13:33:42.959387",
|
||||
"modified": "2024-02-27 13:45:17.422435",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Production Plan Sub Assembly Item",
|
||||
|
||||
@@ -1778,6 +1778,113 @@ class TestWorkOrder(FrappeTestCase):
|
||||
"Manufacturing Settings", "set_op_cost_and_scrape_from_sub_assemblies", 0
|
||||
)
|
||||
|
||||
def test_capcity_planning_for_workstation(self):
|
||||
frappe.db.set_single_value(
|
||||
"Manufacturing Settings",
|
||||
{
|
||||
"disable_capacity_planning": 0,
|
||||
"capacity_planning_for_days": 1,
|
||||
"mins_between_operations": 10,
|
||||
},
|
||||
)
|
||||
|
||||
properties = {"is_stock_item": 1, "valuation_rate": 100}
|
||||
fg_item = make_item("Test FG Item For Capacity Planning", properties).name
|
||||
|
||||
rm_item = make_item("Test RM Item For Capacity Planning", properties).name
|
||||
|
||||
workstation = "Test Workstation For Capacity Planning"
|
||||
if not frappe.db.exists("Workstation", workstation):
|
||||
make_workstation(workstation=workstation, production_capacity=1)
|
||||
|
||||
operation = "Test Operation For Capacity Planning"
|
||||
if not frappe.db.exists("Operation", operation):
|
||||
make_operation(operation=operation, workstation=workstation)
|
||||
|
||||
bom_doc = make_bom(
|
||||
item=fg_item,
|
||||
source_warehouse="Stores - _TC",
|
||||
raw_materials=[rm_item],
|
||||
with_operations=1,
|
||||
do_not_submit=True,
|
||||
)
|
||||
|
||||
bom_doc.append(
|
||||
"operations",
|
||||
{"operation": operation, "time_in_mins": 1420, "hour_rate": 100, "workstation": workstation},
|
||||
)
|
||||
bom_doc.submit()
|
||||
|
||||
# 1st Work Order,
|
||||
# Capacity to run parallel the operation 'Test Operation For Capacity Planning' is 2
|
||||
wo_doc = make_wo_order_test_record(
|
||||
production_item=fg_item, qty=1, planned_start_date="2024-02-25 00:00:00", do_not_submit=1
|
||||
)
|
||||
|
||||
wo_doc.submit()
|
||||
job_cards = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": wo_doc.name},
|
||||
)
|
||||
|
||||
self.assertEqual(len(job_cards), 1)
|
||||
|
||||
# 2nd Work Order,
|
||||
wo_doc = make_wo_order_test_record(
|
||||
production_item=fg_item, qty=1, planned_start_date="2024-02-25 00:00:00", do_not_submit=1
|
||||
)
|
||||
|
||||
wo_doc.submit()
|
||||
job_cards = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": wo_doc.name},
|
||||
)
|
||||
|
||||
self.assertEqual(len(job_cards), 1)
|
||||
|
||||
# 3rd Work Order, capacity is full
|
||||
wo_doc = make_wo_order_test_record(
|
||||
production_item=fg_item, qty=1, planned_start_date="2024-02-25 00:00:00", do_not_submit=1
|
||||
)
|
||||
|
||||
self.assertRaises(CapacityError, wo_doc.submit)
|
||||
|
||||
frappe.db.set_single_value(
|
||||
"Manufacturing Settings", {"disable_capacity_planning": 1, "mins_between_operations": 0}
|
||||
)
|
||||
|
||||
|
||||
def make_operation(**kwargs):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
operation_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Operation",
|
||||
"name": kwargs.operation,
|
||||
"workstation": kwargs.workstation,
|
||||
}
|
||||
)
|
||||
operation_doc.insert()
|
||||
|
||||
return operation_doc
|
||||
|
||||
|
||||
def make_workstation(**kwargs):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
workstation_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Workstation",
|
||||
"workstation_name": kwargs.workstation,
|
||||
"workstation_type": kwargs.workstation_type,
|
||||
"production_capacity": kwargs.production_capacity or 0,
|
||||
"hour_rate": kwargs.hour_rate or 100,
|
||||
}
|
||||
)
|
||||
workstation_doc.insert()
|
||||
|
||||
return workstation_doc
|
||||
|
||||
|
||||
def prepare_boms_for_sub_assembly_test():
|
||||
if not frappe.db.exists("BOM", {"item": "Test Final SF Item 1"}):
|
||||
|
||||
@@ -172,8 +172,12 @@ class WorkOrder(Document):
|
||||
def calculate_operating_cost(self):
|
||||
self.planned_operating_cost, self.actual_operating_cost = 0.0, 0.0
|
||||
for d in self.get("operations"):
|
||||
d.planned_operating_cost = flt(d.hour_rate) * (flt(d.time_in_mins) / 60.0)
|
||||
d.actual_operating_cost = flt(d.hour_rate) * (flt(d.actual_operation_time) / 60.0)
|
||||
d.planned_operating_cost = flt(
|
||||
flt(d.hour_rate) * (flt(d.time_in_mins) / 60.0), d.precision("planned_operating_cost")
|
||||
)
|
||||
d.actual_operating_cost = flt(
|
||||
flt(d.hour_rate) * (flt(d.actual_operation_time) / 60.0), d.precision("actual_operating_cost")
|
||||
)
|
||||
|
||||
self.planned_operating_cost += flt(d.planned_operating_cost)
|
||||
self.actual_operating_cost += flt(d.actual_operating_cost)
|
||||
@@ -489,7 +493,6 @@ class WorkOrder(Document):
|
||||
def prepare_data_for_job_card(self, row, index, plan_days, enable_capacity_planning):
|
||||
self.set_operation_start_end_time(index, row)
|
||||
|
||||
original_start_time = row.planned_start_time
|
||||
job_card_doc = create_job_card(
|
||||
self, row, auto_create=True, enable_capacity_planning=enable_capacity_planning
|
||||
)
|
||||
@@ -498,11 +501,15 @@ class WorkOrder(Document):
|
||||
row.planned_start_time = job_card_doc.time_logs[-1].from_time
|
||||
row.planned_end_time = job_card_doc.time_logs[-1].to_time
|
||||
|
||||
if date_diff(row.planned_start_time, original_start_time) > plan_days:
|
||||
if date_diff(row.planned_end_time, self.planned_start_date) > plan_days:
|
||||
frappe.message_log.pop()
|
||||
frappe.throw(
|
||||
_("Unable to find the time slot in the next {0} days for the operation {1}.").format(
|
||||
plan_days, row.operation
|
||||
_(
|
||||
"Unable to find the time slot in the next {0} days for the operation {1}. Please increase the 'Capacity Planning For (Days)' in the {2}."
|
||||
).format(
|
||||
plan_days,
|
||||
row.operation,
|
||||
get_link_to_form("Manufacturing Settings", "Manufacturing Settings"),
|
||||
),
|
||||
CapacityError,
|
||||
)
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
{
|
||||
"add_total_row": 0,
|
||||
"apply_user_permissions": 1,
|
||||
"creation": "2013-08-12 12:44:27",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2018-02-13 04:58:51.549413",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Completed Work Orders",
|
||||
"owner": "Administrator",
|
||||
"query": "SELECT\n `tabWork Order`.name as \"Work Order:Link/Work Order:200\",\n `tabWork Order`.creation as \"Date:Date:120\",\n `tabWork Order`.production_item as \"Item:Link/Item:150\",\n `tabWork Order`.qty as \"To Produce:Int:100\",\n `tabWork Order`.produced_qty as \"Produced:Int:100\",\n `tabWork Order`.company as \"Company:Link/Company:\"\nFROM\n `tabWork Order`\nWHERE\n `tabWork Order`.docstatus=1\n AND ifnull(`tabWork Order`.produced_qty,0) = `tabWork Order`.qty",
|
||||
"ref_doctype": "Work Order",
|
||||
"report_name": "Completed Work Orders",
|
||||
"report_type": "Query Report",
|
||||
"add_total_row": 0,
|
||||
"columns": [],
|
||||
"creation": "2013-08-12 12:44:27",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"letterhead": null,
|
||||
"modified": "2024-02-21 14:35:14.301848",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Completed Work Orders",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"query": "SELECT\n `tabWork Order`.name as \"Work Order:Link/Work Order:200\",\n `tabWork Order`.creation as \"Date:Date:120\",\n `tabWork Order`.production_item as \"Item:Link/Item:150\",\n `tabWork Order`.qty as \"To Produce:Int:100\",\n `tabWork Order`.produced_qty as \"Produced:Int:100\",\n `tabWork Order`.company as \"Company:Link/Company:\"\nFROM\n `tabWork Order`\nWHERE\n `tabWork Order`.docstatus=1\n AND ifnull(`tabWork Order`.produced_qty,0) >= `tabWork Order`.qty",
|
||||
"ref_doctype": "Work Order",
|
||||
"report_name": "Completed Work Orders",
|
||||
"report_type": "Query Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "Manufacturing User"
|
||||
},
|
||||
},
|
||||
{
|
||||
"role": "Stock User"
|
||||
}
|
||||
|
||||
@@ -353,8 +353,10 @@ erpnext.patches.v14_0.update_zero_asset_quantity_field
|
||||
execute:frappe.db.set_single_value("Buying Settings", "project_update_frequency", "Each Transaction")
|
||||
erpnext.patches.v14_0.clear_reconciliation_values_from_singles
|
||||
erpnext.patches.v14_0.update_total_asset_cost_field
|
||||
erpnext.patches.v14_0.create_accounting_dimensions_in_reconciliation_tool
|
||||
# below migration patch should always run last
|
||||
erpnext.patches.v14_0.migrate_gl_to_payment_ledger
|
||||
erpnext.stock.doctype.delivery_note.patches.drop_unused_return_against_index # 2023-12-20
|
||||
erpnext.patches.v14_0.set_maintain_stock_for_bom_item
|
||||
execute:frappe.db.set_single_value('E Commerce Settings', 'show_actual_qty', 1)
|
||||
erpnext.patches.v14_0.delete_orphaned_asset_movement_item_records
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
create_accounting_dimensions_for_doctype,
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
create_accounting_dimensions_for_doctype(doctype="Payment Reconciliation")
|
||||
create_accounting_dimensions_for_doctype(doctype="Payment Reconciliation Allocation")
|
||||
@@ -0,0 +1,11 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
# nosemgrep
|
||||
frappe.db.sql(
|
||||
"""
|
||||
DELETE FROM `tabAsset Movement Item`
|
||||
WHERE parent NOT IN (SELECT name FROM `tabAsset Movement`)
|
||||
"""
|
||||
)
|
||||
@@ -77,7 +77,7 @@ class Timesheet(Document):
|
||||
def set_status(self):
|
||||
self.status = {"0": "Draft", "1": "Submitted", "2": "Cancelled"}[str(self.docstatus or 0)]
|
||||
|
||||
if self.per_billed == 100:
|
||||
if flt(self.per_billed, self.precision("per_billed")) >= 100.0:
|
||||
self.status = "Billed"
|
||||
|
||||
if self.sales_invoice:
|
||||
|
||||
@@ -140,7 +140,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
}
|
||||
|
||||
if(this.frm.fields_dict["items"]) {
|
||||
this["items_remove"] = this.calculate_net_weight;
|
||||
this["items_remove"] = this.process_item_removal;
|
||||
}
|
||||
|
||||
if(this.frm.fields_dict["recurring_print_format"]) {
|
||||
@@ -1192,6 +1192,11 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
}
|
||||
}
|
||||
|
||||
process_item_removal() {
|
||||
this.frm.trigger("calculate_taxes_and_totals");
|
||||
this.frm.trigger("calculate_net_weight");
|
||||
}
|
||||
|
||||
calculate_net_weight(){
|
||||
/* Calculate Total Net Weight then further applied shipping rule to calculate shipping charges.*/
|
||||
var me = this;
|
||||
|
||||
@@ -40,7 +40,10 @@ $.extend(erpnext, {
|
||||
|
||||
is_perpetual_inventory_enabled: function(company) {
|
||||
if(company) {
|
||||
return frappe.get_doc(":Company", company).enable_perpetual_inventory
|
||||
let company_local = locals[":Company"] && locals[":Company"][company];
|
||||
if(company_local) {
|
||||
return cint(company_local.enable_perpetual_inventory);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -297,11 +297,35 @@ class TestCustomer(FrappeTestCase):
|
||||
if credit_limit > outstanding_amt:
|
||||
set_credit_limit("_Test Customer", "_Test Company", credit_limit)
|
||||
|
||||
# Makes Sales invoice from Sales Order
|
||||
so.save(ignore_permissions=True)
|
||||
si = make_sales_invoice(so.name)
|
||||
si.save(ignore_permissions=True)
|
||||
self.assertRaises(frappe.ValidationError, make_sales_order)
|
||||
def test_customer_credit_limit_after_submit(self):
|
||||
from erpnext.controllers.accounts_controller import update_child_qty_rate
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
outstanding_amt = self.get_customer_outstanding_amount()
|
||||
credit_limit = get_credit_limit("_Test Customer", "_Test Company")
|
||||
|
||||
if outstanding_amt <= 0.0:
|
||||
item_qty = int((abs(outstanding_amt) + 200) / 100)
|
||||
make_sales_order(qty=item_qty)
|
||||
|
||||
if credit_limit <= 0.0:
|
||||
set_credit_limit("_Test Customer", "_Test Company", outstanding_amt + 100)
|
||||
|
||||
so = make_sales_order(rate=100, qty=1)
|
||||
# Update qty in submitted Sales Order to trigger Credit Limit validation
|
||||
fields = ["name", "item_code", "delivery_date", "conversion_factor", "qty", "rate", "uom", "idx"]
|
||||
modified_item = frappe._dict()
|
||||
for x in fields:
|
||||
modified_item[x] = so.items[0].get(x)
|
||||
modified_item["docname"] = so.items[0].name
|
||||
modified_item["qty"] = 2
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
update_child_qty_rate,
|
||||
so.doctype,
|
||||
frappe.json.dumps([modified_item]),
|
||||
so.name,
|
||||
)
|
||||
|
||||
def test_customer_credit_limit_on_change(self):
|
||||
outstanding_amt = self.get_customer_outstanding_amount()
|
||||
|
||||
@@ -362,6 +362,9 @@ class SalesOrder(SellingController):
|
||||
def on_update(self):
|
||||
pass
|
||||
|
||||
def on_update_after_submit(self):
|
||||
self.check_credit_limit()
|
||||
|
||||
def before_update_after_submit(self):
|
||||
self.validate_po()
|
||||
self.validate_drop_ship()
|
||||
|
||||
@@ -229,7 +229,7 @@ erpnext.SalesFunnel = class SalesFunnel {
|
||||
context.fill();
|
||||
|
||||
// draw text
|
||||
context.fillStyle = "black";
|
||||
context.fillStyle = "";
|
||||
context.textBaseline = "middle";
|
||||
context.font = "1.1em sans-serif";
|
||||
context.fillText(__(title), width + 20, y_mid);
|
||||
|
||||
@@ -206,42 +206,36 @@ def prepare_data(
|
||||
|
||||
def get_actual_data(filters, sales_users_or_territory_data, date_field, sales_field):
|
||||
fiscal_year = get_fiscal_year(fiscal_year=filters.get("fiscal_year"), as_dict=1)
|
||||
dates = [fiscal_year.year_start_date, fiscal_year.year_end_date]
|
||||
|
||||
select_field = "`tab{0}`.{1}".format(filters.get("doctype"), sales_field)
|
||||
child_table = "`tab{0}`".format(filters.get("doctype") + " Item")
|
||||
parent_doc = frappe.qb.DocType(filters.get("doctype"))
|
||||
child_doc = frappe.qb.DocType(filters.get("doctype") + " Item")
|
||||
sales_team = frappe.qb.DocType("Sales Team")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(parent_doc)
|
||||
.inner_join(child_doc)
|
||||
.on(child_doc.parent == parent_doc.name)
|
||||
.inner_join(sales_team)
|
||||
.on(sales_team.parent == parent_doc.name)
|
||||
.select(
|
||||
child_doc.item_group,
|
||||
(child_doc.stock_qty * sales_team.allocated_percentage / 100).as_("stock_qty"),
|
||||
(child_doc.base_net_amount * sales_team.allocated_percentage / 100).as_("base_net_amount"),
|
||||
sales_team.sales_person,
|
||||
parent_doc[date_field],
|
||||
)
|
||||
.where(
|
||||
(parent_doc.docstatus == 1)
|
||||
& (parent_doc[date_field].between(fiscal_year.year_start_date, fiscal_year.year_end_date))
|
||||
)
|
||||
)
|
||||
|
||||
if sales_field == "sales_person":
|
||||
select_field = "`tabSales Team`.sales_person"
|
||||
child_table = "`tab{0}`, `tabSales Team`".format(filters.get("doctype") + " Item")
|
||||
cond = """`tabSales Team`.parent = `tab{0}`.name and
|
||||
`tabSales Team`.sales_person in ({1}) """.format(
|
||||
filters.get("doctype"), ",".join(["%s"] * len(sales_users_or_territory_data))
|
||||
)
|
||||
query = query.where(sales_team.sales_person.isin(sales_users_or_territory_data))
|
||||
else:
|
||||
cond = "`tab{0}`.{1} in ({2})".format(
|
||||
filters.get("doctype"), sales_field, ",".join(["%s"] * len(sales_users_or_territory_data))
|
||||
)
|
||||
query = query.where(parent_doc[sales_field].isin(sales_users_or_territory_data))
|
||||
|
||||
return frappe.db.sql(
|
||||
""" SELECT `tab{child_doc}`.item_group,
|
||||
`tab{child_doc}`.stock_qty, `tab{child_doc}`.base_net_amount,
|
||||
{select_field}, `tab{parent_doc}`.{date_field}
|
||||
FROM `tab{parent_doc}`, {child_table}
|
||||
WHERE
|
||||
`tab{child_doc}`.parent = `tab{parent_doc}`.name
|
||||
and `tab{parent_doc}`.docstatus = 1 and {cond}
|
||||
and `tab{parent_doc}`.{date_field} between %s and %s""".format(
|
||||
cond=cond,
|
||||
date_field=date_field,
|
||||
select_field=select_field,
|
||||
child_table=child_table,
|
||||
parent_doc=filters.get("doctype"),
|
||||
child_doc=filters.get("doctype") + " Item",
|
||||
),
|
||||
tuple(sales_users_or_territory_data + dates),
|
||||
as_dict=1,
|
||||
)
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_parents_data(filters, partner_doctype):
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import flt, nowdate
|
||||
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.sales_person_target_variance_based_on_item_group.sales_person_target_variance_based_on_item_group import (
|
||||
execute,
|
||||
)
|
||||
|
||||
|
||||
class TestSalesPersonTargetVarianceBasedOnItemGroup(FrappeTestCase):
|
||||
def setUp(self):
|
||||
self.fiscal_year = get_fiscal_year(nowdate())[0]
|
||||
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_achieved_target_and_variance(self):
|
||||
# Create a Target Distribution
|
||||
distribution = frappe.new_doc("Monthly Distribution")
|
||||
distribution.distribution_id = "Target Report Distribution"
|
||||
distribution.fiscal_year = self.fiscal_year
|
||||
distribution.get_months()
|
||||
distribution.insert()
|
||||
|
||||
# Create sales people with targets
|
||||
person_1 = create_sales_person_with_target("Sales Person 1", self.fiscal_year, distribution.name)
|
||||
person_2 = create_sales_person_with_target("Sales Person 2", self.fiscal_year, distribution.name)
|
||||
|
||||
# Create a Sales Order with 50-50 contribution
|
||||
so = make_sales_order(
|
||||
rate=1000,
|
||||
qty=20,
|
||||
do_not_submit=True,
|
||||
)
|
||||
so.set(
|
||||
"sales_team",
|
||||
[
|
||||
{
|
||||
"sales_person": person_1.name,
|
||||
"allocated_percentage": 50,
|
||||
"allocated_amount": 10000,
|
||||
},
|
||||
{
|
||||
"sales_person": person_2.name,
|
||||
"allocated_percentage": 50,
|
||||
"allocated_amount": 10000,
|
||||
},
|
||||
],
|
||||
)
|
||||
so.submit()
|
||||
|
||||
# Check Achieved Target and Variance
|
||||
result = execute(
|
||||
frappe._dict(
|
||||
{
|
||||
"fiscal_year": self.fiscal_year,
|
||||
"doctype": "Sales Order",
|
||||
"period": "Yearly",
|
||||
"target_on": "Quantity",
|
||||
}
|
||||
)
|
||||
)[1]
|
||||
row = frappe._dict(result[0])
|
||||
self.assertSequenceEqual(
|
||||
[flt(value, 2) for value in (row.total_target, row.total_achieved, row.total_variance)],
|
||||
[50, 10, -40],
|
||||
)
|
||||
|
||||
|
||||
def create_sales_person_with_target(sales_person_name, fiscal_year, distribution_id):
|
||||
sales_person = frappe.new_doc("Sales Person")
|
||||
sales_person.sales_person_name = sales_person_name
|
||||
sales_person.append(
|
||||
"targets",
|
||||
{
|
||||
"fiscal_year": fiscal_year,
|
||||
"target_qty": 50,
|
||||
"target_amount": 30000,
|
||||
"distribution_id": distribution_id,
|
||||
},
|
||||
)
|
||||
return sales_person.insert()
|
||||
@@ -36,6 +36,7 @@ def execute(filters=None):
|
||||
d.base_net_amount,
|
||||
d.sales_person,
|
||||
d.allocated_percentage,
|
||||
(d.stock_qty * d.allocated_percentage / 100),
|
||||
d.contribution_amt,
|
||||
company_currency,
|
||||
]
|
||||
@@ -103,7 +104,7 @@ def get_columns(filters):
|
||||
"fieldtype": "Link",
|
||||
"width": 140,
|
||||
},
|
||||
{"label": _("Qty"), "fieldname": "qty", "fieldtype": "Float", "width": 140},
|
||||
{"label": _("SO Total Qty"), "fieldname": "qty", "fieldtype": "Float", "width": 140},
|
||||
{
|
||||
"label": _("Amount"),
|
||||
"options": "currency",
|
||||
@@ -119,6 +120,12 @@ def get_columns(filters):
|
||||
"width": 140,
|
||||
},
|
||||
{"label": _("Contribution %"), "fieldname": "contribution", "fieldtype": "Float", "width": 140},
|
||||
{
|
||||
"label": _("Contribution Qty"),
|
||||
"fieldname": "contribution_qty",
|
||||
"fieldtype": "Float",
|
||||
"width": 140,
|
||||
},
|
||||
{
|
||||
"label": _("Contribution Amount"),
|
||||
"options": "currency",
|
||||
|
||||
@@ -199,6 +199,7 @@ frappe.ui.form.on('Material Request', {
|
||||
|
||||
get_item_data: function(frm, item, overwrite_warehouse=false) {
|
||||
if (item && !item.item_code) { return; }
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.stock.get_item_details.get_item_details",
|
||||
args: {
|
||||
@@ -225,20 +226,22 @@ frappe.ui.form.on('Material Request', {
|
||||
},
|
||||
callback: function(r) {
|
||||
const d = item;
|
||||
const qty_fields = ['actual_qty', 'projected_qty', 'min_order_qty'];
|
||||
const allow_to_change_fields = ['actual_qty', 'projected_qty', 'min_order_qty', 'item_name', 'description', 'stock_uom', 'uom', 'conversion_factor', 'stock_qty'];
|
||||
|
||||
if(!r.exc) {
|
||||
$.each(r.message, function(key, value) {
|
||||
if(!d[key] || qty_fields.includes(key)) {
|
||||
if(!d[key] || allow_to_change_fields.includes(key)) {
|
||||
d[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (d.price_list_rate != r.message.price_list_rate) {
|
||||
d.rate = 0.0;
|
||||
d.price_list_rate = r.message.price_list_rate;
|
||||
|
||||
frappe.model.set_value(d.doctype, d.name, "rate", d.price_list_rate);
|
||||
}
|
||||
|
||||
refresh_field("items");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -435,7 +438,7 @@ frappe.ui.form.on("Material Request Item", {
|
||||
frm.events.get_item_data(frm, item, false);
|
||||
},
|
||||
|
||||
rate: function(frm, doctype, name) {
|
||||
rate(frm, doctype, name) {
|
||||
const item = locals[doctype][name];
|
||||
item.amount = flt(item.qty) * flt(item.rate);
|
||||
frappe.model.set_value(doctype, name, "amount", item.amount);
|
||||
|
||||
@@ -2189,6 +2189,41 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
pr.cancel()
|
||||
self.assertTrue(frappe.db.exists("Batch", batch_no))
|
||||
|
||||
def test_pr_billed_amount_against_return_entry(self):
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_debit_note
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
make_purchase_invoice as make_pi_from_pr,
|
||||
)
|
||||
|
||||
# Create a Purchase Receipt and Fully Bill it
|
||||
pr = make_purchase_receipt(qty=10)
|
||||
pi = make_pi_from_pr(pr.name)
|
||||
pi.insert()
|
||||
pi.submit()
|
||||
|
||||
# Debit Note - 50% Qty & enable updating PR billed amount
|
||||
pi_return = make_debit_note(pi.name)
|
||||
pi_return.items[0].qty = -5
|
||||
pi_return.update_billed_amount_in_purchase_receipt = 1
|
||||
pi_return.submit()
|
||||
|
||||
# Check if the billed amount reduced
|
||||
pr.reload()
|
||||
self.assertEqual(pr.per_billed, 50)
|
||||
|
||||
pi_return.reload()
|
||||
pi_return.cancel()
|
||||
|
||||
# Debit Note - 50% Qty & disable updating PR billed amount
|
||||
pi_return = make_debit_note(pi.name)
|
||||
pi_return.items[0].qty = -5
|
||||
pi_return.update_billed_amount_in_purchase_receipt = 0
|
||||
pi_return.submit()
|
||||
|
||||
# Check if the billed amount stayed the same
|
||||
pr.reload()
|
||||
self.assertEqual(pr.per_billed, 100)
|
||||
|
||||
|
||||
def prepare_data_for_internal_transfer():
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
|
||||
|
||||
@@ -226,6 +226,7 @@ def on_doctype_update():
|
||||
|
||||
def repost(doc):
|
||||
try:
|
||||
frappe.flags.through_repost_item_valuation = True
|
||||
if not frappe.db.exists("Repost Item Valuation", doc.name):
|
||||
return
|
||||
|
||||
|
||||
@@ -702,10 +702,7 @@ class StockReconciliation(StockController):
|
||||
if allow_negative_stock:
|
||||
return True
|
||||
|
||||
if any(
|
||||
(d.batch_no and flt(d.qty) == flt(d.current_qty))
|
||||
for d in self.items
|
||||
):
|
||||
if any((d.batch_no and flt(d.qty) == flt(d.current_qty)) for d in self.items):
|
||||
allow_negative_stock = True
|
||||
|
||||
return allow_negative_stock
|
||||
|
||||
@@ -43,3 +43,6 @@ force_grid_wrap = 0
|
||||
use_parentheses = true
|
||||
ensure_newline_before_comments = true
|
||||
indent = "\t"
|
||||
|
||||
[tool.bench.frappe-dependencies]
|
||||
frappe = ">=14.0.0,<15.0.0"
|
||||
|
||||
Reference in New Issue
Block a user