diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 48337cee640..d1a97f87ffb 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -68,6 +68,6 @@ if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi wait $wkpid -bench start &> bench_run_logs.txt & +bench start &>> ~/frappe-bench/bench_start.log & CI=Yes bench build --app frappe & bench --site test_site reinstall --yes diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml index aae2928bf0d..07b8de7a900 100644 --- a/.github/workflows/patch.yml +++ b/.github/workflows/patch.yml @@ -23,7 +23,7 @@ jobs: services: mysql: - image: mariadb:10.3 + image: mariadb:10.6 env: MARIADB_ROOT_PASSWORD: 'root' ports: @@ -45,9 +45,7 @@ jobs: - name: Setup Python uses: "actions/setup-python@v4" with: - python-version: | - 3.7 - 3.10 + python-version: '3.10' - name: Setup Node uses: actions/setup-node@v2 @@ -102,40 +100,60 @@ jobs: - name: Run Patch Tests run: | cd ~/frappe-bench/ - wget https://erpnext.com/files/v10-erpnext.sql.gz - bench --site test_site --force restore ~/frappe-bench/v10-erpnext.sql.gz + bench remove-app payments --force + jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json + mv tmp.json ~/frappe-bench/sites/test_site/site_config.json + + wget https://erpnext.com/files/v13-erpnext.sql.gz + bench --site test_site --force restore ~/frappe-bench/v13-erpnext.sql.gz git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git - for version in $(seq 12 13) - do - echo "Updating to v$version" - branch_name="version-$version-hotfix" - git -C "apps/frappe" fetch --depth 1 upstream $branch_name:$branch_name - git -C "apps/erpnext" fetch --depth 1 upstream $branch_name:$branch_name + function update_to_version() { + version=$1 - git -C "apps/frappe" checkout -q -f $branch_name - git -C "apps/erpnext" checkout -q -f $branch_name + branch_name="version-$version-hotfix" + echo "Updating to v$version" - rm -rf ~/frappe-bench/env - bench setup env --python python3.7 - bench pip install -e ./apps/payments - bench pip install -e ./apps/erpnext + # Fetch and checkout branches + git -C "apps/frappe" fetch --depth 1 upstream $branch_name:$branch_name + git -C "apps/erpnext" fetch --depth 1 upstream $branch_name:$branch_name + git -C "apps/frappe" checkout -q -f $branch_name + git -C "apps/erpnext" checkout -q -f $branch_name - bench --site test_site migrate - done + # Resetup env and install apps + pgrep honcho | xargs kill + rm -rf ~/frappe-bench/env + bench -v setup env + bench pip install -e ./apps/erpnext + bench start &>> ~/frappe-bench/bench_start.log & + bench --site test_site migrate + } + + update_to_version 14 echo "Updating to latest version" git -C "apps/frappe" checkout -q -f "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}" git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA" + pgrep honcho | xargs kill rm -rf ~/frappe-bench/env - bench -v setup env --python python3.10 - bench pip install -e ./apps/payments + bench -v setup env bench pip install -e ./apps/erpnext + bench start &>> ~/frappe-bench/bench_start.log & bench --site test_site migrate - bench --site test_site install-app payments + + - name: Show bench output + if: ${{ always() }} + run: | + cd ~/frappe-bench + cat bench_start.log || true + cd logs + for f in ./*.log*; do + echo "Printing log: $f"; + cat $f + done diff --git a/.github/workflows/server-tests-mariadb.yml b/.github/workflows/server-tests-mariadb.yml index 2ce1125456e..559be06993e 100644 --- a/.github/workflows/server-tests-mariadb.yml +++ b/.github/workflows/server-tests-mariadb.yml @@ -123,6 +123,10 @@ jobs: CI_BUILD_ID: ${{ github.run_id }} ORCHESTRATOR_URL: http://test-orchestrator.frappe.io + - name: Show bench output + if: ${{ always() }} + run: cat ~/frappe-bench/bench_start.log || true + - name: Upload coverage data uses: actions/upload-artifact@v3 with: diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index 35a378856b0..cdd1203d49a 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -50,6 +50,8 @@ frappe.ui.form.on("Journal Entry", { frm.trigger("make_inter_company_journal_entry"); }, __('Make')); } + + erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm); }, make_inter_company_journal_entry: function(frm) { diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 9a0adf5815d..794a4ef1bc0 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -9,7 +9,7 @@ erpnext.accounts.taxes.setup_tax_filters("Advance Taxes and Charges"); frappe.ui.form.on('Payment Entry', { onload: function(frm) { - frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting Ledger']; + frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting Ledger', 'Unreconcile Payments', 'Unreconcile Payment Entries']; if(frm.doc.__islocal) { if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null); @@ -154,6 +154,7 @@ frappe.ui.form.on('Payment Entry', { frm.events.set_dynamic_labels(frm); frm.events.show_general_ledger(frm); erpnext.accounts.ledger_preview.show_accounting_ledger_preview(frm); + erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm); }, validate_company: (frm) => { diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 2c2efc06455..feacb0fd2ba 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -98,7 +98,6 @@ class PaymentEntry(AccountsController): if self.difference_amount: frappe.throw(_("Difference Amount must be zero")) self.make_gl_entries() - self.make_advance_gl_entries() self.update_outstanding_amounts() self.update_advance_paid() self.update_payment_schedule() @@ -149,10 +148,11 @@ class PaymentEntry(AccountsController): "Repost Payment Ledger Items", "Repost Accounting Ledger", "Repost Accounting Ledger Items", + "Unreconcile Payments", + "Unreconcile Payment Entries", ) super(PaymentEntry, self).on_cancel() self.make_gl_entries(cancel=1) - self.make_advance_gl_entries(cancel=1) self.update_outstanding_amounts() self.update_advance_paid() self.delink_advance_entry_references() @@ -1060,6 +1060,8 @@ class PaymentEntry(AccountsController): else: self.make_exchange_gain_loss_journal() + self.make_advance_gl_entries(cancel=cancel) + def add_party_gl_entries(self, gl_entries): if self.party_account: if self.payment_type == "Receive": @@ -1128,7 +1130,7 @@ class PaymentEntry(AccountsController): if self.book_advance_payments_in_separate_party_account: gl_entries = [] for d in self.get("references"): - if d.reference_doctype in ("Sales Invoice", "Purchase Invoice"): + if d.reference_doctype in ("Sales Invoice", "Purchase Invoice", "Journal Entry"): if not (against_voucher_type and against_voucher) or ( d.reference_doctype == against_voucher_type and d.reference_name == against_voucher ): @@ -1164,6 +1166,13 @@ class PaymentEntry(AccountsController): "voucher_detail_no": invoice.name, } + posting_date = frappe.db.get_value( + invoice.reference_doctype, invoice.reference_name, "posting_date" + ) + + if getdate(posting_date) < getdate(self.posting_date): + posting_date = self.posting_date + dr_or_cr = "credit" if invoice.reference_doctype == "Sales Invoice" else "debit" args_dict["account"] = invoice.account args_dict[dr_or_cr] = invoice.allocated_amount @@ -1172,6 +1181,7 @@ class PaymentEntry(AccountsController): { "against_voucher_type": invoice.reference_doctype, "against_voucher": invoice.reference_name, + "posting_date": posting_date, } ) gle = self.get_gl_dict( diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js index 7b7ce7a8920..d9f00befa90 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js @@ -24,7 +24,8 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo filters: { "company": this.frm.doc.company, "is_group": 0, - "account_type": frappe.boot.party_account_types[this.frm.doc.party_type] + "account_type": frappe.boot.party_account_types[this.frm.doc.party_type], + "root_type": this.frm.doc.party_type == 'Customer' ? "Asset" : "Liability" } }; }); diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index efe97415a55..c8c9ad1b3a9 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -161,6 +161,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. } this.frm.set_df_property("tax_withholding_category", "hidden", doc.apply_tds ? 0 : 1); + erpnext.accounts.unreconcile_payments.add_unreconcile_btn(me.frm); } unblock_invoice() { diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 642e99cd58a..d4d923902f1 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -37,7 +37,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e super.onload(); this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log', - 'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger", "Repost Accounting Ledger"]; + 'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger", "Repost Accounting Ledger", "Unreconcile Payments", "Unreconcile Payment Entries"]; if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) { // show debit_to in print format @@ -183,8 +183,11 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e }, __('Create')); } } + + erpnext.accounts.unreconcile_payments.add_unreconcile_btn(me.frm); } + make_maintenance_schedule() { frappe.model.open_mapped_doc({ method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_maintenance_schedule", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index fba2fa7552e..7bdb2b49cea 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -388,6 +388,8 @@ class SalesInvoice(SellingController): "Repost Payment Ledger Items", "Repost Accounting Ledger", "Repost Accounting Ledger Items", + "Unreconcile Payments", + "Unreconcile Payment Entries", "Payment Ledger Entry", "Serial and Batch Bundle", ) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py index 6fdcf263a55..fd95c1fe0e5 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py @@ -15,9 +15,11 @@ def get_data(): }, "internal_links": { "Sales Order": ["items", "sales_order"], - "Delivery Note": ["items", "delivery_note"], "Timesheet": ["timesheets", "time_sheet"], }, + "internal_and_external_links": { + "Delivery Note": ["items", "delivery_note"], + }, "transactions": [ { "label": _("Payment"), diff --git a/erpnext/accounts/doctype/subscription_plan/subscription_plan.py b/erpnext/accounts/doctype/subscription_plan/subscription_plan.py index f3acdc5aa87..75223c2ccca 100644 --- a/erpnext/accounts/doctype/subscription_plan/subscription_plan.py +++ b/erpnext/accounts/doctype/subscription_plan/subscription_plan.py @@ -57,18 +57,17 @@ def get_plan_rate( prorate = frappe.db.get_single_value("Subscription Settings", "prorate") if prorate: - prorate_factor = flt( - date_diff(start_date, get_first_day(start_date)) - / date_diff(get_last_day(start_date), get_first_day(start_date)), - 1, - ) - - prorate_factor += flt( - date_diff(get_last_day(end_date), end_date) - / date_diff(get_last_day(end_date), get_first_day(end_date)), - 1, - ) - - cost -= plan.cost * prorate_factor - + cost -= plan.cost * get_prorate_factor(start_date, end_date) return cost + + +def get_prorate_factor(start_date, end_date): + total_days_to_skip = date_diff(start_date, get_first_day(start_date)) + total_days_in_month = int(get_last_day(start_date).strftime("%d")) + prorate_factor = flt(total_days_to_skip / total_days_in_month) + + total_days_to_skip = date_diff(get_last_day(end_date), end_date) + total_days_in_month = int(get_last_day(end_date).strftime("%d")) + prorate_factor += flt(total_days_to_skip / total_days_in_month) + + return prorate_factor diff --git a/erpnext/crm/doctype/linkedin_settings/__init__.py b/erpnext/accounts/doctype/unreconcile_payment_entries/__init__.py similarity index 100% rename from erpnext/crm/doctype/linkedin_settings/__init__.py rename to erpnext/accounts/doctype/unreconcile_payment_entries/__init__.py diff --git a/erpnext/accounts/doctype/unreconcile_payment_entries/unreconcile_payment_entries.json b/erpnext/accounts/doctype/unreconcile_payment_entries/unreconcile_payment_entries.json new file mode 100644 index 00000000000..42da669e650 --- /dev/null +++ b/erpnext/accounts/doctype/unreconcile_payment_entries/unreconcile_payment_entries.json @@ -0,0 +1,83 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-08-22 10:28:10.196712", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "account", + "party_type", + "party", + "reference_doctype", + "reference_name", + "allocated_amount", + "account_currency", + "unlinked" + ], + "fields": [ + { + "fieldname": "reference_name", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Reference Name", + "options": "reference_doctype" + }, + { + "fieldname": "allocated_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Allocated Amount", + "options": "account_currency" + }, + { + "default": "0", + "fieldname": "unlinked", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Unlinked", + "read_only": 1 + }, + { + "fieldname": "reference_doctype", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Reference Type", + "options": "DocType" + }, + { + "fieldname": "account", + "fieldtype": "Data", + "label": "Account" + }, + { + "fieldname": "party_type", + "fieldtype": "Data", + "label": "Party Type" + }, + { + "fieldname": "party", + "fieldtype": "Data", + "label": "Party" + }, + { + "fieldname": "account_currency", + "fieldtype": "Link", + "label": "Account Currency", + "options": "Currency", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2023-09-05 09:33:28.620149", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Unreconcile Payment Entries", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/unreconcile_payment_entries/unreconcile_payment_entries.py b/erpnext/accounts/doctype/unreconcile_payment_entries/unreconcile_payment_entries.py new file mode 100644 index 00000000000..c41545c2685 --- /dev/null +++ b/erpnext/accounts/doctype/unreconcile_payment_entries/unreconcile_payment_entries.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class UnreconcilePaymentEntries(Document): + pass diff --git a/erpnext/crm/doctype/social_media_post/__init__.py b/erpnext/accounts/doctype/unreconcile_payments/__init__.py similarity index 100% rename from erpnext/crm/doctype/social_media_post/__init__.py rename to erpnext/accounts/doctype/unreconcile_payments/__init__.py diff --git a/erpnext/accounts/doctype/unreconcile_payments/test_unreconcile_payments.py b/erpnext/accounts/doctype/unreconcile_payments/test_unreconcile_payments.py new file mode 100644 index 00000000000..78e04bff819 --- /dev/null +++ b/erpnext/accounts/doctype/unreconcile_payments/test_unreconcile_payments.py @@ -0,0 +1,316 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import today + +from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin + + +class TestUnreconcilePayments(AccountsTestMixin, FrappeTestCase): + def setUp(self): + self.create_company() + self.create_customer() + self.create_usd_receivable_account() + self.create_item() + self.clear_old_entries() + + def tearDown(self): + frappe.db.rollback() + + def create_sales_invoice(self, do_not_submit=False): + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debit_to, + posting_date=today(), + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + price_list_rate=100, + do_not_submit=do_not_submit, + ) + return si + + def create_payment_entry(self): + pe = create_payment_entry( + company=self.company, + payment_type="Receive", + party_type="Customer", + party=self.customer, + paid_from=self.debit_to, + paid_to=self.cash, + paid_amount=200, + save=True, + ) + return pe + + def test_01_unreconcile_invoice(self): + si1 = self.create_sales_invoice() + si2 = self.create_sales_invoice() + + pe = self.create_payment_entry() + pe.append( + "references", + {"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 100}, + ) + pe.append( + "references", + {"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 100}, + ) + # Allocation payment against both invoices + pe.save().submit() + + # Assert outstanding + [doc.reload() for doc in [si1, si2, pe]] + self.assertEqual(si1.outstanding_amount, 0) + self.assertEqual(si2.outstanding_amount, 0) + self.assertEqual(pe.unallocated_amount, 0) + + unreconcile = frappe.get_doc( + { + "doctype": "Unreconcile Payments", + "company": self.company, + "voucher_type": pe.doctype, + "voucher_no": pe.name, + } + ) + unreconcile.add_references() + self.assertEqual(len(unreconcile.allocations), 2) + allocations = [x.reference_name for x in unreconcile.allocations] + self.assertEquals([si1.name, si2.name], allocations) + # unreconcile si1 + for x in unreconcile.allocations: + if x.reference_name != si1.name: + unreconcile.remove(x) + unreconcile.save().submit() + + # Assert outstanding + [doc.reload() for doc in [si1, si2, pe]] + self.assertEqual(si1.outstanding_amount, 100) + self.assertEqual(si2.outstanding_amount, 0) + self.assertEqual(len(pe.references), 1) + self.assertEqual(pe.unallocated_amount, 100) + + def test_02_unreconcile_one_payment_from_multi_payments(self): + """ + Scenario: 2 payments, both split against 2 different invoices + Unreconcile only one payment from one invoice + """ + si1 = self.create_sales_invoice() + si2 = self.create_sales_invoice() + pe1 = self.create_payment_entry() + pe1.paid_amount = 100 + # Allocate payment against both invoices + pe1.append( + "references", + {"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50}, + ) + pe1.append( + "references", + {"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50}, + ) + pe1.save().submit() + + pe2 = self.create_payment_entry() + pe2.paid_amount = 100 + # Allocate payment against both invoices + pe2.append( + "references", + {"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50}, + ) + pe2.append( + "references", + {"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50}, + ) + pe2.save().submit() + + # Assert outstanding and unallocated + [doc.reload() for doc in [si1, si2, pe1, pe2]] + self.assertEqual(si1.outstanding_amount, 0.0) + self.assertEqual(si2.outstanding_amount, 0.0) + self.assertEqual(pe1.unallocated_amount, 0.0) + self.assertEqual(pe2.unallocated_amount, 0.0) + + unreconcile = frappe.get_doc( + { + "doctype": "Unreconcile Payments", + "company": self.company, + "voucher_type": pe2.doctype, + "voucher_no": pe2.name, + } + ) + unreconcile.add_references() + self.assertEqual(len(unreconcile.allocations), 2) + allocations = [x.reference_name for x in unreconcile.allocations] + self.assertEquals([si1.name, si2.name], allocations) + # unreconcile si1 from pe2 + for x in unreconcile.allocations: + if x.reference_name != si1.name: + unreconcile.remove(x) + unreconcile.save().submit() + + # Assert outstanding and unallocated + [doc.reload() for doc in [si1, si2, pe1, pe2]] + self.assertEqual(si1.outstanding_amount, 50) + self.assertEqual(si2.outstanding_amount, 0) + self.assertEqual(len(pe1.references), 2) + self.assertEqual(len(pe2.references), 1) + self.assertEqual(pe1.unallocated_amount, 0) + self.assertEqual(pe2.unallocated_amount, 50) + + def test_03_unreconciliation_on_multi_currency_invoice(self): + self.create_customer("_Test MC Customer USD", "USD") + si1 = self.create_sales_invoice(do_not_submit=True) + si1.currency = "USD" + si1.debit_to = self.debtors_usd + si1.conversion_rate = 80 + si1.save().submit() + + si2 = self.create_sales_invoice(do_not_submit=True) + si2.currency = "USD" + si2.debit_to = self.debtors_usd + si2.conversion_rate = 80 + si2.save().submit() + + pe = self.create_payment_entry() + pe.paid_from = self.debtors_usd + pe.paid_from_account_currency = "USD" + pe.source_exchange_rate = 75 + pe.received_amount = 75 * 200 + pe.save() + # Allocate payment against both invoices + pe.append( + "references", + {"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 100}, + ) + pe.append( + "references", + {"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 100}, + ) + pe.save().submit() + + unreconcile = frappe.get_doc( + { + "doctype": "Unreconcile Payments", + "company": self.company, + "voucher_type": pe.doctype, + "voucher_no": pe.name, + } + ) + unreconcile.add_references() + self.assertEqual(len(unreconcile.allocations), 2) + allocations = [x.reference_name for x in unreconcile.allocations] + self.assertEquals([si1.name, si2.name], allocations) + # unreconcile si1 from pe + for x in unreconcile.allocations: + if x.reference_name != si1.name: + unreconcile.remove(x) + unreconcile.save().submit() + + # Assert outstanding and unallocated + [doc.reload() for doc in [si1, si2, pe]] + self.assertEqual(si1.outstanding_amount, 100) + self.assertEqual(si2.outstanding_amount, 0) + self.assertEqual(len(pe.references), 1) + self.assertEqual(pe.unallocated_amount, 100) + + # Exc gain/loss JE should've been cancelled as well + self.assertEqual( + frappe.db.count( + "Journal Entry Account", + filters={"reference_type": si1.doctype, "reference_name": si1.name, "docstatus": 1}, + ), + 0, + ) + + def test_04_unreconciliation_on_multi_currency_invoice(self): + """ + 2 payments split against 2 foreign currency invoices + """ + self.create_customer("_Test MC Customer USD", "USD") + si1 = self.create_sales_invoice(do_not_submit=True) + si1.currency = "USD" + si1.debit_to = self.debtors_usd + si1.conversion_rate = 80 + si1.save().submit() + + si2 = self.create_sales_invoice(do_not_submit=True) + si2.currency = "USD" + si2.debit_to = self.debtors_usd + si2.conversion_rate = 80 + si2.save().submit() + + pe1 = self.create_payment_entry() + pe1.paid_from = self.debtors_usd + pe1.paid_from_account_currency = "USD" + pe1.source_exchange_rate = 75 + pe1.received_amount = 75 * 100 + pe1.save() + # Allocate payment against both invoices + pe1.append( + "references", + {"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50}, + ) + pe1.append( + "references", + {"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50}, + ) + pe1.save().submit() + + pe2 = self.create_payment_entry() + pe2.paid_from = self.debtors_usd + pe2.paid_from_account_currency = "USD" + pe2.source_exchange_rate = 75 + pe2.received_amount = 75 * 100 + pe2.save() + # Allocate payment against both invoices + pe2.append( + "references", + {"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50}, + ) + pe2.append( + "references", + {"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50}, + ) + pe2.save().submit() + + unreconcile = frappe.get_doc( + { + "doctype": "Unreconcile Payments", + "company": self.company, + "voucher_type": pe2.doctype, + "voucher_no": pe2.name, + } + ) + unreconcile.add_references() + self.assertEqual(len(unreconcile.allocations), 2) + allocations = [x.reference_name for x in unreconcile.allocations] + self.assertEquals([si1.name, si2.name], allocations) + # unreconcile si1 from pe2 + for x in unreconcile.allocations: + if x.reference_name != si1.name: + unreconcile.remove(x) + unreconcile.save().submit() + + # Assert outstanding and unallocated + [doc.reload() for doc in [si1, si2, pe1, pe2]] + self.assertEqual(si1.outstanding_amount, 50) + self.assertEqual(si2.outstanding_amount, 0) + self.assertEqual(len(pe1.references), 2) + self.assertEqual(len(pe2.references), 1) + self.assertEqual(pe1.unallocated_amount, 0) + self.assertEqual(pe2.unallocated_amount, 50) + + # Exc gain/loss JE from PE1 should be available + self.assertEqual( + frappe.db.count( + "Journal Entry Account", + filters={"reference_type": si1.doctype, "reference_name": si1.name, "docstatus": 1}, + ), + 1, + ) diff --git a/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.js b/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.js new file mode 100644 index 00000000000..c522567637f --- /dev/null +++ b/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.js @@ -0,0 +1,41 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Unreconcile Payments", { + refresh(frm) { + frm.set_query("voucher_type", function() { + return { + filters: { + name: ["in", ["Payment Entry", "Journal Entry"]] + } + } + }); + + + frm.set_query("voucher_no", function(doc) { + return { + filters: { + company: doc.company, + docstatus: 1 + } + } + }); + + }, + get_allocations: function(frm) { + frm.clear_table("allocations"); + frappe.call({ + method: "get_allocations_from_payment", + doc: frm.doc, + callback: function(r) { + if (r.message) { + r.message.forEach(x => { + frm.add_child("allocations", x) + }) + frm.refresh_fields(); + } + } + }) + + } +}); diff --git a/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.json b/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.json new file mode 100644 index 00000000000..f29e61b6ef6 --- /dev/null +++ b/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.json @@ -0,0 +1,93 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:UNREC-{#####}", + "creation": "2023-08-22 10:26:34.421423", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company", + "voucher_type", + "voucher_no", + "get_allocations", + "allocations", + "amended_from" + ], + "fields": [ + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Unreconcile Payments", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "fieldname": "voucher_type", + "fieldtype": "Link", + "label": "Voucher Type", + "options": "DocType" + }, + { + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "label": "Voucher No", + "options": "voucher_type" + }, + { + "fieldname": "get_allocations", + "fieldtype": "Button", + "label": "Get Allocations" + }, + { + "fieldname": "allocations", + "fieldtype": "Table", + "label": "Allocations", + "options": "Unreconcile Payment Entries" + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2023-08-28 17:42:50.261377", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Unreconcile Payments", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "read": 1, + "role": "Accounts Manager", + "select": 1, + "share": 1, + "submit": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "read": 1, + "role": "Accounts User", + "select": 1, + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.py b/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.py new file mode 100644 index 00000000000..4f9fb50d463 --- /dev/null +++ b/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.py @@ -0,0 +1,158 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _, qb +from frappe.model.document import Document +from frappe.query_builder import Criterion +from frappe.query_builder.functions import Abs, Sum +from frappe.utils.data import comma_and + +from erpnext.accounts.utils import ( + cancel_exchange_gain_loss_journal, + unlink_ref_doc_from_payment_entries, + update_voucher_outstanding, +) + + +class UnreconcilePayments(Document): + def validate(self): + self.supported_types = ["Payment Entry", "Journal Entry"] + if not self.voucher_type in self.supported_types: + frappe.throw(_("Only {0} are supported").format(comma_and(self.supported_types))) + + @frappe.whitelist() + def get_allocations_from_payment(self): + allocated_references = [] + ple = qb.DocType("Payment Ledger Entry") + allocated_references = ( + qb.from_(ple) + .select( + ple.account, + ple.party_type, + ple.party, + ple.against_voucher_type.as_("reference_doctype"), + ple.against_voucher_no.as_("reference_name"), + Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"), + ple.account_currency, + ) + .where( + (ple.docstatus == 1) + & (ple.voucher_type == self.voucher_type) + & (ple.voucher_no == self.voucher_no) + & (ple.voucher_no != ple.against_voucher_no) + ) + .groupby(ple.against_voucher_type, ple.against_voucher_no) + .run(as_dict=True) + ) + + return allocated_references + + def add_references(self): + allocations = self.get_allocations_from_payment() + + for alloc in allocations: + self.append("allocations", alloc) + + def on_submit(self): + # todo: more granular unreconciliation + for alloc in self.allocations: + doc = frappe.get_doc(alloc.reference_doctype, alloc.reference_name) + unlink_ref_doc_from_payment_entries(doc, self.voucher_no) + cancel_exchange_gain_loss_journal(doc, self.voucher_type, self.voucher_no) + update_voucher_outstanding( + alloc.reference_doctype, alloc.reference_name, alloc.account, alloc.party_type, alloc.party + ) + frappe.db.set_value("Unreconcile Payment Entries", alloc.name, "unlinked", True) + + +@frappe.whitelist() +def doc_has_references(doctype: str = None, docname: str = None): + if doctype in ["Sales Invoice", "Purchase Invoice"]: + return frappe.db.count( + "Payment Ledger Entry", + filters={"delinked": 0, "against_voucher_no": docname, "amount": ["<", 0]}, + ) + else: + return frappe.db.count( + "Payment Ledger Entry", + filters={"delinked": 0, "voucher_no": docname, "against_voucher_no": ["!=", docname]}, + ) + + +@frappe.whitelist() +def get_linked_payments_for_doc( + company: str = None, doctype: str = None, docname: str = None +) -> list: + if company and doctype and docname: + _dt = doctype + _dn = docname + ple = qb.DocType("Payment Ledger Entry") + if _dt in ["Sales Invoice", "Purchase Invoice"]: + criteria = [ + (ple.company == company), + (ple.delinked == 0), + (ple.against_voucher_no == _dn), + (ple.amount < 0), + ] + + res = ( + qb.from_(ple) + .select( + ple.company, + ple.voucher_type, + ple.voucher_no, + Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"), + ple.account_currency, + ) + .where(Criterion.all(criteria)) + .groupby(ple.voucher_no, ple.against_voucher_no) + .having(qb.Field("allocated_amount") > 0) + .run(as_dict=True) + ) + return res + else: + criteria = [ + (ple.company == company), + (ple.delinked == 0), + (ple.voucher_no == _dn), + (ple.against_voucher_no != _dn), + ] + + query = ( + qb.from_(ple) + .select( + ple.company, + ple.against_voucher_type.as_("voucher_type"), + ple.against_voucher_no.as_("voucher_no"), + Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"), + ple.account_currency, + ) + .where(Criterion.all(criteria)) + .groupby(ple.against_voucher_no) + ) + res = query.run(as_dict=True) + return res + return [] + + +@frappe.whitelist() +def create_unreconcile_doc_for_selection(selections=None): + if selections: + selections = frappe.json.loads(selections) + # assuming each row is a unique voucher + for row in selections: + unrecon = frappe.new_doc("Unreconcile Payments") + unrecon.company = row.get("company") + unrecon.voucher_type = row.get("voucher_type") + unrecon.voucher_no = row.get("voucher_no") + unrecon.add_references() + + # remove unselected references + unrecon.allocations = [ + x + for x in unrecon.allocations + if x.reference_doctype == row.get("against_voucher_type") + and x.reference_name == row.get("against_voucher_no") + ] + unrecon.save().submit() diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index ae24675ff57..79bfd7833ab 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -434,6 +434,7 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map): for gle in gl_entries: group_by_value = gle.get(group_by) + gle.voucher_type = _(gle.voucher_type) if gle.posting_date < from_date or (cstr(gle.is_opening) == "Yes" and not show_opening_entries): if not group_by_voucher_consolidated: diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index eed74a5f017..1360f73767b 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -491,14 +491,13 @@ def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # n gl_map = doc.build_gl_map() create_payment_ledger_entry(gl_map, update_outstanding="No", cancel=0, adv_adj=1) - if voucher_type == "Payment Entry": - doc.make_advance_gl_entries() - # Only update outstanding for newly linked vouchers for entry in entries: update_voucher_outstanding( entry.against_voucher_type, entry.against_voucher, entry.account, entry.party_type, entry.party ) + if voucher_type == "Payment Entry": + doc.make_advance_gl_entries(entry.against_voucher_type, entry.against_voucher) frappe.flags.ignore_party_validation = False @@ -675,7 +674,9 @@ def update_reference_in_payment_entry( payment_entry.save(ignore_permissions=True) -def cancel_exchange_gain_loss_journal(parent_doc: dict | object) -> None: +def cancel_exchange_gain_loss_journal( + parent_doc: dict | object, referenced_dt: str = None, referenced_dn: str = None +) -> None: """ Cancel Exchange Gain/Loss for Sales/Purchase Invoice, if they have any. """ @@ -702,76 +703,147 @@ def cancel_exchange_gain_loss_journal(parent_doc: dict | object) -> None: as_list=1, ) for doc in gain_loss_journals: - frappe.get_doc("Journal Entry", doc[0]).cancel() + gain_loss_je = frappe.get_doc("Journal Entry", doc[0]) + if referenced_dt and referenced_dn: + references = [(x.reference_type, x.reference_name) for x in gain_loss_je.accounts] + if ( + len(references) == 2 + and (referenced_dt, referenced_dn) in references + and (parent_doc.doctype, parent_doc.name) in references + ): + # only cancel JE generated against parent_doc and referenced_dn + gain_loss_je.cancel() + else: + gain_loss_je.cancel() -def unlink_ref_doc_from_payment_entries(ref_doc): - remove_ref_doc_link_from_jv(ref_doc.doctype, ref_doc.name) - remove_ref_doc_link_from_pe(ref_doc.doctype, ref_doc.name) - - frappe.db.sql( - """update `tabGL Entry` - set against_voucher_type=null, against_voucher=null, - modified=%s, modified_by=%s - where against_voucher_type=%s and against_voucher=%s - and voucher_no != ifnull(against_voucher, '')""", - (now(), frappe.session.user, ref_doc.doctype, ref_doc.name), +def update_accounting_ledgers_after_reference_removal( + ref_type: str = None, ref_no: str = None, payment_name: str = None +): + # General Ledger + gle = qb.DocType("GL Entry") + gle_update_query = ( + qb.update(gle) + .set(gle.against_voucher_type, None) + .set(gle.against_voucher, None) + .set(gle.modified, now()) + .set(gle.modified_by, frappe.session.user) + .where((gle.against_voucher_type == ref_type) & (gle.against_voucher == ref_no)) ) + if payment_name: + gle_update_query = gle_update_query.where(gle.voucher_no == payment_name) + gle_update_query.run() + + # Payment Ledger ple = qb.DocType("Payment Ledger Entry") + ple_update_query = ( + qb.update(ple) + .set(ple.against_voucher_type, ple.voucher_type) + .set(ple.against_voucher_no, ple.voucher_no) + .set(ple.modified, now()) + .set(ple.modified_by, frappe.session.user) + .where( + (ple.against_voucher_type == ref_type) + & (ple.against_voucher_no == ref_no) + & (ple.delinked == 0) + ) + ) - qb.update(ple).set(ple.against_voucher_type, ple.voucher_type).set( - ple.against_voucher_no, ple.voucher_no - ).set(ple.modified, now()).set(ple.modified_by, frappe.session.user).where( - (ple.against_voucher_type == ref_doc.doctype) - & (ple.against_voucher_no == ref_doc.name) - & (ple.delinked == 0) - ).run() + if payment_name: + ple_update_query = ple_update_query.where(ple.voucher_no == payment_name) + ple_update_query.run() + +def remove_ref_from_advance_section(ref_doc: object = None): + # TODO: this might need some testing if ref_doc.doctype in ("Sales Invoice", "Purchase Invoice"): ref_doc.set("advances", []) - - frappe.db.sql( - """delete from `tab{0} Advance` where parent = %s""".format(ref_doc.doctype), ref_doc.name - ) + adv_type = qb.DocType(f"{ref_doc.doctype} Advance") + qb.from_(adv_type).delete().where(adv_type.parent == ref_doc.name).run() -def remove_ref_doc_link_from_jv(ref_type, ref_no): - linked_jv = frappe.db.sql_list( - """select parent from `tabJournal Entry Account` - where reference_type=%s and reference_name=%s and docstatus < 2""", - (ref_type, ref_no), +def unlink_ref_doc_from_payment_entries(ref_doc: object = None, payment_name: str = None): + remove_ref_doc_link_from_jv(ref_doc.doctype, ref_doc.name, payment_name) + remove_ref_doc_link_from_pe(ref_doc.doctype, ref_doc.name, payment_name) + update_accounting_ledgers_after_reference_removal(ref_doc.doctype, ref_doc.name, payment_name) + remove_ref_from_advance_section(ref_doc) + + +def remove_ref_doc_link_from_jv( + ref_type: str = None, ref_no: str = None, payment_name: str = None +): + jea = qb.DocType("Journal Entry Account") + + linked_jv = ( + qb.from_(jea) + .select(jea.parent) + .where((jea.reference_type == ref_type) & (jea.reference_name == ref_no) & (jea.docstatus.lt(2))) + .run(as_list=1) ) + linked_jv = convert_to_list(linked_jv) + # remove reference only from specified payment + linked_jv = [x for x in linked_jv if x == payment_name] if payment_name else linked_jv if linked_jv: - frappe.db.sql( - """update `tabJournal Entry Account` - set reference_type=null, reference_name = null, - modified=%s, modified_by=%s - where reference_type=%s and reference_name=%s - and docstatus < 2""", - (now(), frappe.session.user, ref_type, ref_no), + update_query = ( + qb.update(jea) + .set(jea.reference_type, None) + .set(jea.reference_name, None) + .set(jea.modified, now()) + .set(jea.modified_by, frappe.session.user) + .where((jea.reference_type == ref_type) & (jea.reference_name == ref_no)) ) + if payment_name: + update_query = update_query.where(jea.parent == payment_name) + + update_query.run() + frappe.msgprint(_("Journal Entries {0} are un-linked").format("\n".join(linked_jv))) -def remove_ref_doc_link_from_pe(ref_type, ref_no): - linked_pe = frappe.db.sql_list( - """select parent from `tabPayment Entry Reference` - where reference_doctype=%s and reference_name=%s and docstatus < 2""", - (ref_type, ref_no), +def convert_to_list(result): + """ + Convert tuple to list + """ + return [x[0] for x in result] + + +def remove_ref_doc_link_from_pe( + ref_type: str = None, ref_no: str = None, payment_name: str = None +): + per = qb.DocType("Payment Entry Reference") + pay = qb.DocType("Payment Entry") + + linked_pe = ( + qb.from_(per) + .select(per.parent) + .where( + (per.reference_doctype == ref_type) & (per.reference_name == ref_no) & (per.docstatus.lt(2)) + ) + .run(as_list=1) ) + linked_pe = convert_to_list(linked_pe) + # remove reference only from specified payment + linked_pe = [x for x in linked_pe if x == payment_name] if payment_name else linked_pe if linked_pe: - frappe.db.sql( - """update `tabPayment Entry Reference` - set allocated_amount=0, modified=%s, modified_by=%s - where reference_doctype=%s and reference_name=%s - and docstatus < 2""", - (now(), frappe.session.user, ref_type, ref_no), + update_query = ( + qb.update(per) + .set(per.allocated_amount, 0) + .set(per.modified, now()) + .set(per.modified_by, frappe.session.user) + .where( + (per.docstatus.lt(2) & (per.reference_doctype == ref_type) & (per.reference_name == ref_no)) + ) ) + if payment_name: + update_query = update_query.where(per.parent == payment_name) + + update_query.run() + for pe in linked_pe: try: pe_doc = frappe.get_doc("Payment Entry", pe) @@ -785,19 +857,13 @@ def remove_ref_doc_link_from_pe(ref_type, ref_no): msg += _("Please cancel payment entry manually first") frappe.throw(msg, exc=PaymentEntryUnlinkError, title=_("Payment Unlink Error")) - frappe.db.sql( - """update `tabPayment Entry` set total_allocated_amount=%s, - base_total_allocated_amount=%s, unallocated_amount=%s, modified=%s, modified_by=%s - where name=%s""", - ( - pe_doc.total_allocated_amount, - pe_doc.base_total_allocated_amount, - pe_doc.unallocated_amount, - now(), - frappe.session.user, - pe, - ), - ) + qb.update(pay).set(pay.total_allocated_amount, pe_doc.total_allocated_amount).set( + pay.base_total_allocated_amount, pe_doc.base_total_allocated_amount + ).set(pay.unallocated_amount, pe_doc.unallocated_amount).set(pay.modified, now()).set( + pay.modified_by, frappe.session.user + ).where( + pay.name == pe + ).run() frappe.msgprint(_("Payment Entries {0} are un-linked").format("\n".join(linked_pe))) @@ -1163,8 +1229,13 @@ def get_autoname_with_number(number_value, doc_title, company): def parse_naming_series_variable(doc, variable): if variable == "FY": - date = doc.get("posting_date") or doc.get("transaction_date") or getdate() - return get_fiscal_year(date=date, company=doc.get("company"))[0] + if doc: + date = doc.get("posting_date") or doc.get("transaction_date") + company = doc.get("company") + else: + date = getdate() + company = None + return get_fiscal_year(date=date, company=company)[0] @frappe.whitelist() diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index b242108a9a9..5b5cc2b0217 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -1173,6 +1173,7 @@ "depends_on": "is_internal_supplier", "fieldname": "set_from_warehouse", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Set From Warehouse", "options": "Warehouse" }, @@ -1273,7 +1274,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2023-06-03 16:19:45.710444", + "modified": "2023-09-13 16:21:07.361700", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index 414f0866ccb..f79b6223bf5 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -878,6 +878,7 @@ "depends_on": "eval:parent.is_internal_supplier", "fieldname": "from_warehouse", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "From Warehouse", "options": "Warehouse" }, @@ -902,7 +903,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-08-17 10:17:40.893393", + "modified": "2023-09-13 16:22:40.825092", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/buying/doctype/supplier/supplier.js b/erpnext/buying/doctype/supplier/supplier.js index 372ca56b86b..08dc44c71b4 100644 --- a/erpnext/buying/doctype/supplier/supplier.js +++ b/erpnext/buying/doctype/supplier/supplier.js @@ -12,6 +12,7 @@ frappe.ui.form.on("Supplier", { return { filters: { 'account_type': 'Payable', + 'root_type': 'Liability', 'company': d.company, "is_group": 0 } diff --git a/erpnext/buying/doctype/supplier/test_supplier.py b/erpnext/buying/doctype/supplier/test_supplier.py index 7be1d834a65..ee2ada3b655 100644 --- a/erpnext/buying/doctype/supplier/test_supplier.py +++ b/erpnext/buying/doctype/supplier/test_supplier.py @@ -206,6 +206,7 @@ def create_supplier(**args): { "doctype": "Supplier", "supplier_name": args.supplier_name, + "default_currency": args.default_currency, "supplier_group": args.supplier_group or "Services", "supplier_type": args.supplier_type or "Company", "tax_withholding_category": args.tax_withholding_category, diff --git a/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py b/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py index 21241e08603..07187352eb7 100644 --- a/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py +++ b/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py @@ -7,7 +7,7 @@ import copy import frappe from frappe import _ from frappe.query_builder.functions import Coalesce, Sum -from frappe.utils import date_diff, flt, getdate +from frappe.utils import cint, date_diff, flt, getdate def execute(filters=None): @@ -47,8 +47,10 @@ def get_data(filters): mr.transaction_date.as_("date"), mr_item.schedule_date.as_("required_date"), mr_item.item_code.as_("item_code"), - Sum(Coalesce(mr_item.stock_qty, 0)).as_("qty"), - Coalesce(mr_item.stock_uom, "").as_("uom"), + Sum(Coalesce(mr_item.qty, 0)).as_("qty"), + Sum(Coalesce(mr_item.stock_qty, 0)).as_("stock_qty"), + Coalesce(mr_item.uom, "").as_("uom"), + Coalesce(mr_item.stock_uom, "").as_("stock_uom"), Sum(Coalesce(mr_item.ordered_qty, 0)).as_("ordered_qty"), Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"), (Sum(Coalesce(mr_item.stock_qty, 0)) - Sum(Coalesce(mr_item.received_qty, 0))).as_( @@ -96,7 +98,7 @@ def get_conditions(filters, query, mr, mr_item): def update_qty_columns(row_to_update, data_row): - fields = ["qty", "ordered_qty", "received_qty", "qty_to_receive", "qty_to_order"] + fields = ["qty", "stock_qty", "ordered_qty", "received_qty", "qty_to_receive", "qty_to_order"] for field in fields: row_to_update[field] += flt(data_row[field]) @@ -104,16 +106,20 @@ def update_qty_columns(row_to_update, data_row): def prepare_data(data, filters): """Prepare consolidated Report data and Chart data""" material_request_map, item_qty_map = {}, {} + precision = cint(frappe.db.get_default("float_precision")) or 2 for row in data: # item wise map for charts if not row["item_code"] in item_qty_map: item_qty_map[row["item_code"]] = { - "qty": row["qty"], - "ordered_qty": row["ordered_qty"], - "received_qty": row["received_qty"], - "qty_to_receive": row["qty_to_receive"], - "qty_to_order": row["qty_to_order"], + "qty": flt(row["stock_qty"], precision), + "stock_qty": flt(row["stock_qty"], precision), + "stock_uom": row["stock_uom"], + "uom": row["uom"], + "ordered_qty": flt(row["ordered_qty"], precision), + "received_qty": flt(row["received_qty"], precision), + "qty_to_receive": flt(row["qty_to_receive"], precision), + "qty_to_order": flt(row["qty_to_order"], precision), } else: item_entry = item_qty_map[row["item_code"]] @@ -200,21 +206,34 @@ def get_columns(filters): {"label": _("Item Name"), "fieldname": "item_name", "fieldtype": "Data", "width": 100}, {"label": _("Description"), "fieldname": "description", "fieldtype": "Data", "width": 200}, { - "label": _("Stock UOM"), + "label": _("UOM"), "fieldname": "uom", "fieldtype": "Data", "width": 100, }, + { + "label": _("Stock UOM"), + "fieldname": "stock_uom", + "fieldtype": "Data", + "width": 100, + }, ] ) columns.extend( [ { - "label": _("Stock Qty"), + "label": _("Qty"), "fieldname": "qty", "fieldtype": "Float", - "width": 120, + "width": 140, + "convertible": "qty", + }, + { + "label": _("Qty in Stock UOM"), + "fieldname": "stock_qty", + "fieldtype": "Float", + "width": 140, "convertible": "qty", }, { diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 0ca1e94427e..838fe527dfb 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -211,6 +211,37 @@ class AccountsController(TransactionBase): def before_cancel(self): validate_einvoice_fields(self) + def _remove_references_in_unreconcile(self): + upe = frappe.qb.DocType("Unreconcile Payment Entries") + rows = ( + frappe.qb.from_(upe) + .select(upe.name, upe.parent) + .where((upe.reference_doctype == self.doctype) & (upe.reference_name == self.name)) + .run(as_dict=True) + ) + + if rows: + references_map = frappe._dict() + for x in rows: + references_map.setdefault(x.parent, []).append(x.name) + + for doc, rows in references_map.items(): + unreconcile_doc = frappe.get_doc("Unreconcile Payments", doc) + for row in rows: + unreconcile_doc.remove(unreconcile_doc.get("allocations", {"name": row})[0]) + + unreconcile_doc.flags.ignore_validate_update_after_submit = True + unreconcile_doc.flags.ignore_links = True + unreconcile_doc.save(ignore_permissions=True) + + # delete docs upon parent doc deletion + unreconcile_docs = frappe.db.get_all("Unreconcile Payments", filters={"voucher_no": self.name}) + for x in unreconcile_docs: + _doc = frappe.get_doc("Unreconcile Payments", x.name) + if _doc.docstatus == 1: + _doc.cancel() + _doc.delete() + def on_trash(self): # delete references in 'Repost Payment Ledger' rpi = frappe.qb.DocType("Repost Payment Ledger Items") @@ -218,6 +249,8 @@ class AccountsController(TransactionBase): (rpi.voucher_type == self.doctype) & (rpi.voucher_no == self.name) ).run() + self._remove_references_in_unreconcile() + # delete sl and gl entries on deletion of transaction if frappe.db.get_single_value("Accounts Settings", "delete_linked_ledger_entries"): ple = frappe.qb.DocType("Payment Ledger Entry") diff --git a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js deleted file mode 100644 index 7d6b3955cde..00000000000 --- a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt - -frappe.ui.form.on('LinkedIn Settings', { - onload: function(frm) { - if (frm.doc.session_status == 'Expired' && frm.doc.consumer_key && frm.doc.consumer_secret) { - frappe.confirm( - __('Session not valid. Do you want to login?'), - function(){ - frm.trigger("login"); - }, - function(){ - window.close(); - } - ); - } - frm.dashboard.set_headline(__("For more information, {0}.", [`${__('click here')}`])); - }, - refresh: function(frm) { - if (frm.doc.session_status=="Expired"){ - let msg = __("Session not active. Save document to login."); - frm.dashboard.set_headline_alert( - `
-
- -
-
` - ); - } - - if (frm.doc.session_status=="Active"){ - let d = new Date(frm.doc.modified); - d.setDate(d.getDate()+60); - let dn = new Date(); - let days = d.getTime() - dn.getTime(); - days = Math.floor(days/(1000 * 3600 * 24)); - let msg,color; - - if (days>0){ - msg = __("Your session will be expire in {0} days.", [days]); - color = "green"; - } - else { - msg = __("Session is expired. Save doc to login."); - color = "red"; - } - - frm.dashboard.set_headline_alert( - `
-
- -
-
` - ); - } - }, - login: function(frm) { - if (frm.doc.consumer_key && frm.doc.consumer_secret){ - frappe.dom.freeze(); - frappe.call({ - doc: frm.doc, - method: "get_authorization_url", - callback : function(r) { - window.location.href = r.message; - } - }).fail(function() { - frappe.dom.unfreeze(); - }); - } - }, - after_save: function(frm) { - frm.trigger("login"); - } -}); diff --git a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.json b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.json deleted file mode 100644 index f882e36c32a..00000000000 --- a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "actions": [], - "creation": "2020-01-30 13:36:39.492931", - "doctype": "DocType", - "documentation": "https://docs.erpnext.com/docs/user/manual/en/CRM/linkedin-settings", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "account_name", - "column_break_2", - "company_id", - "oauth_details", - "consumer_key", - "column_break_5", - "consumer_secret", - "user_details_section", - "access_token", - "person_urn", - "session_status" - ], - "fields": [ - { - "fieldname": "account_name", - "fieldtype": "Data", - "label": "Account Name", - "read_only": 1 - }, - { - "fieldname": "oauth_details", - "fieldtype": "Section Break", - "label": "OAuth Credentials" - }, - { - "fieldname": "consumer_key", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Consumer Key", - "reqd": 1 - }, - { - "fieldname": "consumer_secret", - "fieldtype": "Password", - "in_list_view": 1, - "label": "Consumer Secret", - "reqd": 1 - }, - { - "fieldname": "access_token", - "fieldtype": "Data", - "hidden": 1, - "label": "Access Token", - "read_only": 1 - }, - { - "fieldname": "person_urn", - "fieldtype": "Data", - "hidden": 1, - "label": "Person URN", - "read_only": 1 - }, - { - "fieldname": "column_break_5", - "fieldtype": "Column Break" - }, - { - "fieldname": "user_details_section", - "fieldtype": "Section Break", - "label": "User Details" - }, - { - "fieldname": "session_status", - "fieldtype": "Select", - "hidden": 1, - "label": "Session Status", - "options": "Expired\nActive", - "read_only": 1 - }, - { - "fieldname": "column_break_2", - "fieldtype": "Column Break" - }, - { - "fieldname": "company_id", - "fieldtype": "Data", - "label": "Company ID", - "reqd": 1 - } - ], - "issingle": 1, - "links": [], - "modified": "2021-02-18 15:19:21.920725", - "modified_by": "Administrator", - "module": "CRM", - "name": "LinkedIn Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "quick_entry": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py deleted file mode 100644 index 64b3a017b46..00000000000 --- a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py +++ /dev/null @@ -1,208 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - - -from urllib.parse import urlencode - -import frappe -import requests -from frappe import _ -from frappe.model.document import Document -from frappe.utils import get_url_to_form -from frappe.utils.file_manager import get_file_path - - -class LinkedInSettings(Document): - @frappe.whitelist() - def get_authorization_url(self): - params = urlencode( - { - "response_type": "code", - "client_id": self.consumer_key, - "redirect_uri": "{0}/api/method/erpnext.crm.doctype.linkedin_settings.linkedin_settings.callback?".format( - frappe.utils.get_url() - ), - "scope": "r_emailaddress w_organization_social r_basicprofile r_liteprofile r_organization_social rw_organization_admin w_member_social", - } - ) - - url = "https://www.linkedin.com/oauth/v2/authorization?{}".format(params) - - return url - - def get_access_token(self, code): - url = "https://www.linkedin.com/oauth/v2/accessToken" - body = { - "grant_type": "authorization_code", - "code": code, - "client_id": self.consumer_key, - "client_secret": self.get_password(fieldname="consumer_secret"), - "redirect_uri": "{0}/api/method/erpnext.crm.doctype.linkedin_settings.linkedin_settings.callback?".format( - frappe.utils.get_url() - ), - } - headers = {"Content-Type": "application/x-www-form-urlencoded"} - - response = self.http_post(url=url, data=body, headers=headers) - response = frappe.parse_json(response.content.decode()) - self.db_set("access_token", response["access_token"]) - - def get_member_profile(self): - response = requests.get(url="https://api.linkedin.com/v2/me", headers=self.get_headers()) - response = frappe.parse_json(response.content.decode()) - - frappe.db.set_value( - self.doctype, - self.name, - { - "person_urn": response["id"], - "account_name": response["vanityName"], - "session_status": "Active", - }, - ) - frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = get_url_to_form("LinkedIn Settings", "LinkedIn Settings") - - def post(self, text, title, media=None): - if not media: - return self.post_text(text, title) - else: - media_id = self.upload_image(media) - - if media_id: - return self.post_text(text, title, media_id=media_id) - else: - self.log_error("LinkedIn: Failed to upload media") - - def upload_image(self, media): - media = get_file_path(media) - register_url = "https://api.linkedin.com/v2/assets?action=registerUpload" - body = { - "registerUploadRequest": { - "recipes": ["urn:li:digitalmediaRecipe:feedshare-image"], - "owner": "urn:li:organization:{0}".format(self.company_id), - "serviceRelationships": [ - {"relationshipType": "OWNER", "identifier": "urn:li:userGeneratedContent"} - ], - } - } - headers = self.get_headers() - response = self.http_post(url=register_url, body=body, headers=headers) - - if response.status_code == 200: - response = response.json() - asset = response["value"]["asset"] - upload_url = response["value"]["uploadMechanism"][ - "com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest" - ]["uploadUrl"] - headers["Content-Type"] = "image/jpeg" - response = self.http_post(upload_url, headers=headers, data=open(media, "rb")) - if response.status_code < 200 and response.status_code > 299: - frappe.throw( - _("Error While Uploading Image"), - title="{0} {1}".format(response.status_code, response.reason), - ) - return None - return asset - - return None - - def post_text(self, text, title, media_id=None): - url = "https://api.linkedin.com/v2/shares" - headers = self.get_headers() - headers["X-Restli-Protocol-Version"] = "2.0.0" - headers["Content-Type"] = "application/json; charset=UTF-8" - - body = { - "distribution": {"linkedInDistributionTarget": {}}, - "owner": "urn:li:organization:{0}".format(self.company_id), - "subject": title, - "text": {"text": text}, - } - - reference_url = self.get_reference_url(text) - if reference_url: - body["content"] = {"contentEntities": [{"entityLocation": reference_url}]} - - if media_id: - body["content"] = {"contentEntities": [{"entity": media_id}], "shareMediaCategory": "IMAGE"} - - response = self.http_post(url=url, headers=headers, body=body) - return response - - def http_post(self, url, headers=None, body=None, data=None): - try: - response = requests.post(url=url, json=body, data=data, headers=headers) - if response.status_code not in [201, 200]: - raise - - except Exception as e: - self.api_error(response) - - return response - - def get_headers(self): - return {"Authorization": "Bearer {}".format(self.access_token)} - - def get_reference_url(self, text): - import re - - regex_url = r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+" - urls = re.findall(regex_url, text) - if urls: - return urls[0] - - def delete_post(self, post_id): - try: - response = requests.delete( - url="https://api.linkedin.com/v2/shares/urn:li:share:{0}".format(post_id), - headers=self.get_headers(), - ) - if response.status_code != 200: - raise - except Exception: - self.api_error(response) - - def get_post(self, post_id): - url = "https://api.linkedin.com/v2/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=urn:li:organization:{0}&shares[0]=urn:li:share:{1}".format( - self.company_id, post_id - ) - - try: - response = requests.get(url=url, headers=self.get_headers()) - if response.status_code != 200: - raise - - except Exception: - self.api_error(response) - - response = frappe.parse_json(response.content.decode()) - if len(response.elements): - return response.elements[0] - - return None - - def api_error(self, response): - content = frappe.parse_json(response.content.decode()) - - if response.status_code == 401: - self.db_set("session_status", "Expired") - frappe.db.commit() - frappe.throw(content["message"], title=_("LinkedIn Error - Unauthorized")) - elif response.status_code == 403: - frappe.msgprint(_("You didn't have permission to access this API")) - frappe.throw(content["message"], title=_("LinkedIn Error - Access Denied")) - else: - frappe.throw(response.reason, title=response.status_code) - - -@frappe.whitelist(allow_guest=True) -def callback(code=None, error=None, error_description=None): - if not error: - linkedin_settings = frappe.get_doc("LinkedIn Settings") - linkedin_settings.get_access_token(code) - linkedin_settings.get_member_profile() - frappe.db.commit() - else: - frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = get_url_to_form("LinkedIn Settings", "LinkedIn Settings") diff --git a/erpnext/crm/doctype/linkedin_settings/test_linkedin_settings.py b/erpnext/crm/doctype/linkedin_settings/test_linkedin_settings.py deleted file mode 100644 index 09732e405ee..00000000000 --- a/erpnext/crm/doctype/linkedin_settings/test_linkedin_settings.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -# import frappe -import unittest - - -class TestLinkedInSettings(unittest.TestCase): - pass diff --git a/erpnext/crm/doctype/social_media_post/social_media_post.js b/erpnext/crm/doctype/social_media_post/social_media_post.js deleted file mode 100644 index d4ac0bad16c..00000000000 --- a/erpnext/crm/doctype/social_media_post/social_media_post.js +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt -frappe.ui.form.on('Social Media Post', { - validate: function(frm) { - if (frm.doc.twitter === 0 && frm.doc.linkedin === 0) { - frappe.throw(__("Select atleast one Social Media Platform to Share on.")); - } - if (frm.doc.scheduled_time) { - let scheduled_time = new Date(frm.doc.scheduled_time); - let date_time = new Date(); - if (scheduled_time.getTime() < date_time.getTime()) { - frappe.throw(__("Scheduled Time must be a future time.")); - } - } - frm.trigger('validate_tweet_length'); - }, - - text: function(frm) { - if (frm.doc.text) { - frm.set_df_property('text', 'description', `${frm.doc.text.length}/280`); - frm.refresh_field('text'); - frm.trigger('validate_tweet_length'); - } - }, - - validate_tweet_length: function(frm) { - if (frm.doc.text && frm.doc.text.length > 280) { - frappe.throw(__("Tweet length Must be less than 280.")); - } - }, - - onload: function(frm) { - frm.trigger('make_dashboard'); - }, - - make_dashboard: function(frm) { - if (frm.doc.post_status == "Posted") { - frappe.call({ - doc: frm.doc, - method: 'get_post', - freeze: true, - callback: (r) => { - if (!r.message) { - return; - } - - let datasets = [], colors = []; - if (r.message && r.message.twitter) { - colors.push('#1DA1F2'); - datasets.push({ - name: 'Twitter', - values: [r.message.twitter.favorite_count, r.message.twitter.retweet_count] - }); - } - if (r.message && r.message.linkedin) { - colors.push('#0077b5'); - datasets.push({ - name: 'LinkedIn', - values: [r.message.linkedin.totalShareStatistics.likeCount, r.message.linkedin.totalShareStatistics.shareCount] - }); - } - - if (datasets.length) { - frm.dashboard.render_graph({ - data: { - labels: ['Likes', 'Retweets/Shares'], - datasets: datasets - }, - - title: __("Post Metrics"), - type: 'bar', - height: 300, - colors: colors - }); - } - } - }); - } - }, - - refresh: function(frm) { - frm.trigger('text'); - - if (frm.doc.docstatus === 1) { - if (!['Posted', 'Deleted'].includes(frm.doc.post_status)) { - frm.trigger('add_post_btn'); - } - if (frm.doc.post_status !='Deleted') { - frm.add_custom_button(__('Delete Post'), function() { - frappe.confirm(__('Are you sure want to delete the Post from Social Media platforms?'), - function() { - frappe.call({ - doc: frm.doc, - method: 'delete_post', - freeze: true, - callback: () => { - frm.reload_doc(); - } - }); - } - ); - }); - } - - if (frm.doc.post_status !='Deleted') { - let html=''; - if (frm.doc.twitter) { - let color = frm.doc.twitter_post_id ? "green" : "red"; - let status = frm.doc.twitter_post_id ? "Posted" : "Not Posted"; - html += `
- Twitter : ${status} -
` ; - } - if (frm.doc.linkedin) { - let color = frm.doc.linkedin_post_id ? "green" : "red"; - let status = frm.doc.linkedin_post_id ? "Posted" : "Not Posted"; - html += `
- LinkedIn : ${status} -
` ; - } - html = `
${html}
`; - frm.dashboard.set_headline_alert(html); - } - } - }, - - add_post_btn: function(frm) { - frm.add_custom_button(__('Post Now'), function() { - frappe.call({ - doc: frm.doc, - method: 'post', - freeze: true, - callback: function() { - frm.reload_doc(); - } - }); - }); - } -}); diff --git a/erpnext/crm/doctype/social_media_post/social_media_post.json b/erpnext/crm/doctype/social_media_post/social_media_post.json deleted file mode 100644 index 98e78f949e8..00000000000 --- a/erpnext/crm/doctype/social_media_post/social_media_post.json +++ /dev/null @@ -1,208 +0,0 @@ -{ - "actions": [], - "autoname": "format: CRM-SMP-{YYYY}-{MM}-{DD}-{###}", - "creation": "2020-01-30 11:53:13.872864", - "doctype": "DocType", - "documentation": "https://docs.erpnext.com/docs/user/manual/en/CRM/social-media-post", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "title", - "campaign_name", - "scheduled_time", - "post_status", - "column_break_6", - "twitter", - "linkedin", - "twitter_post_id", - "linkedin_post_id", - "content", - "text", - "column_break_14", - "tweet_preview", - "linkedin_section", - "linkedin_post", - "column_break_15", - "attachments_section", - "image", - "amended_from" - ], - "fields": [ - { - "fieldname": "text", - "fieldtype": "Small Text", - "label": "Tweet", - "mandatory_depends_on": "eval:doc.twitter ==1" - }, - { - "fieldname": "image", - "fieldtype": "Attach Image", - "label": "Image" - }, - { - "default": "1", - "fieldname": "twitter", - "fieldtype": "Check", - "label": "Twitter" - }, - { - "default": "1", - "fieldname": "linkedin", - "fieldtype": "Check", - "label": "LinkedIn" - }, - { - "fieldname": "amended_from", - "fieldtype": "Link", - "label": "Amended From", - "no_copy": 1, - "options": "Social Media Post", - "print_hide": 1, - "read_only": 1 - }, - { - "depends_on": "eval:doc.twitter ==1", - "fieldname": "content", - "fieldtype": "Section Break", - "label": "Twitter" - }, - { - "allow_on_submit": 1, - "fieldname": "post_status", - "fieldtype": "Select", - "label": "Post Status", - "no_copy": 1, - "options": "\nScheduled\nPosted\nCancelled\nDeleted\nError", - "read_only": 1 - }, - { - "allow_on_submit": 1, - "fieldname": "twitter_post_id", - "fieldtype": "Data", - "hidden": 1, - "label": "Twitter Post Id", - "no_copy": 1, - "read_only": 1 - }, - { - "allow_on_submit": 1, - "fieldname": "linkedin_post_id", - "fieldtype": "Data", - "hidden": 1, - "label": "LinkedIn Post Id", - "no_copy": 1, - "read_only": 1 - }, - { - "fieldname": "campaign_name", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Campaign", - "options": "Campaign" - }, - { - "fieldname": "column_break_6", - "fieldtype": "Column Break", - "label": "Share On" - }, - { - "fieldname": "column_break_14", - "fieldtype": "Column Break" - }, - { - "fieldname": "tweet_preview", - "fieldtype": "HTML" - }, - { - "collapsible": 1, - "depends_on": "eval:doc.linkedin==1", - "fieldname": "linkedin_section", - "fieldtype": "Section Break", - "label": "LinkedIn" - }, - { - "collapsible": 1, - "fieldname": "attachments_section", - "fieldtype": "Section Break", - "label": "Attachments" - }, - { - "fieldname": "linkedin_post", - "fieldtype": "Text", - "label": "Post", - "mandatory_depends_on": "eval:doc.linkedin ==1" - }, - { - "fieldname": "column_break_15", - "fieldtype": "Column Break" - }, - { - "allow_on_submit": 1, - "fieldname": "scheduled_time", - "fieldtype": "Datetime", - "label": "Scheduled Time", - "read_only_depends_on": "eval:doc.post_status == \"Posted\"" - }, - { - "fieldname": "title", - "fieldtype": "Data", - "label": "Title", - "reqd": 1 - } - ], - "is_submittable": 1, - "links": [], - "modified": "2021-04-14 14:24:59.821223", - "modified_by": "Administrator", - "module": "CRM", - "name": "Social Media Post", - "owner": "Administrator", - "permissions": [ - { - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "submit": 1, - "write": 1 - }, - { - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Sales User", - "share": 1, - "submit": 1, - "write": 1 - }, - { - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Sales Manager", - "share": 1, - "submit": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "title_field": "title", - "track_changes": 1 -} \ No newline at end of file diff --git a/erpnext/crm/doctype/social_media_post/social_media_post.py b/erpnext/crm/doctype/social_media_post/social_media_post.py deleted file mode 100644 index 3654d29bdc0..00000000000 --- a/erpnext/crm/doctype/social_media_post/social_media_post.py +++ /dev/null @@ -1,89 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - - -import datetime - -import frappe -from frappe import _ -from frappe.model.document import Document - - -class SocialMediaPost(Document): - def validate(self): - if not self.twitter and not self.linkedin: - frappe.throw(_("Select atleast one Social Media Platform to Share on.")) - - if self.scheduled_time: - current_time = frappe.utils.now_datetime() - scheduled_time = frappe.utils.get_datetime(self.scheduled_time) - if scheduled_time < current_time: - frappe.throw(_("Scheduled Time must be a future time.")) - - if self.text and len(self.text) > 280: - frappe.throw(_("Tweet length must be less than 280.")) - - def submit(self): - if self.scheduled_time: - self.post_status = "Scheduled" - super(SocialMediaPost, self).submit() - - def on_cancel(self): - self.db_set("post_status", "Cancelled") - - @frappe.whitelist() - def delete_post(self): - if self.twitter and self.twitter_post_id: - twitter = frappe.get_doc("Twitter Settings") - twitter.delete_tweet(self.twitter_post_id) - - if self.linkedin and self.linkedin_post_id: - linkedin = frappe.get_doc("LinkedIn Settings") - linkedin.delete_post(self.linkedin_post_id) - - self.db_set("post_status", "Deleted") - - @frappe.whitelist() - def get_post(self): - response = {} - if self.linkedin and self.linkedin_post_id: - linkedin = frappe.get_doc("LinkedIn Settings") - response["linkedin"] = linkedin.get_post(self.linkedin_post_id) - if self.twitter and self.twitter_post_id: - twitter = frappe.get_doc("Twitter Settings") - response["twitter"] = twitter.get_tweet(self.twitter_post_id) - - return response - - @frappe.whitelist() - def post(self): - try: - if self.twitter and not self.twitter_post_id: - twitter = frappe.get_doc("Twitter Settings") - twitter_post = twitter.post(self.text, self.image) - self.db_set("twitter_post_id", twitter_post.id) - if self.linkedin and not self.linkedin_post_id: - linkedin = frappe.get_doc("LinkedIn Settings") - linkedin_post = linkedin.post(self.linkedin_post, self.title, self.image) - self.db_set("linkedin_post_id", linkedin_post.headers["X-RestLi-Id"]) - self.db_set("post_status", "Posted") - - except Exception: - self.db_set("post_status", "Error") - self.log_error("Social posting failed") - - -def process_scheduled_social_media_posts(): - posts = frappe.get_all( - "Social Media Post", - filters={"post_status": "Scheduled", "docstatus": 1}, - fields=["name", "scheduled_time"], - ) - start = frappe.utils.now_datetime() - end = start + datetime.timedelta(minutes=10) - for post in posts: - if post.scheduled_time: - post_time = frappe.utils.get_datetime(post.scheduled_time) - if post_time > start and post_time <= end: - sm_post = frappe.get_doc("Social Media Post", post.name) - sm_post.post() diff --git a/erpnext/crm/doctype/social_media_post/social_media_post_list.js b/erpnext/crm/doctype/social_media_post/social_media_post_list.js deleted file mode 100644 index a8c8272ad08..00000000000 --- a/erpnext/crm/doctype/social_media_post/social_media_post_list.js +++ /dev/null @@ -1,11 +0,0 @@ -frappe.listview_settings['Social Media Post'] = { - add_fields: ["status", "post_status"], - get_indicator: function(doc) { - return [__(doc.post_status), { - "Scheduled": "orange", - "Posted": "green", - "Error": "red", - "Deleted": "red" - }[doc.post_status]]; - } -} diff --git a/erpnext/crm/doctype/social_media_post/test_social_media_post.py b/erpnext/crm/doctype/social_media_post/test_social_media_post.py deleted file mode 100644 index 75744767dca..00000000000 --- a/erpnext/crm/doctype/social_media_post/test_social_media_post.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -# import frappe -import unittest - - -class TestSocialMediaPost(unittest.TestCase): - pass diff --git a/erpnext/crm/doctype/twitter_settings/__init__.py b/erpnext/crm/doctype/twitter_settings/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/erpnext/crm/doctype/twitter_settings/test_twitter_settings.py b/erpnext/crm/doctype/twitter_settings/test_twitter_settings.py deleted file mode 100644 index 9dbce8f8aba..00000000000 --- a/erpnext/crm/doctype/twitter_settings/test_twitter_settings.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -# import frappe -import unittest - - -class TestTwitterSettings(unittest.TestCase): - pass diff --git a/erpnext/crm/doctype/twitter_settings/twitter_settings.js b/erpnext/crm/doctype/twitter_settings/twitter_settings.js deleted file mode 100644 index c322092d6f3..00000000000 --- a/erpnext/crm/doctype/twitter_settings/twitter_settings.js +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Twitter Settings', { - onload: function(frm) { - if (frm.doc.session_status == 'Expired' && frm.doc.consumer_key && frm.doc.consumer_secret){ - frappe.confirm( - __('Session not valid, Do you want to login?'), - function(){ - frm.trigger("login"); - }, - function(){ - window.close(); - } - ); - } - frm.dashboard.set_headline(__("For more information, {0}.", [`${__('click here')}`])); - }, - refresh: function(frm) { - let msg, color, flag=false; - if (frm.doc.session_status == "Active") { - msg = __("Session Active"); - color = 'green'; - flag = true; - } - else if(frm.doc.consumer_key && frm.doc.consumer_secret) { - msg = __("Session Not Active. Save doc to login."); - color = 'red'; - flag = true; - } - - if (flag) { - frm.dashboard.set_headline_alert( - `
-
- -
-
` - ); - } - }, - login: function(frm) { - if (frm.doc.consumer_key && frm.doc.consumer_secret){ - frappe.dom.freeze(); - frappe.call({ - doc: frm.doc, - method: "get_authorize_url", - callback : function(r) { - window.location.href = r.message; - } - }).fail(function() { - frappe.dom.unfreeze(); - }); - } - }, - after_save: function(frm) { - frm.trigger("login"); - } -}); diff --git a/erpnext/crm/doctype/twitter_settings/twitter_settings.json b/erpnext/crm/doctype/twitter_settings/twitter_settings.json deleted file mode 100644 index 8d05877f060..00000000000 --- a/erpnext/crm/doctype/twitter_settings/twitter_settings.json +++ /dev/null @@ -1,102 +0,0 @@ -{ - "actions": [], - "creation": "2020-01-30 10:29:08.562108", - "doctype": "DocType", - "documentation": "https://docs.erpnext.com/docs/user/manual/en/CRM/twitter-settings", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "account_name", - "profile_pic", - "oauth_details", - "consumer_key", - "column_break_5", - "consumer_secret", - "access_token", - "access_token_secret", - "session_status" - ], - "fields": [ - { - "fieldname": "account_name", - "fieldtype": "Data", - "label": "Account Name", - "read_only": 1 - }, - { - "fieldname": "oauth_details", - "fieldtype": "Section Break", - "label": "OAuth Credentials" - }, - { - "fieldname": "consumer_key", - "fieldtype": "Data", - "in_list_view": 1, - "label": "API Key", - "reqd": 1 - }, - { - "fieldname": "consumer_secret", - "fieldtype": "Password", - "in_list_view": 1, - "label": "API Secret Key", - "reqd": 1 - }, - { - "fieldname": "column_break_5", - "fieldtype": "Column Break" - }, - { - "fieldname": "profile_pic", - "fieldtype": "Attach Image", - "hidden": 1, - "read_only": 1 - }, - { - "fieldname": "session_status", - "fieldtype": "Select", - "hidden": 1, - "label": "Session Status", - "options": "Expired\nActive", - "read_only": 1 - }, - { - "fieldname": "access_token", - "fieldtype": "Data", - "hidden": 1, - "label": "Access Token", - "read_only": 1 - }, - { - "fieldname": "access_token_secret", - "fieldtype": "Data", - "hidden": 1, - "label": "Access Token Secret", - "read_only": 1 - } - ], - "image_field": "profile_pic", - "issingle": 1, - "links": [], - "modified": "2021-02-18 15:18:07.900031", - "modified_by": "Administrator", - "module": "CRM", - "name": "Twitter Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "quick_entry": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/erpnext/crm/doctype/twitter_settings/twitter_settings.py b/erpnext/crm/doctype/twitter_settings/twitter_settings.py deleted file mode 100644 index 442aa77a5ff..00000000000 --- a/erpnext/crm/doctype/twitter_settings/twitter_settings.py +++ /dev/null @@ -1,141 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - - -import json - -import frappe -import tweepy -from frappe import _ -from frappe.model.document import Document -from frappe.utils import get_url_to_form -from frappe.utils.file_manager import get_file_path - - -class TwitterSettings(Document): - @frappe.whitelist() - def get_authorize_url(self): - callback_url = ( - "{0}/api/method/erpnext.crm.doctype.twitter_settings.twitter_settings.callback?".format( - frappe.utils.get_url() - ) - ) - auth = tweepy.OAuth1UserHandler( - self.consumer_key, self.get_password(fieldname="consumer_secret"), callback_url - ) - try: - redirect_url = auth.get_authorization_url() - return redirect_url - except (tweepy.TweepyException, tweepy.HTTPException) as e: - frappe.msgprint(_("Error! Failed to get request token.")) - frappe.throw( - _("Invalid {0} or {1}").format(frappe.bold("Consumer Key"), frappe.bold("Consumer Secret Key")) - ) - - def get_access_token(self, oauth_token, oauth_verifier): - auth = tweepy.OAuth1UserHandler( - self.consumer_key, self.get_password(fieldname="consumer_secret") - ) - auth.request_token = {"oauth_token": oauth_token, "oauth_token_secret": oauth_verifier} - - try: - auth.get_access_token(oauth_verifier) - self.access_token = auth.access_token - self.access_token_secret = auth.access_token_secret - api = self.get_api() - user = api.me() - profile_pic = (user._json["profile_image_url"]).replace("_normal", "") - - frappe.db.set_value( - self.doctype, - self.name, - { - "access_token": auth.access_token, - "access_token_secret": auth.access_token_secret, - "account_name": user._json["screen_name"], - "profile_pic": profile_pic, - "session_status": "Active", - }, - ) - - frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = get_url_to_form("Twitter Settings", "Twitter Settings") - except (tweepy.TweepyException, tweepy.HTTPException) as e: - frappe.msgprint(_("Error! Failed to get access token.")) - frappe.throw(_("Invalid Consumer Key or Consumer Secret Key")) - - def get_api(self): - # authentication of consumer key and secret - auth = tweepy.OAuth1UserHandler( - self.consumer_key, self.get_password(fieldname="consumer_secret") - ) - # authentication of access token and secret - auth.set_access_token(self.access_token, self.access_token_secret) - - return tweepy.API(auth) - - def post(self, text, media=None): - if not media: - return self.send_tweet(text) - - if media: - media_id = self.upload_image(media) - return self.send_tweet(text, media_id) - - def upload_image(self, media): - media = get_file_path(media) - api = self.get_api() - media = api.media_upload(media) - - return media.media_id - - def send_tweet(self, text, media_id=None): - api = self.get_api() - try: - if media_id: - response = api.update_status(status=text, media_ids=[media_id]) - else: - response = api.update_status(status=text) - - return response - - except (tweepy.TweepyException, tweepy.HTTPException) as e: - self.api_error(e) - - def delete_tweet(self, tweet_id): - api = self.get_api() - try: - api.destroy_status(tweet_id) - except (tweepy.TweepyException, tweepy.HTTPException) as e: - self.api_error(e) - - def get_tweet(self, tweet_id): - api = self.get_api() - try: - response = api.get_status(tweet_id, trim_user=True, include_entities=True) - except (tweepy.TweepyException, tweepy.HTTPException) as e: - self.api_error(e) - - return response._json - - def api_error(self, e): - content = json.loads(e.response.content) - content = content["errors"][0] - if e.response.status_code == 401: - self.db_set("session_status", "Expired") - frappe.db.commit() - frappe.throw( - content["message"], - title=_("Twitter Error {0} : {1}").format(e.response.status_code, e.response.reason), - ) - - -@frappe.whitelist(allow_guest=True) -def callback(oauth_token=None, oauth_verifier=None): - if oauth_token and oauth_verifier: - twitter_settings = frappe.get_single("Twitter Settings") - twitter_settings.get_access_token(oauth_token, oauth_verifier) - frappe.db.commit() - else: - frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = get_url_to_form("Twitter Settings", "Twitter Settings") diff --git a/erpnext/crm/workspace/crm/crm.json b/erpnext/crm/workspace/crm/crm.json index b107df76f8f..4b5b9af714b 100644 --- a/erpnext/crm/workspace/crm/crm.json +++ b/erpnext/crm/workspace/crm/crm.json @@ -122,131 +122,6 @@ "onboard": 0, "type": "Link" }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Campaign", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Campaign", - "link_count": 0, - "link_to": "Campaign", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Email Campaign", - "link_count": 0, - "link_to": "Email Campaign", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Social Media Post", - "link_count": 0, - "link_to": "Social Media Post", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "SMS Center", - "link_count": 0, - "link_to": "SMS Center", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "SMS Log", - "link_count": 0, - "link_to": "SMS Log", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Email Group", - "link_count": 0, - "link_to": "Email Group", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Settings", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, - { - "hidden": 0, - "is_query_report": 0, - "label": "CRM Settings", - "link_count": 0, - "link_to": "CRM Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "SMS Settings", - "link_count": 0, - "link_to": "SMS Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Twitter Settings", - "link_count": 0, - "link_to": "Twitter Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "LinkedIn Settings", - "link_count": 0, - "link_to": "LinkedIn Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, { "hidden": 0, "is_query_report": 0, @@ -450,9 +325,101 @@ "link_type": "DocType", "onboard": 0, "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Settings", + "link_count": 2, + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "CRM Settings", + "link_count": 0, + "link_to": "CRM Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "SMS Settings", + "link_count": 0, + "link_to": "SMS Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Campaign", + "link_count": 5, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Campaign", + "link_count": 0, + "link_to": "Campaign", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Email Campaign", + "link_count": 0, + "link_to": "Email Campaign", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "SMS Center", + "link_count": 0, + "link_to": "SMS Center", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "SMS Log", + "link_count": 0, + "link_to": "SMS Log", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Email Group", + "link_count": 0, + "link_to": "Email Group", + "link_type": "DocType", + "onboard": 0, + "type": "Link" } ], - "modified": "2023-05-26 16:49:04.298122", + "modified": "2023-09-14 12:11:03.968048", "modified_by": "Administrator", "module": "CRM", "name": "CRM", @@ -463,7 +430,7 @@ "quick_lists": [], "restrict_to_domain": "", "roles": [], - "sequence_id": 10.0, + "sequence_id": 17.0, "shortcuts": [ { "color": "Blue", diff --git a/erpnext/e_commerce/shopping_cart/cart.py b/erpnext/e_commerce/shopping_cart/cart.py index 85d9a6585ce..c66ae1d6009 100644 --- a/erpnext/e_commerce/shopping_cart/cart.py +++ b/erpnext/e_commerce/shopping_cart/cart.py @@ -631,7 +631,6 @@ def get_applicable_shipping_rules(party=None, quotation=None): shipping_rules = get_shipping_rules(quotation) if shipping_rules: - rule_label_map = frappe.db.get_values("Shipping Rule", shipping_rules, "label") # we need this in sorted order as per the position of the rule in the settings page return [[rule, rule] for rule in shipping_rules] diff --git a/erpnext/erpnext_integrations/connectors/__init__.py b/erpnext/erpnext_integrations/connectors/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/erpnext/erpnext_integrations/connectors/woocommerce_connection.py b/erpnext/erpnext_integrations/connectors/woocommerce_connection.py deleted file mode 100644 index 2b2da7b971b..00000000000 --- a/erpnext/erpnext_integrations/connectors/woocommerce_connection.py +++ /dev/null @@ -1,256 +0,0 @@ -import base64 -import hashlib -import hmac -import json - -import frappe -from frappe import _ -from frappe.utils import cstr - - -def verify_request(): - woocommerce_settings = frappe.get_doc("Woocommerce Settings") - sig = base64.b64encode( - hmac.new( - woocommerce_settings.secret.encode("utf8"), frappe.request.data, hashlib.sha256 - ).digest() - ) - - if ( - frappe.request.data - and not sig == frappe.get_request_header("X-Wc-Webhook-Signature", "").encode() - ): - frappe.throw(_("Unverified Webhook Data")) - frappe.set_user(woocommerce_settings.creation_user) - - -@frappe.whitelist(allow_guest=True) -def order(*args, **kwargs): - try: - _order(*args, **kwargs) - except Exception: - error_message = ( - frappe.get_traceback() + "\n\n Request Data: \n" + json.loads(frappe.request.data).__str__() - ) - frappe.log_error("WooCommerce Error", error_message) - raise - - -def _order(*args, **kwargs): - woocommerce_settings = frappe.get_doc("Woocommerce Settings") - if frappe.flags.woocomm_test_order_data: - order = frappe.flags.woocomm_test_order_data - event = "created" - # Ignore the test ping issued during WooCommerce webhook configuration - # Ref: https://github.com/woocommerce/woocommerce/issues/15642 - if frappe.request.data.decode("utf-8").startswith("webhook_id="): - return "success" - elif frappe.request and frappe.request.data: - verify_request() - try: - order = json.loads(frappe.request.data) - except ValueError: - # woocommerce returns 'webhook_id=value' for the first request which is not JSON - order = frappe.request.data - event = frappe.get_request_header("X-Wc-Webhook-Event") - - else: - return "success" - - if event == "created": - sys_lang = frappe.get_single("System Settings").language or "en" - raw_billing_data = order.get("billing") - raw_shipping_data = order.get("shipping") - customer_name = raw_billing_data.get("first_name") + " " + raw_billing_data.get("last_name") - link_customer_and_address(raw_billing_data, raw_shipping_data, customer_name) - link_items(order.get("line_items"), woocommerce_settings, sys_lang) - create_sales_order(order, woocommerce_settings, customer_name, sys_lang) - - -def link_customer_and_address(raw_billing_data, raw_shipping_data, customer_name): - customer_woo_com_email = raw_billing_data.get("email") - customer_exists = frappe.get_value("Customer", {"woocommerce_email": customer_woo_com_email}) - if not customer_exists: - # Create Customer - customer = frappe.new_doc("Customer") - else: - # Edit Customer - customer = frappe.get_doc("Customer", {"woocommerce_email": customer_woo_com_email}) - old_name = customer.customer_name - - customer.customer_name = customer_name - customer.woocommerce_email = customer_woo_com_email - customer.flags.ignore_mandatory = True - customer.save() - - if customer_exists: - # Fixes https://github.com/frappe/erpnext/issues/33708 - if old_name != customer_name: - frappe.rename_doc("Customer", old_name, customer_name) - for address_type in ( - "Billing", - "Shipping", - ): - try: - address = frappe.get_doc( - "Address", {"woocommerce_email": customer_woo_com_email, "address_type": address_type} - ) - rename_address(address, customer) - except ( - frappe.DoesNotExistError, - frappe.DuplicateEntryError, - frappe.ValidationError, - ): - pass - else: - create_address(raw_billing_data, customer, "Billing") - create_address(raw_shipping_data, customer, "Shipping") - create_contact(raw_billing_data, customer) - - -def create_contact(data, customer): - email = data.get("email", None) - phone = data.get("phone", None) - - if not email and not phone: - return - - contact = frappe.new_doc("Contact") - contact.first_name = data.get("first_name") - contact.last_name = data.get("last_name") - contact.is_primary_contact = 1 - contact.is_billing_contact = 1 - - if phone: - contact.add_phone(phone, is_primary_mobile_no=1, is_primary_phone=1) - - if email: - contact.add_email(email, is_primary=1) - - contact.append("links", {"link_doctype": "Customer", "link_name": customer.name}) - - contact.flags.ignore_mandatory = True - contact.save() - - -def create_address(raw_data, customer, address_type): - address = frappe.new_doc("Address") - - address.address_line1 = raw_data.get("address_1", "Not Provided") - address.address_line2 = raw_data.get("address_2", "Not Provided") - address.city = raw_data.get("city", "Not Provided") - address.woocommerce_email = customer.woocommerce_email - address.address_type = address_type - address.country = frappe.get_value("Country", {"code": raw_data.get("country", "IN").lower()}) - address.state = raw_data.get("state") - address.pincode = raw_data.get("postcode") - address.phone = raw_data.get("phone") - address.email_id = customer.woocommerce_email - address.append("links", {"link_doctype": "Customer", "link_name": customer.name}) - - address.flags.ignore_mandatory = True - address.save() - - -def rename_address(address, customer): - old_address_title = address.name - new_address_title = customer.name + "-" + address.address_type - address.address_title = customer.customer_name - address.save() - - frappe.rename_doc("Address", old_address_title, new_address_title) - - -def link_items(items_list, woocommerce_settings, sys_lang): - for item_data in items_list: - item_woo_com_id = cstr(item_data.get("product_id")) - - if not frappe.db.get_value("Item", {"woocommerce_id": item_woo_com_id}, "name"): - # Create Item - item = frappe.new_doc("Item") - item.item_code = _("woocommerce - {0}", sys_lang).format(item_woo_com_id) - item.stock_uom = woocommerce_settings.uom or _("Nos", sys_lang) - item.item_group = _("WooCommerce Products", sys_lang) - - item.item_name = item_data.get("name") - item.woocommerce_id = item_woo_com_id - item.flags.ignore_mandatory = True - item.save() - - -def create_sales_order(order, woocommerce_settings, customer_name, sys_lang): - new_sales_order = frappe.new_doc("Sales Order") - new_sales_order.customer = customer_name - - new_sales_order.po_no = new_sales_order.woocommerce_id = order.get("id") - new_sales_order.naming_series = woocommerce_settings.sales_order_series or "SO-WOO-" - - created_date = order.get("date_created").split("T") - new_sales_order.transaction_date = created_date[0] - delivery_after = woocommerce_settings.delivery_after_days or 7 - new_sales_order.delivery_date = frappe.utils.add_days(created_date[0], delivery_after) - - new_sales_order.company = woocommerce_settings.company - - set_items_in_sales_order(new_sales_order, woocommerce_settings, order, sys_lang) - new_sales_order.flags.ignore_mandatory = True - new_sales_order.insert() - new_sales_order.submit() - - frappe.db.commit() - - -def set_items_in_sales_order(new_sales_order, woocommerce_settings, order, sys_lang): - company_abbr = frappe.db.get_value("Company", woocommerce_settings.company, "abbr") - - default_warehouse = _("Stores - {0}", sys_lang).format(company_abbr) - if not frappe.db.exists("Warehouse", default_warehouse) and not woocommerce_settings.warehouse: - frappe.throw(_("Please set Warehouse in Woocommerce Settings")) - - for item in order.get("line_items"): - woocomm_item_id = item.get("product_id") - found_item = frappe.get_doc("Item", {"woocommerce_id": cstr(woocomm_item_id)}) - - ordered_items_tax = item.get("total_tax") - - new_sales_order.append( - "items", - { - "item_code": found_item.name, - "item_name": found_item.item_name, - "description": found_item.item_name, - "delivery_date": new_sales_order.delivery_date, - "uom": woocommerce_settings.uom or _("Nos", sys_lang), - "qty": item.get("quantity"), - "rate": item.get("price"), - "warehouse": woocommerce_settings.warehouse or default_warehouse, - }, - ) - - add_tax_details( - new_sales_order, ordered_items_tax, "Ordered Item tax", woocommerce_settings.tax_account - ) - - # shipping_details = order.get("shipping_lines") # used for detailed order - - add_tax_details( - new_sales_order, order.get("shipping_tax"), "Shipping Tax", woocommerce_settings.f_n_f_account - ) - add_tax_details( - new_sales_order, - order.get("shipping_total"), - "Shipping Total", - woocommerce_settings.f_n_f_account, - ) - - -def add_tax_details(sales_order, price, desc, tax_account_head): - sales_order.append( - "taxes", - { - "charge_type": "Actual", - "account_head": tax_account_head, - "tax_amount": price, - "description": desc, - }, - ) diff --git a/erpnext/erpnext_integrations/doctype/woocommerce_settings/__init__.py b/erpnext/erpnext_integrations/doctype/woocommerce_settings/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/erpnext/erpnext_integrations/doctype/woocommerce_settings/test_woocommerce_settings.py b/erpnext/erpnext_integrations/doctype/woocommerce_settings/test_woocommerce_settings.py deleted file mode 100644 index 9945823bf73..00000000000 --- a/erpnext/erpnext_integrations/doctype/woocommerce_settings/test_woocommerce_settings.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -import unittest - - -class TestWoocommerceSettings(unittest.TestCase): - pass diff --git a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.js b/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.js deleted file mode 100644 index d7a3d36a5f1..00000000000 --- a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.js +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Woocommerce Settings', { - refresh (frm) { - frm.trigger("add_button_generate_secret"); - frm.trigger("check_enabled"); - frm.set_query("tax_account", ()=>{ - return { - "filters": { - "company": frappe.defaults.get_default("company"), - "is_group": 0 - } - }; - }); - }, - - enable_sync (frm) { - frm.trigger("check_enabled"); - }, - - add_button_generate_secret(frm) { - frm.add_custom_button(__('Generate Secret'), () => { - frappe.confirm( - __("Apps using current key won't be able to access, are you sure?"), - () => { - frappe.call({ - type:"POST", - method:"erpnext.erpnext_integrations.doctype.woocommerce_settings.woocommerce_settings.generate_secret", - }).done(() => { - frm.reload_doc(); - }).fail(() => { - frappe.msgprint(__("Could not generate Secret")); - }); - } - ); - }); - }, - - check_enabled (frm) { - frm.set_df_property("woocommerce_server_url", "reqd", frm.doc.enable_sync); - frm.set_df_property("api_consumer_key", "reqd", frm.doc.enable_sync); - frm.set_df_property("api_consumer_secret", "reqd", frm.doc.enable_sync); - } -}); - -frappe.ui.form.on("Woocommerce Settings", "onload", function () { - frappe.call({ - method: "erpnext.erpnext_integrations.doctype.woocommerce_settings.woocommerce_settings.get_series", - callback: function (r) { - $.each(r.message, function (key, value) { - set_field_options(key, value); - }); - } - }); -}); diff --git a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.json b/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.json deleted file mode 100644 index 956ae09cbd6..00000000000 --- a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.json +++ /dev/null @@ -1,175 +0,0 @@ -{ - "creation": "2018-02-12 15:10:05.495713", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "enable_sync", - "sb_00", - "woocommerce_server_url", - "secret", - "cb_00", - "api_consumer_key", - "api_consumer_secret", - "sb_accounting_details", - "tax_account", - "column_break_10", - "f_n_f_account", - "defaults_section", - "creation_user", - "warehouse", - "sales_order_series", - "column_break_14", - "company", - "delivery_after_days", - "uom", - "endpoints", - "endpoint" - ], - "fields": [ - { - "default": "0", - "fieldname": "enable_sync", - "fieldtype": "Check", - "label": "Enable Sync" - }, - { - "fieldname": "sb_00", - "fieldtype": "Section Break" - }, - { - "fieldname": "woocommerce_server_url", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Woocommerce Server URL" - }, - { - "fieldname": "secret", - "fieldtype": "Code", - "label": "Secret", - "read_only": 1 - }, - { - "fieldname": "cb_00", - "fieldtype": "Column Break" - }, - { - "fieldname": "api_consumer_key", - "fieldtype": "Data", - "in_list_view": 1, - "label": "API consumer key" - }, - { - "fieldname": "api_consumer_secret", - "fieldtype": "Data", - "in_list_view": 1, - "label": "API consumer secret" - }, - { - "fieldname": "sb_accounting_details", - "fieldtype": "Section Break", - "label": "Accounting Details" - }, - { - "fieldname": "tax_account", - "fieldtype": "Link", - "label": "Tax Account", - "options": "Account", - "reqd": 1 - }, - { - "fieldname": "column_break_10", - "fieldtype": "Column Break" - }, - { - "fieldname": "f_n_f_account", - "fieldtype": "Link", - "label": "Freight and Forwarding Account", - "options": "Account", - "reqd": 1 - }, - { - "fieldname": "defaults_section", - "fieldtype": "Section Break", - "label": "Defaults" - }, - { - "description": "The user that will be used to create Customers, Items and Sales Orders. This user should have the relevant permissions.", - "fieldname": "creation_user", - "fieldtype": "Link", - "label": "Creation User", - "options": "User", - "reqd": 1 - }, - { - "description": "This warehouse will be used to create Sales Orders. The fallback warehouse is \"Stores\".", - "fieldname": "warehouse", - "fieldtype": "Link", - "label": "Warehouse", - "options": "Warehouse" - }, - { - "fieldname": "column_break_14", - "fieldtype": "Column Break" - }, - { - "description": "The fallback series is \"SO-WOO-\".", - "fieldname": "sales_order_series", - "fieldtype": "Select", - "label": "Sales Order Series" - }, - { - "description": "This is the default UOM used for items and Sales orders. The fallback UOM is \"Nos\".", - "fieldname": "uom", - "fieldtype": "Link", - "label": "UOM", - "options": "UOM" - }, - { - "fieldname": "endpoints", - "fieldtype": "Section Break", - "label": "Endpoints" - }, - { - "fieldname": "endpoint", - "fieldtype": "Code", - "label": "Endpoint", - "read_only": 1 - }, - { - "description": "This company will be used to create Sales Orders.", - "fieldname": "company", - "fieldtype": "Link", - "label": "Company", - "options": "Company", - "reqd": 1 - }, - { - "description": "This is the default offset (days) for the Delivery Date in Sales Orders. The fallback offset is 7 days from the order placement date.", - "fieldname": "delivery_after_days", - "fieldtype": "Int", - "label": "Delivery After (Days)" - } - ], - "issingle": 1, - "modified": "2019-11-04 00:45:21.232096", - "modified_by": "Administrator", - "module": "ERPNext Integrations", - "name": "Woocommerce Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "quick_entry": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py b/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py deleted file mode 100644 index 4aa98aab56b..00000000000 --- a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py +++ /dev/null @@ -1,87 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - - -from urllib.parse import urlparse - -import frappe -from frappe import _ -from frappe.custom.doctype.custom_field.custom_field import create_custom_fields -from frappe.model.document import Document -from frappe.utils.nestedset import get_root_of - - -class WoocommerceSettings(Document): - def validate(self): - self.validate_settings() - self.create_delete_custom_fields() - self.create_webhook_url() - - def create_delete_custom_fields(self): - if self.enable_sync: - create_custom_fields( - { - ("Customer", "Sales Order", "Item", "Address"): dict( - fieldname="woocommerce_id", - label="Woocommerce ID", - fieldtype="Data", - read_only=1, - print_hide=1, - ), - ("Customer", "Address"): dict( - fieldname="woocommerce_email", - label="Woocommerce Email", - fieldtype="Data", - read_only=1, - print_hide=1, - ), - } - ) - - if not frappe.get_value("Item Group", {"name": _("WooCommerce Products")}): - item_group = frappe.new_doc("Item Group") - item_group.item_group_name = _("WooCommerce Products") - item_group.parent_item_group = get_root_of("Item Group") - item_group.insert() - - def validate_settings(self): - if self.enable_sync: - if not self.secret: - self.set("secret", frappe.generate_hash()) - - if not self.woocommerce_server_url: - frappe.throw(_("Please enter Woocommerce Server URL")) - - if not self.api_consumer_key: - frappe.throw(_("Please enter API Consumer Key")) - - if not self.api_consumer_secret: - frappe.throw(_("Please enter API Consumer Secret")) - - def create_webhook_url(self): - endpoint = "/api/method/erpnext.erpnext_integrations.connectors.woocommerce_connection.order" - - try: - url = frappe.request.url - except RuntimeError: - # for CI Test to work - url = "http://localhost:8000" - - server_url = "{uri.scheme}://{uri.netloc}".format(uri=urlparse(url)) - - delivery_url = server_url + endpoint - self.endpoint = delivery_url - - -@frappe.whitelist() -def generate_secret(): - woocommerce_settings = frappe.get_doc("Woocommerce Settings") - woocommerce_settings.secret = frappe.generate_hash() - woocommerce_settings.save() - - -@frappe.whitelist() -def get_series(): - return { - "sales_order_series": frappe.get_meta("Sales Order").get_options("naming_series") or "SO-WOO-", - } diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 41db6b3a725..c7398cce999 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -423,9 +423,6 @@ scheduler_events = { "erpnext.stock.reorder_item.reorder_item", ], }, - "all": [ - "erpnext.crm.doctype.social_media_post.social_media_post.process_scheduled_social_media_posts", - ], "hourly": [ "erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization", "erpnext.projects.doctype.project.project.project_status_update_reminder", diff --git a/erpnext/manufacturing/doctype/bom_creator/test_bom_creator.py b/erpnext/manufacturing/doctype/bom_creator/test_bom_creator.py index d8d9f8fbc69..ee5886c1cb0 100644 --- a/erpnext/manufacturing/doctype/bom_creator/test_bom_creator.py +++ b/erpnext/manufacturing/doctype/bom_creator/test_bom_creator.py @@ -191,7 +191,7 @@ class TestBOMCreator(FrappeTestCase): ) doc = make_bom_creator( - name="Bicycle BOM", + name="Bicycle BOM Test", company="_Test Company", item_code=final_product, qty=1, diff --git a/erpnext/patches.txt b/erpnext/patches.txt index b3d6d3e80a4..d0ee2e4dc4e 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -340,5 +340,6 @@ erpnext.patches.v15_0.remove_exotel_integration erpnext.patches.v14_0.single_to_multi_dunning execute:frappe.db.set_single_value('Selling Settings', 'allow_negative_rates_for_items', 0) erpnext.patches.v15_0.correct_asset_value_if_je_with_workflow +erpnext.patches.v15_0.delete_woocommerce_settings_doctype # below migration patch should always run last erpnext.patches.v14_0.migrate_gl_to_payment_ledger diff --git a/erpnext/patches/v15_0/delete_woocommerce_settings_doctype.py b/erpnext/patches/v15_0/delete_woocommerce_settings_doctype.py new file mode 100644 index 00000000000..fb92ca55d17 --- /dev/null +++ b/erpnext/patches/v15_0/delete_woocommerce_settings_doctype.py @@ -0,0 +1,5 @@ +import frappe + + +def execute(): + frappe.delete_doc("DocType", "Woocommerce Settings", ignore_missing=True) diff --git a/erpnext/public/js/erpnext.bundle.js b/erpnext/public/js/erpnext.bundle.js index 966a9e1f9b3..0e1b23b0eae 100644 --- a/erpnext/public/js/erpnext.bundle.js +++ b/erpnext/public/js/erpnext.bundle.js @@ -16,7 +16,8 @@ import "./utils/customer_quick_entry"; import "./utils/supplier_quick_entry"; import "./call_popup/call_popup"; import "./utils/dimension_tree_filter"; -import "./utils/ledger_preview.js" +import "./utils/ledger_preview.js"; +import "./utils/unreconcile.js"; import "./utils/barcode_scanner"; import "./telephony"; import "./templates/call_link.html"; diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 89750f8446c..d435711cf52 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -769,6 +769,9 @@ erpnext.utils.update_child_items = function(opts) { dialog.show(); } + + + erpnext.utils.map_current_doc = function(opts) { function _map() { if($.isArray(cur_frm.doc.items) && cur_frm.doc.items.length > 0) { @@ -1097,4 +1100,4 @@ function attach_selector_button(inner_text, append_loction, context, grid_row) { $btn.on("click", function() { context.show_serial_batch_selector(grid_row.frm, grid_row.doc, "", "", true); }); -} \ No newline at end of file +} diff --git a/erpnext/public/js/utils/unreconcile.js b/erpnext/public/js/utils/unreconcile.js new file mode 100644 index 00000000000..bbdd51d6e54 --- /dev/null +++ b/erpnext/public/js/utils/unreconcile.js @@ -0,0 +1,127 @@ +frappe.provide('erpnext.accounts'); + +erpnext.accounts.unreconcile_payments = { + add_unreconcile_btn(frm) { + if (frm.doc.docstatus == 1) { + if(((frm.doc.doctype == "Journal Entry") && (frm.doc.voucher_type != "Journal Entry")) + || !["Purchase Invoice", "Sales Invoice", "Journal Entry", "Payment Entry"].includes(frm.doc.doctype) + ) { + return; + } + + frappe.call({ + "method": "erpnext.accounts.doctype.unreconcile_payments.unreconcile_payments.doc_has_references", + "args": { + "doctype": frm.doc.doctype, + "docname": frm.doc.name + }, + callback: function(r) { + if (r.message) { + frm.add_custom_button(__("Un-Reconcile"), function() { + erpnext.accounts.unreconcile_payments.build_unreconcile_dialog(frm); + }, __('Actions')); + } + } + }); + } + }, + + build_selection_map(frm, selections) { + // assuming each row is an individual voucher + // pass this to server side method that creates unreconcile doc for each row + let selection_map = []; + if (['Sales Invoice', 'Purchase Invoice'].includes(frm.doc.doctype)) { + selection_map = selections.map(function(elem) { + return { + company: elem.company, + voucher_type: elem.voucher_type, + voucher_no: elem.voucher_no, + against_voucher_type: frm.doc.doctype, + against_voucher_no: frm.doc.name + }; + }); + } else if (['Payment Entry', 'Journal Entry'].includes(frm.doc.doctype)) { + selection_map = selections.map(function(elem) { + return { + company: elem.company, + voucher_type: frm.doc.doctype, + voucher_no: frm.doc.name, + against_voucher_type: elem.voucher_type, + against_voucher_no: elem.voucher_no, + }; + }); + } + return selection_map; + }, + + build_unreconcile_dialog(frm) { + if (['Sales Invoice', 'Purchase Invoice', 'Payment Entry', 'Journal Entry'].includes(frm.doc.doctype)) { + let child_table_fields = [ + { label: __("Voucher Type"), fieldname: "voucher_type", fieldtype: "Dynamic Link", options: "DocType", in_list_view: 1, read_only: 1}, + { label: __("Voucher No"), fieldname: "voucher_no", fieldtype: "Link", options: "voucher_type", in_list_view: 1, read_only: 1 }, + { label: __("Allocated Amount"), fieldname: "allocated_amount", fieldtype: "Currency", in_list_view: 1, read_only: 1 , options: "account_currency"}, + { label: __("Currency"), fieldname: "account_currency", fieldtype: "Currency", read_only: 1}, + ] + let unreconcile_dialog_fields = [ + { + label: __('Allocations'), + fieldname: 'allocations', + fieldtype: 'Table', + read_only: 1, + fields: child_table_fields, + }, + ]; + + // get linked payments + frappe.call({ + "method": "erpnext.accounts.doctype.unreconcile_payments.unreconcile_payments.get_linked_payments_for_doc", + "args": { + "company": frm.doc.company, + "doctype": frm.doc.doctype, + "docname": frm.doc.name + }, + callback: function(r) { + if (r.message) { + // populate child table with allocations + unreconcile_dialog_fields[0].data = r.message; + unreconcile_dialog_fields[0].get_data = function(){ return r.message}; + + let d = new frappe.ui.Dialog({ + title: 'Un-Reconcile Allocations', + fields: unreconcile_dialog_fields, + size: 'large', + cannot_add_rows: true, + primary_action_label: 'Un-Reconcile', + primary_action(values) { + + let selected_allocations = values.allocations.filter(x=>x.__checked); + if (selected_allocations.length > 0) { + let selection_map = erpnext.accounts.unreconcile_payments.build_selection_map(frm, selected_allocations); + erpnext.accounts.unreconcile_payments.create_unreconcile_docs(selection_map); + d.hide(); + + } else { + frappe.msgprint("No Selection"); + } + } + }); + + d.show(); + } + } + }); + } + }, + + create_unreconcile_docs(selection_map) { + frappe.call({ + "method": "erpnext.accounts.doctype.unreconcile_payments.unreconcile_payments.create_unreconcile_doc_for_selection", + "args": { + "selections": selection_map + }, + }); + } + + + +} diff --git a/erpnext/selling/doctype/customer/customer.js b/erpnext/selling/doctype/customer/customer.js index 60f0941559a..e274a52690b 100644 --- a/erpnext/selling/doctype/customer/customer.js +++ b/erpnext/selling/doctype/customer/customer.js @@ -23,6 +23,7 @@ frappe.ui.form.on("Customer", { let d = locals[cdt][cdn]; let filters = { 'account_type': 'Receivable', + 'root_type': 'Asset', 'company': d.company, "is_group": 0 }; diff --git a/erpnext/setup/doctype/authorization_control/authorization_control.py b/erpnext/setup/doctype/authorization_control/authorization_control.py index 5e77c6fa816..fd5a2012c7d 100644 --- a/erpnext/setup/doctype/authorization_control/authorization_control.py +++ b/erpnext/setup/doctype/authorization_control/authorization_control.py @@ -10,7 +10,7 @@ from erpnext.utilities.transaction_base import TransactionBase class AuthorizationControl(TransactionBase): - def get_appr_user_role(self, det, doctype_name, total, based_on, condition, item, company): + def get_appr_user_role(self, det, doctype_name, total, based_on, condition, master_name, company): amt_list, appr_users, appr_roles = [], [], [] users, roles = "", "" if det: @@ -47,11 +47,11 @@ class AuthorizationControl(TransactionBase): frappe.msgprint(_("Not authroized since {0} exceeds limits").format(_(based_on))) frappe.throw(_("Can be approved by {0}").format(comma_or(appr_roles + appr_users))) - def validate_auth_rule(self, doctype_name, total, based_on, cond, company, item=""): + def validate_auth_rule(self, doctype_name, total, based_on, cond, company, master_name=""): chk = 1 add_cond1, add_cond2 = "", "" - if based_on == "Itemwise Discount": - add_cond1 += " and master_name = " + frappe.db.escape(cstr(item)) + if based_on in ["Itemwise Discount", "Item Group wise Discount"]: + add_cond1 += " and master_name = " + frappe.db.escape(cstr(master_name)) itemwise_exists = frappe.db.sql( """select value from `tabAuthorization Rule` where transaction = %s and value <= %s @@ -71,11 +71,11 @@ class AuthorizationControl(TransactionBase): if itemwise_exists: self.get_appr_user_role( - itemwise_exists, doctype_name, total, based_on, cond + add_cond1, item, company + itemwise_exists, doctype_name, total, based_on, cond + add_cond1, master_name, company ) chk = 0 if chk == 1: - if based_on == "Itemwise Discount": + if based_on in ["Itemwise Discount", "Item Group wise Discount"]: add_cond2 += " and ifnull(master_name,'') = ''" appr = frappe.db.sql( @@ -95,7 +95,9 @@ class AuthorizationControl(TransactionBase): (doctype_name, total, based_on), ) - self.get_appr_user_role(appr, doctype_name, total, based_on, cond + add_cond2, item, company) + self.get_appr_user_role( + appr, doctype_name, total, based_on, cond + add_cond2, master_name, company + ) def bifurcate_based_on_type(self, doctype_name, total, av_dis, based_on, doc_obj, val, company): add_cond = "" @@ -123,6 +125,12 @@ class AuthorizationControl(TransactionBase): self.validate_auth_rule( doctype_name, t.discount_percentage, based_on, add_cond, company, t.item_code ) + elif based_on == "Item Group wise Discount": + if doc_obj: + for t in doc_obj.get("items"): + self.validate_auth_rule( + doctype_name, t.discount_percentage, based_on, add_cond, company, t.item_group + ) else: self.validate_auth_rule(doctype_name, auth_value, based_on, add_cond, company) @@ -148,6 +156,7 @@ class AuthorizationControl(TransactionBase): "Average Discount", "Customerwise Discount", "Itemwise Discount", + "Item Group wise Discount", ] # Check for authorization set for individual user @@ -166,7 +175,7 @@ class AuthorizationControl(TransactionBase): # Remove user specific rules from global authorization rules for r in based_on: - if r in final_based_on and r != "Itemwise Discount": + if r in final_based_on and not r in ["Itemwise Discount", "Item Group wise Discount"]: final_based_on.remove(r) # Check for authorization set on particular roles @@ -194,7 +203,7 @@ class AuthorizationControl(TransactionBase): # Remove role specific rules from global authorization rules for r in based_on: - if r in final_based_on and r != "Itemwise Discount": + if r in final_based_on and not r in ["Itemwise Discount", "Item Group wise Discount"]: final_based_on.remove(r) # Check for global authorization diff --git a/erpnext/setup/doctype/authorization_rule/authorization_rule.js b/erpnext/setup/doctype/authorization_rule/authorization_rule.js index 3f6afcae7f5..f00ed3ecd0d 100644 --- a/erpnext/setup/doctype/authorization_rule/authorization_rule.js +++ b/erpnext/setup/doctype/authorization_rule/authorization_rule.js @@ -12,6 +12,9 @@ frappe.ui.form.on("Authorization Rule", { } else if(frm.doc.based_on==="Itemwise Discount") { unhide_field("master_name"); frm.set_value("customer_or_item", "Item"); + } else if(frm.doc.based_on==="Item Group wise Discount") { + unhide_field("master_name"); + frm.set_value("customer_or_item", "Item Group"); } else { frm.set_value("customer_or_item", ""); frm.set_value("master_name", ""); @@ -81,6 +84,13 @@ cur_frm.fields_dict['master_name'].get_query = function(doc) { doctype: "Item", query: "erpnext.controllers.queries.item_query" } + else if (doc.based_on==="Item Group wise Discount") + return { + doctype: "Item Group", + filters: { + "is_group": 0 + } + } else return { filters: [ diff --git a/erpnext/setup/doctype/authorization_rule/authorization_rule.json b/erpnext/setup/doctype/authorization_rule/authorization_rule.json index d3b8887c37b..d750c7bb182 100644 --- a/erpnext/setup/doctype/authorization_rule/authorization_rule.json +++ b/erpnext/setup/doctype/authorization_rule/authorization_rule.json @@ -46,7 +46,7 @@ "label": "Based On", "oldfieldname": "based_on", "oldfieldtype": "Select", - "options": "\nGrand Total\nAverage Discount\nCustomerwise Discount\nItemwise Discount\nNot Applicable", + "options": "\nGrand Total\nAverage Discount\nCustomerwise Discount\nItemwise Discount\nItem Group wise Discount\nNot Applicable", "reqd": 1 }, { @@ -54,14 +54,14 @@ "fieldtype": "Select", "hidden": 1, "label": "Customer or Item", - "options": "Customer\nItem", + "options": "Customer\nItem\nItem Group", "read_only": 1 }, { "fieldname": "master_name", "fieldtype": "Dynamic Link", "in_list_view": 1, - "label": "Customer / Item Name", + "label": "Customer / Item / Item Group", "oldfieldname": "master_name", "oldfieldtype": "Link", "options": "customer_or_item" @@ -162,7 +162,7 @@ "icon": "fa fa-shield", "idx": 1, "links": [], - "modified": "2022-07-01 11:19:45.643991", + "modified": "2023-09-11 10:29:02.863193", "modified_by": "Administrator", "module": "Setup", "name": "Authorization Rule", diff --git a/erpnext/setup/doctype/authorization_rule/authorization_rule.py b/erpnext/setup/doctype/authorization_rule/authorization_rule.py index 44bd826fc6e..9e64e55ff46 100644 --- a/erpnext/setup/doctype/authorization_rule/authorization_rule.py +++ b/erpnext/setup/doctype/authorization_rule/authorization_rule.py @@ -47,6 +47,7 @@ class AuthorizationRule(Document): "Average Discount", "Customerwise Discount", "Itemwise Discount", + "Item Group wise Discount", ]: frappe.throw( _("Cannot set authorization on basis of Discount for {0}").format(self.transaction) @@ -55,8 +56,6 @@ class AuthorizationRule(Document): frappe.throw(_("Discount must be less than 100")) elif self.based_on == "Customerwise Discount" and not self.master_name: frappe.throw(_("Customer required for 'Customerwise Discount'")) - else: - self.based_on = "Not Applicable" def validate(self): self.check_duplicate_entry() diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index fa207ecdd1c..4973dab505e 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -200,8 +200,8 @@ erpnext.company.setup_queries = function(frm) { $.each([ ["default_bank_account", {"account_type": "Bank"}], ["default_cash_account", {"account_type": "Cash"}], - ["default_receivable_account", {"account_type": "Receivable"}], - ["default_payable_account", {"account_type": "Payable"}], + ["default_receivable_account", { "root_type": "Asset", "account_type": "Receivable" }], + ["default_payable_account", { "root_type": "Liability", "account_type": "Payable" }], ["default_expense_account", {"root_type": "Expense"}], ["default_income_account", {"root_type": "Income"}], ["round_off_account", {"root_type": "Expense"}], diff --git a/erpnext/setup/doctype/customer_group/customer_group.js b/erpnext/setup/doctype/customer_group/customer_group.js index 49a90f959d0..3c81b0283ca 100644 --- a/erpnext/setup/doctype/customer_group/customer_group.js +++ b/erpnext/setup/doctype/customer_group/customer_group.js @@ -30,6 +30,7 @@ frappe.ui.form.on("Customer Group", { frm.set_query('account', 'accounts', function (doc, cdt, cdn) { return { filters: { + 'root_type': 'Asset', "account_type": 'Receivable', "company": locals[cdt][cdn].company, "is_group": 0 diff --git a/erpnext/setup/doctype/supplier_group/supplier_group.js b/erpnext/setup/doctype/supplier_group/supplier_group.js index b2acfd73559..33629297ffd 100644 --- a/erpnext/setup/doctype/supplier_group/supplier_group.js +++ b/erpnext/setup/doctype/supplier_group/supplier_group.js @@ -30,6 +30,7 @@ frappe.ui.form.on("Supplier Group", { frm.set_query('account', 'accounts', function (doc, cdt, cdn) { return { filters: { + 'root_type': 'Liability', 'account_type': 'Payable', 'company': locals[cdt][cdn].company, "is_group": 0 diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index 6a9e2414445..e0d49192eb1 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -1253,6 +1253,7 @@ "depends_on": "eval: doc.is_internal_customer", "fieldname": "set_target_warehouse", "fieldtype": "Link", + "ignore_user_permissions": 1, "in_standard_filter": 1, "label": "Set Target Warehouse", "no_copy": 1, @@ -1400,7 +1401,7 @@ "idx": 146, "is_submittable": 1, "links": [], - "modified": "2023-06-16 14:58:55.066602", + "modified": "2023-09-04 14:15:28.363184", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py b/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py index e66c23324da..d4a574da73f 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py @@ -11,10 +11,12 @@ def get_data(): }, "internal_links": { "Sales Order": ["items", "against_sales_order"], - "Sales Invoice": ["items", "against_sales_invoice"], "Material Request": ["items", "material_request"], "Purchase Order": ["items", "purchase_order"], }, + "internal_and_external_links": { + "Sales Invoice": ["items", "against_sales_invoice"], + }, "transactions": [ {"label": _("Related"), "items": ["Sales Invoice", "Packing Slip", "Delivery Trip"]}, {"label": _("Reference"), "items": ["Sales Order", "Shipment", "Quality Inspection"]}, diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 60aefddf4c8..04eff54c43f 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -603,7 +603,7 @@ class PurchaseReceipt(BuyingController): account=provisional_account, cost_center=item.cost_center, debit=0.0, - credit=multiplication_factor * item.amount, + credit=multiplication_factor * item.base_amount, remarks=remarks, against_account=expense_account, account_currency=credit_currency, @@ -617,7 +617,7 @@ class PurchaseReceipt(BuyingController): gl_entries=gl_entries, account=expense_account, cost_center=item.cost_center, - debit=multiplication_factor * item.amount, + debit=multiplication_factor * item.base_amount, credit=0.0, remarks=remarks, against_account=provisional_account, diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 6134bfa1f23..b7712ee5ce2 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -2017,6 +2017,49 @@ class TestPurchaseReceipt(FrappeTestCase): ste7.reload() self.assertEqual(ste7.items[0].valuation_rate, valuation_rate) + def test_purchase_receipt_provisional_accounting(self): + # Step - 1: Create Supplier with Default Currency as USD + from erpnext.buying.doctype.supplier.test_supplier import create_supplier + + supplier = create_supplier(default_currency="USD") + + # Step - 2: Setup Company for Provisional Accounting + from erpnext.accounts.doctype.account.test_account import create_account + + provisional_account = create_account( + account_name="Provision Account", + parent_account="Current Liabilities - _TC", + company="_Test Company", + ) + company = frappe.get_doc("Company", "_Test Company") + company.enable_provisional_accounting_for_non_stock_items = 1 + company.default_provisional_account = provisional_account + company.save() + + # Step - 3: Create Non-Stock Item + item = make_item(properties={"is_stock_item": 0}) + + # Step - 4: Create Purchase Receipt + pr = make_purchase_receipt( + qty=2, + item_code=item.name, + company=company.name, + supplier=supplier.name, + currency=supplier.default_currency, + ) + + # Test - 1: Total and Base Total should not be the same as the currency is different + self.assertNotEqual(flt(pr.total, 2), flt(pr.base_total, 2)) + self.assertEqual(flt(pr.total * pr.conversion_rate, 2), flt(pr.base_total, 2)) + + # Test - 2: Sum of Debit or Credit should be equal to Purchase Receipt Base Total + amount = frappe.db.get_value("GL Entry", {"docstatus": 1, "voucher_no": pr.name}, ["sum(debit)"]) + expected_amount = pr.base_total + self.assertEqual(amount, expected_amount) + + company.enable_provisional_accounting_for_non_stock_items = 0 + company.save() + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier