mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-21 03:42:28 +00:00
Compare commits
174 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd9f0b255 | ||
|
|
54b9392cc5 | ||
|
|
51e7c66043 | ||
|
|
44f3f34c9e | ||
|
|
176d980764 | ||
|
|
44af175556 | ||
|
|
03f3a28f54 | ||
|
|
cceedd669f | ||
|
|
1a8dc7e332 | ||
|
|
49ab25dda8 | ||
|
|
e7a29abdb0 | ||
|
|
78b2e45cb9 | ||
|
|
4dff436104 | ||
|
|
f869e86c9c | ||
|
|
8569ff67ff | ||
|
|
b01049814a | ||
|
|
973444e20e | ||
|
|
68d213a244 | ||
|
|
6df39aec54 | ||
|
|
071a28ff8c | ||
|
|
1b08ac248b | ||
|
|
722dc8c3f1 | ||
|
|
e0013f7618 | ||
|
|
017635ab04 | ||
|
|
8f9a5e6c0c | ||
|
|
9a4c693f2d | ||
|
|
6179449036 | ||
|
|
a3ad1fb163 | ||
|
|
8e12bda108 | ||
|
|
947b282e0c | ||
|
|
f9ae22d85e | ||
|
|
b6902ef960 | ||
|
|
489ff20021 | ||
|
|
bd957a9bbc | ||
|
|
e22326065d | ||
|
|
55cce2a11c | ||
|
|
7630c01e40 | ||
|
|
bd4eb71205 | ||
|
|
9e10ecc4cb | ||
|
|
35bd43775c | ||
|
|
813f4644a0 | ||
|
|
2c1ea8d30c | ||
|
|
ffa0268a57 | ||
|
|
1ccbc9f621 | ||
|
|
799f897036 | ||
|
|
6b7bdfdfd3 | ||
|
|
e3374933ed | ||
|
|
3229fce9a5 | ||
|
|
d9d8fc6912 | ||
|
|
28367ac966 | ||
|
|
67632e81d0 | ||
|
|
9ee059465a | ||
|
|
1e4cafaa0e | ||
|
|
fc3ceff42f | ||
|
|
c74a44e526 | ||
|
|
8b3d65ae78 | ||
|
|
0e9b3b459a | ||
|
|
46a1c6fda0 | ||
|
|
cbe5ad6337 | ||
|
|
17ce550417 | ||
|
|
430705f56c | ||
|
|
0a3f9f0b9f | ||
|
|
cb24d9404d | ||
|
|
bc6780d4c7 | ||
|
|
8b16c310f4 | ||
|
|
76e910e8c0 | ||
|
|
8aede87290 | ||
|
|
1b2c7ca21f | ||
|
|
c44ec7eab4 | ||
|
|
e9c1a09af3 | ||
|
|
db3a40409f | ||
|
|
041f99c926 | ||
|
|
8843068da9 | ||
|
|
2df574baae | ||
|
|
6e438e71eb | ||
|
|
1604c21602 | ||
|
|
97c4cd140b | ||
|
|
9d64d4ac05 | ||
|
|
5de4102dda | ||
|
|
39a473455d | ||
|
|
b88f3f69b0 | ||
|
|
dba8abbabf | ||
|
|
c1591c37db | ||
|
|
3a2dc6f9ee | ||
|
|
4f1203dbd0 | ||
|
|
c70259687a | ||
|
|
8408e81335 | ||
|
|
a56d6984d1 | ||
|
|
deb67db4a0 | ||
|
|
a26c845332 | ||
|
|
bcd6d99549 | ||
|
|
d39d076fba | ||
|
|
21607f39c5 | ||
|
|
39a4760e07 | ||
|
|
d2c6a8958d | ||
|
|
77545042a5 | ||
|
|
13f4ba1857 | ||
|
|
a87015e8e6 | ||
|
|
a2626ed55f | ||
|
|
0cc77274cb | ||
|
|
5b7e6eb831 | ||
|
|
1fb9c5244c | ||
|
|
e68eece3da | ||
|
|
b8063a07fc | ||
|
|
8b42fcf274 | ||
|
|
0063201818 | ||
|
|
2f9643d44d | ||
|
|
0505684d22 | ||
|
|
0b958136be | ||
|
|
652bd396d4 | ||
|
|
904ac62830 | ||
|
|
2fee39017c | ||
|
|
0b0dccd294 | ||
|
|
99df61a0d8 | ||
|
|
7767659b87 | ||
|
|
84d5b52483 | ||
|
|
eee6d7e566 | ||
|
|
d690a0c6bd | ||
|
|
8f01d12b5e | ||
|
|
44d40795df | ||
|
|
841b507502 | ||
|
|
20f81516cf | ||
|
|
229dc23f97 | ||
|
|
f9b1df3572 | ||
|
|
1063a56251 | ||
|
|
75eb5ad584 | ||
|
|
43c507570b | ||
|
|
df049cd277 | ||
|
|
b8ddc2f2b9 | ||
|
|
b87b445802 | ||
|
|
e9ce0a41e6 | ||
|
|
583c7b9819 | ||
|
|
ef15c0581d | ||
|
|
835ae27b38 | ||
|
|
849b2e6ebf | ||
|
|
44f2e9480d | ||
|
|
55ee1dcd04 | ||
|
|
c81c1ea869 | ||
|
|
831ddcd5af | ||
|
|
bcf59e7171 | ||
|
|
ee812687e6 | ||
|
|
14085de332 | ||
|
|
22652f30db | ||
|
|
7794f3033e | ||
|
|
21805bde1f | ||
|
|
93bfd62725 | ||
|
|
def62cf3fe | ||
|
|
ea3fcc214b | ||
|
|
1146c9550a | ||
|
|
c5edeae97e | ||
|
|
f855cc89c9 | ||
|
|
1ffbc399e1 | ||
|
|
13eab9f993 | ||
|
|
97684d3dae | ||
|
|
7a227e048e | ||
|
|
af0116cdc5 | ||
|
|
a71d32e668 | ||
|
|
e33abeef7f | ||
|
|
cb0a548a95 | ||
|
|
b57db06100 | ||
|
|
05d6cf5c9a | ||
|
|
67183ad90c | ||
|
|
7f72189665 | ||
|
|
3c327d5225 | ||
|
|
62d58702a0 | ||
|
|
1d36cb55cd | ||
|
|
3fbfad1b9b | ||
|
|
2597eaad51 | ||
|
|
39aaefc202 | ||
|
|
75344e9e82 | ||
|
|
22774fdf87 | ||
|
|
e159c79766 | ||
|
|
94fe32f189 | ||
|
|
efdb004f0b |
@@ -4,7 +4,7 @@ import inspect
|
||||
import frappe
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "15.103.0"
|
||||
__version__ = "15.106.0"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -82,13 +82,15 @@ class AccountingDimension(Document):
|
||||
else:
|
||||
frappe.throw(_("Company {0} is added more than once").format(frappe.bold(default.company)))
|
||||
|
||||
def after_insert(self):
|
||||
def on_update(self):
|
||||
if frappe.flags.in_test:
|
||||
make_dimension_in_accounting_doctypes(doc=self)
|
||||
else:
|
||||
frappe.enqueue(
|
||||
make_dimension_in_accounting_doctypes, doc=self, queue="long", enqueue_after_commit=True
|
||||
)
|
||||
frappe.flags.accounting_dimensions = None
|
||||
frappe.flags.accounting_dimensions_details = None
|
||||
|
||||
def on_trash(self):
|
||||
if frappe.flags.in_test:
|
||||
@@ -103,10 +105,6 @@ class AccountingDimension(Document):
|
||||
if not self.fieldname:
|
||||
self.fieldname = scrub(self.label)
|
||||
|
||||
def on_update(self):
|
||||
frappe.flags.accounting_dimensions = None
|
||||
frappe.flags.accounting_dimensions_details = None
|
||||
|
||||
|
||||
def make_dimension_in_accounting_doctypes(doc, doclist=None):
|
||||
if not doclist:
|
||||
|
||||
@@ -398,7 +398,7 @@ def add_vouchers(gl_account="_Test Bank - _TC"):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Customer",
|
||||
"customer_group": "All Customer Groups",
|
||||
"customer_group": "Individual",
|
||||
"customer_type": "Company",
|
||||
"customer_name": "Poore Simon's",
|
||||
}
|
||||
@@ -429,7 +429,7 @@ def add_vouchers(gl_account="_Test Bank - _TC"):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Customer",
|
||||
"customer_group": "All Customer Groups",
|
||||
"customer_group": "Individual",
|
||||
"customer_type": "Company",
|
||||
"customer_name": "Fayva",
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ def make_customer(customer=None):
|
||||
{
|
||||
"doctype": "Customer",
|
||||
"customer_name": customer_name,
|
||||
"customer_group": "All Customer Groups",
|
||||
"customer_group": "Individual",
|
||||
"customer_type": "Company",
|
||||
"territory": "All Territories",
|
||||
}
|
||||
|
||||
@@ -2306,22 +2306,20 @@ def get_outstanding_reference_documents(args, validate=False):
|
||||
# Get positive outstanding sales /purchase invoices
|
||||
condition = ""
|
||||
if args.get("voucher_type") and args.get("voucher_no"):
|
||||
condition = " and voucher_type={} and voucher_no={}".format(
|
||||
frappe.db.escape(args["voucher_type"]), frappe.db.escape(args["voucher_no"])
|
||||
)
|
||||
condition = f" and voucher_type={frappe.db.escape(args['voucher_type'])} and voucher_no={frappe.db.escape(args['voucher_no'])}"
|
||||
common_filter.append(ple.voucher_type == args["voucher_type"])
|
||||
common_filter.append(ple.voucher_no == args["voucher_no"])
|
||||
|
||||
# Add cost center condition
|
||||
if args.get("cost_center"):
|
||||
condition += " and cost_center='%s'" % args.get("cost_center")
|
||||
condition += f" and cost_center={frappe.db.escape(args.get('cost_center'))}"
|
||||
accounting_dimensions_filter.append(ple.cost_center == args.get("cost_center"))
|
||||
|
||||
# dynamic dimension filters
|
||||
active_dimensions = get_dimensions()[0]
|
||||
for dim in active_dimensions:
|
||||
if args.get(dim.fieldname):
|
||||
condition += f" and {dim.fieldname}='{args.get(dim.fieldname)}'"
|
||||
condition += f" and {dim.fieldname}={frappe.db.escape(args.get(dim.fieldname))}"
|
||||
accounting_dimensions_filter.append(ple[dim.fieldname] == args.get(dim.fieldname))
|
||||
|
||||
date_fields_dict = {
|
||||
@@ -2331,17 +2329,15 @@ def get_outstanding_reference_documents(args, validate=False):
|
||||
|
||||
for fieldname, date_fields in date_fields_dict.items():
|
||||
if args.get(date_fields[0]) and args.get(date_fields[1]):
|
||||
condition += " and {} between '{}' and '{}'".format(
|
||||
fieldname, args.get(date_fields[0]), args.get(date_fields[1])
|
||||
)
|
||||
condition += f" and {fieldname} between {frappe.db.escape(args.get(date_fields[0]))} and {frappe.db.escape(args.get(date_fields[1]))}"
|
||||
posting_and_due_date.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])])
|
||||
elif args.get(date_fields[0]):
|
||||
# if only from date is supplied
|
||||
condition += f" and {fieldname} >= '{args.get(date_fields[0])}'"
|
||||
condition += f" and {fieldname} >= {frappe.db.escape(args.get(date_fields[0]))}"
|
||||
posting_and_due_date.append(ple[fieldname].gte(args.get(date_fields[0])))
|
||||
elif args.get(date_fields[1]):
|
||||
# if only to date is supplied
|
||||
condition += f" and {fieldname} <= '{args.get(date_fields[1])}'"
|
||||
condition += f" and {fieldname} <= {frappe.db.escape(args.get(date_fields[1]))}"
|
||||
posting_and_due_date.append(ple[fieldname].lte(args.get(date_fields[1])))
|
||||
|
||||
if args.get("company"):
|
||||
@@ -2561,7 +2557,7 @@ def get_orders_to_be_billed(
|
||||
active_dimensions = get_dimensions(True)[0]
|
||||
for dim in active_dimensions:
|
||||
if filters.get(dim.fieldname):
|
||||
condition += f" and {dim.fieldname}='{filters.get(dim.fieldname)}'"
|
||||
condition += f" and {dim.fieldname}={frappe.db.escape(filters.get(dim.fieldname))}"
|
||||
|
||||
if party_account_currency == company_currency:
|
||||
grand_total_field = "base_grand_total"
|
||||
|
||||
@@ -200,6 +200,30 @@ class TestPaymentEntry(FrappeTestCase):
|
||||
outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"))
|
||||
self.assertEqual(outstanding_amount, 100)
|
||||
|
||||
def test_reference_outstanding_amount_on_advance_pull(self):
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
||||
|
||||
so = make_sales_order(qty=1, rate=1000)
|
||||
pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC")
|
||||
pe.paid_amount = pe.received_amount = 500
|
||||
pe.references[0].allocated_amount = 500
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
so.reload()
|
||||
self.assertEqual(so.advance_paid, 500)
|
||||
|
||||
si = make_sales_invoice(so.name)
|
||||
si.allocate_advances_automatically = 1
|
||||
si.save()
|
||||
self.assertEqual(si.get("advances")[0].allocated_amount, 500)
|
||||
self.assertEqual(si.get("advances")[0].reference_name, pe.name)
|
||||
si.submit()
|
||||
|
||||
pe.load_from_db()
|
||||
self.assertEqual(pe.references[0].reference_name, si.name)
|
||||
self.assertEqual(pe.references[0].outstanding_amount, si.outstanding_amount)
|
||||
|
||||
def test_payment_entry_against_pi(self):
|
||||
pi = make_purchase_invoice(
|
||||
supplier="_Test Supplier USD",
|
||||
@@ -1937,6 +1961,37 @@ class TestPaymentEntry(FrappeTestCase):
|
||||
self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, pe.doctype, pe.name)
|
||||
self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, "Journal Entry", jv[0])
|
||||
|
||||
def test_project_name_in_exchange_gain_loss_entry(self):
|
||||
si = create_sales_invoice(
|
||||
customer="_Test Customer USD",
|
||||
debit_to="_Test Receivable USD - _TC",
|
||||
currency="USD",
|
||||
conversion_rate=50,
|
||||
do_not_submit=True,
|
||||
)
|
||||
from erpnext.projects.doctype.project.test_project import make_project
|
||||
|
||||
si.project = make_project({"project_name": "_Test Project for Exchange Gain Loss Entry"}).name
|
||||
|
||||
si.submit()
|
||||
|
||||
pe = get_payment_entry("Sales Invoice", si.name)
|
||||
|
||||
pe.source_exchange_rate = 100
|
||||
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
rows = frappe.get_all(
|
||||
"Journal Entry Account",
|
||||
or_filters=[{"reference_name": pe.name}, {"reference_name": si.name}],
|
||||
fields=["project"],
|
||||
)
|
||||
self.assertEqual(len(rows), 2)
|
||||
|
||||
self.assertEqual(rows[0].project, si.project)
|
||||
self.assertEqual(rows[1].project, si.project)
|
||||
|
||||
|
||||
def create_payment_entry(**args):
|
||||
payment_entry = frappe.new_doc("Payment Entry")
|
||||
@@ -2043,6 +2098,7 @@ def create_customer(name="_Test Customer 2 USD", currency="USD"):
|
||||
customer.customer_name = name
|
||||
customer.default_currency = currency
|
||||
customer.type = "Individual"
|
||||
customer.customer_group = "Individual"
|
||||
customer.save()
|
||||
customer = customer.name
|
||||
return customer
|
||||
|
||||
@@ -80,6 +80,7 @@ class TestPaymentLedgerEntry(FrappeTestCase):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = name
|
||||
customer.type = "Individual"
|
||||
customer.customer_group = "Individual"
|
||||
customer.save()
|
||||
self.customer = customer.name
|
||||
|
||||
|
||||
@@ -2546,6 +2546,7 @@ def make_customer(customer_name, currency=None):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = customer_name
|
||||
customer.type = "Individual"
|
||||
customer.customer_group = "Individual"
|
||||
|
||||
if currency:
|
||||
customer.default_currency = currency
|
||||
|
||||
@@ -46,8 +46,8 @@ frappe.ui.form.on("Period Closing Voucher", {
|
||||
function () {
|
||||
frappe.route_options = {
|
||||
voucher_no: frm.doc.name,
|
||||
from_date: frm.doc.posting_date,
|
||||
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
|
||||
from_date: frm.doc.period_start_date,
|
||||
to_date: frm.doc.period_end_date,
|
||||
company: frm.doc.company,
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
|
||||
@@ -812,6 +812,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fetch_from": "item_code.grant_commission",
|
||||
"fieldname": "grant_commission",
|
||||
"fieldtype": "Check",
|
||||
"label": "Grant Commission",
|
||||
@@ -858,7 +859,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-05-07 15:56:54.343317",
|
||||
"modified": "2026-04-20 16:16:12.322024",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice Item",
|
||||
|
||||
@@ -658,7 +658,7 @@ def get_product_discount_rule(pricing_rule, item_details, args=None, doc=None):
|
||||
if pricing_rule.is_recursive:
|
||||
transaction_qty = sum(
|
||||
[
|
||||
row.qty
|
||||
flt(row.qty)
|
||||
for row in doc.items
|
||||
if not row.is_free_item
|
||||
and row.item_code == args.item_code
|
||||
|
||||
@@ -21,10 +21,12 @@ frappe.ui.form.on("Promotional Scheme", {
|
||||
|
||||
selling: function (frm) {
|
||||
frm.trigger("set_options_for_applicable_for");
|
||||
frm.toggle_enable("buying", !frm.doc.selling);
|
||||
},
|
||||
|
||||
buying: function (frm) {
|
||||
frm.trigger("set_options_for_applicable_for");
|
||||
frm.toggle_enable("selling", !frm.doc.buying);
|
||||
},
|
||||
|
||||
set_options_for_applicable_for: function (frm) {
|
||||
|
||||
@@ -978,6 +978,10 @@ class PurchaseInvoice(BuyingController):
|
||||
if provisional_accounting_for_non_stock_items:
|
||||
self.get_provisional_accounts()
|
||||
|
||||
adjust_incoming_rate = frappe.db.get_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
|
||||
)
|
||||
|
||||
for item in self.get("items"):
|
||||
if flt(item.base_net_amount) or (self.get("update_stock") and item.valuation_rate):
|
||||
if item.item_code:
|
||||
@@ -1146,7 +1150,11 @@ class PurchaseInvoice(BuyingController):
|
||||
)
|
||||
|
||||
# check if the exchange rate has changed
|
||||
if item.get("purchase_receipt") and self.auto_accounting_for_stock:
|
||||
if (
|
||||
not adjust_incoming_rate
|
||||
and item.get("purchase_receipt")
|
||||
and self.auto_accounting_for_stock
|
||||
):
|
||||
if (
|
||||
exchange_rate_map[item.purchase_receipt]
|
||||
and self.conversion_rate != exchange_rate_map[item.purchase_receipt]
|
||||
@@ -1183,6 +1191,7 @@ class PurchaseInvoice(BuyingController):
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
self.auto_accounting_for_stock
|
||||
and self.is_opening == "No"
|
||||
|
||||
@@ -356,6 +356,12 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
make_purchase_invoice as create_purchase_invoice,
|
||||
)
|
||||
|
||||
original_value = frappe.db.get_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
|
||||
)
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
company="_Test Company with perpetual inventory",
|
||||
warehouse="Stores - TCP1",
|
||||
@@ -376,12 +382,17 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
amount = frappe.db.get_value(
|
||||
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit"
|
||||
)
|
||||
|
||||
discrepancy_caused_by_exchange_rate_diff = abs(
|
||||
pi.items[0].base_net_amount - pr.items[0].base_net_amount
|
||||
)
|
||||
|
||||
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
|
||||
|
||||
frappe.db.set_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
|
||||
)
|
||||
|
||||
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
make_purchase_invoice as create_purchase_invoice,
|
||||
|
||||
@@ -731,7 +731,6 @@
|
||||
"label": "Valuation Rate",
|
||||
"no_copy": 1,
|
||||
"options": "Company:company:default_currency",
|
||||
"precision": "6",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -984,7 +983,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-14 13:01:54.441511",
|
||||
"modified": "2026-04-07 15:41:45.687554",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice Item",
|
||||
|
||||
@@ -166,13 +166,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Show buttons only when pos view is active
|
||||
if (cint(doc.docstatus == 0) && cur_frm.page.current_view_name !== "pos" && !doc.is_return) {
|
||||
this.frm.cscript.sales_order_btn();
|
||||
this.frm.cscript.delivery_note_btn();
|
||||
this.frm.cscript.quotation_btn();
|
||||
}
|
||||
this.toggle_get_items();
|
||||
|
||||
this.set_default_print_format();
|
||||
if (doc.docstatus == 1 && !doc.inter_company_invoice_reference) {
|
||||
@@ -258,6 +252,93 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
}
|
||||
}
|
||||
|
||||
toggle_get_items() {
|
||||
const buttons = ["Sales Order", "Quotation", "Timesheet", "Delivery Note"];
|
||||
|
||||
buttons.forEach((label) => {
|
||||
this.frm.remove_custom_button(label, "Get Items From");
|
||||
});
|
||||
|
||||
if (cint(this.frm.doc.docstatus) !== 0 || this.frm.page.current_view_name === "pos") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.frm.doc.is_return) {
|
||||
this.frm.cscript.sales_order_btn();
|
||||
this.frm.cscript.quotation_btn();
|
||||
this.frm.cscript.timesheet_btn();
|
||||
}
|
||||
|
||||
this.frm.cscript.delivery_note_btn();
|
||||
}
|
||||
|
||||
timesheet_btn() {
|
||||
var me = this;
|
||||
|
||||
me.frm.add_custom_button(
|
||||
__("Timesheet"),
|
||||
function () {
|
||||
let d = new frappe.ui.Dialog({
|
||||
title: __("Fetch Timesheet"),
|
||||
fields: [
|
||||
{
|
||||
label: __("From"),
|
||||
fieldname: "from_time",
|
||||
fieldtype: "Date",
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
label: __("Item Code"),
|
||||
fieldname: "item_code",
|
||||
fieldtype: "Link",
|
||||
options: "Item",
|
||||
get_query: () => {
|
||||
return {
|
||||
query: "erpnext.controllers.queries.item_query",
|
||||
filters: {
|
||||
is_sales_item: 1,
|
||||
customer: me.frm.doc.customer,
|
||||
has_variants: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldtype: "Column Break",
|
||||
fieldname: "col_break_1",
|
||||
},
|
||||
{
|
||||
label: __("To"),
|
||||
fieldname: "to_time",
|
||||
fieldtype: "Date",
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
label: __("Project"),
|
||||
fieldname: "project",
|
||||
fieldtype: "Link",
|
||||
options: "Project",
|
||||
default: me.frm.doc.project,
|
||||
},
|
||||
],
|
||||
primary_action: function () {
|
||||
const data = d.get_values();
|
||||
me.frm.events.add_timesheet_data(me.frm, {
|
||||
from_time: data.from_time,
|
||||
to_time: data.to_time,
|
||||
project: data.project,
|
||||
item_code: data.item_code,
|
||||
});
|
||||
d.hide();
|
||||
},
|
||||
primary_action_label: __("Get Timesheets"),
|
||||
});
|
||||
d.show();
|
||||
},
|
||||
__("Get Items From")
|
||||
);
|
||||
}
|
||||
|
||||
sales_order_btn() {
|
||||
var me = this;
|
||||
this.$sales_order_btn = this.frm.add_custom_button(
|
||||
@@ -322,6 +403,12 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
this.$delivery_note_btn = this.frm.add_custom_button(
|
||||
__("Delivery Note"),
|
||||
function () {
|
||||
if (!me.frm.doc.customer) {
|
||||
frappe.throw({
|
||||
title: __("Mandatory"),
|
||||
message: __("Please Select a Customer"),
|
||||
});
|
||||
}
|
||||
erpnext.utils.map_current_doc({
|
||||
method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
|
||||
source_doctype: "Delivery Note",
|
||||
@@ -334,7 +421,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
var filters = {
|
||||
docstatus: 1,
|
||||
company: me.frm.doc.company,
|
||||
is_return: 0,
|
||||
is_return: me.frm.doc.is_return,
|
||||
};
|
||||
if (me.frm.doc.customer) filters["customer"] = me.frm.doc.customer;
|
||||
return {
|
||||
@@ -594,6 +681,14 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
|
||||
this.calculate_taxes_and_totals();
|
||||
}
|
||||
|
||||
apply_tds(frm) {
|
||||
this.frm.clear_table("tax_withholding_entries");
|
||||
}
|
||||
|
||||
is_return() {
|
||||
this.toggle_get_items();
|
||||
}
|
||||
};
|
||||
|
||||
// for backward compatibility: combine new and previous states
|
||||
@@ -1039,71 +1134,6 @@ frappe.ui.form.on("Sales Invoice", {
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.docstatus === 0 && !frm.doc.is_return) {
|
||||
frm.add_custom_button(
|
||||
__("Timesheet"),
|
||||
function () {
|
||||
let d = new frappe.ui.Dialog({
|
||||
title: __("Fetch Timesheet"),
|
||||
fields: [
|
||||
{
|
||||
label: __("From"),
|
||||
fieldname: "from_time",
|
||||
fieldtype: "Date",
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
label: __("Item Code"),
|
||||
fieldname: "item_code",
|
||||
fieldtype: "Link",
|
||||
options: "Item",
|
||||
get_query: () => {
|
||||
return {
|
||||
query: "erpnext.controllers.queries.item_query",
|
||||
filters: {
|
||||
is_sales_item: 1,
|
||||
customer: frm.doc.customer,
|
||||
has_variants: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldtype: "Column Break",
|
||||
fieldname: "col_break_1",
|
||||
},
|
||||
{
|
||||
label: __("To"),
|
||||
fieldname: "to_time",
|
||||
fieldtype: "Date",
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
label: __("Project"),
|
||||
fieldname: "project",
|
||||
fieldtype: "Link",
|
||||
options: "Project",
|
||||
default: frm.doc.project,
|
||||
},
|
||||
],
|
||||
primary_action: function () {
|
||||
const data = d.get_values();
|
||||
frm.events.add_timesheet_data(frm, {
|
||||
from_time: data.from_time,
|
||||
to_time: data.to_time,
|
||||
project: data.project,
|
||||
item_code: data.item_code,
|
||||
});
|
||||
d.hide();
|
||||
},
|
||||
primary_action_label: __("Get Timesheets"),
|
||||
});
|
||||
d.show();
|
||||
},
|
||||
__("Get Items From")
|
||||
);
|
||||
}
|
||||
|
||||
if (frm.doc.is_debit_note) {
|
||||
frm.set_df_property("return_against", "label", __("Adjustment Against"));
|
||||
}
|
||||
|
||||
@@ -777,8 +777,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.total_billing_amount > 0",
|
||||
"depends_on": "eval:!doc.is_return",
|
||||
"collapsible_depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0",
|
||||
"fieldname": "time_sheet_list",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_border": 1,
|
||||
@@ -792,7 +791,6 @@
|
||||
"hide_days": 1,
|
||||
"hide_seconds": 1,
|
||||
"label": "Time Sheets",
|
||||
"no_copy": 1,
|
||||
"options": "Sales Invoice Timesheet",
|
||||
"print_hide": 1
|
||||
},
|
||||
@@ -2112,7 +2110,7 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:(!doc.is_return && doc.total_billing_amount > 0)",
|
||||
"depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0",
|
||||
"fieldname": "section_break_104",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
@@ -2200,7 +2198,7 @@
|
||||
"link_fieldname": "consolidated_invoice"
|
||||
}
|
||||
],
|
||||
"modified": "2026-02-05 20:43:44.732805",
|
||||
"modified": "2026-04-06 22:30:28.513139",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice",
|
||||
|
||||
@@ -323,10 +323,22 @@ class SalesInvoice(SellingController):
|
||||
)
|
||||
|
||||
self.set_against_income_account()
|
||||
self.validate_time_sheets_are_submitted()
|
||||
|
||||
if self.is_return and not self.return_against and self.timesheets:
|
||||
frappe.throw(_("Direct return is not allowed for Timesheet."))
|
||||
|
||||
if not self.is_return:
|
||||
self.validate_time_sheets_are_submitted()
|
||||
|
||||
self.validate_multiple_billing("Delivery Note", "dn_detail", "amount")
|
||||
if self.is_return:
|
||||
self.timesheets = []
|
||||
|
||||
if self.is_return and self.return_against:
|
||||
for row in self.timesheets:
|
||||
if row.billing_hours:
|
||||
row.billing_hours = -abs(row.billing_hours)
|
||||
if row.billing_amount:
|
||||
row.billing_amount = -abs(row.billing_amount)
|
||||
|
||||
self.update_packing_list()
|
||||
self.set_billing_hours_and_amount()
|
||||
self.update_timesheet_billing_for_project()
|
||||
@@ -494,7 +506,7 @@ class SalesInvoice(SellingController):
|
||||
if not cint(self.is_pos) == 1 and not self.is_return:
|
||||
self.update_against_document_in_jv()
|
||||
|
||||
self.update_time_sheet(self.name)
|
||||
self.update_time_sheet(None if (self.is_return and self.return_against) else self.name)
|
||||
|
||||
if frappe.db.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction":
|
||||
update_company_current_month_sales(self.company)
|
||||
@@ -550,7 +562,7 @@ class SalesInvoice(SellingController):
|
||||
self.check_if_consolidated_invoice()
|
||||
|
||||
super().before_cancel()
|
||||
self.update_time_sheet(None)
|
||||
self.update_time_sheet(self.return_against if (self.is_return and self.return_against) else None)
|
||||
|
||||
def on_cancel(self):
|
||||
check_if_return_invoice_linked_with_payment_entry(self)
|
||||
@@ -735,8 +747,20 @@ class SalesInvoice(SellingController):
|
||||
for data in timesheet.time_logs:
|
||||
if (
|
||||
(self.project and args.timesheet_detail == data.name)
|
||||
or (not self.project and not data.sales_invoice)
|
||||
or (not sales_invoice and data.sales_invoice == self.name)
|
||||
or (not self.project and not data.sales_invoice and args.timesheet_detail == data.name)
|
||||
or (
|
||||
not sales_invoice
|
||||
and data.sales_invoice == self.name
|
||||
and args.timesheet_detail == data.name
|
||||
)
|
||||
or (
|
||||
self.is_return
|
||||
and self.return_against
|
||||
and data.sales_invoice
|
||||
and data.sales_invoice == self.return_against
|
||||
and not sales_invoice
|
||||
and args.timesheet_detail == data.name
|
||||
)
|
||||
):
|
||||
data.sales_invoice = sales_invoice
|
||||
|
||||
@@ -776,11 +800,25 @@ class SalesInvoice(SellingController):
|
||||
payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account")
|
||||
|
||||
def validate_time_sheets_are_submitted(self):
|
||||
# Note: This validation is skipped for return invoices
|
||||
# to allow returns to reference already-billed timesheet details
|
||||
for data in self.timesheets:
|
||||
# Handle invoice duplication
|
||||
if data.time_sheet and data.timesheet_detail:
|
||||
if sales_invoice := frappe.db.get_value(
|
||||
"Timesheet Detail", data.timesheet_detail, "sales_invoice"
|
||||
):
|
||||
frappe.throw(
|
||||
_("Row {0}: Sales Invoice {1} is already created for {2}").format(
|
||||
data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet)
|
||||
)
|
||||
)
|
||||
if data.time_sheet:
|
||||
status = frappe.db.get_value("Timesheet", data.time_sheet, "status")
|
||||
if status not in ["Submitted", "Payslip"]:
|
||||
frappe.throw(_("Timesheet {0} is already completed or cancelled").format(data.time_sheet))
|
||||
if status not in ["Submitted", "Payslip", "Partially Billed"]:
|
||||
frappe.throw(
|
||||
_("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet)
|
||||
)
|
||||
|
||||
def set_pos_fields(self, for_validate=False):
|
||||
"""Set retail related fields from POS Profiles"""
|
||||
@@ -1112,7 +1150,12 @@ class SalesInvoice(SellingController):
|
||||
timesheet.billing_amount = ts_doc.total_billable_amount
|
||||
|
||||
def update_timesheet_billing_for_project(self):
|
||||
if not self.timesheets and self.project and self.is_auto_fetch_timesheet_enabled():
|
||||
if (
|
||||
not self.is_return
|
||||
and not self.timesheets
|
||||
and self.project
|
||||
and self.is_auto_fetch_timesheet_enabled()
|
||||
):
|
||||
self.add_timesheet_data()
|
||||
else:
|
||||
self.calculate_billing_amount_for_timesheet()
|
||||
|
||||
@@ -2917,7 +2917,7 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
si.submit()
|
||||
|
||||
# Check if adjustment entry is created
|
||||
self.assertTrue(
|
||||
self.assertFalse(
|
||||
frappe.db.exists(
|
||||
"GL Entry",
|
||||
{
|
||||
@@ -3230,7 +3230,7 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
calculate_depreciation=1,
|
||||
submit=1,
|
||||
)
|
||||
post_depreciation_entries()
|
||||
post_depreciation_entries(date="2025-04-01")
|
||||
|
||||
si = create_sales_invoice(
|
||||
item_code="Macbook Pro", asset=asset.name, qty=1, rate=10000, posting_date=getdate("2025-05-01")
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Timesheet Detail",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -117,7 +116,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-10-02 03:48:44.979777",
|
||||
"modified": "2026-04-06 22:30:28.513139",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Timesheet",
|
||||
|
||||
@@ -25,6 +25,10 @@ frappe.ui.form.on("Shipping Rule", {
|
||||
},
|
||||
calculate_based_on: function (frm) {
|
||||
frm.trigger("toggle_reqd");
|
||||
if (frm.doc.calculate_based_on === "Fixed") {
|
||||
frm.clear_table("conditions");
|
||||
frm.refresh_field("conditions");
|
||||
}
|
||||
},
|
||||
toggle_reqd: function (frm) {
|
||||
frm.toggle_reqd("shipping_amount", frm.doc.calculate_based_on === "Fixed");
|
||||
|
||||
@@ -58,6 +58,11 @@ class ShippingRule(Document):
|
||||
self.validate_overlapping_shipping_rule_conditions()
|
||||
|
||||
def validate_from_to_values(self):
|
||||
if self.calculate_based_on == "Fixed":
|
||||
if self.conditions:
|
||||
self.set("conditions", [])
|
||||
return
|
||||
|
||||
zero_to_values = []
|
||||
|
||||
for d in self.get("conditions"):
|
||||
|
||||
@@ -629,18 +629,21 @@ def create_parties():
|
||||
customer.customer_name = "_Test Subscription Customer"
|
||||
customer.default_currency = "USD"
|
||||
customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"})
|
||||
customer.customer_group = "Individual"
|
||||
customer.insert()
|
||||
|
||||
if not frappe.db.exists("Customer", "_Test Subscription Customer Multi Currency"):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = "Test Subscription Customer Multi Currency"
|
||||
customer.default_currency = "USD"
|
||||
customer.customer_group = "Individual"
|
||||
customer.insert()
|
||||
|
||||
if not frappe.db.exists("Customer", "_Test Subscription Customer John Doe"):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = "_Test Subscription Customer John Doe"
|
||||
customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable - _TC"})
|
||||
customer.customer_group = "Individual"
|
||||
customer.insert()
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,17 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
},
|
||||
options: "Cost Center",
|
||||
},
|
||||
{
|
||||
fieldname: "project",
|
||||
label: __("Project"),
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Project",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Project", txt, {
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "party_account",
|
||||
label: __("Payable Account"),
|
||||
|
||||
@@ -120,3 +120,49 @@ class TestAccountsPayable(AccountsTestMixin, FrappeTestCase):
|
||||
|
||||
self.assertEqual(len(report[1]), 2)
|
||||
self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])
|
||||
|
||||
def test_project_filter(self):
|
||||
project = frappe.get_doc(
|
||||
{"doctype": "Project", "project_name": "_Test AP Project", "company": self.company}
|
||||
).insert()
|
||||
|
||||
pi = self.create_purchase_invoice(do_not_submit=True)
|
||||
pi.project = project.name
|
||||
pi.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"project": [project.name],
|
||||
}
|
||||
|
||||
report = execute(filters)[1]
|
||||
self.assertEqual(len(report), 1)
|
||||
row = report[0]
|
||||
self.assertEqual(row.project, project.name)
|
||||
self.assertEqual(row.invoiced, 300.0)
|
||||
|
||||
def test_project_on_report_output(self):
|
||||
"""
|
||||
Report row must carry the invoice's project.
|
||||
"""
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
|
||||
project = frappe.get_doc(
|
||||
{"doctype": "Project", "project_name": "_Test AP Project Output", "company": self.company}
|
||||
).insert()
|
||||
|
||||
pi = self.create_purchase_invoice(do_not_submit=True)
|
||||
pi.project = project.name
|
||||
pi.save().submit()
|
||||
|
||||
report = execute(filters)
|
||||
|
||||
self.assertEqual(len(report[1]), 1)
|
||||
row = report[1][0]
|
||||
self.assertEqual([pi.name, project.name, 300], [row.voucher_no, row.project, row.outstanding])
|
||||
|
||||
@@ -53,6 +53,17 @@ frappe.query_reports["Accounts Payable Summary"] = {
|
||||
},
|
||||
options: "Cost Center",
|
||||
},
|
||||
{
|
||||
fieldname: "project",
|
||||
label: __("Project"),
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Project",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Project", txt, {
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "party_type",
|
||||
label: __("Party Type"),
|
||||
|
||||
@@ -36,6 +36,17 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
},
|
||||
options: "Cost Center",
|
||||
},
|
||||
{
|
||||
fieldname: "project",
|
||||
label: __("Project"),
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Project",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Project", txt, {
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "party_type",
|
||||
label: __("Party Type"),
|
||||
|
||||
@@ -194,6 +194,7 @@ class ReceivablePayableReport:
|
||||
and ple.against_voucher_type in self.advance_payment_doctypes
|
||||
):
|
||||
self.voucher_balance[key].cost_center = ple.cost_center
|
||||
self.voucher_balance[key].project = ple.project
|
||||
|
||||
self.get_invoices(ple)
|
||||
|
||||
@@ -360,6 +361,7 @@ class ReceivablePayableReport:
|
||||
posting_date,
|
||||
account_currency,
|
||||
cost_center,
|
||||
project,
|
||||
sum(invoiced) `invoiced`,
|
||||
sum(paid) `paid`,
|
||||
sum(credit_note) `credit_note`,
|
||||
@@ -388,6 +390,7 @@ class ReceivablePayableReport:
|
||||
"credit_note_in_account_currency",
|
||||
"outstanding_in_account_currency",
|
||||
"cost_center",
|
||||
"project",
|
||||
]:
|
||||
_d[field] = x.get(field)
|
||||
|
||||
@@ -925,6 +928,7 @@ class ReceivablePayableReport:
|
||||
ple.against_voucher_no,
|
||||
ple.party_type,
|
||||
ple.cost_center,
|
||||
ple.project,
|
||||
ple.party,
|
||||
ple.posting_date,
|
||||
ple.due_date,
|
||||
@@ -992,6 +996,9 @@ class ReceivablePayableReport:
|
||||
if self.filters.cost_center:
|
||||
self.get_cost_center_conditions()
|
||||
|
||||
if self.filters.project:
|
||||
self.qb_selection_filter.append(self.ple.project.isin(self.filters.project))
|
||||
|
||||
self.add_accounting_dimensions_filters()
|
||||
|
||||
def get_cost_center_conditions(self):
|
||||
@@ -1231,6 +1238,7 @@ class ReceivablePayableReport:
|
||||
)
|
||||
|
||||
self.add_column(label=_("Cost Center"), fieldname="cost_center", fieldtype="Data")
|
||||
self.add_column(label=_("Project"), fieldname="project", fieldtype="Link", options="Project")
|
||||
self.add_column(label=_("Voucher Type"), fieldname="voucher_type", fieldtype="Data")
|
||||
self.add_column(
|
||||
label=_("Voucher No"),
|
||||
@@ -1403,6 +1411,7 @@ class InitSQLProceduresForAR:
|
||||
posting_date date,
|
||||
account_currency {_varchar_type},
|
||||
cost_center {_varchar_type},
|
||||
project {_varchar_type},
|
||||
invoiced {_currency_type},
|
||||
paid {_currency_type},
|
||||
credit_note {_currency_type},
|
||||
@@ -1422,6 +1431,7 @@ class InitSQLProceduresForAR:
|
||||
against_voucher_no {_varchar_type},
|
||||
party_type {_varchar_type},
|
||||
cost_center {_varchar_type},
|
||||
project {_varchar_type},
|
||||
party {_varchar_type},
|
||||
posting_date date,
|
||||
due_date date,
|
||||
@@ -1450,7 +1460,7 @@ class InitSQLProceduresForAR:
|
||||
begin
|
||||
if not exists (select name from `{_voucher_balance_name}` where name = `{genkey_function_name}`(ple, false))
|
||||
then
|
||||
insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, false), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, 0, 0, 0, 0, 0, 0);
|
||||
insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, false), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, ple.project, 0, 0, 0, 0, 0, 0);
|
||||
end if;
|
||||
end;
|
||||
"""
|
||||
@@ -1492,7 +1502,7 @@ class InitSQLProceduresForAR:
|
||||
|
||||
end if;
|
||||
|
||||
insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, true), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0);
|
||||
insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, true), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', '', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0);
|
||||
end;
|
||||
"""
|
||||
|
||||
|
||||
@@ -779,6 +779,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
|
||||
"customer_name": "Jane Doe",
|
||||
"type": "Individual",
|
||||
"default_currency": "USD",
|
||||
"customer_group": "Individual",
|
||||
}
|
||||
)
|
||||
.insert()
|
||||
@@ -1002,6 +1003,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
|
||||
"customer_name": "Jane Doe",
|
||||
"type": "Individual",
|
||||
"default_currency": "USD",
|
||||
"customer_group": "Individual",
|
||||
}
|
||||
)
|
||||
.insert()
|
||||
@@ -1202,3 +1204,52 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
|
||||
|
||||
self.assertEqual(len(report[1]), 2)
|
||||
self.assertEqual([si.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])
|
||||
|
||||
def test_project_filter(self):
|
||||
project = frappe.get_doc(
|
||||
{"doctype": "Project", "project_name": "_Test AR Project", "company": self.company}
|
||||
).insert()
|
||||
|
||||
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
|
||||
si.project = project.name
|
||||
si.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"project": [project.name],
|
||||
}
|
||||
|
||||
report = execute(filters)[1]
|
||||
self.assertEqual(len(report), 1)
|
||||
row = report[0]
|
||||
self.assertEqual(row.project, project.name)
|
||||
self.assertEqual(row.invoiced, 100.0)
|
||||
|
||||
def test_project_on_report_output(self):
|
||||
"""
|
||||
Report row must carry the invoice's project even when the payment entry
|
||||
has no project set.
|
||||
"""
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
|
||||
project = frappe.get_doc(
|
||||
{"doctype": "Project", "project_name": "_Test AR Project Output", "company": self.company}
|
||||
).insert()
|
||||
|
||||
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
|
||||
si.project = project.name
|
||||
si.save().submit()
|
||||
|
||||
# payment has no project — report row must still show the invoice's project
|
||||
self.create_payment_entry(si.name)
|
||||
report = execute(filters)
|
||||
|
||||
self.assertEqual(len(report[1]), 1)
|
||||
row = report[1][0]
|
||||
self.assertEqual([si.name, project.name, 60], [row.voucher_no, row.project, row.outstanding])
|
||||
|
||||
@@ -53,6 +53,17 @@ frappe.query_reports["Accounts Receivable Summary"] = {
|
||||
},
|
||||
options: "Cost Center",
|
||||
},
|
||||
{
|
||||
fieldname: "project",
|
||||
label: __("Project"),
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Project",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Project", txt, {
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "party_type",
|
||||
label: __("Party Type"),
|
||||
|
||||
@@ -82,6 +82,7 @@ class TestGrossProfit(FrappeTestCase):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = name
|
||||
customer.type = "Individual"
|
||||
customer.customer_group = "Individual"
|
||||
customer.save()
|
||||
self.customer = customer.name
|
||||
|
||||
|
||||
@@ -501,7 +501,7 @@ def get_invoice_tax_map(invoice_list, invoice_expense_map, expense_accounts, inc
|
||||
else sum(base_tax_amount_after_discount_amount) * -1 end as tax_amount
|
||||
from `tabPurchase Taxes and Charges`
|
||||
where parent in (%s) and category in ('Total', 'Valuation and Total')
|
||||
and base_tax_amount_after_discount_amount != 0
|
||||
and base_tax_amount_after_discount_amount != 0 and parenttype='Purchase Invoice'
|
||||
group by parent, account_head, add_deduct_tax
|
||||
"""
|
||||
% ", ".join(["%s"] * len(invoice_list)),
|
||||
|
||||
@@ -6,6 +6,7 @@ from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import add_months, today
|
||||
|
||||
from erpnext.accounts.report.purchase_register.purchase_register import execute
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
|
||||
|
||||
class TestPurchaseRegister(FrappeTestCase):
|
||||
@@ -26,6 +27,52 @@ class TestPurchaseRegister(FrappeTestCase):
|
||||
self.assertEqual(first_row.total_tax, 100)
|
||||
self.assertEqual(first_row.grand_total, 1100)
|
||||
|
||||
def test_purchase_register_ignores_tax_rows_from_other_doctype(self):
|
||||
frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company 6'")
|
||||
frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 6'")
|
||||
|
||||
filters = frappe._dict(company="_Test Company 6", from_date=add_months(today(), -1), to_date=today())
|
||||
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
# Real workflow setup: create a Purchase Receipt tax row in the same shared child table.
|
||||
pr = make_purchase_receipt(
|
||||
company="_Test Company 6",
|
||||
supplier="_Test Supplier",
|
||||
item="_Test Item",
|
||||
warehouse="_Test Warehouse - _TC6",
|
||||
cost_center="_Test Cost Center - _TC6",
|
||||
do_not_save=1,
|
||||
do_not_submit=1,
|
||||
qty=1,
|
||||
rate=1000,
|
||||
)
|
||||
pr.append(
|
||||
"taxes",
|
||||
{
|
||||
"account_head": "GST - _TC6",
|
||||
"cost_center": "_Test Cost Center - _TC6",
|
||||
"add_deduct_tax": "Add",
|
||||
"category": "Valuation and Total",
|
||||
"charge_type": "Actual",
|
||||
"description": "PR Tax",
|
||||
"tax_amount": 100.0,
|
||||
"rate": 100,
|
||||
},
|
||||
)
|
||||
pr.insert()
|
||||
pr.submit()
|
||||
|
||||
# Mimic custom naming collision across doctypes (same parent value in shared child table).
|
||||
frappe.rename_doc("Purchase Receipt", pr.name, pi.name, force=True)
|
||||
|
||||
report_results = execute(filters)
|
||||
first_row = frappe._dict(report_results[1][0])
|
||||
|
||||
self.assertEqual(first_row.voucher_no, pi.name)
|
||||
self.assertEqual(first_row.total_tax, 100)
|
||||
self.assertEqual(first_row.grand_total, 1100)
|
||||
|
||||
def test_purchase_register_ledger_view(self):
|
||||
frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company 6'")
|
||||
frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 6'")
|
||||
|
||||
@@ -5,6 +5,7 @@ from frappe.utils import getdate, today
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.sales_register.sales_register import execute
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
|
||||
class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase):
|
||||
@@ -75,6 +76,43 @@ class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase):
|
||||
report_output = {k: v for k, v in res[0].items() if k in expected_result}
|
||||
self.assertDictEqual(report_output, expected_result)
|
||||
|
||||
def test_sales_register_ignores_tax_rows_from_other_doctype(self):
|
||||
si = self.create_sales_invoice(rate=98)
|
||||
|
||||
# Real workflow setup: create a Sales Order with taxes in the shared child table.
|
||||
so = make_sales_order(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
rate=77,
|
||||
do_not_save=1,
|
||||
do_not_submit=1,
|
||||
)
|
||||
so.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "Actual",
|
||||
"account_head": self.income_account,
|
||||
"description": "SO Tax",
|
||||
"tax_amount": 55.0,
|
||||
},
|
||||
)
|
||||
so.insert()
|
||||
so.submit()
|
||||
|
||||
# Mimic custom naming collision across doctypes (same parent value in shared child table).
|
||||
frappe.rename_doc("Sales Order", so.name, si.name, force=True)
|
||||
|
||||
filters = frappe._dict({"from_date": today(), "to_date": today(), "company": self.company})
|
||||
report = execute(filters)
|
||||
|
||||
res = [x for x in report[1] if x.get("voucher_no") == si.name]
|
||||
self.assertEqual(len(res), 1)
|
||||
result = frappe._dict(res[0])
|
||||
self.assertEqual(result.net_total, 98.0)
|
||||
self.assertEqual(result.tax_total, 0)
|
||||
self.assertEqual(result.grand_total, 98.0)
|
||||
|
||||
def test_journal_with_cost_center_filter(self):
|
||||
je1 = frappe.get_doc(
|
||||
{
|
||||
|
||||
@@ -119,8 +119,8 @@ def get_result(filters, tds_accounts, tax_category_map, net_total_map):
|
||||
|
||||
row.update(
|
||||
{
|
||||
"section_code": tax_withholding_category or "",
|
||||
"entity_type": party_map.get(party, {}).get(party_type),
|
||||
"tax_withholding_category": tax_withholding_category or "",
|
||||
"party_entity_type": party_map.get(party, {}).get(party_type),
|
||||
"rate": rate,
|
||||
"total_amount": total_amount,
|
||||
"grand_total": grand_total,
|
||||
@@ -141,7 +141,7 @@ def get_result(filters, tds_accounts, tax_category_map, net_total_map):
|
||||
else:
|
||||
entries[key] = row
|
||||
out = list(entries.values())
|
||||
out.sort(key=lambda x: (x["section_code"], x["transaction_date"], x["ref_no"]))
|
||||
out.sort(key=lambda x: (x["tax_withholding_category"], x["transaction_date"], x["ref_no"]))
|
||||
|
||||
return out
|
||||
|
||||
@@ -205,9 +205,9 @@ def get_columns(filters):
|
||||
pan = "pan" if frappe.db.has_column(filters.party_type, "pan") else "tax_id"
|
||||
columns = [
|
||||
{
|
||||
"label": _("Section Code"),
|
||||
"label": _("Tax Withholding Category"),
|
||||
"options": "Tax Withholding Category",
|
||||
"fieldname": "section_code",
|
||||
"fieldname": "tax_withholding_category",
|
||||
"fieldtype": "Link",
|
||||
"width": 90,
|
||||
},
|
||||
@@ -236,7 +236,12 @@ def get_columns(filters):
|
||||
|
||||
columns.extend(
|
||||
[
|
||||
{"label": _("Entity Type"), "fieldname": "entity_type", "fieldtype": "Data", "width": 100},
|
||||
{
|
||||
"label": _(f"{filters.get('party_type', 'Party')} Type"),
|
||||
"fieldname": "party_entity_type",
|
||||
"fieldtype": "Data",
|
||||
"width": 100,
|
||||
},
|
||||
]
|
||||
)
|
||||
if filters.party_type == "Supplier":
|
||||
|
||||
@@ -118,7 +118,7 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase):
|
||||
voucher_expected_values = expected_values[i]
|
||||
voucher_actual_values = (
|
||||
voucher.ref_no,
|
||||
voucher.section_code,
|
||||
voucher.tax_withholding_category,
|
||||
voucher.rate,
|
||||
voucher.base_tax_withholding_net_total,
|
||||
voucher.base_total,
|
||||
|
||||
@@ -48,28 +48,25 @@ def group_by_party_and_category(data, filters):
|
||||
party_category_wise_map = {}
|
||||
|
||||
for row in data:
|
||||
key = (row.get("party_type"), row.get("party"), row.get("tax_withholding_category"))
|
||||
party_category_wise_map.setdefault(
|
||||
(row.get("party"), row.get("section_code")),
|
||||
key,
|
||||
{
|
||||
"pan": row.get("pan"),
|
||||
"tax_id": row.get("tax_id"),
|
||||
"party": row.get("party"),
|
||||
"party_type": row.get("party_type"),
|
||||
"party_name": row.get("party_name"),
|
||||
"section_code": row.get("section_code"),
|
||||
"entity_type": row.get("entity_type"),
|
||||
"tax_withholding_category": row.get("tax_withholding_category"),
|
||||
"party_entity_type": row.get("party_entity_type"),
|
||||
"rate": row.get("rate"),
|
||||
"total_amount": 0.0,
|
||||
"tax_amount": 0.0,
|
||||
},
|
||||
)
|
||||
|
||||
party_category_wise_map.get((row.get("party"), row.get("section_code")))["total_amount"] += row.get(
|
||||
"total_amount", 0.0
|
||||
)
|
||||
|
||||
party_category_wise_map.get((row.get("party"), row.get("section_code")))["tax_amount"] += row.get(
|
||||
"tax_amount", 0.0
|
||||
)
|
||||
party_category_wise_map.get(key)["total_amount"] += row.get("total_amount", 0.0)
|
||||
party_category_wise_map.get(key)["tax_amount"] += row.get("tax_amount", 0.0)
|
||||
|
||||
final_result = get_final_result(party_category_wise_map)
|
||||
|
||||
@@ -110,13 +107,18 @@ def get_columns(filters):
|
||||
columns.extend(
|
||||
[
|
||||
{
|
||||
"label": _("Section Code"),
|
||||
"label": _("Tax Withholding Category"),
|
||||
"options": "Tax Withholding Category",
|
||||
"fieldname": "section_code",
|
||||
"fieldname": "tax_withholding_category",
|
||||
"fieldtype": "Link",
|
||||
"width": 180,
|
||||
},
|
||||
{"label": _("Entity Type"), "fieldname": "entity_type", "fieldtype": "Data", "width": 180},
|
||||
{
|
||||
"label": _(f"{filters.get('party_type', 'Party')} Type"),
|
||||
"fieldname": "party_entity_type",
|
||||
"fieldtype": "Data",
|
||||
"width": 180,
|
||||
},
|
||||
{
|
||||
"label": _("TDS Rate %") if filters.get("party_type") == "Supplier" else _("TCS Rate %"),
|
||||
"fieldname": "rate",
|
||||
|
||||
@@ -12,6 +12,7 @@ class AccountsTestMixin:
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = customer_name
|
||||
customer.type = "Individual"
|
||||
customer.customer_group = "Individual"
|
||||
|
||||
if currency:
|
||||
customer.default_currency = currency
|
||||
@@ -36,6 +37,7 @@ class AccountsTestMixin:
|
||||
"account": default_account,
|
||||
},
|
||||
)
|
||||
customer.customer_group = "Individual"
|
||||
customer.save()
|
||||
self.customer = customer_name
|
||||
|
||||
|
||||
@@ -7,12 +7,8 @@ from erpnext.accounts.party import get_default_price_list
|
||||
class PartyTestCase(FrappeTestCase):
|
||||
def test_get_default_price_list_should_return_none_for_invalid_group(self):
|
||||
customer = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Customer",
|
||||
"customer_name": "test customer",
|
||||
}
|
||||
{"doctype": "Customer", "customer_name": "test customer", "customer_group": "Individual"}
|
||||
).insert(ignore_permissions=True, ignore_mandatory=True)
|
||||
customer.customer_group = None
|
||||
customer.save()
|
||||
price_list = get_default_price_list(customer)
|
||||
assert price_list is None
|
||||
|
||||
@@ -500,7 +500,7 @@ def reconcile_against_document(
|
||||
skip_ref_details_update_for_pe=skip_ref_details_update_for_pe,
|
||||
dimensions_dict=dimensions_dict,
|
||||
)
|
||||
if referenced_row.get("outstanding_amount"):
|
||||
if referenced_row.get("outstanding_amount") and entry.get("outstanding_amount") is None:
|
||||
referenced_row.outstanding_amount -= flt(entry.allocated_amount)
|
||||
|
||||
reposting_rows.append(referenced_row)
|
||||
@@ -2320,6 +2320,7 @@ def create_gain_loss_journal(
|
||||
ref2_detail_no,
|
||||
cost_center,
|
||||
dimensions,
|
||||
project=None,
|
||||
) -> str:
|
||||
journal_entry = frappe.new_doc("Journal Entry")
|
||||
journal_entry.voucher_type = "Exchange Gain Or Loss"
|
||||
@@ -2346,6 +2347,7 @@ def create_gain_loss_journal(
|
||||
"account_currency": party_account_currency,
|
||||
"exchange_rate": 0,
|
||||
"cost_center": cost_center or erpnext.get_default_cost_center(company),
|
||||
"project": project,
|
||||
"reference_type": ref1_dt,
|
||||
"reference_name": ref1_dn,
|
||||
"reference_detail_no": ref1_detail_no,
|
||||
@@ -2363,6 +2365,7 @@ def create_gain_loss_journal(
|
||||
"account_currency": gain_loss_account_currency,
|
||||
"exchange_rate": 1,
|
||||
"cost_center": cost_center or erpnext.get_default_cost_center(company),
|
||||
"project": project,
|
||||
"reference_type": ref2_dt,
|
||||
"reference_name": ref2_dn,
|
||||
"reference_detail_no": ref2_detail_no,
|
||||
|
||||
@@ -41,7 +41,7 @@ frappe.ui.form.on("Asset Movement", {
|
||||
});
|
||||
},
|
||||
|
||||
onload: (frm) => {
|
||||
refresh: (frm) => {
|
||||
frm.trigger("set_required_fields");
|
||||
},
|
||||
|
||||
|
||||
@@ -289,6 +289,30 @@ class TestPurchaseOrder(FrappeTestCase):
|
||||
# ordered qty should decrease (back to initial) on row deletion
|
||||
self.assertEqual(get_ordered_qty(), existing_ordered_qty)
|
||||
|
||||
def test_discount_amount_partial_purchase_receipt(self):
|
||||
po = create_purchase_order(qty=4, rate=100, do_not_save=1)
|
||||
po.apply_discount_on = "Grand Total"
|
||||
po.discount_amount = 120
|
||||
po.save()
|
||||
po.submit()
|
||||
|
||||
self.assertEqual(po.grand_total, 280)
|
||||
|
||||
pr1 = make_purchase_receipt(po.name)
|
||||
pr1.items[0].qty = 3
|
||||
pr1.save()
|
||||
pr1.submit()
|
||||
|
||||
self.assertEqual(pr1.discount_amount, 120)
|
||||
self.assertEqual(pr1.grand_total, 180)
|
||||
|
||||
pr2 = make_purchase_receipt(po.name)
|
||||
pr2.save()
|
||||
pr2.submit()
|
||||
|
||||
self.assertEqual(pr2.discount_amount, 0)
|
||||
self.assertEqual(pr2.grand_total, 100)
|
||||
|
||||
def test_update_child_perm(self):
|
||||
po = create_purchase_order(item_code="_Test Item", qty=4)
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ class RequestforQuotation(BuyingController):
|
||||
}
|
||||
)
|
||||
user.save(ignore_permissions=True)
|
||||
update_password_link = user.reset_password()
|
||||
update_password_link = user._reset_password()
|
||||
|
||||
return user, update_password_link
|
||||
|
||||
@@ -474,6 +474,11 @@ def create_supplier_quotation(doc):
|
||||
if isinstance(doc, str):
|
||||
doc = json.loads(doc)
|
||||
|
||||
if frappe.session.user not in frappe.get_all(
|
||||
"Portal User", {"parent": doc.get("supplier")}, pluck="user"
|
||||
):
|
||||
frappe.throw(_("Not Permitted"), frappe.PermissionError)
|
||||
|
||||
try:
|
||||
sq_doc = frappe.get_doc(
|
||||
{
|
||||
|
||||
@@ -263,6 +263,13 @@ def make_request_for_quotation(**args) -> "RequestforQuotation":
|
||||
|
||||
for data in supplier_data:
|
||||
rfq.append("suppliers", data)
|
||||
frappe.new_doc(
|
||||
"Portal User",
|
||||
user="Administrator",
|
||||
parent=data.get("supplier"),
|
||||
parentfield="portal_users",
|
||||
parenttype="Supplier",
|
||||
).insert()
|
||||
|
||||
rfq.append(
|
||||
"items",
|
||||
|
||||
@@ -175,6 +175,15 @@ def create_supplier(**args):
|
||||
if not args.without_supplier_group:
|
||||
doc.supplier_group = args.supplier_group or "Services"
|
||||
|
||||
if args.get("party_account"):
|
||||
doc.append(
|
||||
"accounts",
|
||||
{
|
||||
"company": frappe.db.get_value("Account", args.get("party_account"), "company"),
|
||||
"account": args.get("party_account"),
|
||||
},
|
||||
)
|
||||
|
||||
doc.insert()
|
||||
|
||||
return doc
|
||||
|
||||
@@ -1751,6 +1751,7 @@ class AccountsController(TransactionBase):
|
||||
arg.get("referenced_row"),
|
||||
arg.get("cost_center"),
|
||||
dimensions_dict,
|
||||
arg.get("project"),
|
||||
)
|
||||
frappe.msgprint(
|
||||
_("Exchange Gain/Loss amount has been booked through {0}").format(
|
||||
@@ -1835,6 +1836,7 @@ class AccountsController(TransactionBase):
|
||||
d.idx,
|
||||
self.cost_center,
|
||||
dimensions_dict,
|
||||
self.project,
|
||||
)
|
||||
frappe.msgprint(
|
||||
_("Exchange Gain/Loss amount has been booked through {0}").format(
|
||||
|
||||
@@ -364,7 +364,17 @@ class BuyingController(SubcontractingController):
|
||||
get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0
|
||||
)
|
||||
|
||||
net_rate = item.base_net_amount
|
||||
net_rate = (
|
||||
flt(
|
||||
(item.base_net_amount / item.received_qty) * item.qty,
|
||||
item.precision("base_net_amount"),
|
||||
)
|
||||
if item.received_qty
|
||||
and frappe.get_single_value(
|
||||
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
|
||||
)
|
||||
else item.base_net_amount
|
||||
)
|
||||
if item.sales_incoming_rate: # for internal transfer
|
||||
net_rate = item.qty * item.sales_incoming_rate
|
||||
|
||||
|
||||
@@ -356,38 +356,43 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters):
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_delivery_notes_to_be_billed(doctype, txt, searchfield, start, page_len, filters, as_dict):
|
||||
doctype = "Delivery Note"
|
||||
def get_delivery_notes_to_be_billed(
|
||||
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict, as_dict: bool = False
|
||||
):
|
||||
DeliveryNote = frappe.qb.DocType("Delivery Note")
|
||||
|
||||
fields = get_fields(doctype, ["name", "customer", "posting_date"])
|
||||
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select {fields}
|
||||
from `tabDelivery Note`
|
||||
where `tabDelivery Note`.`{key}` like {txt} and
|
||||
`tabDelivery Note`.docstatus = 1
|
||||
and status not in ('Stopped', 'Closed') {fcond}
|
||||
and (
|
||||
(`tabDelivery Note`.is_return = 0 and `tabDelivery Note`.per_billed < 100)
|
||||
or (`tabDelivery Note`.grand_total = 0 and `tabDelivery Note`.per_billed < 100)
|
||||
or (
|
||||
`tabDelivery Note`.is_return = 1
|
||||
and return_against in (select name from `tabDelivery Note` where per_billed < 100)
|
||||
original_dn = (
|
||||
frappe.qb.from_(DeliveryNote)
|
||||
.select(DeliveryNote.name)
|
||||
.where((DeliveryNote.docstatus == 1) & (DeliveryNote.is_return == 0) & (DeliveryNote.per_billed > 0))
|
||||
)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(DeliveryNote)
|
||||
.select(*[DeliveryNote[f] for f in fields])
|
||||
.where(
|
||||
(DeliveryNote.docstatus == 1)
|
||||
& (DeliveryNote.status.notin(["Stopped", "Closed"]))
|
||||
& (DeliveryNote[searchfield].like(f"%{txt}%"))
|
||||
& (
|
||||
((DeliveryNote.is_return == 0) & (DeliveryNote.per_billed < 100))
|
||||
| ((DeliveryNote.grand_total == 0) & (DeliveryNote.per_billed < 100))
|
||||
| (
|
||||
(DeliveryNote.is_return == 1)
|
||||
& (DeliveryNote.per_billed < 100)
|
||||
& (DeliveryNote.return_against.isin(original_dn))
|
||||
)
|
||||
)
|
||||
{mcond} order by `tabDelivery Note`.`{key}` asc limit {page_len} offset {start}
|
||||
""".format(
|
||||
fields=", ".join([f"`tabDelivery Note`.{f}" for f in fields]),
|
||||
key=searchfield,
|
||||
fcond=get_filters_cond(doctype, filters, []),
|
||||
mcond=get_match_cond(doctype),
|
||||
start=start,
|
||||
page_len=page_len,
|
||||
txt="%(txt)s",
|
||||
),
|
||||
{"txt": ("%%%s%%" % txt)},
|
||||
as_dict=as_dict,
|
||||
)
|
||||
)
|
||||
if filters and isinstance(filters, dict):
|
||||
for key, value in filters.items():
|
||||
query = query.where(DeliveryNote[key] == value)
|
||||
|
||||
query = query.orderby(DeliveryNote[searchfield], order=Order.asc).limit(page_len).offset(start)
|
||||
return query.run(as_dict=as_dict)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -616,11 +616,11 @@ class SellingController(StockController):
|
||||
if allow_at_arms_length_price:
|
||||
continue
|
||||
|
||||
rate = flt(
|
||||
flt(d.incoming_rate, d.precision("incoming_rate")) * d.conversion_factor,
|
||||
d.precision("rate"),
|
||||
)
|
||||
if d.rate != rate:
|
||||
rate = flt(flt(d.incoming_rate) * flt(d.conversion_factor or 1.0))
|
||||
|
||||
if flt(d.rate, d.precision("incoming_rate")) != flt(
|
||||
rate, d.precision("incoming_rate")
|
||||
):
|
||||
d.rate = rate
|
||||
frappe.msgprint(
|
||||
_(
|
||||
|
||||
@@ -183,8 +183,14 @@ class calculate_taxes_and_totals:
|
||||
return
|
||||
|
||||
if not self.discount_amount_applied:
|
||||
bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value(
|
||||
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
|
||||
)
|
||||
|
||||
do_not_round_fields = ["valuation_rate", "incoming_rate"]
|
||||
|
||||
for item in self.doc.items:
|
||||
self.doc.round_floats_in(item)
|
||||
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
|
||||
|
||||
if item.discount_percentage == 100:
|
||||
item.rate = 0.0
|
||||
@@ -238,7 +244,13 @@ class calculate_taxes_and_totals:
|
||||
elif not item.qty and self.doc.get("is_debit_note"):
|
||||
item.amount = flt(item.rate, item.precision("amount"))
|
||||
else:
|
||||
item.amount = flt(item.rate * item.qty, item.precision("amount"))
|
||||
qty = (
|
||||
(item.qty + item.rejected_qty)
|
||||
if bill_for_rejected_quantity_in_purchase_invoice
|
||||
and self.doc.doctype == "Purchase Receipt"
|
||||
else item.qty
|
||||
)
|
||||
item.amount = flt(item.rate * qty, item.precision("amount"))
|
||||
|
||||
item.net_amount = item.amount
|
||||
|
||||
@@ -370,9 +382,16 @@ class calculate_taxes_and_totals:
|
||||
self.doc.total
|
||||
) = self.doc.base_total = self.doc.net_total = self.doc.base_net_total = 0.0
|
||||
|
||||
bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value(
|
||||
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
|
||||
)
|
||||
for item in self._items:
|
||||
self.doc.total += item.amount
|
||||
self.doc.total_qty += item.qty
|
||||
self.doc.total_qty += (
|
||||
(item.qty + item.rejected_qty)
|
||||
if bill_for_rejected_quantity_in_purchase_invoice and self.doc.doctype == "Purchase Receipt"
|
||||
else item.qty
|
||||
)
|
||||
self.doc.base_total += item.base_amount
|
||||
self.doc.net_total += item.net_amount
|
||||
self.doc.base_net_total += item.base_net_amount
|
||||
@@ -674,18 +693,17 @@ class calculate_taxes_and_totals:
|
||||
if self.doc.meta.get_field("rounded_total"):
|
||||
if self.doc.is_rounded_total_disabled():
|
||||
self.doc.rounded_total = 0
|
||||
self.doc.base_rounded_total = 0
|
||||
self.doc.rounding_adjustment = 0
|
||||
return
|
||||
|
||||
self.doc.rounded_total = round_based_on_smallest_currency_fraction(
|
||||
self.doc.grand_total, self.doc.currency, self.doc.precision("rounded_total")
|
||||
)
|
||||
else:
|
||||
self.doc.rounded_total = round_based_on_smallest_currency_fraction(
|
||||
self.doc.grand_total, self.doc.currency, self.doc.precision("rounded_total")
|
||||
)
|
||||
|
||||
# rounding adjustment should always be the difference vetween grand and rounded total
|
||||
self.doc.rounding_adjustment = flt(
|
||||
self.doc.rounded_total - self.doc.grand_total, self.doc.precision("rounding_adjustment")
|
||||
)
|
||||
# rounding adjustment should always be the difference between grand and rounded total
|
||||
self.doc.rounding_adjustment = flt(
|
||||
self.doc.rounded_total - self.doc.grand_total, self.doc.precision("rounding_adjustment")
|
||||
)
|
||||
|
||||
self._set_in_company_currency(self.doc, ["rounding_adjustment", "rounded_total"])
|
||||
|
||||
@@ -724,7 +742,8 @@ class calculate_taxes_and_totals:
|
||||
discount_amount += total_return_discount
|
||||
|
||||
# validate that discount amount cannot exceed the total before discount
|
||||
if (
|
||||
# only during save (i.e. when `_action` is set)
|
||||
if self.doc.get("_action") and (
|
||||
(grand_total >= 0 and discount_amount > grand_total)
|
||||
or (grand_total < 0 and discount_amount < grand_total) # returns
|
||||
):
|
||||
|
||||
@@ -29,6 +29,7 @@ def make_customer(customer_name, currency=None):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = customer_name
|
||||
customer.customer_type = "Individual"
|
||||
customer.customer_group = "Individual"
|
||||
|
||||
if currency:
|
||||
customer.default_currency = currency
|
||||
|
||||
@@ -66,7 +66,7 @@ class TestTaxes(unittest.TestCase):
|
||||
{
|
||||
"doctype": "Customer",
|
||||
"customer_name": uuid4(),
|
||||
"customer_group": "All Customer Groups",
|
||||
"customer_group": "Individual",
|
||||
}
|
||||
).insert()
|
||||
self.supplier = frappe.get_doc(
|
||||
|
||||
37
erpnext/controllers/tests/test_taxes_and_totals.py
Normal file
37
erpnext/controllers/tests/test_taxes_and_totals.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
|
||||
class TestTaxesAndTotals(FrappeTestCase):
|
||||
def test_disabling_rounded_total_resets_base_fields(self):
|
||||
"""Disabling rounded total should also clear base rounded values."""
|
||||
so = make_sales_order(do_not_save=True)
|
||||
so.items[0].qty = 1
|
||||
so.items[0].rate = 1000.25
|
||||
so.items[0].price_list_rate = 1000.25
|
||||
so.items[0].discount_percentage = 0
|
||||
so.items[0].discount_amount = 0
|
||||
so.set("taxes", [])
|
||||
|
||||
so.disable_rounded_total = 0
|
||||
calculate_taxes_and_totals(so)
|
||||
|
||||
self.assertEqual(so.grand_total, 1000.25)
|
||||
self.assertEqual(so.rounded_total, 1000.0)
|
||||
self.assertEqual(so.rounding_adjustment, -0.25)
|
||||
self.assertEqual(so.base_grand_total, 1000.25)
|
||||
self.assertEqual(so.base_rounded_total, 1000.0)
|
||||
self.assertEqual(so.base_rounding_adjustment, -0.25)
|
||||
|
||||
# User toggles disable_rounded_total after values are already set.
|
||||
so.disable_rounded_total = 1
|
||||
|
||||
calculate_taxes_and_totals(so)
|
||||
|
||||
self.assertEqual(so.rounded_total, 0)
|
||||
self.assertEqual(so.rounding_adjustment, 0)
|
||||
self.assertEqual(so.base_rounded_total, 0)
|
||||
self.assertEqual(so.base_rounding_adjustment, 0)
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import getdate
|
||||
from frappe.utils import DateTimeLikeObject, getdate, today
|
||||
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
|
||||
|
||||
def get_columns(filters, trans):
|
||||
@@ -45,6 +47,10 @@ def get_columns(filters, trans):
|
||||
|
||||
|
||||
def validate_filters(filters):
|
||||
if not filters.get("fiscal_year"):
|
||||
filters["fiscal_year"] = get_fiscal_year(today())[0]
|
||||
if not filters.get("company"):
|
||||
filters["company"] = frappe.defaults.get_user_default("Company")
|
||||
for f in ["Fiscal Year", "Based On", "Period", "Company"]:
|
||||
if not filters.get(f.lower().replace(" ", "_")):
|
||||
frappe.throw(_("{0} is mandatory").format(_(f)))
|
||||
|
||||
@@ -35,7 +35,9 @@ class TestOpportunity(unittest.TestCase):
|
||||
self.assertEqual(frappe.db.get_value("Lead", opp_doc.party_name, "email_id"), opp_doc.contact_email)
|
||||
|
||||
# create new customer and create new contact against 'new.opportunity@example.com'
|
||||
customer = make_customer(opp_doc.party_name).insert(ignore_permissions=True)
|
||||
customer = make_customer(opp_doc.party_name)
|
||||
customer.customer_group = "Individual"
|
||||
customer.insert(ignore_permissions=True)
|
||||
contact = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Contact",
|
||||
|
||||
@@ -117,7 +117,7 @@ def get_join(filters):
|
||||
join = """JOIN `tabOpportunity Lost Reason Detail`
|
||||
ON `tabOpportunity Lost Reason Detail`.parenttype = 'Opportunity' and
|
||||
`tabOpportunity Lost Reason Detail`.parent = `tabOpportunity`.name and
|
||||
`tabOpportunity Lost Reason Detail`.lost_reason = '{}'
|
||||
""".format(filters.get("lost_reason"))
|
||||
`tabOpportunity Lost Reason Detail`.lost_reason=%(lost_reason)s
|
||||
"""
|
||||
|
||||
return join
|
||||
|
||||
@@ -196,6 +196,7 @@ def create_customer():
|
||||
if not doc:
|
||||
doc = frappe.new_doc("Customer")
|
||||
doc.customer_name = "_Test NC"
|
||||
doc.customer_group = "Individual"
|
||||
doc.insert()
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import escape_html
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lxml.etree import Element
|
||||
@@ -63,14 +64,16 @@ class CodeList(Document):
|
||||
|
||||
def from_genericode(self, root: "Element"):
|
||||
"""Extract Code List details from genericode XML"""
|
||||
self.title = root.find(".//Identification/ShortName").text
|
||||
self.title = escape_html(root.find(".//Identification/ShortName").text)
|
||||
self.version = root.find(".//Identification/Version").text
|
||||
self.canonical_uri = root.find(".//CanonicalUri").text
|
||||
# optionals
|
||||
self.description = getattr(root.find(".//Identification/LongName"), "text", None)
|
||||
self.publisher = getattr(root.find(".//Identification/Agency/ShortName"), "text", None)
|
||||
self.description = escape_html(getattr(root.find(".//Identification/LongName"), "text", None))
|
||||
self.publisher = escape_html(getattr(root.find(".//Identification/Agency/ShortName"), "text", None))
|
||||
if not self.publisher:
|
||||
self.publisher = getattr(root.find(".//Identification/Agency/LongName"), "text", None)
|
||||
self.publisher = escape_html(
|
||||
getattr(root.find(".//Identification/Agency/LongName"), "text", None)
|
||||
)
|
||||
self.publisher_id = getattr(root.find(".//Identification/Agency/Identifier"), "text", None)
|
||||
self.url = getattr(root.find(".//Identification/LocationUri"), "text", None)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ erpnext.edi.import_genericode = function (listview_or_form) {
|
||||
method: "erpnext.edi.doctype.code_list.code_list_import.import_genericode",
|
||||
doctype: doctype,
|
||||
docname: docname,
|
||||
allow_web_link: false,
|
||||
allow_toggle_private: false,
|
||||
allow_take_photo: false,
|
||||
on_success: function (_file_doc, r) {
|
||||
|
||||
@@ -1,42 +1,118 @@
|
||||
import json
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import frappe
|
||||
import requests
|
||||
from frappe import _
|
||||
from frappe.utils import escape_html
|
||||
from frappe.utils.file_manager import save_file
|
||||
from lxml import etree
|
||||
|
||||
URL_PREFIXES = ("http://", "https://")
|
||||
GENERICODE_FETCH_TIMEOUT = 15
|
||||
LOCAL_FILE_PREFIXES = ("/files/", "/private/files/")
|
||||
|
||||
|
||||
class RemoteGenericodeUrlNotAllowedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CodeListSelectionMismatchError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def import_genericode():
|
||||
doctype = "Code List"
|
||||
docname = frappe.form_dict.docname
|
||||
content = frappe.local.uploaded_file
|
||||
|
||||
# recover the content, if it's a link
|
||||
if (file_url := frappe.local.uploaded_file_url) and file_url.startswith(URL_PREFIXES):
|
||||
try:
|
||||
# If it's a URL, fetch the content and make it a local file (for durable audit)
|
||||
response = requests.get(frappe.local.uploaded_file_url)
|
||||
response.raise_for_status()
|
||||
frappe.local.uploaded_file = content = response.content
|
||||
frappe.local.uploaded_filename = frappe.local.uploaded_file_url.split("/")[-1]
|
||||
frappe.local.uploaded_file_url = None
|
||||
except Exception as e:
|
||||
frappe.throw(f"<pre>{e!s}</pre>", title=_("Fetching Error"))
|
||||
|
||||
if file_url := frappe.local.uploaded_file_url:
|
||||
file_path = frappe.utils.file_manager.get_file_path(file_url)
|
||||
with open(file_path.encode(), mode="rb") as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse the xml content
|
||||
parser = etree.XMLParser(remove_blank_text=True)
|
||||
try:
|
||||
root = etree.fromstring(content, parser=parser)
|
||||
except Exception as e:
|
||||
frappe.throw(f"<pre>{e!s}</pre>", title=_("Parsing Error"))
|
||||
content, file_name = get_uploaded_genericode_file()
|
||||
|
||||
return import_genericode_content(
|
||||
doctype="Code List",
|
||||
docname=frappe.form_dict.docname,
|
||||
content=content,
|
||||
file_name=file_name,
|
||||
)
|
||||
except RemoteGenericodeUrlNotAllowedError:
|
||||
frappe.throw(
|
||||
_("Importing Code Lists from remote URLs is not allowed."),
|
||||
title=_("Invalid Upload"),
|
||||
)
|
||||
except CodeListSelectionMismatchError:
|
||||
frappe.throw(_("The uploaded file does not match the selected Code List."))
|
||||
except etree.XMLSyntaxError:
|
||||
frappe.throw(
|
||||
_("The uploaded file could not be parsed as a genericode XML document."),
|
||||
title=_("Parsing Error"),
|
||||
)
|
||||
|
||||
|
||||
def import_genericode_from_url(
|
||||
url: str,
|
||||
doctype: str = "Code List",
|
||||
docname: str | None = None,
|
||||
):
|
||||
"""Import a Code List from a trusted backend URL."""
|
||||
content = fetch_genericode_from_url(url)
|
||||
file_name = urlsplit(url).path.rsplit("/", 1)[-1] or "genericode.xml"
|
||||
|
||||
return import_genericode_content(
|
||||
doctype=doctype,
|
||||
docname=docname,
|
||||
content=content,
|
||||
file_name=file_name,
|
||||
)
|
||||
|
||||
|
||||
def get_uploaded_genericode_file() -> tuple[bytes, str | None]:
|
||||
uploaded_data = frappe.local.uploaded_file
|
||||
file_name = frappe.local.uploaded_filename
|
||||
if uploaded_data and file_name:
|
||||
return uploaded_data, file_name
|
||||
|
||||
file_url = frappe.local.uploaded_file_url
|
||||
if not file_url:
|
||||
raise frappe.ValidationError(_("No file uploaded or URL provided."))
|
||||
|
||||
if not is_local_file_url(file_url):
|
||||
raise RemoteGenericodeUrlNotAllowedError
|
||||
|
||||
file_doc = frappe.get_doc("File", {"file_url": file_url})
|
||||
file_doc.check_permission("read")
|
||||
return read_file_bytes(file_doc), file_name
|
||||
|
||||
|
||||
def read_file_bytes(file_doc) -> bytes:
|
||||
"""Return the raw bytes of a File document.
|
||||
|
||||
v15's `File.get_content` eagerly decodes to utf-8 and returns `str` for text
|
||||
files, but `lxml.etree.fromstring` needs bytes when the XML declares an encoding.
|
||||
"""
|
||||
content = file_doc.get_content()
|
||||
if isinstance(content, str):
|
||||
content = content.encode("utf-8")
|
||||
return content
|
||||
|
||||
|
||||
def is_local_file_url(file_url: str | None) -> bool:
|
||||
if not file_url:
|
||||
return False
|
||||
|
||||
parsed = urlsplit(file_url.strip())
|
||||
return not parsed.scheme and not parsed.netloc and parsed.path.startswith(LOCAL_FILE_PREFIXES)
|
||||
|
||||
|
||||
def fetch_genericode_from_url(url: str) -> bytes:
|
||||
response = requests.get(url, timeout=GENERICODE_FETCH_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
|
||||
def import_genericode_content(
|
||||
doctype: str,
|
||||
docname: str | None,
|
||||
content: bytes,
|
||||
file_name: str | None,
|
||||
):
|
||||
root = parse_genericode_content(content)
|
||||
|
||||
# Extract the name (CanonicalVersionUri) from the parsed XML
|
||||
name = root.find(".//CanonicalVersionUri").text
|
||||
@@ -45,7 +121,7 @@ def import_genericode():
|
||||
if frappe.db.exists(doctype, docname):
|
||||
code_list = frappe.get_doc(doctype, docname)
|
||||
if code_list.name != name:
|
||||
frappe.throw(_("The uploaded file does not match the selected Code List."))
|
||||
raise CodeListSelectionMismatchError
|
||||
else:
|
||||
# Create a new Code List document with the extracted name
|
||||
code_list = frappe.new_doc(doctype)
|
||||
@@ -54,19 +130,13 @@ def import_genericode():
|
||||
code_list.from_genericode(root)
|
||||
code_list.save()
|
||||
|
||||
# Attach the file and provide a recoverable identifier
|
||||
file_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "File",
|
||||
"attached_to_doctype": "Code List",
|
||||
"attached_to_name": code_list.name,
|
||||
"folder": frappe.db.get_value("File", {"is_attachments_folder": 1}),
|
||||
"file_name": frappe.local.uploaded_filename,
|
||||
"file_url": frappe.local.uploaded_file_url,
|
||||
"is_private": 1,
|
||||
"content": content,
|
||||
}
|
||||
).save()
|
||||
file_doc = save_file(
|
||||
fname=file_name,
|
||||
content=content,
|
||||
dt=doctype,
|
||||
dn=code_list.name,
|
||||
is_private=1,
|
||||
)
|
||||
|
||||
# Get available columns and example values
|
||||
columns, example_values, filterable_columns = get_genericode_columns_and_examples(root)
|
||||
@@ -81,6 +151,16 @@ def import_genericode():
|
||||
}
|
||||
|
||||
|
||||
def parse_genericode_content(content: bytes):
|
||||
parser = etree.XMLParser(
|
||||
remove_blank_text=True,
|
||||
resolve_entities=False,
|
||||
load_dtd=False,
|
||||
no_network=True,
|
||||
)
|
||||
return etree.fromstring(content, parser=parser)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def process_genericode_import(
|
||||
code_list_name: str,
|
||||
@@ -104,7 +184,7 @@ def get_genericode_columns_and_examples(root):
|
||||
|
||||
# Get column names
|
||||
for column in root.findall(".//Column"):
|
||||
column_id = column.get("Id")
|
||||
column_id = escape_html(column.get("Id"))
|
||||
columns.append(column_id)
|
||||
example_values[column_id] = []
|
||||
filterable_columns[column_id] = set()
|
||||
@@ -112,7 +192,7 @@ def get_genericode_columns_and_examples(root):
|
||||
# Get all values and count unique occurrences
|
||||
for row in root.findall(".//SimpleCodeList/Row"):
|
||||
for value in row.findall("Value"):
|
||||
column_id = value.get("ColumnRef")
|
||||
column_id = escape_html(value.get("ColumnRef"))
|
||||
if column_id not in columns:
|
||||
# Handle undeclared column
|
||||
columns.append(column_id)
|
||||
@@ -123,7 +203,7 @@ def get_genericode_columns_and_examples(root):
|
||||
if simple_value is None:
|
||||
continue
|
||||
|
||||
filterable_columns[column_id].add(simple_value.text)
|
||||
filterable_columns[column_id].add(escape_html(simple_value.text))
|
||||
|
||||
# Get example values (up to 3) and filter columns with cardinality <= 5
|
||||
for row in root.findall(".//SimpleCodeList/Row")[:3]:
|
||||
@@ -133,7 +213,7 @@ def get_genericode_columns_and_examples(root):
|
||||
if simple_value is None:
|
||||
continue
|
||||
|
||||
example_values[column_id].append(simple_value.text)
|
||||
example_values[column_id].append(escape_html(simple_value.text))
|
||||
|
||||
filterable_columns = {k: list(v) for k, v in filterable_columns.items() if len(v) <= 5}
|
||||
|
||||
|
||||
200
erpnext/edi/doctype/code_list/test_code_list_import.py
Normal file
200
erpnext/edi/doctype/code_list/test_code_list_import.py
Normal file
@@ -0,0 +1,200 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import frappe
|
||||
import requests
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
from erpnext.edi.doctype.code_list import code_list_import
|
||||
|
||||
SAMPLE_GENERICODE = b"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CodeList>
|
||||
<Identification>
|
||||
<ShortName>Test Code List</ShortName>
|
||||
<Version>1.0</Version>
|
||||
<CanonicalUri>test-code-list</CanonicalUri>
|
||||
<LongName>Code list for tests</LongName>
|
||||
<Agency>
|
||||
<ShortName>Test Agency</ShortName>
|
||||
<Identifier>TEST</Identifier>
|
||||
</Agency>
|
||||
<LocationUri>https://example.com/codelists/test.xml</LocationUri>
|
||||
</Identification>
|
||||
<CanonicalVersionUri>test-code-list-v1</CanonicalVersionUri>
|
||||
<ColumnSet>
|
||||
<Column Id="code" />
|
||||
<Column Id="name" />
|
||||
<Column Id="category" />
|
||||
</ColumnSet>
|
||||
<SimpleCodeList>
|
||||
<Row>
|
||||
<Value ColumnRef="code"><SimpleValue>A</SimpleValue></Value>
|
||||
<Value ColumnRef="name"><SimpleValue>Alpha</SimpleValue></Value>
|
||||
<Value ColumnRef="category"><SimpleValue>Group 1</SimpleValue></Value>
|
||||
</Row>
|
||||
<Row>
|
||||
<Value ColumnRef="code"><SimpleValue>B</SimpleValue></Value>
|
||||
<Value ColumnRef="name"><SimpleValue>Beta</SimpleValue></Value>
|
||||
<Value ColumnRef="category"><SimpleValue>Group 2</SimpleValue></Value>
|
||||
</Row>
|
||||
<Row>
|
||||
<Value ColumnRef="code"><SimpleValue>C</SimpleValue></Value>
|
||||
<Value ColumnRef="name"><SimpleValue>Gamma</SimpleValue></Value>
|
||||
<Value ColumnRef="category"><SimpleValue>Group 1</SimpleValue></Value>
|
||||
</Row>
|
||||
</SimpleCodeList>
|
||||
</CodeList>
|
||||
"""
|
||||
|
||||
|
||||
class TestCodeListImport(FrappeTestCase):
|
||||
def test_import_genericode_rejects_remote_file_url(self):
|
||||
self.set_upload_context(
|
||||
file_name="trusted.xml",
|
||||
file_url="https://example.com/codelists/trusted.xml",
|
||||
)
|
||||
|
||||
with patch("erpnext.edi.doctype.code_list.code_list_import.requests.get") as mock_get:
|
||||
with self.assertRaisesRegex(
|
||||
frappe.ValidationError, "Importing Code Lists from remote URLs is not allowed."
|
||||
):
|
||||
code_list_import.import_genericode()
|
||||
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_import_genericode_rejects_file_scheme_url(self):
|
||||
self.set_upload_context(
|
||||
file_name="trusted.xml",
|
||||
file_url="file:///tmp/trusted.xml",
|
||||
)
|
||||
|
||||
with patch("erpnext.edi.doctype.code_list.code_list_import.requests.get") as mock_get:
|
||||
with self.assertRaisesRegex(
|
||||
frappe.ValidationError, "Importing Code Lists from remote URLs is not allowed."
|
||||
):
|
||||
code_list_import.import_genericode()
|
||||
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_import_genericode_from_trusted_url(self):
|
||||
response = Mock()
|
||||
response.content = SAMPLE_GENERICODE
|
||||
response.raise_for_status.return_value = None
|
||||
|
||||
with patch(
|
||||
"erpnext.edi.doctype.code_list.code_list_import.requests.get",
|
||||
return_value=response,
|
||||
) as mock_get:
|
||||
import_result = code_list_import.import_genericode_from_url(
|
||||
"https://example.com/codelists/trusted.xml"
|
||||
)
|
||||
|
||||
self.assert_import_response(import_result)
|
||||
mock_get.assert_called_once_with(
|
||||
"https://example.com/codelists/trusted.xml",
|
||||
timeout=code_list_import.GENERICODE_FETCH_TIMEOUT,
|
||||
)
|
||||
|
||||
file_doc = frappe.get_doc("File", import_result["file"])
|
||||
self.assertEqual(code_list_import.read_file_bytes(file_doc), SAMPLE_GENERICODE)
|
||||
self.assertFalse(file_doc.file_url.startswith("https://"))
|
||||
|
||||
def test_import_genericode_from_trusted_url_propagates_fetch_errors(self):
|
||||
with patch(
|
||||
"erpnext.edi.doctype.code_list.code_list_import.requests.get",
|
||||
side_effect=requests.Timeout,
|
||||
):
|
||||
with self.assertRaises(requests.Timeout):
|
||||
code_list_import.import_genericode_from_url("https://example.com/codelists/trusted.xml")
|
||||
|
||||
def test_import_genericode_from_uploaded_file_returns_metadata(self):
|
||||
self.set_upload_context(content=SAMPLE_GENERICODE, file_name="uploaded_genericode.xml")
|
||||
|
||||
import_result = code_list_import.import_genericode()
|
||||
|
||||
self.assert_import_response(import_result)
|
||||
|
||||
file_doc = frappe.get_doc("File", import_result["file"])
|
||||
self.assertEqual(code_list_import.read_file_bytes(file_doc), SAMPLE_GENERICODE)
|
||||
|
||||
def test_process_genericode_import_reads_file_doc_content(self):
|
||||
self.set_upload_context(content=SAMPLE_GENERICODE, file_name="uploaded_genericode.xml")
|
||||
|
||||
import_result = code_list_import.import_genericode()
|
||||
count = code_list_import.process_genericode_import(
|
||||
code_list_name=import_result["code_list"],
|
||||
file_name=import_result["file"],
|
||||
code_column="code",
|
||||
title_column="name",
|
||||
)
|
||||
|
||||
self.assertEqual(count, 3)
|
||||
self.assertEqual(frappe.db.count("Common Code", {"code_list": import_result["code_list"]}), 3)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value(
|
||||
"Common Code",
|
||||
{"code_list": import_result["code_list"], "common_code": "A"},
|
||||
"title",
|
||||
),
|
||||
"Alpha",
|
||||
)
|
||||
|
||||
def test_import_genericode_from_local_file_url(self):
|
||||
source_file = frappe.get_doc(
|
||||
{
|
||||
"doctype": "File",
|
||||
"file_name": "library_genericode.xml",
|
||||
"content": SAMPLE_GENERICODE,
|
||||
"is_private": 1,
|
||||
}
|
||||
).insert()
|
||||
self.set_upload_context(file_name=source_file.file_name, file_url=source_file.file_url)
|
||||
|
||||
import_result = code_list_import.import_genericode()
|
||||
|
||||
self.assert_import_response(import_result)
|
||||
|
||||
def set_upload_context(
|
||||
self,
|
||||
content: bytes | None = None,
|
||||
file_name: str = "genericode.xml",
|
||||
file_url: str | None = None,
|
||||
docname: str | None = None,
|
||||
):
|
||||
attrs = ("form_dict", "uploaded_file", "uploaded_file_url", "uploaded_filename")
|
||||
originals = {attr: getattr(frappe.local, attr, None) for attr in attrs}
|
||||
|
||||
frappe.local.form_dict = frappe._dict(doctype="Code List", docname=docname)
|
||||
frappe.local.uploaded_file = content
|
||||
frappe.local.uploaded_file_url = file_url
|
||||
frappe.local.uploaded_filename = file_name
|
||||
|
||||
def restore():
|
||||
for attr, value in originals.items():
|
||||
setattr(frappe.local, attr, value)
|
||||
|
||||
self.addCleanup(restore)
|
||||
|
||||
def assert_import_response(self, import_result):
|
||||
self.assertEqual(
|
||||
set(import_result),
|
||||
{
|
||||
"code_list",
|
||||
"code_list_title",
|
||||
"file",
|
||||
"columns",
|
||||
"example_values",
|
||||
"filterable_columns",
|
||||
},
|
||||
)
|
||||
self.assertEqual(import_result["code_list"], "test-code-list-v1")
|
||||
self.assertEqual(import_result["code_list_title"], "Test Code List")
|
||||
self.assertEqual(import_result["columns"], ["code", "name", "category"])
|
||||
self.assertEqual(import_result["example_values"]["code"], ["A", "B", "C"])
|
||||
self.assertEqual(import_result["example_values"]["name"], ["Alpha", "Beta", "Gamma"])
|
||||
self.assertEqual(import_result["example_values"]["category"], ["Group 1", "Group 2", "Group 1"])
|
||||
self.assertCountEqual(import_result["filterable_columns"]["category"], ["Group 1", "Group 2"])
|
||||
self.assertTrue(frappe.db.exists("Code List", import_result["code_list"]))
|
||||
self.assertTrue(frappe.db.exists("File", import_result["file"]))
|
||||
@@ -9,6 +9,8 @@ from frappe.model.document import Document
|
||||
from frappe.utils.data import get_link_to_form
|
||||
from lxml import etree
|
||||
|
||||
from erpnext.edi.doctype.code_list.code_list_import import parse_genericode_content, read_file_bytes
|
||||
|
||||
|
||||
class CommonCode(Document):
|
||||
# begin: auto-generated types
|
||||
@@ -86,15 +88,15 @@ def simple_hash(input_string, length=6):
|
||||
|
||||
def import_genericode(code_list: str, file_name: str, column_map: dict, filters: dict | None = None):
|
||||
"""Import genericode file and create Common Code entries"""
|
||||
file_path = frappe.utils.file_manager.get_file_path(file_name)
|
||||
parser = etree.XMLParser(remove_blank_text=True)
|
||||
tree = etree.parse(file_path, parser=parser)
|
||||
root = tree.getroot()
|
||||
file_doc = frappe.get_doc("File", file_name)
|
||||
file_doc.check_permission("read")
|
||||
root = parse_genericode_content(read_file_bytes(file_doc))
|
||||
|
||||
# Construct the XPath expression
|
||||
xpath_expr = ".//SimpleCodeList/Row"
|
||||
filter_conditions = [
|
||||
f"Value[@ColumnRef='{column_ref}']/SimpleValue='{value}'" for column_ref, value in filters.items()
|
||||
f"Value[@ColumnRef='{column_ref}']/SimpleValue='{value}'"
|
||||
for column_ref, value in (filters or {}).items()
|
||||
]
|
||||
if filter_conditions:
|
||||
xpath_expr += "[" + " and ".join(filter_conditions) + "]"
|
||||
@@ -102,7 +104,7 @@ def import_genericode(code_list: str, file_name: str, column_map: dict, filters:
|
||||
elements = root.xpath(xpath_expr)
|
||||
total_elements = len(elements)
|
||||
for i, xml_element in enumerate(elements, start=1):
|
||||
common_code: "CommonCode" = frappe.new_doc("Common Code")
|
||||
common_code: CommonCode = frappe.new_doc("Common Code")
|
||||
common_code.code_list = code_list
|
||||
common_code.from_genericode(column_map, xml_element)
|
||||
common_code.save()
|
||||
|
||||
@@ -120,7 +120,7 @@ class BlanketOrder(Document):
|
||||
|
||||
def validate_item_qty(self):
|
||||
for d in self.items:
|
||||
if d.qty < 0:
|
||||
if flt(d.qty) < 0:
|
||||
frappe.throw(_("Row {0}: Quantity cannot be negative.").format(d.idx))
|
||||
|
||||
|
||||
|
||||
@@ -264,6 +264,7 @@ frappe.ui.form.on("BOM", {
|
||||
reqd: 1,
|
||||
default: 1,
|
||||
onchange: () => {
|
||||
if (!cur_dialog) return;
|
||||
const { quantity, items: rm } = frm.doc;
|
||||
const variant_items_map = rm.reduce((acc, item) => {
|
||||
acc[item.item_code] = item.qty;
|
||||
@@ -759,6 +760,8 @@ frappe.ui.form.on("BOM Item", "sourced_by_supplier", function (frm, cdt, cdn) {
|
||||
if (d.sourced_by_supplier) {
|
||||
d.rate = 0;
|
||||
refresh_field("rate", d.name, d.parentfield);
|
||||
} else {
|
||||
get_bom_material_detail(frm.doc, cdt, cdn, false);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -31,34 +31,6 @@ frappe.ui.form.on("Job Card", {
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("operation", "time_logs", () => {
|
||||
let operations = (frm.doc.sub_operations || []).map((d) => d.sub_operation);
|
||||
return {
|
||||
filters: {
|
||||
name: ["in", operations],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("work_order", function () {
|
||||
return {
|
||||
filters: {
|
||||
status: ["not in", ["Cancelled", "Closed", "Stopped"]],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.events.set_company_filters(frm, "target_warehouse");
|
||||
frm.events.set_company_filters(frm, "source_warehouse");
|
||||
frm.events.set_company_filters(frm, "wip_warehouse");
|
||||
frm.set_query("source_warehouse", "items", () => {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_indicator_formatter("sub_operation", function (doc) {
|
||||
if (doc.status == "Pending") {
|
||||
return "red";
|
||||
@@ -75,6 +47,14 @@ frappe.ui.form.on("Job Card", {
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("work_order", function () {
|
||||
return {
|
||||
filters: {
|
||||
status: ["not in", ["Cancelled", "Closed", "Stopped"]],
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"bom_section",
|
||||
"update_bom_costs_automatically",
|
||||
"column_break_lhyt",
|
||||
"allow_editing_of_items_and_quantities_in_work_order",
|
||||
"section_break_6",
|
||||
"default_wip_warehouse",
|
||||
"default_fg_warehouse",
|
||||
@@ -243,13 +244,20 @@
|
||||
"fieldname": "enforce_time_logs",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enforce Time Logs"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If enabled, the system will allow users to edit the raw materials and their quantities in the Work Order. The system will not reset the quantities as per the BOM, if the user has changed them.",
|
||||
"fieldname": "allow_editing_of_items_and_quantities_in_work_order",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Editing of Items and Quantities in Work Order"
|
||||
}
|
||||
],
|
||||
"icon": "icon-wrench",
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-05-16 11:23:16.916512",
|
||||
"modified": "2025-11-07 14:52:56.241459",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Manufacturing Settings",
|
||||
|
||||
@@ -18,6 +18,7 @@ class ManufacturingSettings(Document):
|
||||
from frappe.types import DF
|
||||
|
||||
add_corrective_operation_cost_in_finished_good_valuation: DF.Check
|
||||
allow_editing_of_items_and_quantities_in_work_order: DF.Check
|
||||
allow_overtime: DF.Check
|
||||
allow_production_on_holidays: DF.Check
|
||||
backflush_raw_materials_based_on: DF.Literal["BOM", "Material Transferred for Manufacture"]
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Operation",
|
||||
"options": "Operation"
|
||||
"options": "Operation",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -40,7 +41,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-04 16:15:11.425349",
|
||||
"modified": "2026-04-13 12:17:33.776504",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Sub Operation",
|
||||
|
||||
@@ -16,7 +16,7 @@ class SubOperation(Document):
|
||||
from frappe.types import DF
|
||||
|
||||
description: DF.SmallText | None
|
||||
operation: DF.Link | None
|
||||
operation: DF.Link
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase, change_settings, timeout
|
||||
from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, today
|
||||
from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, nowdate, nowtime, today
|
||||
|
||||
from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError
|
||||
from erpnext.manufacturing.doctype.job_card.job_card import make_stock_entry as make_stock_entry_from_jc
|
||||
@@ -509,7 +509,41 @@ class TestWorkOrder(FrappeTestCase):
|
||||
|
||||
def test_work_order_material_transferred_qty_with_process_loss(self):
|
||||
stock_entries = []
|
||||
bom = frappe.get_doc("BOM", {"docstatus": 1, "with_operations": 1, "company": "_Test Company"})
|
||||
item_code = make_item("_Test Item For Process Loss", {"is_stock_item": 1}).name
|
||||
rm_item_code = make_item("Test Item For Process Loss RM", {"is_stock_item": 1}).name
|
||||
|
||||
bom = make_bom(
|
||||
item=item_code,
|
||||
raw_materials=[rm_item_code],
|
||||
with_operations=1,
|
||||
do_not_save=True,
|
||||
)
|
||||
|
||||
operation_name = "_Test Custom Operation"
|
||||
workstation_name = "_Test Custom Workstation"
|
||||
|
||||
if not frappe.db.exists("Workstation", workstation_name):
|
||||
doc = frappe.new_doc("Workstation")
|
||||
doc.workstation_name = workstation_name
|
||||
doc.save()
|
||||
|
||||
if not frappe.db.exists("Operation", operation_name):
|
||||
doc = frappe.new_doc("Operation")
|
||||
doc.name = operation_name
|
||||
doc.workstation = workstation_name
|
||||
doc.save()
|
||||
|
||||
operation = {
|
||||
"operation": operation_name,
|
||||
"workstation": workstation_name,
|
||||
"description": "Test Data",
|
||||
"operating_cost": 100,
|
||||
"time_in_mins": 40,
|
||||
}
|
||||
|
||||
bom.append("operations", operation)
|
||||
bom.save()
|
||||
bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=bom.item,
|
||||
@@ -2395,7 +2429,7 @@ class TestWorkOrder(FrappeTestCase):
|
||||
|
||||
stock_entry.submit()
|
||||
|
||||
def test_disassembly_order_with_qty_behavior(self):
|
||||
def test_disassembly_order_with_qty_from_wo_behavior(self):
|
||||
# Create raw material and FG item
|
||||
raw_item = make_item("Test Raw for Disassembly", {"is_stock_item": 1}).name
|
||||
fg_item = make_item("Test FG for Disassembly", {"is_stock_item": 1}).name
|
||||
@@ -2435,27 +2469,9 @@ class TestWorkOrder(FrappeTestCase):
|
||||
se_for_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty))
|
||||
se_for_manufacture.submit()
|
||||
|
||||
# Simulate a disassembly stock entry
|
||||
# Disassembly via WO required_items path (no source_stock_entry)
|
||||
disassemble_qty = 4
|
||||
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
|
||||
stock_entry.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": fg_item,
|
||||
"qty": disassemble_qty,
|
||||
"s_warehouse": wo.fg_warehouse,
|
||||
},
|
||||
)
|
||||
|
||||
for bom_item in bom.items:
|
||||
stock_entry.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": bom_item.item_code,
|
||||
"qty": (bom_item.qty / bom.quantity) * disassemble_qty,
|
||||
"t_warehouse": wo.source_warehouse,
|
||||
},
|
||||
)
|
||||
|
||||
wo.reload()
|
||||
stock_entry.save()
|
||||
@@ -2470,7 +2486,7 @@ class TestWorkOrder(FrappeTestCase):
|
||||
f"Expected FG qty {disassemble_qty}, found {finished_good_entry.qty}",
|
||||
)
|
||||
|
||||
# Assert raw materials
|
||||
# Assert raw materials - qty scaled from WO required_items
|
||||
for item in stock_entry.items:
|
||||
if item.item_code == fg_item:
|
||||
continue
|
||||
@@ -2494,10 +2510,35 @@ class TestWorkOrder(FrappeTestCase):
|
||||
f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}",
|
||||
)
|
||||
|
||||
# Second disassembly: explicitly linked to manufacture SE — verifies SE-linked path
|
||||
# (first disassembly auto-set source_stock_entry since there's only one manufacture entry)
|
||||
disassemble_qty_2 = 2
|
||||
stock_entry_2 = frappe.get_doc(
|
||||
make_stock_entry(
|
||||
wo.name, "Disassemble", disassemble_qty_2, source_stock_entry=se_for_manufacture.name
|
||||
)
|
||||
)
|
||||
stock_entry_2.save()
|
||||
stock_entry_2.submit()
|
||||
|
||||
# All rows must trace back to se_for_manufacture
|
||||
for item in stock_entry_2.items:
|
||||
self.assertEqual(item.against_stock_entry, se_for_manufacture.name)
|
||||
self.assertTrue(item.ste_detail)
|
||||
|
||||
# RM qty scaled from the manufacture SE rows
|
||||
rm_row = next((i for i in stock_entry_2.items if i.item_code == raw_item), None)
|
||||
expected_rm_qty = (bom.items[0].qty / bom.quantity) * disassemble_qty_2
|
||||
self.assertAlmostEqual(rm_row.qty, expected_rm_qty, places=3)
|
||||
|
||||
wo.reload()
|
||||
self.assertEqual(wo.disassembled_qty, disassemble_qty + disassemble_qty_2)
|
||||
|
||||
def test_disassembly_with_multiple_manufacture_entries(self):
|
||||
"""
|
||||
Test that disassembly does not create duplicate items when manufacturing
|
||||
is done in multiple batches (multiple manufacture stock entries).
|
||||
is done in multiple batches (multiple manufacture stock entries), including
|
||||
secondary/scrap items.
|
||||
|
||||
Scenario:
|
||||
1. Create Work Order for 10 units
|
||||
@@ -2506,11 +2547,17 @@ class TestWorkOrder(FrappeTestCase):
|
||||
4. Create Disassembly for 4 units
|
||||
5. Verify no duplicate items in the disassembly stock entry
|
||||
"""
|
||||
# Create RM and FG item
|
||||
# Create RM, scrap and FG item
|
||||
raw_item1 = make_item("Test Raw for Multi Batch Disassembly 1", {"is_stock_item": 1}).name
|
||||
raw_item2 = make_item("Test Raw for Multi Batch Disassembly 2", {"is_stock_item": 1}).name
|
||||
scrap_item = make_item("Test Scrap for Multi Batch Disassembly", {"is_stock_item": 1}).name
|
||||
fg_item = make_item("Test FG for Multi Batch Disassembly", {"is_stock_item": 1}).name
|
||||
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2)
|
||||
bom = make_bom(
|
||||
item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2, do_not_submit=True
|
||||
)
|
||||
# add scrap item
|
||||
bom.append("scrap_items", {"item_code": scrap_item, "stock_qty": 10})
|
||||
bom.submit()
|
||||
|
||||
# Create WO
|
||||
wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")
|
||||
@@ -2585,7 +2632,7 @@ class TestWorkOrder(FrappeTestCase):
|
||||
f"Found duplicate items in disassembly stock entry: {duplicates}",
|
||||
)
|
||||
|
||||
expected_items = 3 # FG item + 2 raw materials
|
||||
expected_items = 4 # FG item + 2 raw materials + 1 scrap item
|
||||
self.assertEqual(
|
||||
len(stock_entry.items),
|
||||
expected_items,
|
||||
@@ -2596,6 +2643,16 @@ class TestWorkOrder(FrappeTestCase):
|
||||
fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
|
||||
self.assertEqual(fg_item_row.qty, disassemble_qty)
|
||||
|
||||
# Scrap item: should be taken from scrap warehouse in disassembly
|
||||
scrap_row = next((i for i in stock_entry.items if i.item_code == scrap_item), None)
|
||||
self.assertIsNotNone(scrap_row)
|
||||
self.assertEqual(scrap_row.is_scrap_item, 1)
|
||||
self.assertTrue(scrap_row.s_warehouse)
|
||||
self.assertFalse(scrap_row.t_warehouse)
|
||||
self.assertEqual(scrap_row.s_warehouse, wo.scrap_warehouse)
|
||||
# BOM has scrap_qty=10/FG, total produced = 10*10 = 100, disassemble 4/10 → 40
|
||||
self.assertEqual(scrap_row.qty, 40)
|
||||
|
||||
# RM quantities
|
||||
for bom_item in bom.items:
|
||||
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
|
||||
@@ -2607,19 +2664,57 @@ class TestWorkOrder(FrappeTestCase):
|
||||
msg=f"Raw material {bom_item.item_code} qty mismatch",
|
||||
)
|
||||
|
||||
# -- BOM-path disassembly (no source_stock_entry, no work_order) --
|
||||
|
||||
make_stock_entry_test_record(
|
||||
item_code=scrap_item,
|
||||
purpose="Material Receipt",
|
||||
target=wo.fg_warehouse,
|
||||
qty=50,
|
||||
basic_rate=10,
|
||||
)
|
||||
|
||||
bom_disassemble_qty = 2
|
||||
bom_se = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Entry",
|
||||
"stock_entry_type": "Disassemble",
|
||||
"purpose": "Disassemble",
|
||||
"from_bom": 1,
|
||||
"bom_no": bom.name,
|
||||
"fg_completed_qty": bom_disassemble_qty,
|
||||
"from_warehouse": wo.fg_warehouse,
|
||||
"to_warehouse": wo.wip_warehouse,
|
||||
"company": wo.company,
|
||||
"posting_date": nowdate(),
|
||||
"posting_time": nowtime(),
|
||||
}
|
||||
)
|
||||
bom_se.get_items()
|
||||
bom_se.save()
|
||||
bom_se.submit()
|
||||
|
||||
bom_scrap_row = next((i for i in bom_se.items if i.item_code == scrap_item), None)
|
||||
self.assertIsNotNone(bom_scrap_row, "Scrap item must appear in BOM-path disassembly")
|
||||
# v15: BOM scrap_qty=10/FG, no process_loss_per field → qty = 10 * 2 = 20
|
||||
self.assertEqual(
|
||||
bom_scrap_row.qty,
|
||||
20,
|
||||
f"BOM-path disassembly scrap qty mismatch; expected 20, got {bom_scrap_row.qty}",
|
||||
)
|
||||
|
||||
def test_disassembly_with_additional_rm_not_in_bom(self):
|
||||
"""
|
||||
Test that disassembly correctly handles additional raw materials that were
|
||||
manually added during manufacturing (not part of the BOM).
|
||||
Test that SE-linked disassembly includes additional raw materials
|
||||
that were manually added during manufacturing (not part of the BOM).
|
||||
|
||||
Scenario:
|
||||
1. Create Work Order for 10 units with 2 raw materials in BOM
|
||||
2. Transfer raw materials for manufacture
|
||||
3. Manufacture in 2 parts (3 units, then 7 units)
|
||||
4. In each manufacture entry, manually add an extra consumable item
|
||||
(not in BOM) in proportion to the manufactured qty
|
||||
5. Create Disassembly for 4 units
|
||||
6. Verify that the additional RM is included in disassembly with proportional qty
|
||||
5. Disassemble 3 units linked to first manufacture entry
|
||||
6. Verify additional RM is included with correct proportional qty from SE1
|
||||
"""
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
@@ -2655,9 +2750,8 @@ class TestWorkOrder(FrappeTestCase):
|
||||
se_for_material_transfer.save()
|
||||
se_for_material_transfer.submit()
|
||||
|
||||
# First Manufacture Entry - 3 units
|
||||
# First Manufacture Entry - 3 units with additional RM
|
||||
se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
|
||||
# Additional RM
|
||||
se_manufacture1.append(
|
||||
"items",
|
||||
{
|
||||
@@ -2670,9 +2764,8 @@ class TestWorkOrder(FrappeTestCase):
|
||||
se_manufacture1.save()
|
||||
se_manufacture1.submit()
|
||||
|
||||
# Second Manufacture Entry - 7 units
|
||||
# Second Manufacture Entry - 7 units with additional RM
|
||||
se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7))
|
||||
# AAdditional RM
|
||||
se_manufacture2.append(
|
||||
"items",
|
||||
{
|
||||
@@ -2688,13 +2781,15 @@ class TestWorkOrder(FrappeTestCase):
|
||||
wo.reload()
|
||||
self.assertEqual(wo.produced_qty, 10)
|
||||
|
||||
# Disassembly for 4 units
|
||||
disassemble_qty = 4
|
||||
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
|
||||
# Disassemble 3 units linked to first manufacture entry
|
||||
disassemble_qty = 3
|
||||
stock_entry = frappe.get_doc(
|
||||
make_stock_entry(wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture1.name)
|
||||
)
|
||||
stock_entry.save()
|
||||
stock_entry.submit()
|
||||
|
||||
# No duplicate
|
||||
# No duplicates
|
||||
item_counts = {}
|
||||
for item in stock_entry.items:
|
||||
item_code = item.item_code
|
||||
@@ -2707,16 +2802,15 @@ class TestWorkOrder(FrappeTestCase):
|
||||
f"Found duplicate items in disassembly stock entry: {duplicates}",
|
||||
)
|
||||
|
||||
# Additional RM qty
|
||||
# Additional RM should be included — qty proportional to SE1 (3 units -> 3 additional RM)
|
||||
additional_rm_row = next((i for i in stock_entry.items if i.item_code == additional_rm), None)
|
||||
self.assertIsNotNone(
|
||||
additional_rm_row,
|
||||
f"Additional raw material {additional_rm} not found in disassembly",
|
||||
)
|
||||
|
||||
# intentional full reversal as not part of BOM
|
||||
# eg: dies or consumables used during manufacturing
|
||||
expected_additional_rm_qty = 3 + 7
|
||||
# SE1 had 3 additional RM for 3 manufactured units, disassembling all 3
|
||||
expected_additional_rm_qty = 3
|
||||
self.assertAlmostEqual(
|
||||
additional_rm_row.qty,
|
||||
expected_additional_rm_qty,
|
||||
@@ -2724,7 +2818,7 @@ class TestWorkOrder(FrappeTestCase):
|
||||
msg=f"Additional RM qty mismatch: expected {expected_additional_rm_qty}, got {additional_rm_row.qty}",
|
||||
)
|
||||
|
||||
# RM qty
|
||||
# BOM RM qty — scaled from SE1's rows
|
||||
for bom_item in bom.items:
|
||||
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
|
||||
rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None)
|
||||
@@ -2740,6 +2834,7 @@ class TestWorkOrder(FrappeTestCase):
|
||||
fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
|
||||
self.assertEqual(fg_item_row.qty, disassemble_qty)
|
||||
|
||||
# FG + 2 BOM RM + 1 additional RM = 4 items
|
||||
expected_items = 4
|
||||
self.assertEqual(
|
||||
len(stock_entry.items),
|
||||
@@ -2747,6 +2842,282 @@ class TestWorkOrder(FrappeTestCase):
|
||||
f"Expected {expected_items} items, found {len(stock_entry.items)}",
|
||||
)
|
||||
|
||||
# Verify traceability
|
||||
for item in stock_entry.items:
|
||||
self.assertEqual(item.against_stock_entry, se_manufacture1.name)
|
||||
self.assertTrue(item.ste_detail)
|
||||
|
||||
def test_disassembly_auto_sets_source_stock_entry(self):
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
)
|
||||
|
||||
raw_item = make_item("Test Raw Auto Set Disassembly", {"is_stock_item": 1}).name
|
||||
fg_item = make_item("Test FG Auto Set Disassembly", {"is_stock_item": 1}).name
|
||||
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item], rm_qty=2)
|
||||
|
||||
wo = make_wo_order_test_record(production_item=fg_item, qty=5, bom_no=bom.name, status="Not Started")
|
||||
|
||||
make_stock_entry_test_record(
|
||||
item_code=raw_item, purpose="Material Receipt", target=wo.wip_warehouse, qty=50, basic_rate=100
|
||||
)
|
||||
|
||||
se_transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty))
|
||||
for item in se_transfer.items:
|
||||
item.s_warehouse = wo.wip_warehouse
|
||||
se_transfer.save()
|
||||
se_transfer.submit()
|
||||
|
||||
se_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty))
|
||||
se_manufacture.submit()
|
||||
|
||||
# Disassemble without specifying source_stock_entry
|
||||
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", 3))
|
||||
stock_entry.save()
|
||||
|
||||
# source_stock_entry should be auto-set since only one manufacture entry
|
||||
self.assertEqual(stock_entry.source_stock_entry, se_manufacture.name)
|
||||
|
||||
# All items should have against_stock_entry linked
|
||||
for item in stock_entry.items:
|
||||
self.assertEqual(item.against_stock_entry, se_manufacture.name)
|
||||
self.assertTrue(item.ste_detail)
|
||||
|
||||
stock_entry.submit()
|
||||
|
||||
def test_disassembly_batch_tracked_items(self):
|
||||
from erpnext.stock.doctype.batch.batch import make_batch
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
)
|
||||
|
||||
wip_wh = "_Test Warehouse - _TC"
|
||||
|
||||
rm_item = make_item(
|
||||
"Test Batch RM for Disassembly SB",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TBRD-RM-.###",
|
||||
},
|
||||
).name
|
||||
fg_item = make_item(
|
||||
"Test Batch FG for Disassembly SB",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TBRD-FG-.###",
|
||||
},
|
||||
).name
|
||||
|
||||
bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2)
|
||||
wo = make_wo_order_test_record(
|
||||
production_item=fg_item,
|
||||
qty=6,
|
||||
bom_no=bom.name,
|
||||
skip_transfer=1,
|
||||
source_warehouse=wip_wh,
|
||||
status="Not Started",
|
||||
)
|
||||
|
||||
# Two separate RM receipts → two distinct batches (batch_1, batch_2)
|
||||
rm_receipt_1 = make_stock_entry_test_record(
|
||||
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
|
||||
)
|
||||
rm_batch_1 = get_batch_from_bundle(
|
||||
frappe.db.get_value(
|
||||
"Stock Entry Detail",
|
||||
{"parent": rm_receipt_1.name, "item_code": rm_item},
|
||||
"serial_and_batch_bundle",
|
||||
)
|
||||
)
|
||||
|
||||
rm_receipt_2 = make_stock_entry_test_record(
|
||||
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
|
||||
)
|
||||
rm_batch_2 = get_batch_from_bundle(
|
||||
frappe.db.get_value(
|
||||
"Stock Entry Detail",
|
||||
{"parent": rm_receipt_2.name, "item_code": rm_item},
|
||||
"serial_and_batch_bundle",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertNotEqual(rm_batch_1, rm_batch_2, "Two receipts must create two distinct RM batches")
|
||||
|
||||
fg_batch_1 = make_batch(frappe._dict(item=fg_item))
|
||||
fg_batch_2 = make_batch(frappe._dict(item=fg_item))
|
||||
|
||||
# Manufacture entry 1 — 3 FG using batch_1 RM/FG
|
||||
se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
|
||||
for row in se_manufacture_1.items:
|
||||
if row.item_code == rm_item:
|
||||
row.batch_no = rm_batch_1
|
||||
row.use_serial_batch_fields = 1
|
||||
elif row.item_code == fg_item:
|
||||
row.batch_no = fg_batch_1
|
||||
row.use_serial_batch_fields = 1
|
||||
se_manufacture_1.save()
|
||||
se_manufacture_1.submit()
|
||||
|
||||
# Manufacture entry 2 — 3 FG using batch_2 RM/FG
|
||||
se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
|
||||
for row in se_manufacture_2.items:
|
||||
if row.item_code == rm_item:
|
||||
row.batch_no = rm_batch_2
|
||||
row.use_serial_batch_fields = 1
|
||||
elif row.item_code == fg_item:
|
||||
row.batch_no = fg_batch_2
|
||||
row.use_serial_batch_fields = 1
|
||||
se_manufacture_2.save()
|
||||
se_manufacture_2.submit()
|
||||
|
||||
# Disassemble 2 units from SE_1 only — must use SE_1's batches, not SE_2's
|
||||
disassemble_qty = 2
|
||||
stock_entry = frappe.get_doc(
|
||||
make_stock_entry(
|
||||
wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name
|
||||
)
|
||||
)
|
||||
stock_entry.save()
|
||||
stock_entry.submit()
|
||||
|
||||
# FG row: must use fg_batch_1 exclusively (fg_batch_2 must not appear)
|
||||
fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
|
||||
self.assertIsNotNone(fg_row)
|
||||
self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle")
|
||||
self.assertEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_1)
|
||||
self.assertNotEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_2)
|
||||
|
||||
# RM row: must use rm_batch_1 exclusively (rm_batch_2 must not appear)
|
||||
rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None)
|
||||
self.assertIsNotNone(rm_row)
|
||||
self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle")
|
||||
self.assertEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_1)
|
||||
self.assertNotEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_2)
|
||||
|
||||
# RM qty: 2 FG disassembled x 2 RM per FG = 4
|
||||
self.assertAlmostEqual(rm_row.qty, 4.0, places=3)
|
||||
|
||||
def test_disassembly_serial_tracked_items(self):
|
||||
from frappe.model.naming import make_autoname
|
||||
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
)
|
||||
|
||||
wip_wh = "_Test Warehouse - _TC"
|
||||
|
||||
rm_item = make_item(
|
||||
"Test Serial RM for Disassembly SB",
|
||||
{"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-RM-.####"},
|
||||
).name
|
||||
fg_item = make_item(
|
||||
"Test Serial FG for Disassembly SB",
|
||||
{"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-FG-.####"},
|
||||
).name
|
||||
|
||||
bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2)
|
||||
wo = make_wo_order_test_record(
|
||||
production_item=fg_item,
|
||||
qty=6,
|
||||
bom_no=bom.name,
|
||||
skip_transfer=1,
|
||||
source_warehouse=wip_wh,
|
||||
status="Not Started",
|
||||
)
|
||||
|
||||
# Two separate RM receipts → two disjoint sets of serial numbers
|
||||
rm_receipt_1 = make_stock_entry_test_record(
|
||||
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
|
||||
)
|
||||
rm_serials_1 = get_serial_nos_from_bundle(
|
||||
frappe.db.get_value(
|
||||
"Stock Entry Detail",
|
||||
{"parent": rm_receipt_1.name, "item_code": rm_item},
|
||||
"serial_and_batch_bundle",
|
||||
)
|
||||
)
|
||||
self.assertEqual(len(rm_serials_1), 6)
|
||||
|
||||
rm_receipt_2 = make_stock_entry_test_record(
|
||||
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
|
||||
)
|
||||
rm_serials_2 = get_serial_nos_from_bundle(
|
||||
frappe.db.get_value(
|
||||
"Stock Entry Detail",
|
||||
{"parent": rm_receipt_2.name, "item_code": rm_item},
|
||||
"serial_and_batch_bundle",
|
||||
)
|
||||
)
|
||||
self.assertEqual(len(rm_serials_2), 6)
|
||||
self.assertFalse(
|
||||
set(rm_serials_1) & set(rm_serials_2), "Two receipts must produce disjoint RM serial sets"
|
||||
)
|
||||
|
||||
# Pre-generate two sets of FG serial numbers
|
||||
series = frappe.db.get_value("Item", fg_item, "serial_no_series")
|
||||
fg_serials_1 = [make_autoname(series) for _ in range(3)]
|
||||
fg_serials_2 = [make_autoname(series) for _ in range(3)]
|
||||
|
||||
# Manufacture entry 1 — consumes rm_serials_1, produces fg_serials_1
|
||||
se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
|
||||
for row in se_manufacture_1.items:
|
||||
if row.item_code == rm_item:
|
||||
row.serial_no = "\n".join(rm_serials_1)
|
||||
row.use_serial_batch_fields = 1
|
||||
elif row.item_code == fg_item:
|
||||
row.serial_no = "\n".join(fg_serials_1)
|
||||
row.use_serial_batch_fields = 1
|
||||
se_manufacture_1.save()
|
||||
se_manufacture_1.submit()
|
||||
|
||||
# Manufacture entry 2 — consumes rm_serials_2, produces fg_serials_2
|
||||
se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
|
||||
for row in se_manufacture_2.items:
|
||||
if row.item_code == rm_item:
|
||||
row.serial_no = "\n".join(rm_serials_2)
|
||||
row.use_serial_batch_fields = 1
|
||||
elif row.item_code == fg_item:
|
||||
row.serial_no = "\n".join(fg_serials_2)
|
||||
row.use_serial_batch_fields = 1
|
||||
se_manufacture_2.save()
|
||||
se_manufacture_2.submit()
|
||||
|
||||
# Disassemble 2 units from SE_1 only — must use SE_1's serials, not SE_2's
|
||||
disassemble_qty = 2
|
||||
stock_entry = frappe.get_doc(
|
||||
make_stock_entry(
|
||||
wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name
|
||||
)
|
||||
)
|
||||
stock_entry.save()
|
||||
stock_entry.submit()
|
||||
|
||||
# FG row: 2 serials consumed — must be subset of fg_serials_1, disjoint from fg_serials_2
|
||||
fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
|
||||
self.assertIsNotNone(fg_row)
|
||||
self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle")
|
||||
fg_dasm_serials = get_serial_nos_from_bundle(fg_row.serial_and_batch_bundle)
|
||||
self.assertEqual(len(fg_dasm_serials), disassemble_qty)
|
||||
self.assertTrue(set(fg_dasm_serials).issubset(set(fg_serials_1)))
|
||||
self.assertFalse(
|
||||
set(fg_dasm_serials) & set(fg_serials_2), "Disassembly must not use SE_2's FG serials"
|
||||
)
|
||||
|
||||
# RM row: 4 serials returned (2 FG x 2 RM each) — subset of rm_serials_1, disjoint from rm_serials_2
|
||||
rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None)
|
||||
self.assertIsNotNone(rm_row)
|
||||
self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle")
|
||||
rm_dasm_serials = get_serial_nos_from_bundle(rm_row.serial_and_batch_bundle)
|
||||
self.assertEqual(len(rm_dasm_serials), disassemble_qty * 2)
|
||||
self.assertTrue(set(rm_dasm_serials).issubset(set(rm_serials_1)))
|
||||
self.assertFalse(
|
||||
set(rm_dasm_serials) & set(rm_serials_2), "Disassembly must not use SE_2's RM serials"
|
||||
)
|
||||
|
||||
def test_components_alternate_item_for_bom_based_manufacture_entry(self):
|
||||
frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM")
|
||||
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1)
|
||||
|
||||
@@ -243,6 +243,20 @@ frappe.ui.form.on("Work Order", {
|
||||
|
||||
frm.trigger("add_custom_button_to_return_components");
|
||||
frm.trigger("allow_alternative_item");
|
||||
frm.trigger("toggle_items_editable");
|
||||
},
|
||||
|
||||
toggle_items_editable(frm) {
|
||||
let allow_edit = true;
|
||||
if (!frm.doc.__onload?.allow_editing_items) allow_edit = false;
|
||||
|
||||
frm.set_df_property("required_items", "cannot_delete_rows", !allow_edit);
|
||||
frm.set_df_property("required_items", "cannot_add_rows", !allow_edit);
|
||||
|
||||
const grid = frm.fields_dict["required_items"].grid;
|
||||
grid.update_docfield_property("item_code", "read_only", !allow_edit);
|
||||
grid.update_docfield_property("required_qty", "read_only", !allow_edit);
|
||||
grid.refresh();
|
||||
},
|
||||
|
||||
add_custom_button_to_return_components: function (frm) {
|
||||
@@ -401,7 +415,7 @@ frappe.ui.form.on("Work Order", {
|
||||
|
||||
make_disassembly_order(frm) {
|
||||
erpnext.work_order
|
||||
.show_prompt_for_qty_input(frm, "Disassemble")
|
||||
.show_disassembly_prompt(frm)
|
||||
.then((data) => {
|
||||
if (flt(data.qty) <= 0) {
|
||||
frappe.msgprint(__("Disassemble Qty cannot be less than or equal to <b>0</b>."));
|
||||
@@ -411,11 +425,14 @@ frappe.ui.form.on("Work Order", {
|
||||
work_order_id: frm.doc.name,
|
||||
purpose: "Disassemble",
|
||||
qty: data.qty,
|
||||
source_stock_entry: data.source_stock_entry,
|
||||
});
|
||||
})
|
||||
.then((stock_entry) => {
|
||||
frappe.model.sync(stock_entry);
|
||||
frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
|
||||
if (stock_entry) {
|
||||
frappe.model.sync(stock_entry);
|
||||
frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -865,6 +882,60 @@ erpnext.work_order = {
|
||||
return flt(max, precision("qty"));
|
||||
},
|
||||
|
||||
show_disassembly_prompt: function (frm) {
|
||||
let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
|
||||
|
||||
let fields = [
|
||||
{
|
||||
fieldtype: "Link",
|
||||
label: __("Source Manufacture Entry"),
|
||||
fieldname: "source_stock_entry",
|
||||
options: "Stock Entry",
|
||||
description: __("Optional. Select a specific manufacture entry to reverse."),
|
||||
get_query: () => {
|
||||
return {
|
||||
filters: {
|
||||
work_order: frm.doc.name,
|
||||
purpose: "Manufacture",
|
||||
docstatus: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
onchange: async function () {
|
||||
if (!frm.disassembly_prompt) return;
|
||||
|
||||
let se_name = this.value;
|
||||
let qty = max_qty;
|
||||
if (se_name) {
|
||||
qty = await frappe.xcall(
|
||||
"erpnext.manufacturing.doctype.work_order.work_order.get_disassembly_available_qty",
|
||||
{ stock_entry_name: se_name }
|
||||
);
|
||||
}
|
||||
|
||||
frm.disassembly_prompt.set_value("qty", qty);
|
||||
frm.disassembly_prompt.fields_dict.qty.set_description(__("Max: {0}", [qty]));
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldtype: "Float",
|
||||
label: __("Qty for {0}", [__("Disassemble")]),
|
||||
fieldname: "qty",
|
||||
description: __("Max: {0}", [max_qty]),
|
||||
default: max_qty,
|
||||
},
|
||||
];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
frm.disassembly_prompt = frappe.prompt(
|
||||
fields,
|
||||
(data) => resolve(data),
|
||||
__("Disassemble"),
|
||||
__("Create")
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
show_prompt_for_qty_input: function (frm, purpose) {
|
||||
let max = this.get_max_transferable_qty(frm, purpose);
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ class WorkOrder(Document):
|
||||
|
||||
def onload(self):
|
||||
ms = frappe.get_doc("Manufacturing Settings")
|
||||
self.set_onload("allow_editing_items", ms.allow_editing_of_items_and_quantities_in_work_order)
|
||||
self.set_onload("material_consumption", ms.material_consumption)
|
||||
self.set_onload("backflush_raw_materials_based_on", ms.backflush_raw_materials_based_on)
|
||||
self.set_onload("overproduction_percentage", ms.overproduction_percentage_for_work_order)
|
||||
@@ -167,7 +168,11 @@ class WorkOrder(Document):
|
||||
|
||||
validate_uom_is_integer(self, "stock_uom", ["required_qty"])
|
||||
|
||||
self.set_required_items(reset_only_qty=len(self.get("required_items")))
|
||||
if not len(self.get("required_items")) or not frappe.db.get_single_value(
|
||||
"Manufacturing Settings", "allow_editing_of_items_and_quantities_in_work_order"
|
||||
):
|
||||
self.set_required_items(reset_only_qty=len(self.get("required_items")))
|
||||
|
||||
self.validate_operations_sequence()
|
||||
|
||||
def validate_operations_sequence(self):
|
||||
@@ -223,6 +228,18 @@ class WorkOrder(Document):
|
||||
if self.production_plan_sub_assembly_item:
|
||||
return
|
||||
|
||||
production_item = self.production_item
|
||||
|
||||
if self.material_request_item and (
|
||||
mr_plan_item := frappe.get_value(
|
||||
"Material Request Item", self.material_request_item, "material_request_plan_item"
|
||||
)
|
||||
):
|
||||
if main_item_code := frappe.get_value(
|
||||
"Material Request Plan Item", mr_plan_item, "main_item_code"
|
||||
):
|
||||
production_item = main_item_code
|
||||
|
||||
if self.sales_order:
|
||||
self.check_sales_order_on_hold_or_close()
|
||||
|
||||
@@ -243,8 +260,8 @@ class WorkOrder(Document):
|
||||
& (SalesOrder.docstatus == 1)
|
||||
& (SalesOrder.name == self.sales_order)
|
||||
& (
|
||||
(SalesOrderItem.item_code == self.production_item)
|
||||
| (ProductBundleItem.item_code == self.production_item)
|
||||
(SalesOrderItem.item_code == production_item)
|
||||
| (ProductBundleItem.item_code == production_item)
|
||||
)
|
||||
)
|
||||
.run(as_dict=1)
|
||||
@@ -263,7 +280,7 @@ class WorkOrder(Document):
|
||||
& (SalesOrder.skip_delivery_note == 0)
|
||||
& (SalesOrderItem.item_code == PackedItem.parent_item)
|
||||
& (SalesOrder.docstatus == 1)
|
||||
& (PackedItem.item_code == self.production_item)
|
||||
& (PackedItem.item_code == production_item)
|
||||
)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
@@ -1480,7 +1497,13 @@ def set_work_order_ops(name):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
|
||||
def make_stock_entry(
|
||||
work_order_id: str,
|
||||
purpose: str,
|
||||
qty: float | None = None,
|
||||
target_warehouse: str | None = None,
|
||||
source_stock_entry: str | None = None,
|
||||
):
|
||||
work_order = frappe.get_doc("Work Order", work_order_id)
|
||||
if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"):
|
||||
wip_warehouse = work_order.wip_warehouse
|
||||
@@ -1517,6 +1540,8 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
|
||||
if purpose == "Disassemble":
|
||||
stock_entry.from_warehouse = work_order.fg_warehouse
|
||||
stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse
|
||||
if source_stock_entry:
|
||||
stock_entry.source_stock_entry = source_stock_entry
|
||||
|
||||
stock_entry.set_stock_entry_type()
|
||||
stock_entry.get_items()
|
||||
@@ -1527,6 +1552,28 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
|
||||
return stock_entry.as_dict()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_disassembly_available_qty(stock_entry_name: str, current_se_name: str | None = None) -> float:
|
||||
se = frappe.db.get_value("Stock Entry", stock_entry_name, ["fg_completed_qty"], as_dict=True)
|
||||
if not se:
|
||||
return 0.0
|
||||
|
||||
filters = {
|
||||
"source_stock_entry": stock_entry_name,
|
||||
"purpose": "Disassemble",
|
||||
"docstatus": 1,
|
||||
}
|
||||
|
||||
if current_se_name:
|
||||
filters["name"] = ("!=", current_se_name)
|
||||
|
||||
already_disassembled = flt(
|
||||
frappe.db.get_value("Stock Entry", filters, "sum(fg_completed_qty)", order_by=None)
|
||||
)
|
||||
|
||||
return flt(se.fg_completed_qty) - already_disassembled
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_default_warehouse():
|
||||
doc = frappe.get_cached_doc("Manufacturing Settings")
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-11-19 15:48:16.823384",
|
||||
"modified": "2025-12-02 11:16:05.081613",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order Item",
|
||||
|
||||
@@ -10,6 +10,6 @@ frappe.listview_settings["Workstation"] = {
|
||||
Setup: "blue",
|
||||
};
|
||||
|
||||
return [__(doc.status), color_map[doc.status], true];
|
||||
return [__(doc.status), color_map[doc.status], "status,=," + doc.status];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -432,3 +432,4 @@ erpnext.patches.v16_0.set_ordered_qty_in_quotation_item
|
||||
erpnext.patches.v15_0.replace_http_with_https_in_sales_partner
|
||||
erpnext.patches.v16_0.add_portal_redirects
|
||||
erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po
|
||||
erpnext.patches.v16_0.depends_on_inv_dimensions
|
||||
|
||||
89
erpnext/patches/v16_0/depends_on_inv_dimensions.py
Normal file
89
erpnext/patches/v16_0/depends_on_inv_dimensions.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def get_inventory_dimensions():
|
||||
return frappe.get_all(
|
||||
"Inventory Dimension",
|
||||
fields=[
|
||||
"target_fieldname as fieldname",
|
||||
"source_fieldname",
|
||||
"reference_document as doctype",
|
||||
"reqd",
|
||||
"mandatory_depends_on",
|
||||
],
|
||||
order_by="creation",
|
||||
distinct=True,
|
||||
)
|
||||
|
||||
|
||||
def get_display_depends_on(doctype, fieldname):
|
||||
if doctype not in [
|
||||
"Stock Entry Detail",
|
||||
"Sales Invoice Item",
|
||||
"Delivery Note Item",
|
||||
"Purchase Invoice Item",
|
||||
"Purchase Receipt Item",
|
||||
]:
|
||||
return None, None
|
||||
|
||||
fieldname_start_with = "to"
|
||||
display_depends_on = ""
|
||||
|
||||
if doctype in ["Purchase Invoice Item", "Purchase Receipt Item"]:
|
||||
display_depends_on = "eval:parent.is_internal_supplier == 1"
|
||||
fieldname_start_with = "from"
|
||||
elif doctype != "Stock Entry Detail":
|
||||
display_depends_on = "eval:parent.is_internal_customer == 1"
|
||||
elif doctype == "Stock Entry Detail":
|
||||
display_depends_on = "eval:doc.t_warehouse"
|
||||
|
||||
return f"{fieldname_start_with}_{fieldname}", display_depends_on
|
||||
|
||||
|
||||
def execute():
|
||||
for dimension in get_inventory_dimensions():
|
||||
if frappe.db.exists(
|
||||
"Custom Field", {"fieldname": dimension.source_fieldname, "dt": "Stock Entry Detail"}
|
||||
):
|
||||
frappe.set_value(
|
||||
"Custom Field",
|
||||
{"fieldname": dimension.source_fieldname, "dt": "Stock Entry Detail"},
|
||||
"depends_on",
|
||||
"eval:doc.s_warehouse",
|
||||
)
|
||||
if frappe.db.exists(
|
||||
"Custom Field", {"fieldname": dimension.source_fieldname, "dt": "Stock Entry Detail", "reqd": 1}
|
||||
):
|
||||
frappe.set_value(
|
||||
"Custom Field",
|
||||
{"fieldname": dimension.source_fieldname, "dt": "Stock Entry Detail", "reqd": 1},
|
||||
{"mandatory_depends_on": "eval:doc.s_warehouse", "reqd": 0},
|
||||
)
|
||||
if frappe.db.exists(
|
||||
"Custom Field",
|
||||
{
|
||||
"fieldname": f"to_{dimension.fieldname}",
|
||||
"dt": "Stock Entry Detail",
|
||||
"depends_on": "eval:parent.purpose != 'Material Issue'",
|
||||
},
|
||||
):
|
||||
frappe.set_value(
|
||||
"Custom Field",
|
||||
{
|
||||
"fieldname": f"to_{dimension.fieldname}",
|
||||
"dt": "Stock Entry Detail",
|
||||
"depends_on": "eval:parent.purpose != 'Material Issue'",
|
||||
},
|
||||
"depends_on",
|
||||
"eval:doc.t_warehouse",
|
||||
)
|
||||
fieldname, display_depends_on = get_display_depends_on(dimension.doctype, dimension.fieldname)
|
||||
if display_depends_on and frappe.db.exists(
|
||||
"Custom Field", {"fieldname": fieldname, "dt": dimension.doctype}
|
||||
):
|
||||
frappe.set_value(
|
||||
"Custom Field",
|
||||
{"fieldname": fieldname, "dt": dimension.doctype},
|
||||
"mandatory_depends_on",
|
||||
display_depends_on if dimension.reqd else dimension.mandatory_depends_on,
|
||||
)
|
||||
@@ -10,7 +10,18 @@ def execute():
|
||||
)
|
||||
if data:
|
||||
frappe.db.auto_commit_on_many_writes = 1
|
||||
frappe.db.bulk_update(
|
||||
"Quotation Item", {d.quotation_item: {"ordered_qty": d.ordered_qty} for d in data}
|
||||
)
|
||||
frappe.db.auto_commit_on_many_writes = 0
|
||||
try:
|
||||
frappe.db.bulk_update(
|
||||
"Quotation Item", {d.quotation_item: {"ordered_qty": d.ordered_qty} for d in data}
|
||||
)
|
||||
quotations = frappe.get_all(
|
||||
"Quotation Item",
|
||||
filters={"name": ["in", [d.quotation_item for d in data]]},
|
||||
pluck="parent",
|
||||
distinct=True,
|
||||
)
|
||||
for quotation in quotations:
|
||||
doc = frappe.get_doc("Quotation", quotation)
|
||||
doc.set_status(update=True, update_modified=False)
|
||||
finally:
|
||||
frappe.db.auto_commit_on_many_writes = 0
|
||||
|
||||
@@ -8,6 +8,7 @@ import frappe
|
||||
from frappe.tests.utils import change_settings
|
||||
from frappe.utils import add_to_date, now_datetime, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.projects.doctype.timesheet.timesheet import OverlapError, make_sales_invoice
|
||||
from erpnext.setup.doctype.employee.test_employee import make_employee
|
||||
@@ -202,6 +203,58 @@ class TestTimesheet(unittest.TestCase):
|
||||
ts.calculate_percentage_billed()
|
||||
self.assertEqual(ts.per_billed, 100)
|
||||
|
||||
def test_partial_billing_and_return(self):
|
||||
"""
|
||||
Test Timesheet status transitions during partial billing, full billing,
|
||||
sales return, and return cancellation.
|
||||
Scenario:
|
||||
1. Create a Timesheet with two billable time logs.
|
||||
2. Create a Sales Invoice billing only one time log → Timesheet becomes Partially Billed.
|
||||
3. Create another Sales Invoice billing the remaining time log → Timesheet becomes Billed.
|
||||
4. Create a Sales Return against the second invoice → Timesheet reverts to Partially Billed.
|
||||
5. Cancel the Sales Return → Timesheet returns to Billed status.
|
||||
This test ensures Timesheet status is recalculated correctly
|
||||
across billing and return lifecycle events.
|
||||
"""
|
||||
emp = make_employee("test_employee_6@salary.com")
|
||||
|
||||
timesheet = make_timesheet(emp, simulate=True, is_billable=1, do_not_submit=True)
|
||||
timesheet_detail = timesheet.append("time_logs", {})
|
||||
timesheet_detail.is_billable = 1
|
||||
timesheet_detail.activity_type = "_Test Activity Type"
|
||||
timesheet_detail.from_time = timesheet.time_logs[0].to_time + datetime.timedelta(minutes=1)
|
||||
timesheet_detail.hours = 2
|
||||
timesheet_detail.to_time = timesheet_detail.from_time + datetime.timedelta(
|
||||
hours=timesheet_detail.hours
|
||||
)
|
||||
timesheet.save().submit()
|
||||
|
||||
sales_invoice = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR")
|
||||
sales_invoice.due_date = nowdate()
|
||||
sales_invoice.timesheets.pop()
|
||||
sales_invoice.submit()
|
||||
|
||||
timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
|
||||
self.assertEqual(timesheet_status, "Partially Billed")
|
||||
|
||||
sales_invoice2 = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR")
|
||||
sales_invoice2.due_date = nowdate()
|
||||
sales_invoice2.submit()
|
||||
|
||||
timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
|
||||
self.assertEqual(timesheet_status, "Billed")
|
||||
|
||||
sales_return = make_sales_return(sales_invoice2.name).submit()
|
||||
timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
|
||||
self.assertEqual(timesheet_status, "Partially Billed")
|
||||
|
||||
sales_return.load_from_db()
|
||||
sales_return.cancel()
|
||||
|
||||
timesheet.load_from_db()
|
||||
self.assertEqual(timesheet.time_logs[1].sales_invoice, sales_invoice2.name)
|
||||
self.assertEqual(timesheet.status, "Billed")
|
||||
|
||||
|
||||
def make_timesheet(
|
||||
employee,
|
||||
@@ -211,6 +264,7 @@ def make_timesheet(
|
||||
project=None,
|
||||
task=None,
|
||||
company=None,
|
||||
do_not_submit=False,
|
||||
):
|
||||
update_activity_type(activity_type)
|
||||
timesheet = frappe.new_doc("Timesheet")
|
||||
@@ -237,7 +291,8 @@ def make_timesheet(
|
||||
else:
|
||||
timesheet.save(ignore_permissions=True)
|
||||
|
||||
timesheet.submit()
|
||||
if not do_not_submit:
|
||||
timesheet.submit()
|
||||
|
||||
return timesheet
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
"in_standard_filter": 1,
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "Draft\nSubmitted\nBilled\nPayslip\nCompleted\nCancelled",
|
||||
"options": "Draft\nSubmitted\nPartially Billed\nBilled\nPayslip\nCompleted\nCancelled",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -310,7 +310,7 @@
|
||||
"idx": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-04-20 15:59:11.107831",
|
||||
"modified": "2026-04-06 22:30:28.513139",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Projects",
|
||||
"name": "Timesheet",
|
||||
|
||||
@@ -50,7 +50,9 @@ class Timesheet(Document):
|
||||
per_billed: DF.Percent
|
||||
sales_invoice: DF.Link | None
|
||||
start_date: DF.Date | None
|
||||
status: DF.Literal["Draft", "Submitted", "Billed", "Payslip", "Completed", "Cancelled"]
|
||||
status: DF.Literal[
|
||||
"Draft", "Submitted", "Partially Billed", "Billed", "Payslip", "Completed", "Cancelled"
|
||||
]
|
||||
time_logs: DF.Table[TimesheetDetail]
|
||||
title: DF.Data | None
|
||||
total_billable_amount: DF.Currency
|
||||
@@ -126,6 +128,9 @@ class Timesheet(Document):
|
||||
if flt(self.per_billed, self.precision("per_billed")) >= 100.0:
|
||||
self.status = "Billed"
|
||||
|
||||
if 0.0 < flt(self.per_billed, self.precision("per_billed")) < 100.0:
|
||||
self.status = "Partially Billed"
|
||||
|
||||
if self.sales_invoice:
|
||||
self.status = "Completed"
|
||||
|
||||
@@ -423,7 +428,9 @@ def get_timesheet_data(name, project):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_sales_invoice(source_name, item_code=None, customer=None, currency=None):
|
||||
def make_sales_invoice(
|
||||
source_name: str, item_code: str | None = None, customer: str | None = None, currency: str | None = None
|
||||
):
|
||||
target = frappe.new_doc("Sales Invoice")
|
||||
timesheet = frappe.get_doc("Timesheet", source_name)
|
||||
|
||||
@@ -452,7 +459,7 @@ def make_sales_invoice(source_name, item_code=None, customer=None, currency=None
|
||||
target.append("items", {"item_code": item_code, "qty": hours, "rate": billing_rate})
|
||||
|
||||
for time_log in timesheet.time_logs:
|
||||
if time_log.is_billable:
|
||||
if time_log.is_billable and not time_log.sales_invoice:
|
||||
target.append(
|
||||
"timesheets",
|
||||
{
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
frappe.listview_settings["Timesheet"] = {
|
||||
add_fields: ["status", "total_hours", "start_date", "end_date"],
|
||||
get_indicator: function (doc) {
|
||||
if (doc.status == "Partially Billed") {
|
||||
return [__("Partially Billed"), "orange", "status,=," + "Partially Billed"];
|
||||
}
|
||||
if (doc.status == "Billed") {
|
||||
return [__("Billed"), "green", "status,=," + "Billed"];
|
||||
}
|
||||
|
||||
@@ -674,24 +674,27 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
|
||||
set_rounded_total() {
|
||||
var disable_rounded_total = 0;
|
||||
if(frappe.meta.get_docfield(this.frm.doc.doctype, "disable_rounded_total", this.frm.doc.name)) {
|
||||
if (frappe.meta.get_docfield(this.frm.doc.doctype, "disable_rounded_total", this.frm.doc.name)) {
|
||||
disable_rounded_total = this.frm.doc.disable_rounded_total;
|
||||
} else if (frappe.sys_defaults.disable_rounded_total) {
|
||||
disable_rounded_total = frappe.sys_defaults.disable_rounded_total;
|
||||
}
|
||||
|
||||
if (cint(disable_rounded_total)) {
|
||||
this.frm.doc.rounded_total = 0;
|
||||
this.frm.doc.base_rounded_total = 0;
|
||||
this.frm.doc.rounding_adjustment = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if(frappe.meta.get_docfield(this.frm.doc.doctype, "rounded_total", this.frm.doc.name)) {
|
||||
this.frm.doc.rounded_total = round_based_on_smallest_currency_fraction(this.frm.doc.grand_total,
|
||||
this.frm.doc.currency, precision("rounded_total"));
|
||||
this.frm.doc.rounding_adjustment = flt(this.frm.doc.rounded_total - this.frm.doc.grand_total,
|
||||
precision("rounding_adjustment"));
|
||||
if (frappe.meta.get_docfield(this.frm.doc.doctype, "rounded_total", this.frm.doc.name)) {
|
||||
if (cint(disable_rounded_total)) {
|
||||
this.frm.doc.rounded_total = 0;
|
||||
this.frm.doc.rounding_adjustment = 0;
|
||||
} else {
|
||||
this.frm.doc.rounded_total = round_based_on_smallest_currency_fraction(
|
||||
this.frm.doc.grand_total,
|
||||
this.frm.doc.currency,
|
||||
precision("rounded_total"),
|
||||
);
|
||||
this.frm.doc.rounding_adjustment = flt(
|
||||
this.frm.doc.rounded_total - this.frm.doc.grand_total,
|
||||
precision("rounding_adjustment"),
|
||||
);
|
||||
}
|
||||
|
||||
this.set_in_company_currency(this.frm.doc, ["rounding_adjustment", "rounded_total"]);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ frappe.ui.form.ContactAddressQuickEntryForm = class ContactAddressQuickEntryForm
|
||||
{
|
||||
fieldtype: "Section Break",
|
||||
label: __("Primary Contact Details"),
|
||||
collapsible: 1,
|
||||
collapsible: 0,
|
||||
},
|
||||
{
|
||||
label: __("First Name"),
|
||||
@@ -69,7 +69,7 @@ frappe.ui.form.ContactAddressQuickEntryForm = class ContactAddressQuickEntryForm
|
||||
{
|
||||
fieldtype: "Section Break",
|
||||
label: __("Primary Address Details"),
|
||||
collapsible: 1,
|
||||
collapsible: 0,
|
||||
},
|
||||
{
|
||||
label: __("Address Line 1"),
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ address_line1 }}<br>
|
||||
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
|
||||
{{ pincode }} {{ city | upper }}<br>
|
||||
{{ country | upper }}
|
||||
4
erpnext/regional/address_template/templates/croatia.html
Normal file
4
erpnext/regional/address_template/templates/croatia.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{{ address_line1 }}<br>
|
||||
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
|
||||
{{ pincode }} {{ city | upper }}<br>
|
||||
{{ country | upper }}
|
||||
4
erpnext/regional/address_template/templates/denmark.html
Normal file
4
erpnext/regional/address_template/templates/denmark.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{{ address_line1 }}<br>
|
||||
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
|
||||
{{ pincode }} {{ city | upper }}<br>
|
||||
{{ country | upper }}
|
||||
@@ -1,9 +1,12 @@
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
|
||||
from erpnext import get_region
|
||||
|
||||
|
||||
class SouthAfricaVATSettings(Document):
|
||||
# begin: auto-generated types
|
||||
@@ -22,4 +25,9 @@ class SouthAfricaVATSettings(Document):
|
||||
vat_accounts: DF.Table[SouthAfricaVATAccount]
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
def validate(self):
|
||||
self.validate_company_region()
|
||||
|
||||
def validate_company_region(self):
|
||||
if self.company and get_region(self.company) != "South Africa":
|
||||
frappe.throw(_("Company {0} is not in South Africa.").format(frappe.bold(self.company)))
|
||||
|
||||
@@ -148,6 +148,7 @@ def make_customer():
|
||||
"doctype": "Customer",
|
||||
"customer_name": "_Test UAE Customer",
|
||||
"customer_type": "Company",
|
||||
"customer_group": "Individual",
|
||||
}
|
||||
)
|
||||
customer.insert()
|
||||
|
||||
@@ -115,6 +115,7 @@ def make_customer():
|
||||
"doctype": "Customer",
|
||||
"customer_name": "_Test SA Customer",
|
||||
"customer_type": "Company",
|
||||
"customer_group": "Individual",
|
||||
}
|
||||
).insert()
|
||||
|
||||
|
||||
@@ -10,6 +10,13 @@ frappe.query_reports["VAT Audit Report"] = {
|
||||
options: "Company",
|
||||
reqd: 1,
|
||||
default: frappe.defaults.get_user_default("Company"),
|
||||
get_query: function () {
|
||||
return {
|
||||
filters: {
|
||||
country: "South Africa",
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "from_date",
|
||||
|
||||
@@ -6,8 +6,11 @@ import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Coalesce, NullIf
|
||||
from frappe.utils import formatdate, get_link_to_form
|
||||
|
||||
from erpnext import get_region
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
return VATAuditReport(filters).run()
|
||||
@@ -21,19 +24,10 @@ class VATAuditReport:
|
||||
self.doctypes = ["Purchase Invoice", "Sales Invoice"]
|
||||
|
||||
def run(self):
|
||||
self.validate_company_region()
|
||||
self.get_sa_vat_accounts()
|
||||
self.get_columns()
|
||||
for doctype in self.doctypes:
|
||||
self.select_columns = """
|
||||
name as voucher_no,
|
||||
posting_date, remarks"""
|
||||
columns = (
|
||||
", supplier as party, credit_to as account"
|
||||
if doctype == "Purchase Invoice"
|
||||
else ", customer as party, debit_to as account"
|
||||
)
|
||||
self.select_columns += columns
|
||||
|
||||
self.get_invoice_data(doctype)
|
||||
|
||||
if self.invoices:
|
||||
@@ -43,6 +37,14 @@ class VATAuditReport:
|
||||
|
||||
return self.columns, self.data
|
||||
|
||||
def validate_company_region(self):
|
||||
if self.filters.company and get_region(self.filters.company) != "South Africa":
|
||||
frappe.throw(
|
||||
_(
|
||||
"The company {0} is not in South Africa. VAT Audit Report is only available for companies in South Africa."
|
||||
).format(frappe.bold(self.filters.company))
|
||||
)
|
||||
|
||||
def get_sa_vat_accounts(self):
|
||||
self.sa_vat_accounts = frappe.get_all(
|
||||
"South Africa VAT Account", filters={"parent": self.filters.company}, pluck="account"
|
||||
@@ -54,47 +56,59 @@ class VATAuditReport:
|
||||
frappe.throw(_("Please set VAT Accounts in {0}").format(link_to_settings))
|
||||
|
||||
def get_invoice_data(self, doctype):
|
||||
conditions = self.get_conditions()
|
||||
self.invoices = frappe._dict()
|
||||
|
||||
invoice_data = frappe.db.sql(
|
||||
f"""
|
||||
SELECT
|
||||
{self.select_columns}
|
||||
FROM
|
||||
`tab{doctype}`
|
||||
WHERE
|
||||
docstatus = 1 {conditions}
|
||||
and is_opening = 'No'
|
||||
ORDER BY
|
||||
posting_date DESC
|
||||
""",
|
||||
self.filters,
|
||||
as_dict=1,
|
||||
invoice_doctype = frappe.qb.DocType(doctype)
|
||||
party_field = invoice_doctype.supplier if doctype == "Purchase Invoice" else invoice_doctype.customer
|
||||
account_field = (
|
||||
invoice_doctype.credit_to if doctype == "Purchase Invoice" else invoice_doctype.debit_to
|
||||
)
|
||||
|
||||
for d in invoice_data:
|
||||
self.invoices.setdefault(d.voucher_no, d)
|
||||
query = (
|
||||
frappe.qb.from_(invoice_doctype)
|
||||
.select(
|
||||
invoice_doctype.name.as_("voucher_no"),
|
||||
invoice_doctype.posting_date,
|
||||
invoice_doctype.remarks,
|
||||
party_field.as_("party"),
|
||||
account_field.as_("account"),
|
||||
)
|
||||
.where(invoice_doctype.docstatus == 1)
|
||||
.where(invoice_doctype.is_opening == "No")
|
||||
.orderby(invoice_doctype.posting_date, order=frappe.qb.desc)
|
||||
)
|
||||
|
||||
if self.filters.get("company"):
|
||||
query = query.where(invoice_doctype.company == self.filters.company)
|
||||
if self.filters.get("from_date"):
|
||||
query = query.where(invoice_doctype.posting_date >= self.filters.from_date)
|
||||
if self.filters.get("to_date"):
|
||||
query = query.where(invoice_doctype.posting_date <= self.filters.to_date)
|
||||
|
||||
invoice_data = query.run(as_dict=True)
|
||||
|
||||
for row in invoice_data:
|
||||
self.invoices.setdefault(row.voucher_no, row)
|
||||
|
||||
def get_invoice_items(self, doctype):
|
||||
self.invoice_items = frappe._dict()
|
||||
item_doctype = frappe.qb.DocType(doctype + " Item")
|
||||
|
||||
items = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
item_code, parent, base_net_amount, is_zero_rated
|
||||
FROM
|
||||
`tab{} Item`
|
||||
WHERE
|
||||
parent in ({})
|
||||
""".format(doctype, ", ".join(["%s"] * len(self.invoices))),
|
||||
tuple(self.invoices),
|
||||
as_dict=1,
|
||||
items = (
|
||||
frappe.qb.from_(item_doctype)
|
||||
.select(
|
||||
Coalesce(NullIf(item_doctype.item_code, ""), item_doctype.item_name).as_("item"),
|
||||
item_doctype.parent,
|
||||
item_doctype.base_net_amount,
|
||||
item_doctype.is_zero_rated,
|
||||
)
|
||||
.where(item_doctype.parent.isin(list(self.invoices.keys())))
|
||||
.run(as_dict=True)
|
||||
)
|
||||
for d in items:
|
||||
self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, {"net_amount": 0.0})
|
||||
self.invoice_items[d.parent][d.item_code]["net_amount"] += d.get("base_net_amount", 0)
|
||||
self.invoice_items[d.parent][d.item_code]["is_zero_rated"] = d.is_zero_rated
|
||||
|
||||
for row in items:
|
||||
self.invoice_items.setdefault(row.parent, {}).setdefault(row.item, {"net_amount": 0.0})
|
||||
self.invoice_items[row.parent][row.item]["net_amount"] += row.get("base_net_amount", 0)
|
||||
self.invoice_items[row.parent][row.item]["is_zero_rated"] = row.is_zero_rated
|
||||
|
||||
def get_items_based_on_tax_rate(self, doctype):
|
||||
self.items_based_on_tax_rate = frappe._dict()
|
||||
@@ -103,52 +117,54 @@ class VATAuditReport:
|
||||
"Purchase Taxes and Charges" if doctype == "Purchase Invoice" else "Sales Taxes and Charges"
|
||||
)
|
||||
|
||||
self.tax_details = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
parent, account_head, item_wise_tax_detail
|
||||
FROM
|
||||
`tab{}`
|
||||
WHERE
|
||||
parenttype = {} and docstatus = 1
|
||||
and parent in ({})
|
||||
ORDER BY
|
||||
account_head
|
||||
""".format(self.tax_doctype, "%s", ", ".join(["%s"] * len(self.invoices.keys()))),
|
||||
tuple([doctype, *list(self.invoices.keys())]),
|
||||
tax_doctype = frappe.qb.DocType(self.tax_doctype)
|
||||
self.tax_details = (
|
||||
frappe.qb.from_(tax_doctype)
|
||||
.select(tax_doctype.parent, tax_doctype.account_head, tax_doctype.item_wise_tax_detail)
|
||||
.where(tax_doctype.parenttype == doctype)
|
||||
.where(tax_doctype.docstatus == 1)
|
||||
.where(tax_doctype.parent.isin(list(self.invoices.keys())))
|
||||
.where(tax_doctype.account_head.isin(self.sa_vat_accounts))
|
||||
.orderby(tax_doctype.account_head)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
for parent, account, item_wise_tax_detail in self.tax_details:
|
||||
if item_wise_tax_detail:
|
||||
try:
|
||||
if account in self.sa_vat_accounts:
|
||||
item_wise_tax_detail = json.loads(item_wise_tax_detail)
|
||||
else:
|
||||
continue
|
||||
for item_code, taxes in item_wise_tax_detail.items():
|
||||
is_zero_rated = self.invoice_items.get(parent).get(item_code).get("is_zero_rated")
|
||||
# to skip items with non-zero tax rate in multiple rows
|
||||
if taxes[0] == 0 and not is_zero_rated:
|
||||
continue
|
||||
tax_rate = self.get_item_amount_map(parent, item_code, taxes)
|
||||
for tax_detail in self.tax_details:
|
||||
if not tax_detail.item_wise_tax_detail:
|
||||
continue
|
||||
|
||||
if tax_rate is not None:
|
||||
rate_based_dict = self.items_based_on_tax_rate.setdefault(parent, {}).setdefault(
|
||||
tax_rate, []
|
||||
)
|
||||
if item_code not in rate_based_dict:
|
||||
rate_based_dict.append(item_code)
|
||||
except ValueError:
|
||||
try:
|
||||
item_wise_tax_detail = json.loads(tax_detail.item_wise_tax_detail)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
parent_items = self.invoice_items.get(tax_detail.parent, {})
|
||||
parent_tax_rates = self.items_based_on_tax_rate.setdefault(tax_detail.parent, {})
|
||||
|
||||
for item, taxes in item_wise_tax_detail.items():
|
||||
is_zero_rated = parent_items.get(item, {}).get("is_zero_rated")
|
||||
# to skip items with non-zero tax rate in multiple rows
|
||||
if taxes[0] == 0 and not is_zero_rated:
|
||||
continue
|
||||
|
||||
def get_item_amount_map(self, parent, item_code, taxes):
|
||||
net_amount = self.invoice_items.get(parent).get(item_code).get("net_amount")
|
||||
tax_rate = self.get_item_amount_map(tax_detail.parent, item, taxes)
|
||||
if tax_rate is not None:
|
||||
rate_based_dict = parent_tax_rates.setdefault(tax_rate, [])
|
||||
if item not in rate_based_dict:
|
||||
rate_based_dict.append(item)
|
||||
|
||||
def get_item_amount_map(self, parent, item, taxes):
|
||||
item_details = self.invoice_items.get(parent, {}).get(item)
|
||||
if not item_details:
|
||||
return None
|
||||
|
||||
net_amount = item_details.get("net_amount", 0)
|
||||
tax_rate = taxes[0]
|
||||
tax_amount = taxes[1]
|
||||
gross_amount = net_amount + tax_amount
|
||||
|
||||
self.item_tax_rate.setdefault(parent, {}).setdefault(
|
||||
item_code,
|
||||
item,
|
||||
{
|
||||
"tax_rate": tax_rate,
|
||||
"gross_amount": 0.0,
|
||||
@@ -157,24 +173,12 @@ class VATAuditReport:
|
||||
},
|
||||
)
|
||||
|
||||
self.item_tax_rate[parent][item_code]["net_amount"] += net_amount
|
||||
self.item_tax_rate[parent][item_code]["tax_amount"] += tax_amount
|
||||
self.item_tax_rate[parent][item_code]["gross_amount"] += gross_amount
|
||||
self.item_tax_rate[parent][item]["net_amount"] += net_amount
|
||||
self.item_tax_rate[parent][item]["tax_amount"] += tax_amount
|
||||
self.item_tax_rate[parent][item]["gross_amount"] += gross_amount
|
||||
|
||||
return tax_rate
|
||||
|
||||
def get_conditions(self):
|
||||
conditions = ""
|
||||
for opts in (
|
||||
("company", " and company=%(company)s"),
|
||||
("from_date", " and posting_date>=%(from_date)s"),
|
||||
("to_date", " and posting_date<=%(to_date)s"),
|
||||
):
|
||||
if self.filters.get(opts[0]):
|
||||
conditions += opts[1]
|
||||
|
||||
return conditions
|
||||
|
||||
def get_data(self, doctype):
|
||||
consolidated_data = self.get_consolidated_data(doctype)
|
||||
section_name = _("Purchases") if doctype == "Purchase Invoice" else _("Sales")
|
||||
|
||||
@@ -145,6 +145,7 @@ class Customer(TransactionBase):
|
||||
def validate(self):
|
||||
self.flags.is_new_doc = self.is_new()
|
||||
self.flags.old_lead = self.lead_name
|
||||
self.validate_customer_group()
|
||||
validate_party_accounts(self)
|
||||
self.validate_credit_limit_on_change()
|
||||
self.set_loyalty_program()
|
||||
@@ -324,6 +325,17 @@ class Customer(TransactionBase):
|
||||
frappe.NameError,
|
||||
)
|
||||
|
||||
def validate_customer_group(self):
|
||||
if not self.customer_group:
|
||||
return
|
||||
|
||||
is_group = frappe.db.get_value("Customer Group", self.customer_group, "is_group")
|
||||
if is_group:
|
||||
frappe.throw(
|
||||
_("Cannot select a Group type Customer Group. Please select a non-group Customer Group."),
|
||||
title=_("Invalid Customer Group"),
|
||||
)
|
||||
|
||||
def validate_credit_limit_on_change(self):
|
||||
if self.get("__islocal") or not self.credit_limits:
|
||||
return
|
||||
|
||||
@@ -450,6 +450,7 @@ def make_customer(customer_name):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = customer_name
|
||||
customer.customer_type = "Individual"
|
||||
customer.customer_group = "Individual"
|
||||
customer.insert()
|
||||
return customer.name
|
||||
else:
|
||||
|
||||
@@ -123,6 +123,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
|
||||
frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0)
|
||||
) {
|
||||
this.frm.add_custom_button(__("Sales Order"), () => this.make_sales_order(), __("Create"));
|
||||
cur_frm.page.set_inner_btn_group_as_primary(__("Create"));
|
||||
this.frm.add_custom_button(__("Update Items"), () => {
|
||||
erpnext.utils.update_child_items({
|
||||
frm: this.frm,
|
||||
@@ -137,8 +138,6 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
|
||||
this.frm.trigger("set_as_lost_dialog");
|
||||
});
|
||||
}
|
||||
|
||||
cur_frm.page.set_inner_btn_group_as_primary(__("Create"));
|
||||
}
|
||||
|
||||
if (this.frm.doc.docstatus === 0 && frappe.model.can_read("Opportunity")) {
|
||||
|
||||
@@ -188,7 +188,7 @@ class Quotation(SellingController):
|
||||
)
|
||||
|
||||
for row in self._items:
|
||||
if row.name not in ordered_items or row.qty > ordered_items[row.name]:
|
||||
if row.name not in ordered_items or row.stock_qty > ordered_items[row.name]:
|
||||
return "Partially Ordered"
|
||||
|
||||
return "Ordered"
|
||||
@@ -409,9 +409,9 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
|
||||
target.run_method("calculate_taxes_and_totals")
|
||||
|
||||
def update_item(obj, target, source_parent):
|
||||
balance_qty = obj.qty if is_unit_price_row(obj) else obj.qty - ordered_items.get(obj.name, 0.0)
|
||||
target.qty = balance_qty if balance_qty > 0 else 0
|
||||
target.stock_qty = flt(target.qty) * flt(obj.conversion_factor)
|
||||
balance_stock_qty = obj.stock_qty - ordered_items.get(obj.name, 0.0)
|
||||
target.stock_qty = balance_stock_qty if balance_stock_qty > 0 else 0
|
||||
target.qty = flt(target.stock_qty) / flt(obj.conversion_factor)
|
||||
|
||||
if obj.against_blanket_order:
|
||||
target.against_blanket_order = obj.against_blanket_order
|
||||
@@ -425,7 +425,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
|
||||
2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty
|
||||
3. If no selections: Simple row: Map if adequate qty
|
||||
"""
|
||||
if not ((item.qty > ordered_items.get(item.name, 0.0)) or is_unit_price_row(item)):
|
||||
if not ((item.stock_qty > ordered_items.get(item.name, 0.0)) or is_unit_price_row(item)):
|
||||
return False
|
||||
|
||||
if not selected_rows:
|
||||
|
||||
@@ -63,6 +63,13 @@ frappe.ui.form.on("Sales Order", {
|
||||
});
|
||||
}
|
||||
},
|
||||
transaction_date(frm) {
|
||||
prevent_past_delivery_dates(frm);
|
||||
frm.set_value("delivery_date", "");
|
||||
frm.doc.items.forEach((d) => {
|
||||
frappe.model.set_value(d.doctype, d.name, "delivery_date", "");
|
||||
});
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.docstatus === 1) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import frappe
|
||||
from frappe import _, msgprint
|
||||
from frappe.core.doctype.sms_settings.sms_settings import send_sms
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import functions as fn
|
||||
from frappe.utils import cstr
|
||||
|
||||
|
||||
@@ -41,73 +42,117 @@ class SMSCenter(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_receiver_list(self):
|
||||
rec, where_clause = "", ""
|
||||
if self.send_to == "All Customer Contact":
|
||||
where_clause = " and dl.link_doctype = 'Customer'"
|
||||
if self.customer:
|
||||
where_clause += (
|
||||
" and dl.link_name = '%s'" % self.customer.replace("'", "'")
|
||||
or " and ifnull(dl.link_name, '') != ''"
|
||||
)
|
||||
if self.send_to == "All Supplier Contact":
|
||||
where_clause = " and dl.link_doctype = 'Supplier'"
|
||||
if self.supplier:
|
||||
where_clause += (
|
||||
" and dl.link_name = '%s'" % self.supplier.replace("'", "'")
|
||||
or " and ifnull(dl.link_name, '') != ''"
|
||||
)
|
||||
if self.send_to == "All Sales Partner Contact":
|
||||
where_clause = " and dl.link_doctype = 'Sales Partner'"
|
||||
if self.sales_partner:
|
||||
where_clause += (
|
||||
"and dl.link_name = '%s'" % self.sales_partner.replace("'", "'")
|
||||
or " and ifnull(dl.link_name, '') != ''"
|
||||
)
|
||||
query = None
|
||||
|
||||
if self.send_to == "":
|
||||
return
|
||||
|
||||
if self.send_to in [
|
||||
"All Contact",
|
||||
"All Customer Contact",
|
||||
"All Supplier Contact",
|
||||
"All Sales Partner Contact",
|
||||
]:
|
||||
rec = frappe.db.sql(
|
||||
"""select CONCAT(ifnull(c.first_name,''), ' ', ifnull(c.last_name,'')),
|
||||
c.mobile_no from `tabContact` c, `tabDynamic Link` dl where ifnull(c.mobile_no,'')!='' and
|
||||
c.docstatus != 2 and dl.parent = c.name%s"""
|
||||
% where_clause
|
||||
)
|
||||
query = self.get_contact_query_for_all_contacts()
|
||||
|
||||
elif self.send_to == "All Lead (Open)":
|
||||
rec = frappe.db.sql(
|
||||
"""select lead_name, mobile_no from `tabLead` where
|
||||
ifnull(mobile_no,'')!='' and docstatus != 2 and status='Open'"""
|
||||
)
|
||||
query = self.get_contact_query_for_all_open_leads()
|
||||
|
||||
elif self.send_to == "All Employee (Active)":
|
||||
where_clause = (
|
||||
self.department and " and department = '%s'" % self.department.replace("'", "'") or ""
|
||||
)
|
||||
where_clause += self.branch and " and branch = '%s'" % self.branch.replace("'", "'") or ""
|
||||
|
||||
rec = frappe.db.sql(
|
||||
"""select employee_name, cell_number from
|
||||
`tabEmployee` where status = 'Active' and docstatus < 2 and
|
||||
ifnull(cell_number,'')!='' %s"""
|
||||
% where_clause
|
||||
)
|
||||
query = self.get_contact_query_for_all_active_employee()
|
||||
|
||||
elif self.send_to == "All Sales Person":
|
||||
rec = frappe.db.sql(
|
||||
"""select sales_person_name,
|
||||
tabEmployee.cell_number from `tabSales Person` left join tabEmployee
|
||||
on `tabSales Person`.employee = tabEmployee.name
|
||||
where ifnull(tabEmployee.cell_number,'')!=''"""
|
||||
)
|
||||
query = self.get_contact_query_for_all_sales_person()
|
||||
|
||||
rec = query.run(as_list=1)
|
||||
|
||||
rec_list = ""
|
||||
for d in rec:
|
||||
rec_list += d[0] + " - " + d[1] + "\n"
|
||||
self.receiver_list = rec_list
|
||||
|
||||
def get_contact_query_for_all_contacts(self):
|
||||
Contact = frappe.qb.DocType("Contact")
|
||||
DynamicLink = frappe.qb.DocType("Dynamic Link")
|
||||
query = (
|
||||
frappe.qb.from_(Contact)
|
||||
.join(DynamicLink)
|
||||
.on(DynamicLink.parent == Contact.name)
|
||||
.select(
|
||||
fn.Concat(fn.IfNull(Contact.first_name, ""), " ", fn.IfNull(Contact.last_name, "")),
|
||||
Contact.mobile_no,
|
||||
)
|
||||
.where((fn.IfNull(Contact.mobile_no, "") != "") & (Contact.docstatus != 2))
|
||||
)
|
||||
|
||||
if self.send_to == "All Customer Contact":
|
||||
query = query.where(DynamicLink.link_doctype == "Customer")
|
||||
query = (
|
||||
query.where(DynamicLink.link_name == self.customer)
|
||||
if self.customer
|
||||
else query.where(fn.IfNull(DynamicLink.link_name, "") != "")
|
||||
)
|
||||
|
||||
elif self.send_to == "All Supplier Contact":
|
||||
query = query.where(DynamicLink.link_doctype == "Supplier")
|
||||
query = (
|
||||
query.where(DynamicLink.link_name == self.supplier)
|
||||
if self.supplier
|
||||
else query.where(fn.IfNull(DynamicLink.link_name, "") != "")
|
||||
)
|
||||
|
||||
elif self.send_to == "All Sales Partner Contact":
|
||||
query = query.where(DynamicLink.link_doctype == "Sales Partner")
|
||||
query = (
|
||||
query.where(DynamicLink.link_name == self.sales_partner)
|
||||
if self.sales_partner
|
||||
else query.where(fn.IfNull(DynamicLink.link_name, "") != "")
|
||||
)
|
||||
return query
|
||||
|
||||
def get_contact_query_for_all_open_leads(self):
|
||||
Lead = frappe.qb.DocType("Lead")
|
||||
query = (
|
||||
frappe.qb.from_(Lead)
|
||||
.select(Lead.lead_name, Lead.mobile)
|
||||
.where((fn.IfNull(Lead.mobile_no, "") != "") & (Lead.docstatus != 2) & (Lead.status == "Open"))
|
||||
)
|
||||
return query
|
||||
|
||||
def get_contact_query_for_all_active_employee(self):
|
||||
Employee = frappe.qb.DocType("Employee")
|
||||
query = (
|
||||
frappe.qb.from_(Employee)
|
||||
.select(Employee.employee_name, Employee.cell_number)
|
||||
.where(
|
||||
(Employee.status == "Active")
|
||||
& (Employee.docstatus != 2)
|
||||
& (fn.IfNull(Employee.cell_number, "") != "")
|
||||
)
|
||||
)
|
||||
|
||||
if self.department:
|
||||
query = query.where(Employee.department == self.department)
|
||||
|
||||
if self.branch:
|
||||
query = query.where(Employee.branch == self.branch)
|
||||
|
||||
return query
|
||||
|
||||
def get_contact_query_for_all_sales_person(self):
|
||||
SalesPerson = frappe.qb.DocType("Sales Person")
|
||||
Employee = frappe.qb.DocType("Employee")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(SalesPerson)
|
||||
.left_join(Employee)
|
||||
.on(SalesPerson.employee == Employee.name)
|
||||
.select(SalesPerson.sales_person_name, Employee.cell_number)
|
||||
.where(fn.IfNull(Employee.cell_number, "") != "")
|
||||
)
|
||||
|
||||
return query
|
||||
|
||||
def get_receiver_nos(self):
|
||||
receiver_nos = []
|
||||
if self.receiver_list:
|
||||
|
||||
@@ -189,7 +189,7 @@ class Employee(NestedSet):
|
||||
frappe.throw(_("User {0} does not exist").format(self.user_id))
|
||||
|
||||
if self.status != "Active" and enabled or self.status == "Active" and enabled == 0:
|
||||
frappe.set_value("User", self.user_id, "enabled", not enabled)
|
||||
frappe.db.set_value("User", self.user_id, "enabled", not enabled)
|
||||
|
||||
def validate_duplicate_user_id(self):
|
||||
Employee = frappe.qb.DocType("Employee")
|
||||
|
||||
@@ -759,7 +759,6 @@
|
||||
"label": "Incoming Rate",
|
||||
"no_copy": 1,
|
||||
"options": "Company:company:default_currency",
|
||||
"precision": "6",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -952,7 +951,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-05-31 18:51:32.651562",
|
||||
"modified": "2026-04-07 15:44:20.892151",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Delivery Note Item",
|
||||
|
||||
@@ -8,9 +8,8 @@
|
||||
"field_order": [
|
||||
"dimension_details_tab",
|
||||
"dimension_name",
|
||||
"reference_document",
|
||||
"column_break_4",
|
||||
"disabled",
|
||||
"reference_document",
|
||||
"field_mapping_section",
|
||||
"source_fieldname",
|
||||
"column_break_9",
|
||||
@@ -93,12 +92,6 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "Apply to All Inventory Documents"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "disabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disabled"
|
||||
},
|
||||
{
|
||||
"fieldname": "target_fieldname",
|
||||
"fieldtype": "Data",
|
||||
@@ -159,6 +152,7 @@
|
||||
"label": "Conditional Rule Examples"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.apply_to_all_doctypes",
|
||||
"description": "To apply condition on parent field use parent.field_name and to apply condition on child table use doc.field_name. Here field_name could be based on the actual column name of the respective field.",
|
||||
"fieldname": "mandatory_depends_on",
|
||||
"fieldtype": "Small Text",
|
||||
@@ -188,7 +182,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-07 15:51:29.329064",
|
||||
"modified": "2026-04-08 10:10:16.884388",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Inventory Dimension",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user