Merge branch 'develop' into version-15-beta

This commit is contained in:
Ankush Menat
2023-09-15 19:16:09 +05:30
70 changed files with 1317 additions and 2031 deletions

View File

@@ -68,6 +68,6 @@ if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
wait $wkpid wait $wkpid
bench start &> bench_run_logs.txt & bench start &>> ~/frappe-bench/bench_start.log &
CI=Yes bench build --app frappe & CI=Yes bench build --app frappe &
bench --site test_site reinstall --yes bench --site test_site reinstall --yes

View File

@@ -23,7 +23,7 @@ jobs:
services: services:
mysql: mysql:
image: mariadb:10.3 image: mariadb:10.6
env: env:
MARIADB_ROOT_PASSWORD: 'root' MARIADB_ROOT_PASSWORD: 'root'
ports: ports:
@@ -45,9 +45,7 @@ jobs:
- name: Setup Python - name: Setup Python
uses: "actions/setup-python@v4" uses: "actions/setup-python@v4"
with: with:
python-version: | python-version: '3.10'
3.7
3.10
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v2 uses: actions/setup-node@v2
@@ -102,40 +100,60 @@ jobs:
- name: Run Patch Tests - name: Run Patch Tests
run: | run: |
cd ~/frappe-bench/ cd ~/frappe-bench/
wget https://erpnext.com/files/v10-erpnext.sql.gz bench remove-app payments --force
bench --site test_site --force restore ~/frappe-bench/v10-erpnext.sql.gz 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/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 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 function update_to_version() {
git -C "apps/erpnext" fetch --depth 1 upstream $branch_name:$branch_name version=$1
git -C "apps/frappe" checkout -q -f $branch_name branch_name="version-$version-hotfix"
git -C "apps/erpnext" checkout -q -f $branch_name echo "Updating to v$version"
rm -rf ~/frappe-bench/env # Fetch and checkout branches
bench setup env --python python3.7 git -C "apps/frappe" fetch --depth 1 upstream $branch_name:$branch_name
bench pip install -e ./apps/payments git -C "apps/erpnext" fetch --depth 1 upstream $branch_name:$branch_name
bench pip install -e ./apps/erpnext git -C "apps/frappe" checkout -q -f $branch_name
git -C "apps/erpnext" checkout -q -f $branch_name
bench --site test_site migrate # Resetup env and install apps
done 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" echo "Updating to latest version"
git -C "apps/frappe" checkout -q -f "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}" git -C "apps/frappe" checkout -q -f "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"
git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA" git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA"
pgrep honcho | xargs kill
rm -rf ~/frappe-bench/env rm -rf ~/frappe-bench/env
bench -v setup env --python python3.10 bench -v setup env
bench pip install -e ./apps/payments
bench pip install -e ./apps/erpnext bench pip install -e ./apps/erpnext
bench start &>> ~/frappe-bench/bench_start.log &
bench --site test_site migrate 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

View File

@@ -123,6 +123,10 @@ jobs:
CI_BUILD_ID: ${{ github.run_id }} CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io 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 - name: Upload coverage data
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:

View File

@@ -50,6 +50,8 @@ frappe.ui.form.on("Journal Entry", {
frm.trigger("make_inter_company_journal_entry"); frm.trigger("make_inter_company_journal_entry");
}, __('Make')); }, __('Make'));
} }
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm);
}, },
make_inter_company_journal_entry: function(frm) { make_inter_company_journal_entry: function(frm) {

View File

@@ -9,7 +9,7 @@ erpnext.accounts.taxes.setup_tax_filters("Advance Taxes and Charges");
frappe.ui.form.on('Payment Entry', { frappe.ui.form.on('Payment Entry', {
onload: function(frm) { onload: function(frm) {
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting Ledger']; 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.__islocal) {
if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null); if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null);
@@ -154,6 +154,7 @@ frappe.ui.form.on('Payment Entry', {
frm.events.set_dynamic_labels(frm); frm.events.set_dynamic_labels(frm);
frm.events.show_general_ledger(frm); frm.events.show_general_ledger(frm);
erpnext.accounts.ledger_preview.show_accounting_ledger_preview(frm); erpnext.accounts.ledger_preview.show_accounting_ledger_preview(frm);
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm);
}, },
validate_company: (frm) => { validate_company: (frm) => {

View File

@@ -98,7 +98,6 @@ class PaymentEntry(AccountsController):
if self.difference_amount: if self.difference_amount:
frappe.throw(_("Difference Amount must be zero")) frappe.throw(_("Difference Amount must be zero"))
self.make_gl_entries() self.make_gl_entries()
self.make_advance_gl_entries()
self.update_outstanding_amounts() self.update_outstanding_amounts()
self.update_advance_paid() self.update_advance_paid()
self.update_payment_schedule() self.update_payment_schedule()
@@ -149,10 +148,11 @@ class PaymentEntry(AccountsController):
"Repost Payment Ledger Items", "Repost Payment Ledger Items",
"Repost Accounting Ledger", "Repost Accounting Ledger",
"Repost Accounting Ledger Items", "Repost Accounting Ledger Items",
"Unreconcile Payments",
"Unreconcile Payment Entries",
) )
super(PaymentEntry, self).on_cancel() super(PaymentEntry, self).on_cancel()
self.make_gl_entries(cancel=1) self.make_gl_entries(cancel=1)
self.make_advance_gl_entries(cancel=1)
self.update_outstanding_amounts() self.update_outstanding_amounts()
self.update_advance_paid() self.update_advance_paid()
self.delink_advance_entry_references() self.delink_advance_entry_references()
@@ -1060,6 +1060,8 @@ class PaymentEntry(AccountsController):
else: else:
self.make_exchange_gain_loss_journal() self.make_exchange_gain_loss_journal()
self.make_advance_gl_entries(cancel=cancel)
def add_party_gl_entries(self, gl_entries): def add_party_gl_entries(self, gl_entries):
if self.party_account: if self.party_account:
if self.payment_type == "Receive": if self.payment_type == "Receive":
@@ -1128,7 +1130,7 @@ class PaymentEntry(AccountsController):
if self.book_advance_payments_in_separate_party_account: if self.book_advance_payments_in_separate_party_account:
gl_entries = [] gl_entries = []
for d in self.get("references"): 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 ( if not (against_voucher_type and against_voucher) or (
d.reference_doctype == against_voucher_type and d.reference_name == against_voucher d.reference_doctype == against_voucher_type and d.reference_name == against_voucher
): ):
@@ -1164,6 +1166,13 @@ class PaymentEntry(AccountsController):
"voucher_detail_no": invoice.name, "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" dr_or_cr = "credit" if invoice.reference_doctype == "Sales Invoice" else "debit"
args_dict["account"] = invoice.account args_dict["account"] = invoice.account
args_dict[dr_or_cr] = invoice.allocated_amount args_dict[dr_or_cr] = invoice.allocated_amount
@@ -1172,6 +1181,7 @@ class PaymentEntry(AccountsController):
{ {
"against_voucher_type": invoice.reference_doctype, "against_voucher_type": invoice.reference_doctype,
"against_voucher": invoice.reference_name, "against_voucher": invoice.reference_name,
"posting_date": posting_date,
} }
) )
gle = self.get_gl_dict( gle = self.get_gl_dict(

View File

@@ -24,7 +24,8 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
filters: { filters: {
"company": this.frm.doc.company, "company": this.frm.doc.company,
"is_group": 0, "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"
} }
}; };
}); });

View File

@@ -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); 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() { unblock_invoice() {

View File

@@ -37,7 +37,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
super.onload(); super.onload();
this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log', this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log',
'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger", "Repost Accounting Ledger"]; '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) { if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) {
// show debit_to in print format // show debit_to in print format
@@ -183,8 +183,11 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
}, __('Create')); }, __('Create'));
} }
} }
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(me.frm);
} }
make_maintenance_schedule() { make_maintenance_schedule() {
frappe.model.open_mapped_doc({ frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_maintenance_schedule", method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_maintenance_schedule",

View File

@@ -388,6 +388,8 @@ class SalesInvoice(SellingController):
"Repost Payment Ledger Items", "Repost Payment Ledger Items",
"Repost Accounting Ledger", "Repost Accounting Ledger",
"Repost Accounting Ledger Items", "Repost Accounting Ledger Items",
"Unreconcile Payments",
"Unreconcile Payment Entries",
"Payment Ledger Entry", "Payment Ledger Entry",
"Serial and Batch Bundle", "Serial and Batch Bundle",
) )

View File

@@ -15,9 +15,11 @@ def get_data():
}, },
"internal_links": { "internal_links": {
"Sales Order": ["items", "sales_order"], "Sales Order": ["items", "sales_order"],
"Delivery Note": ["items", "delivery_note"],
"Timesheet": ["timesheets", "time_sheet"], "Timesheet": ["timesheets", "time_sheet"],
}, },
"internal_and_external_links": {
"Delivery Note": ["items", "delivery_note"],
},
"transactions": [ "transactions": [
{ {
"label": _("Payment"), "label": _("Payment"),

View File

@@ -57,18 +57,17 @@ def get_plan_rate(
prorate = frappe.db.get_single_value("Subscription Settings", "prorate") prorate = frappe.db.get_single_value("Subscription Settings", "prorate")
if prorate: if prorate:
prorate_factor = flt( cost -= plan.cost * get_prorate_factor(start_date, end_date)
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
return cost 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

View File

@@ -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": []
}

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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();
}
}
})
}
});

View File

@@ -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
}

View File

@@ -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()

View File

@@ -434,6 +434,7 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map):
for gle in gl_entries: for gle in gl_entries:
group_by_value = gle.get(group_by) 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 gle.posting_date < from_date or (cstr(gle.is_opening) == "Yes" and not show_opening_entries):
if not group_by_voucher_consolidated: if not group_by_voucher_consolidated:

View File

@@ -491,14 +491,13 @@ def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # n
gl_map = doc.build_gl_map() gl_map = doc.build_gl_map()
create_payment_ledger_entry(gl_map, update_outstanding="No", cancel=0, adv_adj=1) 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 # Only update outstanding for newly linked vouchers
for entry in entries: for entry in entries:
update_voucher_outstanding( update_voucher_outstanding(
entry.against_voucher_type, entry.against_voucher, entry.account, entry.party_type, entry.party 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 frappe.flags.ignore_party_validation = False
@@ -675,7 +674,9 @@ def update_reference_in_payment_entry(
payment_entry.save(ignore_permissions=True) 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. 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, as_list=1,
) )
for doc in gain_loss_journals: 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): def update_accounting_ledgers_after_reference_removal(
remove_ref_doc_link_from_jv(ref_doc.doctype, ref_doc.name) ref_type: str = None, ref_no: str = None, payment_name: str = None
remove_ref_doc_link_from_pe(ref_doc.doctype, ref_doc.name) ):
# General Ledger
frappe.db.sql( gle = qb.DocType("GL Entry")
"""update `tabGL Entry` gle_update_query = (
set against_voucher_type=null, against_voucher=null, qb.update(gle)
modified=%s, modified_by=%s .set(gle.against_voucher_type, None)
where against_voucher_type=%s and against_voucher=%s .set(gle.against_voucher, None)
and voucher_no != ifnull(against_voucher, '')""", .set(gle.modified, now())
(now(), frappe.session.user, ref_doc.doctype, ref_doc.name), .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 = 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( if payment_name:
ple.against_voucher_no, ple.voucher_no ple_update_query = ple_update_query.where(ple.voucher_no == payment_name)
).set(ple.modified, now()).set(ple.modified_by, frappe.session.user).where( ple_update_query.run()
(ple.against_voucher_type == ref_doc.doctype)
& (ple.against_voucher_no == ref_doc.name)
& (ple.delinked == 0)
).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"): if ref_doc.doctype in ("Sales Invoice", "Purchase Invoice"):
ref_doc.set("advances", []) ref_doc.set("advances", [])
adv_type = qb.DocType(f"{ref_doc.doctype} Advance")
frappe.db.sql( qb.from_(adv_type).delete().where(adv_type.parent == ref_doc.name).run()
"""delete from `tab{0} Advance` where parent = %s""".format(ref_doc.doctype), ref_doc.name
)
def remove_ref_doc_link_from_jv(ref_type, ref_no): def unlink_ref_doc_from_payment_entries(ref_doc: object = None, payment_name: str = None):
linked_jv = frappe.db.sql_list( remove_ref_doc_link_from_jv(ref_doc.doctype, ref_doc.name, payment_name)
"""select parent from `tabJournal Entry Account` remove_ref_doc_link_from_pe(ref_doc.doctype, ref_doc.name, payment_name)
where reference_type=%s and reference_name=%s and docstatus < 2""", update_accounting_ledgers_after_reference_removal(ref_doc.doctype, ref_doc.name, payment_name)
(ref_type, ref_no), 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: if linked_jv:
frappe.db.sql( update_query = (
"""update `tabJournal Entry Account` qb.update(jea)
set reference_type=null, reference_name = null, .set(jea.reference_type, None)
modified=%s, modified_by=%s .set(jea.reference_name, None)
where reference_type=%s and reference_name=%s .set(jea.modified, now())
and docstatus < 2""", .set(jea.modified_by, frappe.session.user)
(now(), frappe.session.user, ref_type, ref_no), .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))) frappe.msgprint(_("Journal Entries {0} are un-linked").format("\n".join(linked_jv)))
def remove_ref_doc_link_from_pe(ref_type, ref_no): def convert_to_list(result):
linked_pe = frappe.db.sql_list( """
"""select parent from `tabPayment Entry Reference` Convert tuple to list
where reference_doctype=%s and reference_name=%s and docstatus < 2""", """
(ref_type, ref_no), 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: if linked_pe:
frappe.db.sql( update_query = (
"""update `tabPayment Entry Reference` qb.update(per)
set allocated_amount=0, modified=%s, modified_by=%s .set(per.allocated_amount, 0)
where reference_doctype=%s and reference_name=%s .set(per.modified, now())
and docstatus < 2""", .set(per.modified_by, frappe.session.user)
(now(), frappe.session.user, ref_type, ref_no), .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: for pe in linked_pe:
try: try:
pe_doc = frappe.get_doc("Payment Entry", pe) 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") msg += _("Please cancel payment entry manually first")
frappe.throw(msg, exc=PaymentEntryUnlinkError, title=_("Payment Unlink Error")) frappe.throw(msg, exc=PaymentEntryUnlinkError, title=_("Payment Unlink Error"))
frappe.db.sql( qb.update(pay).set(pay.total_allocated_amount, pe_doc.total_allocated_amount).set(
"""update `tabPayment Entry` set total_allocated_amount=%s, pay.base_total_allocated_amount, pe_doc.base_total_allocated_amount
base_total_allocated_amount=%s, unallocated_amount=%s, modified=%s, modified_by=%s ).set(pay.unallocated_amount, pe_doc.unallocated_amount).set(pay.modified, now()).set(
where name=%s""", pay.modified_by, frappe.session.user
( ).where(
pe_doc.total_allocated_amount, pay.name == pe
pe_doc.base_total_allocated_amount, ).run()
pe_doc.unallocated_amount,
now(),
frappe.session.user,
pe,
),
)
frappe.msgprint(_("Payment Entries {0} are un-linked").format("\n".join(linked_pe))) 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): def parse_naming_series_variable(doc, variable):
if variable == "FY": if variable == "FY":
date = doc.get("posting_date") or doc.get("transaction_date") or getdate() if doc:
return get_fiscal_year(date=date, company=doc.get("company"))[0] 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() @frappe.whitelist()

View File

@@ -1173,6 +1173,7 @@
"depends_on": "is_internal_supplier", "depends_on": "is_internal_supplier",
"fieldname": "set_from_warehouse", "fieldname": "set_from_warehouse",
"fieldtype": "Link", "fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Set From Warehouse", "label": "Set From Warehouse",
"options": "Warehouse" "options": "Warehouse"
}, },
@@ -1273,7 +1274,7 @@
"idx": 105, "idx": 105,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2023-06-03 16:19:45.710444", "modified": "2023-09-13 16:21:07.361700",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Purchase Order", "name": "Purchase Order",

View File

@@ -878,6 +878,7 @@
"depends_on": "eval:parent.is_internal_supplier", "depends_on": "eval:parent.is_internal_supplier",
"fieldname": "from_warehouse", "fieldname": "from_warehouse",
"fieldtype": "Link", "fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "From Warehouse", "label": "From Warehouse",
"options": "Warehouse" "options": "Warehouse"
}, },
@@ -902,7 +903,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-08-17 10:17:40.893393", "modified": "2023-09-13 16:22:40.825092",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Purchase Order Item", "name": "Purchase Order Item",

View File

@@ -12,6 +12,7 @@ frappe.ui.form.on("Supplier", {
return { return {
filters: { filters: {
'account_type': 'Payable', 'account_type': 'Payable',
'root_type': 'Liability',
'company': d.company, 'company': d.company,
"is_group": 0 "is_group": 0
} }

View File

@@ -206,6 +206,7 @@ def create_supplier(**args):
{ {
"doctype": "Supplier", "doctype": "Supplier",
"supplier_name": args.supplier_name, "supplier_name": args.supplier_name,
"default_currency": args.default_currency,
"supplier_group": args.supplier_group or "Services", "supplier_group": args.supplier_group or "Services",
"supplier_type": args.supplier_type or "Company", "supplier_type": args.supplier_type or "Company",
"tax_withholding_category": args.tax_withholding_category, "tax_withholding_category": args.tax_withholding_category,

View File

@@ -7,7 +7,7 @@ import copy
import frappe import frappe
from frappe import _ from frappe import _
from frappe.query_builder.functions import Coalesce, Sum 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): def execute(filters=None):
@@ -47,8 +47,10 @@ def get_data(filters):
mr.transaction_date.as_("date"), mr.transaction_date.as_("date"),
mr_item.schedule_date.as_("required_date"), mr_item.schedule_date.as_("required_date"),
mr_item.item_code.as_("item_code"), mr_item.item_code.as_("item_code"),
Sum(Coalesce(mr_item.stock_qty, 0)).as_("qty"), Sum(Coalesce(mr_item.qty, 0)).as_("qty"),
Coalesce(mr_item.stock_uom, "").as_("uom"), 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.ordered_qty, 0)).as_("ordered_qty"),
Sum(Coalesce(mr_item.received_qty, 0)).as_("received_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_( (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): 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: for field in fields:
row_to_update[field] += flt(data_row[field]) 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): def prepare_data(data, filters):
"""Prepare consolidated Report data and Chart data""" """Prepare consolidated Report data and Chart data"""
material_request_map, item_qty_map = {}, {} material_request_map, item_qty_map = {}, {}
precision = cint(frappe.db.get_default("float_precision")) or 2
for row in data: for row in data:
# item wise map for charts # item wise map for charts
if not row["item_code"] in item_qty_map: if not row["item_code"] in item_qty_map:
item_qty_map[row["item_code"]] = { item_qty_map[row["item_code"]] = {
"qty": row["qty"], "qty": flt(row["stock_qty"], precision),
"ordered_qty": row["ordered_qty"], "stock_qty": flt(row["stock_qty"], precision),
"received_qty": row["received_qty"], "stock_uom": row["stock_uom"],
"qty_to_receive": row["qty_to_receive"], "uom": row["uom"],
"qty_to_order": row["qty_to_order"], "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: else:
item_entry = item_qty_map[row["item_code"]] 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": _("Item Name"), "fieldname": "item_name", "fieldtype": "Data", "width": 100},
{"label": _("Description"), "fieldname": "description", "fieldtype": "Data", "width": 200}, {"label": _("Description"), "fieldname": "description", "fieldtype": "Data", "width": 200},
{ {
"label": _("Stock UOM"), "label": _("UOM"),
"fieldname": "uom", "fieldname": "uom",
"fieldtype": "Data", "fieldtype": "Data",
"width": 100, "width": 100,
}, },
{
"label": _("Stock UOM"),
"fieldname": "stock_uom",
"fieldtype": "Data",
"width": 100,
},
] ]
) )
columns.extend( columns.extend(
[ [
{ {
"label": _("Stock Qty"), "label": _("Qty"),
"fieldname": "qty", "fieldname": "qty",
"fieldtype": "Float", "fieldtype": "Float",
"width": 120, "width": 140,
"convertible": "qty",
},
{
"label": _("Qty in Stock UOM"),
"fieldname": "stock_qty",
"fieldtype": "Float",
"width": 140,
"convertible": "qty", "convertible": "qty",
}, },
{ {

View File

@@ -211,6 +211,37 @@ class AccountsController(TransactionBase):
def before_cancel(self): def before_cancel(self):
validate_einvoice_fields(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): def on_trash(self):
# delete references in 'Repost Payment Ledger' # delete references in 'Repost Payment Ledger'
rpi = frappe.qb.DocType("Repost Payment Ledger Items") 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) (rpi.voucher_type == self.doctype) & (rpi.voucher_no == self.name)
).run() ).run()
self._remove_references_in_unreconcile()
# delete sl and gl entries on deletion of transaction # delete sl and gl entries on deletion of transaction
if frappe.db.get_single_value("Accounts Settings", "delete_linked_ledger_entries"): if frappe.db.get_single_value("Accounts Settings", "delete_linked_ledger_entries"):
ple = frappe.qb.DocType("Payment Ledger Entry") ple = frappe.qb.DocType("Payment Ledger Entry")

View File

@@ -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}.", [`<a target='_blank' href='https://docs.erpnext.com/docs/user/manual/en/CRM/linkedin-settings'>${__('click here')}</a>`]));
},
refresh: function(frm) {
if (frm.doc.session_status=="Expired"){
let msg = __("Session not active. Save document to login.");
frm.dashboard.set_headline_alert(
`<div class="row">
<div class="col-xs-12">
<span class="indicator whitespace-nowrap red"><span class="hidden-xs">${msg}</span></span>
</div>
</div>`
);
}
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(
`<div class="row">
<div class="col-xs-12">
<span class="indicator whitespace-nowrap ${color}"><span class="hidden-xs">${msg}</span></span>
</div>
</div>`
);
}
},
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");
}
});

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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

View File

@@ -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 += `<div class="col-xs-6">
<span class="indicator whitespace-nowrap ${color}"><span>Twitter : ${status} </span></span>
</div>` ;
}
if (frm.doc.linkedin) {
let color = frm.doc.linkedin_post_id ? "green" : "red";
let status = frm.doc.linkedin_post_id ? "Posted" : "Not Posted";
html += `<div class="col-xs-6">
<span class="indicator whitespace-nowrap ${color}"><span>LinkedIn : ${status} </span></span>
</div>` ;
}
html = `<div class="row">${html}</div>`;
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();
}
});
});
}
});

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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]];
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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}.", [`<a target='_blank' href='https://docs.erpnext.com/docs/user/manual/en/CRM/twitter-settings'>${__('click here')}</a>`]));
},
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(
`<div class="row">
<div class="col-xs-12">
<span class="indicator whitespace-nowrap ${color}"><span class="hidden-xs">${msg}</span></span>
</div>
</div>`
);
}
},
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");
}
});

View File

@@ -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
}

View File

@@ -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")

View File

@@ -122,131 +122,6 @@
"onboard": 0, "onboard": 0,
"type": "Link" "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, "hidden": 0,
"is_query_report": 0, "is_query_report": 0,
@@ -450,9 +325,101 @@
"link_type": "DocType", "link_type": "DocType",
"onboard": 0, "onboard": 0,
"type": "Link" "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", "modified_by": "Administrator",
"module": "CRM", "module": "CRM",
"name": "CRM", "name": "CRM",
@@ -463,7 +430,7 @@
"quick_lists": [], "quick_lists": [],
"restrict_to_domain": "", "restrict_to_domain": "",
"roles": [], "roles": [],
"sequence_id": 10.0, "sequence_id": 17.0,
"shortcuts": [ "shortcuts": [
{ {
"color": "Blue", "color": "Blue",

View File

@@ -631,7 +631,6 @@ def get_applicable_shipping_rules(party=None, quotation=None):
shipping_rules = get_shipping_rules(quotation) shipping_rules = get_shipping_rules(quotation)
if shipping_rules: 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 # 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] return [[rule, rule] for rule in shipping_rules]

View File

@@ -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,
},
)

View File

@@ -1,8 +0,0 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import unittest
class TestWoocommerceSettings(unittest.TestCase):
pass

View File

@@ -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);
});
}
});
});

View File

@@ -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
}

View File

@@ -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-",
}

View File

@@ -423,9 +423,6 @@ scheduler_events = {
"erpnext.stock.reorder_item.reorder_item", "erpnext.stock.reorder_item.reorder_item",
], ],
}, },
"all": [
"erpnext.crm.doctype.social_media_post.social_media_post.process_scheduled_social_media_posts",
],
"hourly": [ "hourly": [
"erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization", "erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization",
"erpnext.projects.doctype.project.project.project_status_update_reminder", "erpnext.projects.doctype.project.project.project_status_update_reminder",

View File

@@ -191,7 +191,7 @@ class TestBOMCreator(FrappeTestCase):
) )
doc = make_bom_creator( doc = make_bom_creator(
name="Bicycle BOM", name="Bicycle BOM Test",
company="_Test Company", company="_Test Company",
item_code=final_product, item_code=final_product,
qty=1, qty=1,

View File

@@ -340,5 +340,6 @@ erpnext.patches.v15_0.remove_exotel_integration
erpnext.patches.v14_0.single_to_multi_dunning erpnext.patches.v14_0.single_to_multi_dunning
execute:frappe.db.set_single_value('Selling Settings', 'allow_negative_rates_for_items', 0) 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.correct_asset_value_if_je_with_workflow
erpnext.patches.v15_0.delete_woocommerce_settings_doctype
# below migration patch should always run last # below migration patch should always run last
erpnext.patches.v14_0.migrate_gl_to_payment_ledger erpnext.patches.v14_0.migrate_gl_to_payment_ledger

View File

@@ -0,0 +1,5 @@
import frappe
def execute():
frappe.delete_doc("DocType", "Woocommerce Settings", ignore_missing=True)

View File

@@ -16,7 +16,8 @@ import "./utils/customer_quick_entry";
import "./utils/supplier_quick_entry"; import "./utils/supplier_quick_entry";
import "./call_popup/call_popup"; import "./call_popup/call_popup";
import "./utils/dimension_tree_filter"; import "./utils/dimension_tree_filter";
import "./utils/ledger_preview.js" import "./utils/ledger_preview.js";
import "./utils/unreconcile.js";
import "./utils/barcode_scanner"; import "./utils/barcode_scanner";
import "./telephony"; import "./telephony";
import "./templates/call_link.html"; import "./templates/call_link.html";

View File

@@ -769,6 +769,9 @@ erpnext.utils.update_child_items = function(opts) {
dialog.show(); dialog.show();
} }
erpnext.utils.map_current_doc = function(opts) { erpnext.utils.map_current_doc = function(opts) {
function _map() { function _map() {
if($.isArray(cur_frm.doc.items) && cur_frm.doc.items.length > 0) { if($.isArray(cur_frm.doc.items) && cur_frm.doc.items.length > 0) {

View File

@@ -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
},
});
}
}

View File

@@ -23,6 +23,7 @@ frappe.ui.form.on("Customer", {
let d = locals[cdt][cdn]; let d = locals[cdt][cdn];
let filters = { let filters = {
'account_type': 'Receivable', 'account_type': 'Receivable',
'root_type': 'Asset',
'company': d.company, 'company': d.company,
"is_group": 0 "is_group": 0
}; };

View File

@@ -10,7 +10,7 @@ from erpnext.utilities.transaction_base import TransactionBase
class AuthorizationControl(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 = [], [], [] amt_list, appr_users, appr_roles = [], [], []
users, roles = "", "" users, roles = "", ""
if det: if det:
@@ -47,11 +47,11 @@ class AuthorizationControl(TransactionBase):
frappe.msgprint(_("Not authroized since {0} exceeds limits").format(_(based_on))) 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))) 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 chk = 1
add_cond1, add_cond2 = "", "" add_cond1, add_cond2 = "", ""
if based_on == "Itemwise Discount": if based_on in ["Itemwise Discount", "Item Group wise Discount"]:
add_cond1 += " and master_name = " + frappe.db.escape(cstr(item)) add_cond1 += " and master_name = " + frappe.db.escape(cstr(master_name))
itemwise_exists = frappe.db.sql( itemwise_exists = frappe.db.sql(
"""select value from `tabAuthorization Rule` """select value from `tabAuthorization Rule`
where transaction = %s and value <= %s where transaction = %s and value <= %s
@@ -71,11 +71,11 @@ class AuthorizationControl(TransactionBase):
if itemwise_exists: if itemwise_exists:
self.get_appr_user_role( 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 chk = 0
if chk == 1: if chk == 1:
if based_on == "Itemwise Discount": if based_on in ["Itemwise Discount", "Item Group wise Discount"]:
add_cond2 += " and ifnull(master_name,'') = ''" add_cond2 += " and ifnull(master_name,'') = ''"
appr = frappe.db.sql( appr = frappe.db.sql(
@@ -95,7 +95,9 @@ class AuthorizationControl(TransactionBase):
(doctype_name, total, based_on), (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): def bifurcate_based_on_type(self, doctype_name, total, av_dis, based_on, doc_obj, val, company):
add_cond = "" add_cond = ""
@@ -123,6 +125,12 @@ class AuthorizationControl(TransactionBase):
self.validate_auth_rule( self.validate_auth_rule(
doctype_name, t.discount_percentage, based_on, add_cond, company, t.item_code 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: else:
self.validate_auth_rule(doctype_name, auth_value, based_on, add_cond, company) self.validate_auth_rule(doctype_name, auth_value, based_on, add_cond, company)
@@ -148,6 +156,7 @@ class AuthorizationControl(TransactionBase):
"Average Discount", "Average Discount",
"Customerwise Discount", "Customerwise Discount",
"Itemwise Discount", "Itemwise Discount",
"Item Group wise Discount",
] ]
# Check for authorization set for individual user # Check for authorization set for individual user
@@ -166,7 +175,7 @@ class AuthorizationControl(TransactionBase):
# Remove user specific rules from global authorization rules # Remove user specific rules from global authorization rules
for r in based_on: 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) final_based_on.remove(r)
# Check for authorization set on particular roles # Check for authorization set on particular roles
@@ -194,7 +203,7 @@ class AuthorizationControl(TransactionBase):
# Remove role specific rules from global authorization rules # Remove role specific rules from global authorization rules
for r in based_on: 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) final_based_on.remove(r)
# Check for global authorization # Check for global authorization

View File

@@ -12,6 +12,9 @@ frappe.ui.form.on("Authorization Rule", {
} else if(frm.doc.based_on==="Itemwise Discount") { } else if(frm.doc.based_on==="Itemwise Discount") {
unhide_field("master_name"); unhide_field("master_name");
frm.set_value("customer_or_item", "Item"); 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 { } else {
frm.set_value("customer_or_item", ""); frm.set_value("customer_or_item", "");
frm.set_value("master_name", ""); frm.set_value("master_name", "");
@@ -81,6 +84,13 @@ cur_frm.fields_dict['master_name'].get_query = function(doc) {
doctype: "Item", doctype: "Item",
query: "erpnext.controllers.queries.item_query" query: "erpnext.controllers.queries.item_query"
} }
else if (doc.based_on==="Item Group wise Discount")
return {
doctype: "Item Group",
filters: {
"is_group": 0
}
}
else else
return { return {
filters: [ filters: [

View File

@@ -46,7 +46,7 @@
"label": "Based On", "label": "Based On",
"oldfieldname": "based_on", "oldfieldname": "based_on",
"oldfieldtype": "Select", "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 "reqd": 1
}, },
{ {
@@ -54,14 +54,14 @@
"fieldtype": "Select", "fieldtype": "Select",
"hidden": 1, "hidden": 1,
"label": "Customer or Item", "label": "Customer or Item",
"options": "Customer\nItem", "options": "Customer\nItem\nItem Group",
"read_only": 1 "read_only": 1
}, },
{ {
"fieldname": "master_name", "fieldname": "master_name",
"fieldtype": "Dynamic Link", "fieldtype": "Dynamic Link",
"in_list_view": 1, "in_list_view": 1,
"label": "Customer / Item Name", "label": "Customer / Item / Item Group",
"oldfieldname": "master_name", "oldfieldname": "master_name",
"oldfieldtype": "Link", "oldfieldtype": "Link",
"options": "customer_or_item" "options": "customer_or_item"
@@ -162,7 +162,7 @@
"icon": "fa fa-shield", "icon": "fa fa-shield",
"idx": 1, "idx": 1,
"links": [], "links": [],
"modified": "2022-07-01 11:19:45.643991", "modified": "2023-09-11 10:29:02.863193",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Setup", "module": "Setup",
"name": "Authorization Rule", "name": "Authorization Rule",

View File

@@ -47,6 +47,7 @@ class AuthorizationRule(Document):
"Average Discount", "Average Discount",
"Customerwise Discount", "Customerwise Discount",
"Itemwise Discount", "Itemwise Discount",
"Item Group wise Discount",
]: ]:
frappe.throw( frappe.throw(
_("Cannot set authorization on basis of Discount for {0}").format(self.transaction) _("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")) frappe.throw(_("Discount must be less than 100"))
elif self.based_on == "Customerwise Discount" and not self.master_name: elif self.based_on == "Customerwise Discount" and not self.master_name:
frappe.throw(_("Customer required for 'Customerwise Discount'")) frappe.throw(_("Customer required for 'Customerwise Discount'"))
else:
self.based_on = "Not Applicable"
def validate(self): def validate(self):
self.check_duplicate_entry() self.check_duplicate_entry()

View File

@@ -200,8 +200,8 @@ erpnext.company.setup_queries = function(frm) {
$.each([ $.each([
["default_bank_account", {"account_type": "Bank"}], ["default_bank_account", {"account_type": "Bank"}],
["default_cash_account", {"account_type": "Cash"}], ["default_cash_account", {"account_type": "Cash"}],
["default_receivable_account", {"account_type": "Receivable"}], ["default_receivable_account", { "root_type": "Asset", "account_type": "Receivable" }],
["default_payable_account", {"account_type": "Payable"}], ["default_payable_account", { "root_type": "Liability", "account_type": "Payable" }],
["default_expense_account", {"root_type": "Expense"}], ["default_expense_account", {"root_type": "Expense"}],
["default_income_account", {"root_type": "Income"}], ["default_income_account", {"root_type": "Income"}],
["round_off_account", {"root_type": "Expense"}], ["round_off_account", {"root_type": "Expense"}],

View File

@@ -30,6 +30,7 @@ frappe.ui.form.on("Customer Group", {
frm.set_query('account', 'accounts', function (doc, cdt, cdn) { frm.set_query('account', 'accounts', function (doc, cdt, cdn) {
return { return {
filters: { filters: {
'root_type': 'Asset',
"account_type": 'Receivable', "account_type": 'Receivable',
"company": locals[cdt][cdn].company, "company": locals[cdt][cdn].company,
"is_group": 0 "is_group": 0

View File

@@ -30,6 +30,7 @@ frappe.ui.form.on("Supplier Group", {
frm.set_query('account', 'accounts', function (doc, cdt, cdn) { frm.set_query('account', 'accounts', function (doc, cdt, cdn) {
return { return {
filters: { filters: {
'root_type': 'Liability',
'account_type': 'Payable', 'account_type': 'Payable',
'company': locals[cdt][cdn].company, 'company': locals[cdt][cdn].company,
"is_group": 0 "is_group": 0

View File

@@ -1253,6 +1253,7 @@
"depends_on": "eval: doc.is_internal_customer", "depends_on": "eval: doc.is_internal_customer",
"fieldname": "set_target_warehouse", "fieldname": "set_target_warehouse",
"fieldtype": "Link", "fieldtype": "Link",
"ignore_user_permissions": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Set Target Warehouse", "label": "Set Target Warehouse",
"no_copy": 1, "no_copy": 1,
@@ -1400,7 +1401,7 @@
"idx": 146, "idx": 146,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2023-06-16 14:58:55.066602", "modified": "2023-09-04 14:15:28.363184",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Delivery Note", "name": "Delivery Note",

View File

@@ -11,10 +11,12 @@ def get_data():
}, },
"internal_links": { "internal_links": {
"Sales Order": ["items", "against_sales_order"], "Sales Order": ["items", "against_sales_order"],
"Sales Invoice": ["items", "against_sales_invoice"],
"Material Request": ["items", "material_request"], "Material Request": ["items", "material_request"],
"Purchase Order": ["items", "purchase_order"], "Purchase Order": ["items", "purchase_order"],
}, },
"internal_and_external_links": {
"Sales Invoice": ["items", "against_sales_invoice"],
},
"transactions": [ "transactions": [
{"label": _("Related"), "items": ["Sales Invoice", "Packing Slip", "Delivery Trip"]}, {"label": _("Related"), "items": ["Sales Invoice", "Packing Slip", "Delivery Trip"]},
{"label": _("Reference"), "items": ["Sales Order", "Shipment", "Quality Inspection"]}, {"label": _("Reference"), "items": ["Sales Order", "Shipment", "Quality Inspection"]},

View File

@@ -603,7 +603,7 @@ class PurchaseReceipt(BuyingController):
account=provisional_account, account=provisional_account,
cost_center=item.cost_center, cost_center=item.cost_center,
debit=0.0, debit=0.0,
credit=multiplication_factor * item.amount, credit=multiplication_factor * item.base_amount,
remarks=remarks, remarks=remarks,
against_account=expense_account, against_account=expense_account,
account_currency=credit_currency, account_currency=credit_currency,
@@ -617,7 +617,7 @@ class PurchaseReceipt(BuyingController):
gl_entries=gl_entries, gl_entries=gl_entries,
account=expense_account, account=expense_account,
cost_center=item.cost_center, cost_center=item.cost_center,
debit=multiplication_factor * item.amount, debit=multiplication_factor * item.base_amount,
credit=0.0, credit=0.0,
remarks=remarks, remarks=remarks,
against_account=provisional_account, against_account=provisional_account,

View File

@@ -2017,6 +2017,49 @@ class TestPurchaseReceipt(FrappeTestCase):
ste7.reload() ste7.reload()
self.assertEqual(ste7.items[0].valuation_rate, valuation_rate) 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(): def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier