From d0823adf42fc625dc12a2c550c41f442fa8e8fb7 Mon Sep 17 00:00:00 2001 From: Makarand Bauskar Date: Tue, 12 Sep 2017 10:52:24 +0530 Subject: [PATCH 1/9] [minor] translate the action name in get_slide_settings (#10740) --- erpnext/utilities/user_progress.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/erpnext/utilities/user_progress.py b/erpnext/utilities/user_progress.py index 482179beee..98b3932a4f 100644 --- a/erpnext/utilities/user_progress.py +++ b/erpnext/utilities/user_progress.py @@ -12,7 +12,7 @@ def get_slide_settings(): # Initial state of slides return [ frappe._dict( - action_name='Add Company', + action_name=_('Add Company'), title=_("Setup Company") if domain != 'Education' else _("Setup Institution"), help=_('Setup your ' + ('company' if domain != 'Education' else 'institution') + ' and brand.'), # image_src="/assets/erpnext/images/illustrations/shop.jpg", @@ -32,7 +32,7 @@ def get_slide_settings(): ) , frappe._dict( - action_name='Add Customers', + action_name=_('Add Customers'), domains=('Manufacturing', 'Services', 'Retail', 'Distribution'), icon="fa fa-group", title=_("Add Customers"), @@ -57,7 +57,7 @@ def get_slide_settings(): ] ), frappe._dict( - action_name='Add Suppliers', + action_name=_('Add Suppliers'), domains=('Manufacturing', 'Services', 'Retail', 'Distribution'), icon="fa fa-group", title=_("Your Suppliers"), @@ -86,7 +86,7 @@ def get_slide_settings(): ] ), frappe._dict( - action_name='Add Products', + action_name=_('Add Products'), domains=['Manufacturing', 'Services', 'Retail', 'Distribution'], icon="fa fa-barcode", title=_("Your Products or Services"), @@ -117,7 +117,7 @@ def get_slide_settings(): # School slides begin frappe._dict( - action_name='Add Programs', + action_name=_('Add Programs'), domains=("Education"), title=_("Program"), help=_("Example: Masters in Computer Science"), @@ -138,7 +138,7 @@ def get_slide_settings(): ), frappe._dict( - action_name='Add Courses', + action_name=_('Add Courses'), domains=["Education"], title=_("Course"), help=_("Example: Basic Mathematics"), @@ -158,7 +158,7 @@ def get_slide_settings(): ] ), frappe._dict( - action_name='Add Instructors', + action_name=_('Add Instructors'), domains=["Education"], title=_("Instructor"), help=_("People who teach at your organisation"), @@ -178,7 +178,7 @@ def get_slide_settings(): ] ), frappe._dict( - action_name='Add Rooms', + action_name=_('Add Rooms'), domains=["Education"], title=_("Room"), help=_("Classrooms/ Laboratories etc where lectures can be scheduled."), @@ -197,7 +197,7 @@ def get_slide_settings(): # School slides end frappe._dict( - action_name='Add Users', + action_name=_('Add Users'), title=_("Add Users"), help=_("Add users to your organization, other than yourself."), fields=[ From 1a77922e852d3501f4cde383e215cb91e1812279 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 11 Sep 2017 11:49:25 +0530 Subject: [PATCH 2/9] rename Is Online field to Use POS in Offline Mode in pos settings --- .../doctype/pos_settings/pos_settings.json | 49 +++++++++++++++++-- .../doctype/pos_settings/test_pos_settings.py | 9 ++++ erpnext/accounts/page/pos/pos.js | 2 +- erpnext/patches.txt | 2 +- .../v8_7/set_offline_in_pos_settings.py | 2 +- .../page/point_of_sale/point_of_sale.js | 2 +- .../page/point_of_sale/point_of_sale.json | 17 ++++++- .../point_of_sale/tests/test_pos_settings.js | 4 +- 8 files changed, 74 insertions(+), 13 deletions(-) create mode 100644 erpnext/accounts/doctype/pos_settings/test_pos_settings.py diff --git a/erpnext/accounts/doctype/pos_settings/pos_settings.json b/erpnext/accounts/doctype/pos_settings/pos_settings.json index a04558da26..8f5b631c89 100644 --- a/erpnext/accounts/doctype/pos_settings/pos_settings.json +++ b/erpnext/accounts/doctype/pos_settings/pos_settings.json @@ -18,8 +18,8 @@ "bold": 0, "collapsible": 0, "columns": 0, - "default": "1", - "fieldname": "is_online", + "default": "0", + "fieldname": "use_pos_in_offline_mode", "fieldtype": "Check", "hidden": 0, "ignore_user_permissions": 0, @@ -28,10 +28,9 @@ "in_global_search": 0, "in_list_view": 0, "in_standard_filter": 0, - "label": "Online", + "label": "Use POS in Offline Mode", "length": 0, "no_copy": 0, - "options": "", "permlevel": 0, "precision": "", "print_hide": 0, @@ -55,7 +54,7 @@ "issingle": 1, "istable": 0, "max_attachments": 0, - "modified": "2017-08-30 18:34:58.960276", + "modified": "2017-09-11 13:57:28.787023", "modified_by": "Administrator", "module": "Accounts", "name": "POS Settings", @@ -81,6 +80,46 @@ "share": 1, "submit": 0, "write": 1 + }, + { + "amend": 0, + "apply_user_permissions": 0, + "cancel": 0, + "create": 0, + "delete": 0, + "email": 1, + "export": 0, + "if_owner": 0, + "import": 0, + "permlevel": 0, + "print": 1, + "read": 1, + "report": 0, + "role": "Accounts User", + "set_user_permissions": 0, + "share": 1, + "submit": 0, + "write": 1 + }, + { + "amend": 0, + "apply_user_permissions": 0, + "cancel": 0, + "create": 0, + "delete": 0, + "email": 1, + "export": 0, + "if_owner": 0, + "import": 0, + "permlevel": 0, + "print": 1, + "read": 1, + "report": 0, + "role": "Sales User", + "set_user_permissions": 0, + "share": 1, + "submit": 0, + "write": 1 } ], "quick_entry": 1, diff --git a/erpnext/accounts/doctype/pos_settings/test_pos_settings.py b/erpnext/accounts/doctype/pos_settings/test_pos_settings.py new file mode 100644 index 0000000000..a3df10803c --- /dev/null +++ b/erpnext/accounts/doctype/pos_settings/test_pos_settings.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import unittest + +class TestPOSSettings(unittest.TestCase): + pass diff --git a/erpnext/accounts/page/pos/pos.js b/erpnext/accounts/page/pos/pos.js index 2f425248a1..4b1e3900bb 100644 --- a/erpnext/accounts/page/pos/pos.js +++ b/erpnext/accounts/page/pos/pos.js @@ -9,7 +9,7 @@ frappe.pages['pos'].on_page_load = function (wrapper) { }); frappe.db.get_value('POS Settings', {name: 'POS Settings'}, 'is_online', (r) => { - if (r && r.is_online && !cint(r.is_online)) { + if (r && r.use_pos_in_offline_mode && cint(r.use_pos_in_offline_mode)) { // offline wrapper.pos = new erpnext.pos.PointOfSale(wrapper); cur_pos = wrapper.pos; diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 50c64b5a16..6829bd4033 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -435,7 +435,7 @@ erpnext.patches.v8_5.remove_project_type_property_setter erpnext.patches.v8_7.add_more_gst_fields erpnext.patches.v8_7.fix_purchase_receipt_status erpnext.patches.v8_6.rename_bom_update_tool -erpnext.patches.v8_7.set_offline_in_pos_settings +erpnext.patches.v8_7.set_offline_in_pos_settings #11-09-17 erpnext.patches.v8_9.add_setup_progress_actions erpnext.patches.v8_9.rename_company_sales_target_field erpnext.patches.v8_8.set_bom_rate_as_per_uom diff --git a/erpnext/patches/v8_7/set_offline_in_pos_settings.py b/erpnext/patches/v8_7/set_offline_in_pos_settings.py index 64a3a7c806..b24fe37a28 100644 --- a/erpnext/patches/v8_7/set_offline_in_pos_settings.py +++ b/erpnext/patches/v8_7/set_offline_in_pos_settings.py @@ -8,5 +8,5 @@ def execute(): frappe.reload_doc('accounts', 'doctype', 'pos_settings') doc = frappe.get_doc('POS Settings') - doc.is_online = 0 + doc.use_pos_in_offline_mode = 1 doc.save() \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.js b/erpnext/selling/page/point_of_sale/point_of_sale.js index 9cd2a49912..d4b7eba107 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.js +++ b/erpnext/selling/page/point_of_sale/point_of_sale.js @@ -9,7 +9,7 @@ frappe.pages['point-of-sale'].on_page_load = function(wrapper) { }); frappe.db.get_value('POS Settings', {name: 'POS Settings'}, 'is_online', (r) => { - if (r && r.is_online && cint(r.is_online)) { + if (r && r.use_pos_in_offline_mode && !cint(r.use_pos_in_offline_mode)) { // online wrapper.pos = new erpnext.pos.PointOfSale(wrapper); window.cur_pos = wrapper.pos; diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.json b/erpnext/selling/page/point_of_sale/point_of_sale.json index 1e348c09af..6d2f5f2f8d 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.json +++ b/erpnext/selling/page/point_of_sale/point_of_sale.json @@ -4,14 +4,27 @@ "docstatus": 0, "doctype": "Page", "idx": 0, - "modified": "2017-08-07 17:08:56.737947", + "modified": "2017-09-11 13:49:05.415211", "modified_by": "Administrator", "module": "Selling", "name": "point-of-sale", "owner": "Administrator", "page_name": "Point of Sale", "restrict_to_domain": "Retail", - "roles": [], + "roles": [ + { + "role": "Accounts User" + }, + { + "role": "Accounts Manager" + }, + { + "role": "Sales User" + }, + { + "role": "Sales Manager" + } + ], "script": null, "standard": "Yes", "style": null, diff --git a/erpnext/selling/page/point_of_sale/tests/test_pos_settings.js b/erpnext/selling/page/point_of_sale/tests/test_pos_settings.js index d9b8cf8274..9b02e54802 100644 --- a/erpnext/selling/page/point_of_sale/tests/test_pos_settings.js +++ b/erpnext/selling/page/point_of_sale/tests/test_pos_settings.js @@ -4,13 +4,13 @@ QUnit.test("test:POS Settings", function(assert) { frappe.run_serially([ () => frappe.set_route('Form', 'POS Settings'), - () => cur_frm.set_value('is_online', 1), + () => cur_frm.set_value('use_pos_in_offline_mode', 0), () => frappe.timeout(0.2), () => cur_frm.save(), () => frappe.timeout(1), () => frappe.ui.toolbar.clear_cache(), () => frappe.timeout(10), - () => assert.ok(cur_frm.doc.is_online==1, "Enabled online"), + () => assert.ok(cur_frm.doc.use_pos_in_offline_mode==0, "Enabled online"), () => frappe.timeout(2), () => done() ]); From dd092e30fb5eedf48bad7d6dd6c9df69c3238c9b Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Fri, 15 Sep 2017 16:16:36 +0530 Subject: [PATCH 3/9] Move subscription module to accounts (#10772) --- .../doctype/payment_entry/payment_entry.py | 7 ++- .../doctype}/subscription/__init__.py | 0 .../doctype/subscription/subscription.js | 32 ++++++++++ .../doctype/subscription/subscription.json | 56 +++++++++++++++--- .../doctype/subscription/subscription.py | 55 +++++++++++------ .../doctype/subscription/subscription_list.js | 6 +- .../doctype/subscription/test_subscription.js | 0 .../doctype/subscription/test_subscription.py | 2 +- erpnext/config/accounts.py | 6 ++ .../subscription.gif | Bin .../subscription.png | Bin .../docs/user/manual/en/accounts/index.txt | 1 + .../user/manual/en/accounts/subscription.md | 24 ++++++++ erpnext/hooks.py | 2 +- erpnext/modules.txt | 3 +- erpnext/patches.txt | 1 + .../make_subscription_from_recurring_data.py | 2 +- erpnext/patches/v9_0/__init__.py | 1 + .../v9_0/remove_subscription_module.py | 9 +++ erpnext/public/js/utils.js | 2 +- .../doctype/sales_order/sales_order.py | 7 +-- erpnext/subscription/__init__.py | 0 erpnext/subscription/doctype/__init__.py | 0 .../doctype/subscription/__init__.py | 0 .../emails/recurring_document_failed.html | 9 ++- 25 files changed, 183 insertions(+), 42 deletions(-) rename erpnext/{docs/assets/img => accounts/doctype}/subscription/__init__.py (100%) rename erpnext/{subscription => accounts}/doctype/subscription/subscription.js (52%) rename erpnext/{subscription => accounts}/doctype/subscription/subscription.json (94%) rename erpnext/{subscription => accounts}/doctype/subscription/subscription.py (82%) rename erpnext/{subscription => accounts}/doctype/subscription/subscription_list.js (56%) rename erpnext/{subscription => accounts}/doctype/subscription/test_subscription.js (100%) rename erpnext/{subscription => accounts}/doctype/subscription/test_subscription.py (97%) rename erpnext/docs/assets/img/{subscription => accounts}/subscription.gif (100%) rename erpnext/docs/assets/img/{subscription => accounts}/subscription.png (100%) create mode 100644 erpnext/docs/user/manual/en/accounts/subscription.md create mode 100644 erpnext/patches/v9_0/__init__.py create mode 100644 erpnext/patches/v9_0/remove_subscription_module.py delete mode 100644 erpnext/subscription/__init__.py delete mode 100644 erpnext/subscription/doctype/__init__.py delete mode 100644 erpnext/subscription/doctype/subscription/__init__.py diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 908e58eeac..944916a715 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -483,9 +483,14 @@ class PaymentEntry(AccountsController): doc = frappe.get_doc("Expense Claim", d.reference_name) update_reimbursed_amount(doc) + def on_recurring(self, reference_doc, subscription_doc): + self.reference_no = reference_doc.name + self.reference_date = nowdate() + @frappe.whitelist() def get_outstanding_reference_documents(args): - args = json.loads(args) + if isinstance(args, basestring): + args = json.loads(args) party_account_currency = get_account_currency(args.get("party_account")) company_currency = frappe.db.get_value("Company", args.get("company"), "default_currency") diff --git a/erpnext/docs/assets/img/subscription/__init__.py b/erpnext/accounts/doctype/subscription/__init__.py similarity index 100% rename from erpnext/docs/assets/img/subscription/__init__.py rename to erpnext/accounts/doctype/subscription/__init__.py diff --git a/erpnext/subscription/doctype/subscription/subscription.js b/erpnext/accounts/doctype/subscription/subscription.js similarity index 52% rename from erpnext/subscription/doctype/subscription/subscription.js rename to erpnext/accounts/doctype/subscription/subscription.js index 75e1473b00..9c5b264bc0 100644 --- a/erpnext/subscription/doctype/subscription/subscription.js +++ b/erpnext/accounts/doctype/subscription/subscription.js @@ -31,6 +31,38 @@ frappe.ui.form.on('Subscription', { frappe.set_route("List", frm.doc.reference_doctype); } ); + + if(frm.doc.status != 'Stopped') { + frm.add_custom_button(__("Stop"), + function() { + frm.events.stop_resume_subscription(frm, "Stopped"); + } + ); + } + + if(frm.doc.status == 'Stopped') { + frm.add_custom_button(__("Resume"), + function() { + frm.events.stop_resume_subscription(frm, "Resumed"); + } + ); + } } + }, + + stop_resume_subscription: function(frm, status) { + frappe.call({ + method: "erpnext.accounts.doctype.subscription.subscription.stop_resume_subscription", + args: { + subscription: frm.doc.name, + status: status + }, + callback: function(r) { + if(r.message) { + frm.set_value("status", r.message); + frm.reload_doc(); + } + } + }); } }); \ No newline at end of file diff --git a/erpnext/subscription/doctype/subscription/subscription.json b/erpnext/accounts/doctype/subscription/subscription.json similarity index 94% rename from erpnext/subscription/doctype/subscription/subscription.json rename to erpnext/accounts/doctype/subscription/subscription.json index 6cfee1e44f..85779533ea 100644 --- a/erpnext/subscription/doctype/subscription/subscription.json +++ b/erpnext/accounts/doctype/subscription/subscription.json @@ -148,7 +148,7 @@ "ignore_xss_filter": 0, "in_filter": 0, "in_global_search": 0, - "in_list_view": 0, + "in_list_view": 1, "in_standard_filter": 0, "label": "Disabled", "length": 0, @@ -619,24 +619,24 @@ }, { "allow_bulk_edit": 0, - "allow_on_submit": 0, + "allow_on_submit": 1, "bold": 0, "collapsible": 0, "columns": 0, "default": "Draft", "fieldname": "status", "fieldtype": "Select", - "hidden": 1, + "hidden": 0, "ignore_user_permissions": 0, "ignore_xss_filter": 0, "in_filter": 0, "in_global_search": 0, - "in_list_view": 0, + "in_list_view": 1, "in_standard_filter": 0, "label": "Status", "length": 0, "no_copy": 0, - "options": "\nDraft\nSubmitted\nCancelled\nCompleted", + "options": "\nDraft\nStopped\nSubmitted\nCancelled\nCompleted", "permlevel": 0, "precision": "", "print_hide": 0, @@ -690,9 +690,9 @@ "issingle": 0, "istable": 0, "max_attachments": 0, - "modified": "2017-08-29 15:45:16.157643", + "modified": "2017-09-14 12:09:38.471458", "modified_by": "Administrator", - "module": "Subscription", + "module": "Accounts", "name": "Subscription", "name_case": "", "owner": "Administrator", @@ -700,7 +700,7 @@ { "amend": 0, "apply_user_permissions": 0, - "cancel": 1, + "cancel": 0, "create": 1, "delete": 1, "email": 1, @@ -716,6 +716,46 @@ "share": 1, "submit": 1, "write": 1 + }, + { + "amend": 0, + "apply_user_permissions": 0, + "cancel": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "if_owner": 0, + "import": 0, + "permlevel": 0, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "set_user_permissions": 0, + "share": 1, + "submit": 1, + "write": 1 + }, + { + "amend": 0, + "apply_user_permissions": 0, + "cancel": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "if_owner": 0, + "import": 0, + "permlevel": 0, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "set_user_permissions": 0, + "share": 1, + "submit": 1, + "write": 1 } ], "quick_entry": 0, diff --git a/erpnext/subscription/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py similarity index 82% rename from erpnext/subscription/doctype/subscription/subscription.py rename to erpnext/accounts/doctype/subscription/subscription.py index be36211ec2..c9df7d461e 100644 --- a/erpnext/subscription/doctype/subscription/subscription.py +++ b/erpnext/accounts/doctype/subscription/subscription.py @@ -71,13 +71,16 @@ class Subscription(Document): doc.db_set('subscription', self.name) - def update_status(self): + def update_status(self, status=None): self.status = { '0': 'Draft', '1': 'Submitted', '2': 'Cancelled' }[cstr(self.docstatus or 0)] + if status and status != 'Resumed': + self.status = status + def get_next_schedule_date(start_date, frequency, repeat_on_day): mcount = month_map.get(frequency) if mcount: @@ -93,11 +96,10 @@ def make_subscription_entry(date=None): schedule_date = getdate(data.next_schedule_date) while schedule_date <= getdate(today()): create_documents(data, schedule_date) - schedule_date = get_next_schedule_date(schedule_date, data.frequency, data.repeat_on_day) - if schedule_date: + if schedule_date and not frappe.db.get_value('Subscription', data.name, 'disabled'): frappe.db.set_value('Subscription', data.name, 'next_schedule_date', schedule_date) def get_subscription_entries(date): @@ -105,23 +107,29 @@ def get_subscription_entries(date): where docstatus = 1 and next_schedule_date <=%s and reference_document is not null and reference_document != '' and next_schedule_date <= ifnull(end_date, '2199-12-31') - and ifnull(disabled, 0) = 0""", (date), as_dict=1) + and ifnull(disabled, 0) = 0 and status != 'Stopped' """, (date), as_dict=1) def create_documents(data, schedule_date): try: doc = make_new_document(data, schedule_date) - if data.notify_by_email: - send_notification(doc, data.print_format, data.recipients) + if data.notify_by_email and data.recipients: + print_format = data.print_format or "Standard" + send_notification(doc, print_format, data.recipients) frappe.db.commit() except Exception: frappe.db.rollback() frappe.db.begin() frappe.log_error(frappe.get_traceback()) + disabled_subscription(data) frappe.db.commit() if data.reference_document and not frappe.flags.in_test: notify_error_to_user(data) +def disabled_subscription(data): + subscription = frappe.get_doc('Subscription', data.name) + subscription.db_set('disabled', 1) + def notify_error_to_user(data): party = '' party_type = '' @@ -134,7 +142,7 @@ def notify_error_to_user(data): if party_type: party = frappe.db.get_value(data.reference_doctype, data.reference_document, party_type) - notify_errors(data.reference_document, data.reference_doctype, party, data.owner) + notify_errors(data.reference_document, data.reference_doctype, party, data.owner, data.name) def make_new_document(args, schedule_date): doc = frappe.get_doc(args.reference_doctype, args.reference_document) @@ -168,32 +176,32 @@ def get_next_date(dt, mcount, day=None): def send_notification(new_rv, print_format='Standard', recipients=None): """Notify concerned persons about recurring document generation""" - recipients = recipients or new_rv.notification_email_address - print_format = print_format or new_rv.recurring_print_format + print_format = print_format frappe.sendmail(recipients, subject= _("New {0}: #{1}").format(new_rv.doctype, new_rv.name), message = _("Please find attached {0} #{1}").format(new_rv.doctype, new_rv.name), attachments = [frappe.attach_print(new_rv.doctype, new_rv.name, file_name=new_rv.name, print_format=print_format)]) -def notify_errors(doc, doctype, party, owner): +def notify_errors(doc, doctype, party, owner, name): recipients = get_system_managers(only_name=True) frappe.sendmail(recipients + [frappe.db.get_value("User", owner, "email")], - subject="[Urgent] Error while creating recurring %s for %s" % (doctype, doc), + subject=_("[Urgent] Error while creating recurring %s for %s" % (doctype, doc)), message = frappe.get_template("templates/emails/recurring_document_failed.html").render({ - "type": doctype, + "type": _(doctype), "name": doc, - "party": party or "" + "party": party or "", + "subscription": name })) - assign_task_to_owner(doc, doctype, "Recurring Invoice Failed", recipients) + assign_task_to_owner(name, "Recurring Documents Failed", recipients) -def assign_task_to_owner(doc, doctype, msg, users): +def assign_task_to_owner(name, msg, users): for d in users: args = { + 'doctype' : 'Subscription', 'assign_to' : d, - 'doctype' : doctype, - 'name' : doc, + 'name' : name, 'description' : msg, 'priority' : 'High' } @@ -205,3 +213,16 @@ def make_subscription(doctype, docname): doc.reference_doctype = doctype doc.reference_document = docname return doc + +@frappe.whitelist() +def stop_resume_subscription(subscription, status): + doc = frappe.get_doc('Subscription', subscription) + frappe.msgprint(_("Subscription has been {0}").format(status)) + if status == 'Resumed': + doc.next_schedule_date = get_next_schedule_date(today(), + doc.frequency, doc.repeat_on_day) + + doc.update_status(status) + doc.save() + + return doc.status \ No newline at end of file diff --git a/erpnext/subscription/doctype/subscription/subscription_list.js b/erpnext/accounts/doctype/subscription/subscription_list.js similarity index 56% rename from erpnext/subscription/doctype/subscription/subscription_list.js rename to erpnext/accounts/doctype/subscription/subscription_list.js index 6a33638b39..71e3cce79d 100644 --- a/erpnext/subscription/doctype/subscription/subscription_list.js +++ b/erpnext/accounts/doctype/subscription/subscription_list.js @@ -1,10 +1,14 @@ frappe.listview_settings['Subscription'] = { add_fields: ["next_schedule_date"], get_indicator: function(doc) { - if(doc.next_schedule_date >= frappe.datetime.get_today() ) { + if(doc.disabled) { + return [__("Disabled"), "red"]; + } else if(doc.next_schedule_date >= frappe.datetime.get_today() && doc.status != 'Stopped') { return [__("Active"), "green"]; } else if(doc.docstatus === 0) { return [__("Draft"), "red", "docstatus,=,0"]; + } else if(doc.status === 'Stopped') { + return [__("Stopped"), "red"]; } else { return [__("Expired"), "darkgrey"]; } diff --git a/erpnext/subscription/doctype/subscription/test_subscription.js b/erpnext/accounts/doctype/subscription/test_subscription.js similarity index 100% rename from erpnext/subscription/doctype/subscription/test_subscription.js rename to erpnext/accounts/doctype/subscription/test_subscription.js diff --git a/erpnext/subscription/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py similarity index 97% rename from erpnext/subscription/doctype/subscription/test_subscription.py rename to erpnext/accounts/doctype/subscription/test_subscription.py index 28f8be7257..b74163c92e 100644 --- a/erpnext/subscription/doctype/subscription/test_subscription.py +++ b/erpnext/accounts/doctype/subscription/test_subscription.py @@ -10,7 +10,7 @@ from erpnext.accounts.utils import get_fiscal_year from erpnext.accounts.report.financial_statements import get_months from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order -from erpnext.subscription.doctype.subscription.subscription import make_subscription_entry +from erpnext.accounts.doctype.subscription.subscription import make_subscription_entry class TestSubscription(unittest.TestCase): def test_daily_subscription(self): diff --git a/erpnext/config/accounts.py b/erpnext/config/accounts.py index 6d16e9202e..7c0f540154 100644 --- a/erpnext/config/accounts.py +++ b/erpnext/config/accounts.py @@ -32,6 +32,12 @@ def get_data(): "label": _("POS"), "description": _("Point of Sale") }, + { + "type": "doctype", + "name": "Subscription", + "label": _("Subscription"), + "description": _("To make recurring documents") + }, { "type": "report", "name": "Accounts Receivable", diff --git a/erpnext/docs/assets/img/subscription/subscription.gif b/erpnext/docs/assets/img/accounts/subscription.gif similarity index 100% rename from erpnext/docs/assets/img/subscription/subscription.gif rename to erpnext/docs/assets/img/accounts/subscription.gif diff --git a/erpnext/docs/assets/img/subscription/subscription.png b/erpnext/docs/assets/img/accounts/subscription.png similarity index 100% rename from erpnext/docs/assets/img/subscription/subscription.png rename to erpnext/docs/assets/img/accounts/subscription.png diff --git a/erpnext/docs/user/manual/en/accounts/index.txt b/erpnext/docs/user/manual/en/accounts/index.txt index 6a0da3a894..41cb243dc6 100644 --- a/erpnext/docs/user/manual/en/accounts/index.txt +++ b/erpnext/docs/user/manual/en/accounts/index.txt @@ -6,6 +6,7 @@ purchase-invoice payments journal-entry payment-entry +subscription multi-currency-accounting advance-payment-entry payment-request diff --git a/erpnext/docs/user/manual/en/accounts/subscription.md b/erpnext/docs/user/manual/en/accounts/subscription.md new file mode 100644 index 0000000000..9afab58e25 --- /dev/null +++ b/erpnext/docs/user/manual/en/accounts/subscription.md @@ -0,0 +1,24 @@ +# Subscription + +If you have a contract with the Customer where your organization gives bill to the Customer on a monthly, quarterly, half-yearly or annual basis, you can use subscription feature to make auto invoicing. + +Subscription + +#### Scenario + +Subscription for your hosted ERPNext account requires yearly renewal. We use Sales Invoice for generating proforma invoices. To automate proforma invoicing for renewal, we set original Sales Invoice on the subscription form. Recurring proforma invoice is created automatically just before customer's account is about to expire, and requires renewal. This recurring Proforma Invoice is also emailed automatically to the customer. + +To set the subscription for the sales invoice +Goto Subscription > select base doctype "Sales Invoice" > select base docname "Invoice No" > Save + +Subscription + +**From Date and To Date**: This defines contract period with the customer. + +**Repeat on Day**: If frequency is set as Monthly, then it will be day of the month on which recurring invoice will be generated. + +**Notify By Email**: If you want to notify the user about auto recurring invoice. + +**Print Format**: Select a print format to define document view which should be emailed to customer. + +**Disabled**: It will stop to make auto recurring documents against the subscription \ No newline at end of file diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 0e3aa79b36..6f95f7ef64 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -184,7 +184,7 @@ doc_events = { scheduler_events = { "hourly": [ - "erpnext.subscription.doctype.subscription.subscription.make_subscription_entry", + "erpnext.accounts.doctype.subscription.subscription.make_subscription_entry", 'erpnext.hr.doctype.daily_work_summary_settings.daily_work_summary_settings.trigger_emails' ], "daily": [ diff --git a/erpnext/modules.txt b/erpnext/modules.txt index 0579cc2119..1edff10ca5 100644 --- a/erpnext/modules.txt +++ b/erpnext/modules.txt @@ -14,5 +14,4 @@ Hub Node Portal Maintenance Schools -Regional -Subscription \ No newline at end of file +Regional \ No newline at end of file diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 92def8ded3..8b28a28c9b 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -439,6 +439,7 @@ erpnext.patches.v8_7.set_offline_in_pos_settings #11-09-17 erpnext.patches.v8_9.add_setup_progress_actions erpnext.patches.v8_9.rename_company_sales_target_field erpnext.patches.v8_8.set_bom_rate_as_per_uom +erpnext.patches.v9_0.remove_subscription_module erpnext.patches.v8_7.make_subscription_from_recurring_data erpnext.patches.v8_9.set_print_zero_amount_taxes erpnext.patches.v8_9.set_default_customer_group diff --git a/erpnext/patches/v8_7/make_subscription_from_recurring_data.py b/erpnext/patches/v8_7/make_subscription_from_recurring_data.py index 03d8eb4193..89eca1a0ec 100644 --- a/erpnext/patches/v8_7/make_subscription_from_recurring_data.py +++ b/erpnext/patches/v8_7/make_subscription_from_recurring_data.py @@ -6,7 +6,7 @@ import frappe from frappe.utils import today def execute(): - frappe.reload_doc('subscription', 'doctype', 'subscription') + frappe.reload_doc('accounts', 'doctype', 'subscription') frappe.reload_doc('selling', 'doctype', 'sales_order') frappe.reload_doc('buying', 'doctype', 'purchase_order') frappe.reload_doc('accounts', 'doctype', 'sales_invoice') diff --git a/erpnext/patches/v9_0/__init__.py b/erpnext/patches/v9_0/__init__.py new file mode 100644 index 0000000000..baffc48825 --- /dev/null +++ b/erpnext/patches/v9_0/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/erpnext/patches/v9_0/remove_subscription_module.py b/erpnext/patches/v9_0/remove_subscription_module.py new file mode 100644 index 0000000000..493873f3e8 --- /dev/null +++ b/erpnext/patches/v9_0/remove_subscription_module.py @@ -0,0 +1,9 @@ +# Copyright (c) 2017, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + if frappe.db.exists('Module Def', 'Subscription'): + frappe.db.sql(""" delete from `tabModule Def` where name = 'Subscription'""") \ No newline at end of file diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 8a47df6371..721f216888 100644 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -129,7 +129,7 @@ $.extend(erpnext.utils, { make_subscription: function(doctype, docname) { frappe.call({ - method: "erpnext.subscription.doctype.subscription.subscription.make_subscription", + method: "erpnext.accounts.doctype.subscription.subscription.make_subscription", args: { doctype: doctype, docname: docname diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 5f828900d5..8720482549 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -13,7 +13,7 @@ from erpnext.stock.stock_balance import update_bin_qty, get_reserved_qty from frappe.desk.notifications import clear_doctype_notifications from frappe.contacts.doctype.address.address import get_company_address from erpnext.controllers.selling_controller import SellingController -from erpnext.subscription.doctype.subscription.subscription import month_map, get_next_date +from erpnext.accounts.doctype.subscription.subscription import get_next_schedule_date form_grid_templates = { "items": "templates/form_grid/item_grid.html" @@ -347,8 +347,7 @@ class SalesOrder(SellingController): return items def on_recurring(self, reference_doc, subscription_doc): - mcount = month_map[subscription_doc.frequency] - self.set("delivery_date", get_next_date(reference_doc.delivery_date, mcount, + self.set("delivery_date", get_next_schedule_date(reference_doc.delivery_date, subscription_doc.frequency, cint(subscription_doc.repeat_on_day))) for d in self.get("items"): @@ -356,7 +355,7 @@ class SalesOrder(SellingController): {"parent": reference_doc.name, "item_code": d.item_code, "idx": d.idx}, "delivery_date") d.set("delivery_date", - get_next_date(reference_delivery_date, mcount, cint(subscription_doc.repeat_on_day))) + get_next_schedule_date(reference_delivery_date, subscription_doc.frequency, cint(subscription_doc.repeat_on_day))) def get_list_context(context=None): from erpnext.controllers.website_list_for_contact import get_list_context diff --git a/erpnext/subscription/__init__.py b/erpnext/subscription/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/erpnext/subscription/doctype/__init__.py b/erpnext/subscription/doctype/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/erpnext/subscription/doctype/subscription/__init__.py b/erpnext/subscription/doctype/subscription/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/erpnext/templates/emails/recurring_document_failed.html b/erpnext/templates/emails/recurring_document_failed.html index 9b88731178..ea48034f41 100644 --- a/erpnext/templates/emails/recurring_document_failed.html +++ b/erpnext/templates/emails/recurring_document_failed.html @@ -1,12 +1,11 @@ -

Recurring {{ type }} Failed

+

{{_("Recurring")}} {{ type }} {{ _("Failed")}}

An error occured while creating recurring {{ type }} {{ name }} for {{ party }}.

This could be because of some invalid Email Addresses in the {{ type }}.

-

To stop sending repetitive error notifications from the system, we have unchecked -"Convert into Recurring" field in the {{ type }} {{ name }}.

-

Please correct the {{ type }} and make the {{ type }} recurring again.

+

To stop sending repetitive error notifications from the system, we have checked "Disabled" field in the subscription {{ subscription}} for the {{ type }} {{ name }}.

+

Please correct the {{ type }} and unchcked "Disabled" in the {{ subscription }} for making recurring again.


It is necessary to take this action today itself for the above mentioned recurring {{ type }} to be generated. If delayed, you will have to manually change the "Repeat on Day of Month" field -of this {{ type }} for generating the recurring {{ type }}.

+of this {{ type }} for generating the recurring {{ type }} in the subscription {{ subscription }}.

[This email is autogenerated]

From 6cea73b8344777502b6a7d50855c6b4e27557040 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 18 Sep 2017 16:58:05 +0530 Subject: [PATCH 4/9] [fix] Subscription patch (#10827) --- .../patches/v8_7/make_subscription_from_recurring_data.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/erpnext/patches/v8_7/make_subscription_from_recurring_data.py b/erpnext/patches/v8_7/make_subscription_from_recurring_data.py index 89eca1a0ec..89830a22f8 100644 --- a/erpnext/patches/v8_7/make_subscription_from_recurring_data.py +++ b/erpnext/patches/v8_7/make_subscription_from_recurring_data.py @@ -18,7 +18,7 @@ def execute(): make_subscription(doctype, data) def get_data(doctype): - return frappe.db.sql(""" select name, from_date, end_date, recurring_type,recurring_id + return frappe.db.sql(""" select name, from_date, end_date, recurring_type,recurring_id, next_date, notify_by_email, notification_email_address, recurring_print_format, repeat_on_day_of_month, submit_on_creation from `tab{0}` where is_recurring = 1 and next_date >= %s @@ -39,7 +39,4 @@ def make_subscription(doctype, data): 'submit_on_creation': data.submit_on_creation }).insert(ignore_permissions=True) - doc.submit() - - if not doc.subscription: - frappe.db.set_value(doctype, data.name, "subscription", doc.name) \ No newline at end of file + doc.submit() \ No newline at end of file From 5c1d15f01564eb655480d6351bb210c427cc03cb Mon Sep 17 00:00:00 2001 From: Makarand Bauskar Date: Mon, 18 Sep 2017 17:03:21 +0530 Subject: [PATCH 5/9] [change-log] added change log for v9.0.0 (#10756) * [change-log] added change log for v9.0.0 * Update v9_0_0.md --- erpnext/change_log/v9/v9_0_0.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 erpnext/change_log/v9/v9_0_0.md diff --git a/erpnext/change_log/v9/v9_0_0.md b/erpnext/change_log/v9/v9_0_0.md new file mode 100644 index 0000000000..fb6ae61e07 --- /dev/null +++ b/erpnext/change_log/v9/v9_0_0.md @@ -0,0 +1,8 @@ +#### POS +- Now user has an option to enable or disable Offline POS mode from POS Settings +- Provision to select the Item's serial number from the dropdown while adding item in the cart +- Indicator for stock availability in Online POS Mode. + +#### Subscription +- Setup recurring documents using **Subscription** +- User can schedule the subscription for doctypes other than Sales Invoice, Purchase Invoice etc. From ab5b03011d97972544966b6c09a5fa79fbac8e13 Mon Sep 17 00:00:00 2001 From: tundebabzy Date: Thu, 21 Sep 2017 10:20:39 +0100 Subject: [PATCH 6/9] Enhance Currency Exchange Management (#10482) * add new settings in Accouts Settings * patch for new settings * refactor `get_exchange_rate` * adds validation * tests validation * disables conversion rate field if stale rates not allowed * more test cases more test case... test `get_exchange_rate` behaviour with stale not allowed in sett.. fix currency exchange test case do housekeeping after running accounts settings test * clean up * documentation * make use of correct api url * Fix tests failing due to wrong exchange rate from fixer.io * remove mandatory constraint from `allow_stale` * added info to documentation --- .../accounts_settings/accounts_settings.json | 95 ++++++++++++++++++- .../accounts_settings/accounts_settings.py | 16 +++- .../test_accounts_settings.js | 35 +++++++ .../test_accounts_settings.py | 22 +++++ .../doctype/payment_entry/payment_entry.js | 14 +++ .../en/accounts/setup/accounts-settings.md | 4 + erpnext/patches.txt | 3 +- .../add_new_fields_in_accounts_settings.py | 9 ++ erpnext/public/js/controllers/transaction.js | 7 ++ .../test_currency_exchange.py | 65 ++++++++++++- .../currency_exchange/test_records.json | 7 ++ erpnext/setup/utils.py | 30 ++++-- 12 files changed, 288 insertions(+), 19 deletions(-) create mode 100644 erpnext/accounts/doctype/accounts_settings/test_accounts_settings.js create mode 100644 erpnext/accounts/doctype/accounts_settings/test_accounts_settings.py create mode 100644 erpnext/patches/v8_8/add_new_fields_in_accounts_settings.py diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 0ce3d5dea9..42cd44aeab 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -286,6 +286,99 @@ "search_index": 0, "set_only_once": 0, "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "currency_exchange_section", + "fieldtype": "Section Break", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Currency Exchange Settings", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "1", + "fieldname": "allow_stale", + "fieldtype": "Check", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Allow Stale Exchange Rates", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "1", + "depends_on": "eval:doc.allow_stale==0", + "fieldname": "stale_days", + "fieldtype": "Int", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Stale Days", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 } ], "has_web_view": 0, @@ -299,7 +392,7 @@ "issingle": 1, "istable": 0, "max_attachments": 0, - "modified": "2017-06-16 17:39:50.614522", + "modified": "2017-09-05 10:10:03.117505", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index dd33ff1ab2..8431173a9e 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -5,10 +5,20 @@ from __future__ import unicode_literals import frappe -from frappe import _ -from frappe.utils import cint, comma_and +from frappe.utils import cint from frappe.model.document import Document + class AccountsSettings(Document): def on_update(self): - pass \ No newline at end of file + pass + + def validate(self): + self.validate_stale_days() + + def validate_stale_days(self): + if not self.allow_stale and cint(self.stale_days) <= 0: + frappe.msgprint( + "Stale Days should start from 1.", title='Error', indicator='red', + raise_exception=1) + diff --git a/erpnext/accounts/doctype/accounts_settings/test_accounts_settings.js b/erpnext/accounts/doctype/accounts_settings/test_accounts_settings.js new file mode 100644 index 0000000000..f9aa166964 --- /dev/null +++ b/erpnext/accounts/doctype/accounts_settings/test_accounts_settings.js @@ -0,0 +1,35 @@ +QUnit.module('accounts'); + +QUnit.test("test: Accounts Settings doesn't allow negatives", function (assert) { + let done = assert.async(); + + assert.expect(2); + + frappe.run_serially([ + () => frappe.set_route('Form', 'Accounts Settings', 'Accounts Settings'), + () => frappe.timeout(2), + () => unchecked_if_checked(cur_frm, 'Allow Stale Exchange Rates', frappe.click_check), + () => cur_frm.set_value('stale_days', 0), + () => frappe.click_button('Save'), + () => frappe.timeout(2), + () => { + assert.ok(cur_dialog); + }, + () => frappe.click_button('Close'), + () => cur_frm.set_value('stale_days', -1), + () => frappe.click_button('Save'), + () => frappe.timeout(2), + () => { + assert.ok(cur_dialog); + }, + () => frappe.click_button('Close'), + () => done() + ]); + +}); + +const unchecked_if_checked = function(frm, field_name, fn){ + if (frm.doc.allow_stale) { + return fn(field_name); + } +}; diff --git a/erpnext/accounts/doctype/accounts_settings/test_accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/test_accounts_settings.py new file mode 100644 index 0000000000..bf1e967bdb --- /dev/null +++ b/erpnext/accounts/doctype/accounts_settings/test_accounts_settings.py @@ -0,0 +1,22 @@ +import unittest + +import frappe + + +class TestAccountsSettings(unittest.TestCase): + def tearDown(self): + # Just in case `save` method succeeds, we need to take things back to default so that other tests + # don't break + cur_settings = frappe.get_doc('Accounts Settings', 'Accounts Settings') + cur_settings.allow_stale = 1 + cur_settings.save() + + def test_stale_days(self): + cur_settings = frappe.get_doc('Accounts Settings', 'Accounts Settings') + cur_settings.allow_stale = 0 + cur_settings.stale_days = 0 + + self.assertRaises(frappe.ValidationError, cur_settings.save) + + cur_settings.stale_days = -1 + self.assertRaises(frappe.ValidationError, cur_settings.save) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 61ede97122..04db9e28ae 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -403,6 +403,13 @@ frappe.ui.form.on('Payment Entry', { frm.events.set_difference_amount(frm); } + + // Make read only if Accounts Settings doesn't allow stale rates + frappe.model.get_value("Accounts Settings", null, "allow_stale", + function(d){ + frm.set_df_property("source_exchange_rate", "read_only", cint(d.allow_stale) ? 0 : 1); + } + ); }, target_exchange_rate: function(frm) { @@ -421,6 +428,13 @@ frappe.ui.form.on('Payment Entry', { frm.events.set_difference_amount(frm); } frm.set_paid_amount_based_on_received_amount = false; + + // Make read only if Accounts Settings doesn't allow stale rates + frappe.model.get_value("Accounts Settings", null, "allow_stale", + function(d){ + frm.set_df_property("target_exchange_rate", "read_only", cint(d.allow_stale) ? 0 : 1); + } + ); }, paid_amount: function(frm) { diff --git a/erpnext/docs/user/manual/en/accounts/setup/accounts-settings.md b/erpnext/docs/user/manual/en/accounts/setup/accounts-settings.md index 3bada56ffd..f47f6e6610 100644 --- a/erpnext/docs/user/manual/en/accounts/setup/accounts-settings.md +++ b/erpnext/docs/user/manual/en/accounts/setup/accounts-settings.md @@ -13,4 +13,8 @@ * Unlink Payment on Cancellation of Invoice: If checked, system will unlink the payment against the invoice. Otherwise, it will show the link error. +* Allow Stale Exchange Rate: This should be unchecked if you want ERPNext to check the age of records fetched from Currency Exchange in foreign currency transactions. If it is unchecked, the exchange rate field will be read-only in documents. + +* Stale Days: The number of days to use when deciding if a Currency Exchange record is stale. E.g If Currency Exchange records are to be updated every day, the Stale Days should be set as 1. + {next} diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 4b0c538f89..f961a5b81e 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -439,8 +439,9 @@ erpnext.patches.v8_7.set_offline_in_pos_settings #11-09-17 erpnext.patches.v8_9.add_setup_progress_actions #08-09-2017 erpnext.patches.v8_9.rename_company_sales_target_field erpnext.patches.v8_8.set_bom_rate_as_per_uom -erpnext.patches.v9_0.remove_subscription_module erpnext.patches.v8_7.make_subscription_from_recurring_data +erpnext.patches.v8_8.add_new_fields_in_accounts_settings +erpnext.patches.v9_0.remove_subscription_module erpnext.patches.v8_9.set_print_zero_amount_taxes erpnext.patches.v8_9.set_default_customer_group erpnext.patches.v8_9.remove_employee_from_salary_structure_parent diff --git a/erpnext/patches/v8_8/add_new_fields_in_accounts_settings.py b/erpnext/patches/v8_8/add_new_fields_in_accounts_settings.py new file mode 100644 index 0000000000..bd25f15d78 --- /dev/null +++ b/erpnext/patches/v8_8/add_new_fields_in_accounts_settings.py @@ -0,0 +1,9 @@ +from __future__ import unicode_literals +import frappe + + +def execute(): + frappe.db.sql( + "INSERT INTO `tabSingles` (`doctype`, `field`, `value`) VALUES ('Accounts Settings', 'allow_stale', '1'), " + "('Accounts Settings', 'stale_days', '1')" + ) diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index a5ef15e60b..5f35ea44de 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -519,6 +519,7 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ }, conversion_rate: function() { + const me = this.frm; if(this.frm.doc.currency === this.get_company_currency()) { this.frm.set_value("conversion_rate", 1.0); } @@ -536,6 +537,12 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ } } + // Make read only if Accounts Settings doesn't allow stale rates + frappe.model.get_value("Accounts Settings", null, "allow_stale", + function(d){ + me.set_df_property("conversion_rate", "read_only", cint(d.allow_stale) ? 0 : 1); + } + ); }, set_actual_charges_based_on_currency: function() { diff --git a/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py b/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py index a477379ded..6d5848ad78 100644 --- a/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py +++ b/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py @@ -1,9 +1,9 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt from __future__ import unicode_literals - - import frappe, unittest +from erpnext.setup.utils import get_exchange_rate + test_records = frappe.get_test_records('Currency Exchange') @@ -28,11 +28,21 @@ def save_new_records(test_records): class TestCurrencyExchange(unittest.TestCase): - def test_exchnage_rate(self): - from erpnext.setup.utils import get_exchange_rate + def clear_cache(self): + cache = frappe.cache() + key = "currency_exchange_rate:{0}:{1}".format("USD", "INR") + cache.delete(key) + def tearDown(self): + frappe.db.set_value("Accounts Settings", None, "allow_stale", 1) + self.clear_cache() + + def test_exchange_rate(self): save_new_records(test_records) + frappe.db.set_value("Accounts Settings", None, "allow_stale", 1) + + # Start with allow_stale is True exchange_rate = get_exchange_rate("USD", "INR", "2016-01-01") self.assertEqual(exchange_rate, 60.0) @@ -43,6 +53,51 @@ class TestCurrencyExchange(unittest.TestCase): self.assertEqual(exchange_rate, 62.9) # Exchange rate as on 15th Dec, 2015, should be fetched from fixer.io + self.clear_cache() exchange_rate = get_exchange_rate("USD", "INR", "2015-12-15") self.assertFalse(exchange_rate == 60) - self.assertEqual(exchange_rate, 66.894) \ No newline at end of file + self.assertEqual(exchange_rate, 66.894) + + def test_exchange_rate_strict(self): + # strict currency settings + frappe.db.set_value("Accounts Settings", None, "allow_stale", 0) + frappe.db.set_value("Accounts Settings", None, "stale_days", 1) + + exchange_rate = get_exchange_rate("USD", "INR", "2016-01-01") + self.assertEqual(exchange_rate, 60.0) + + # Will fetch from fixer.io + self.clear_cache() + exchange_rate = get_exchange_rate("USD", "INR", "2016-01-15") + self.assertEqual(exchange_rate, 67.79) + + exchange_rate = get_exchange_rate("USD", "INR", "2016-01-30") + self.assertEqual(exchange_rate, 62.9) + + # Exchange rate as on 15th Dec, 2015, should be fetched from fixer.io + self.clear_cache() + exchange_rate = get_exchange_rate("USD", "INR", "2015-12-15") + self.assertEqual(exchange_rate, 66.894) + + exchange_rate = get_exchange_rate("INR", "NGN", "2016-01-10") + self.assertEqual(exchange_rate, 65.1) + + # NGN is not available on fixer.io so these should return 0 + exchange_rate = get_exchange_rate("INR", "NGN", "2016-01-09") + self.assertEqual(exchange_rate, 0) + + exchange_rate = get_exchange_rate("INR", "NGN", "2016-01-11") + self.assertEqual(exchange_rate, 0) + + def test_exchange_rate_strict_switched(self): + # Start with allow_stale is True + exchange_rate = get_exchange_rate("USD", "INR", "2016-01-15") + self.assertEqual(exchange_rate, 65.1) + + frappe.db.set_value("Accounts Settings", None, "allow_stale", 0) + frappe.db.set_value("Accounts Settings", None, "stale_days", 1) + + # Will fetch from fixer.io + self.clear_cache() + exchange_rate = get_exchange_rate("USD", "INR", "2016-01-15") + self.assertEqual(exchange_rate, 67.79) \ No newline at end of file diff --git a/erpnext/setup/doctype/currency_exchange/test_records.json b/erpnext/setup/doctype/currency_exchange/test_records.json index d2f658b443..0c9cfbb67c 100644 --- a/erpnext/setup/doctype/currency_exchange/test_records.json +++ b/erpnext/setup/doctype/currency_exchange/test_records.json @@ -33,5 +33,12 @@ "exchange_rate": 62.9, "from_currency": "USD", "to_currency": "INR" + }, + { + "doctype": "Currency Exchange", + "date": "2016-01-10", + "exchange_rate": 65.1, + "from_currency": "INR", + "to_currency": "NGN" } ] \ No newline at end of file diff --git a/erpnext/setup/utils.py b/erpnext/setup/utils.py index bdbf3f4ec2..f003ce4b1c 100644 --- a/erpnext/setup/utils.py +++ b/erpnext/setup/utils.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals import frappe from frappe import _ -from frappe.utils import flt +from frappe.utils import flt, add_days from frappe.utils import get_datetime_str, nowdate def get_root_of(doctype): @@ -56,8 +56,6 @@ def before_tests(): @frappe.whitelist() def get_exchange_rate(from_currency, to_currency, transaction_date=None): - if not transaction_date: - transaction_date = nowdate() if not (from_currency and to_currency): # manqala 19/09/2016: Should this be an empty return or should it throw and exception? return @@ -65,13 +63,27 @@ def get_exchange_rate(from_currency, to_currency, transaction_date=None): if from_currency == to_currency: return 1 + if not transaction_date: + transaction_date = nowdate() + + currency_settings = frappe.get_doc("Accounts Settings").as_dict() + allow_stale_rates = currency_settings.get("allow_stale") + + filters = [ + ["date", "<=", get_datetime_str(transaction_date)], + ["from_currency", "=", from_currency], + ["to_currency", "=", to_currency] + ] + + if not allow_stale_rates: + stale_days = currency_settings.get("stale_days") + checkpoint_date = add_days(transaction_date, -stale_days) + filters.append(["date", ">", get_datetime_str(checkpoint_date)]) + # cksgb 19/09/2016: get last entry in Currency Exchange with from_currency and to_currency. - entries = frappe.get_all("Currency Exchange", fields = ["exchange_rate"], - filters=[ - ["date", "<=", get_datetime_str(transaction_date)], - ["from_currency", "=", from_currency], - ["to_currency", "=", to_currency] - ], order_by="date desc", limit=1) + entries = frappe.get_all( + "Currency Exchange", fields=["exchange_rate"], filters=filters, order_by="date desc", + limit=1) if entries: return flt(entries[0].exchange_rate) From 0e28fccb34f95cf301c7b2014f6d228d169b549c Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 29 Aug 2017 18:15:57 +0530 Subject: [PATCH 7/9] [Enahance] Update variants fields defined in the Item Varianst Settings, if template updated --- erpnext/config/stock.py | 5 + erpnext/controllers/item_variant.py | 4 +- .../controllers/tests/test_item_variant.py | 3 + .../img/stock/item_variants_settings.png | Bin 0 -> 46358 bytes .../manual/en/stock/item/item-variants.md | 9 +- erpnext/stock/doctype/item/item.js | 6 + erpnext/stock/doctype/item/item.py | 16 ++ erpnext/stock/doctype/item/test_item.py | 38 +++++ .../doctype/item_variant_settings/__init__.py | 0 .../item_variant_settings.js | 18 +++ .../item_variant_settings.json | 143 ++++++++++++++++++ .../item_variant_settings.py | 9 ++ .../test_item_variant_settings.js | 23 +++ .../doctype/stock_entry/test_stock_entry.py | 14 ++ .../stock/doctype/variant_field/__init__.py | 0 .../variant_field/test_variant_field.js | 23 +++ .../variant_field/test_variant_field.py | 9 ++ .../doctype/variant_field/variant_field.js | 8 + .../doctype/variant_field/variant_field.json | 72 +++++++++ .../doctype/variant_field/variant_field.py | 9 ++ 20 files changed, 406 insertions(+), 3 deletions(-) create mode 100644 erpnext/docs/assets/img/stock/item_variants_settings.png create mode 100644 erpnext/stock/doctype/item_variant_settings/__init__.py create mode 100644 erpnext/stock/doctype/item_variant_settings/item_variant_settings.js create mode 100644 erpnext/stock/doctype/item_variant_settings/item_variant_settings.json create mode 100644 erpnext/stock/doctype/item_variant_settings/item_variant_settings.py create mode 100644 erpnext/stock/doctype/item_variant_settings/test_item_variant_settings.js create mode 100644 erpnext/stock/doctype/variant_field/__init__.py create mode 100644 erpnext/stock/doctype/variant_field/test_variant_field.js create mode 100644 erpnext/stock/doctype/variant_field/test_variant_field.py create mode 100644 erpnext/stock/doctype/variant_field/variant_field.js create mode 100644 erpnext/stock/doctype/variant_field/variant_field.json create mode 100644 erpnext/stock/doctype/variant_field/variant_field.py diff --git a/erpnext/config/stock.py b/erpnext/config/stock.py index a98c40e091..d6b18fdec0 100644 --- a/erpnext/config/stock.py +++ b/erpnext/config/stock.py @@ -105,6 +105,11 @@ def get_data(): "name": "Pricing Rule", "description": _("Rules for applying pricing and discount.") }, + { + "type": "doctype", + "name": "Item Variant Settings", + "description": _("Item Variant Settings."), + }, ] }, diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index 967c1339f1..ff11eb258d 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -180,10 +180,10 @@ def copy_attributes_to_variant(item, variant): # don't copy manufacturer values if based on part no exclude_fields += ['manufacturer', 'manufacturer_part_no'] + allow_fields = [d.field_name for d in frappe.get_all("Variant Field", fields = ['field_name'])] for field in item.meta.fields: # "Table" is part of `no_value_field` but we shouldn't ignore tables - if (field.fieldtype == 'Table' or field.fieldtype not in no_value_fields) \ - and (not field.no_copy) and field.fieldname not in exclude_fields: + if (field.reqd or field.fieldname in allow_fields) and field.fieldname not in exclude_fields: if variant.get(field.fieldname) != item.get(field.fieldname): variant.set(field.fieldname, item.get(field.fieldname)) variant.variant_of = item.name diff --git a/erpnext/controllers/tests/test_item_variant.py b/erpnext/controllers/tests/test_item_variant.py index 9fc45d234c..34d63603b1 100644 --- a/erpnext/controllers/tests/test_item_variant.py +++ b/erpnext/controllers/tests/test_item_variant.py @@ -4,6 +4,7 @@ import frappe import json import unittest +from erpnext.stock.doctype.item.test_item import set_item_variant_settings from erpnext.controllers.item_variant import copy_attributes_to_variant, make_variant_item_code # python 3 compatibility stuff @@ -54,5 +55,7 @@ def make_item_variant(): class TestItemVariant(unittest.TestCase): def test_tables_in_template_copied_to_variant(self): + fields = [{'field_name': 'quality_parameters'}] + set_item_variant_settings(fields) variant = make_item_variant() self.assertNotEqual(variant.get("quality_parameters"), []) diff --git a/erpnext/docs/assets/img/stock/item_variants_settings.png b/erpnext/docs/assets/img/stock/item_variants_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..82b909fb066ed9b133cb7150cec0bfbd566b6109 GIT binary patch literal 46358 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 -The naming of the variant will be the name (ID) of the template Item with a number suffix. e.g. "ITEM000" will have variant "ITEM000-1" \ No newline at end of file +The naming of the variant will be the name (ID) of the template Item with a number suffix. e.g. "ITEM000" will have variant "ITEM000-1" + +### Update Variants Based on Template +To update the value in the variants items from the template item, select the respective fields first in the Item Variant Settings page. After that system will update the value of that fields in the variants if that values has been changed in the template item. + +To set the fields Goto Stock > Item Variant Settings +Item Variant Settings diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 7837f8c73e..03b93c0cb2 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -97,6 +97,12 @@ frappe.ui.form.on("Item", { } frappe.set_route('Form', 'Item', new_item.name); }); + + if(frm.doc.has_variants) { + frm.add_custom_button(__("Item Variant Settings"), function() { + frappe.set_route("Form", "Item Variant Settings"); + }, __("View")); + } }, validate: function(frm){ diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index f2ea1d88bc..a810665997 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -100,6 +100,7 @@ class Item(WebsiteGenerator): def on_update(self): invalidate_cache_for_item(self) self.validate_name_with_item_group() + self.update_variants() self.update_item_price() self.update_template_item() @@ -607,9 +608,24 @@ class Item(WebsiteGenerator): if not template_item.show_in_website: template_item.show_in_website = 1 + template_item.flags.dont_update_variants = True template_item.flags.ignore_permissions = True template_item.save() + def update_variants(self): + if self.flags.dont_update_variants: + return + if self.has_variants: + updated = [] + variants = frappe.db.get_all("Item", fields=["item_code"], filters={"variant_of": self.name }) + for d in variants: + variant = frappe.get_doc("Item", d) + copy_attributes_to_variant(self, variant) + variant.save() + updated.append(d.item_code) + if updated: + frappe.msgprint(_("Item Variants {0} updated").format(", ".join(updated))) + def validate_has_variants(self): if not self.has_variants and frappe.db.get_value("Item", self.name, "has_variants"): if frappe.db.exists("Item", {"variant_of": self.name}): diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 2a8e4344af..34e3af6102 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -119,6 +119,37 @@ class TestItem(unittest.TestCase): variant.item_code = "_Test Variant Item-L-duplicate" self.assertRaises(ItemVariantExistsError, variant.save) + def test_copy_fields_from_template_to_variants(self): + fields = [{'field_name': 'item_group'}, {'field_name': 'is_stock_item'}] + allow_fields = [d.get('field_name') for d in fields] + set_item_variant_settings(fields) + + if not frappe.db.get_value('Item Attribute Value', + {'parent': 'Test Size', 'attribute_value': 'Extra Large'}, 'name'): + item_attribute = frappe.get_doc('Item Attribute', 'Test Size') + item_attribute.append('item_attribute_values', { + 'attribute_value' : 'Extra Large', + 'abbr': 'XL' + }) + item_attribute.save() + + variant = create_variant("_Test Variant Item", {"Test Size": "Extra Large"}) + variant.item_code = "_Test Variant Item-XL" + variant.item_name = "_Test Variant Item-XL" + variant.save() + + template = frappe.get_doc('Item', '_Test Variant Item') + template.item_group = "_Test Item Group D" + template.save() + + variant = frappe.get_doc('Item', '_Test Variant Item-XL') + for fieldname in allow_fields: + self.assertEquals(template.get(fieldname), variant.get(fieldname)) + + template = frappe.get_doc('Item', '_Test Variant Item') + template.item_group = "_Test Item Group Desktops" + template.save() + def test_make_item_variant_with_numeric_values(self): # cleanup for d in frappe.db.get_all('Item', filters={'variant_of': @@ -194,6 +225,9 @@ class TestItem(unittest.TestCase): {"item_code": "Test Item for Merging 2", "warehouse": "_Test Warehouse 1 - _TC"})) def test_item_variant_by_manufacturer(self): + fields = [{'field_name': 'description'}, {'field_name': 'variant_based_on'}] + set_item_variant_settings(fields) + if frappe.db.exists('Item', '_Test Variant Mfg'): frappe.delete_doc('Item', '_Test Variant Mfg') if frappe.db.exists('Item', '_Test Variant Mfg-1'): @@ -227,6 +261,10 @@ class TestItem(unittest.TestCase): self.assertEquals(variant.manufacturer, 'MSG1') self.assertEquals(variant.manufacturer_part_no, '007') +def set_item_variant_settings(fields): + doc = frappe.get_doc('Item Variant Settings') + doc.set('fields', fields) + doc.save() def make_item_variant(): if not frappe.db.exists("Item", "_Test Variant Item-S"): diff --git a/erpnext/stock/doctype/item_variant_settings/__init__.py b/erpnext/stock/doctype/item_variant_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js new file mode 100644 index 0000000000..cd7d8a4085 --- /dev/null +++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js @@ -0,0 +1,18 @@ +// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Item Variant Settings', { + setup: function(frm) { + const allow_fields = []; + frappe.model.with_doctype('Item', () => { + frappe.get_meta('Item').fields.forEach(d => { + if(!in_list(['HTML', 'Section Break', 'Column Break', 'Button'], d.fieldtype) && !d.no_copy) { + allow_fields.push(d.fieldname); + } + }); + + const child = frappe.meta.get_docfield("Variant Field", "field_name", frm.doc.name); + child.options = allow_fields; + }); + } +}); diff --git a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.json b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.json new file mode 100644 index 0000000000..226a07ca6f --- /dev/null +++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.json @@ -0,0 +1,143 @@ +{ + "allow_copy": 0, + "allow_guest_to_view": 0, + "allow_import": 0, + "allow_rename": 0, + "beta": 0, + "creation": "2017-08-29 16:38:31.173830", + "custom": 0, + "docstatus": 0, + "doctype": "DocType", + "document_type": "", + "editable_grid": 1, + "engine": "InnoDB", + "fields": [ + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "variant_fields", + "fieldtype": "Section Break", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Variant Fields", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "fields", + "fieldtype": "Table", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Fields", + "length": 0, + "no_copy": 0, + "options": "Variant Field", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + } + ], + "has_web_view": 0, + "hide_heading": 0, + "hide_toolbar": 0, + "idx": 0, + "image_view": 0, + "in_create": 0, + "is_submittable": 0, + "issingle": 1, + "istable": 0, + "max_attachments": 0, + "modified": "2017-08-29 16:38:49.467749", + "modified_by": "Administrator", + "module": "Stock", + "name": "Item Variant Settings", + "name_case": "", + "owner": "Administrator", + "permissions": [ + { + "amend": 0, + "apply_user_permissions": 0, + "cancel": 0, + "create": 1, + "delete": 0, + "email": 1, + "export": 0, + "if_owner": 0, + "import": 0, + "permlevel": 0, + "print": 1, + "read": 1, + "report": 0, + "role": "System Manager", + "set_user_permissions": 0, + "share": 1, + "submit": 0, + "write": 1 + }, + { + "amend": 0, + "apply_user_permissions": 0, + "cancel": 0, + "create": 1, + "delete": 0, + "email": 1, + "export": 0, + "if_owner": 0, + "import": 0, + "permlevel": 0, + "print": 1, + "read": 1, + "report": 0, + "role": "Item Manager", + "set_user_permissions": 0, + "share": 1, + "submit": 0, + "write": 1 + } + ], + "quick_entry": 1, + "read_only": 0, + "read_only_onload": 0, + "show_name_in_global_search": 0, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "track_seen": 0 +} \ No newline at end of file diff --git a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py new file mode 100644 index 0000000000..26e08d119d --- /dev/null +++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +from frappe.model.document import Document + +class ItemVariantSettings(Document): + pass diff --git a/erpnext/stock/doctype/item_variant_settings/test_item_variant_settings.js b/erpnext/stock/doctype/item_variant_settings/test_item_variant_settings.js new file mode 100644 index 0000000000..3b3bf94f37 --- /dev/null +++ b/erpnext/stock/doctype/item_variant_settings/test_item_variant_settings.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +// rename this file from _test_[name] to test_[name] to activate +// and remove above this line + +QUnit.test("test: Item Variant Settings", function (assert) { + let done = assert.async(); + + // number of asserts + assert.expect(1); + + frappe.run_serially([ + // insert a new Item Variant Settings + () => frappe.tests.make('Item Variant Settings', [ + // values to be set + {key: 'value'} + ]), + () => { + assert.equal(cur_frm.doc.key, 'value'); + }, + () => done() + ]); + +}); diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 7fa232e6f7..4bcbcc4b6f 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -11,6 +11,7 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt \ from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import StockFreezeError from erpnext.stock.stock_ledger import get_previous_sle from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import create_stock_reconciliation +from erpnext.stock.doctype.item.test_item import set_item_variant_settings from frappe.tests.test_permissions import set_user_permission_doctypes from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.accounts.doctype.account.test_account import get_inventory_account @@ -79,6 +80,19 @@ class TestStockEntry(unittest.TestCase): self._test_auto_material_request("_Test Item", material_request_type="Transfer") def test_auto_material_request_for_variant(self): + fields = [{'field_name': 'reorder_levels'}] + set_item_variant_settings(fields) + template = frappe.get_doc("Item", "_Test Variant Item") + + if not template.reorder_levels: + template.append('reorder_levels', { + "material_request_type": "Purchase", + "warehouse": "_Test Warehouse - _TC", + "warehouse_reorder_level": 20, + "warehouse_reorder_qty": 20 + }) + + template.save() self._test_auto_material_request("_Test Variant Item-S") def test_auto_material_request_for_warehouse_group(self): diff --git a/erpnext/stock/doctype/variant_field/__init__.py b/erpnext/stock/doctype/variant_field/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/stock/doctype/variant_field/test_variant_field.js b/erpnext/stock/doctype/variant_field/test_variant_field.js new file mode 100644 index 0000000000..2600a10fe0 --- /dev/null +++ b/erpnext/stock/doctype/variant_field/test_variant_field.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +// rename this file from _test_[name] to test_[name] to activate +// and remove above this line + +QUnit.test("test: Variant Field", function (assert) { + let done = assert.async(); + + // number of asserts + assert.expect(1); + + frappe.run_serially([ + // insert a new Variant Field + () => frappe.tests.make('Variant Field', [ + // values to be set + {key: 'value'} + ]), + () => { + assert.equal(cur_frm.doc.key, 'value'); + }, + () => done() + ]); + +}); diff --git a/erpnext/stock/doctype/variant_field/test_variant_field.py b/erpnext/stock/doctype/variant_field/test_variant_field.py new file mode 100644 index 0000000000..53024bdac1 --- /dev/null +++ b/erpnext/stock/doctype/variant_field/test_variant_field.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import unittest + +class TestVariantField(unittest.TestCase): + pass diff --git a/erpnext/stock/doctype/variant_field/variant_field.js b/erpnext/stock/doctype/variant_field/variant_field.js new file mode 100644 index 0000000000..13db3f9272 --- /dev/null +++ b/erpnext/stock/doctype/variant_field/variant_field.js @@ -0,0 +1,8 @@ +// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Variant Field', { + refresh: function() { + + } +}); diff --git a/erpnext/stock/doctype/variant_field/variant_field.json b/erpnext/stock/doctype/variant_field/variant_field.json new file mode 100644 index 0000000000..ae9088486f --- /dev/null +++ b/erpnext/stock/doctype/variant_field/variant_field.json @@ -0,0 +1,72 @@ +{ + "allow_copy": 0, + "allow_guest_to_view": 0, + "allow_import": 0, + "allow_rename": 0, + "beta": 0, + "creation": "2017-08-29 16:33:33.978574", + "custom": 0, + "docstatus": 0, + "doctype": "DocType", + "document_type": "", + "editable_grid": 1, + "engine": "InnoDB", + "fields": [ + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "field_name", + "fieldtype": "Select", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Field Name", + "length": 0, + "no_copy": 0, + "options": "", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + } + ], + "has_web_view": 0, + "hide_heading": 0, + "hide_toolbar": 0, + "idx": 0, + "image_view": 0, + "in_create": 0, + "is_submittable": 0, + "issingle": 0, + "istable": 1, + "max_attachments": 0, + "modified": "2017-08-29 17:19:20.353197", + "modified_by": "Administrator", + "module": "Stock", + "name": "Variant Field", + "name_case": "", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "read_only": 0, + "read_only_onload": 0, + "show_name_in_global_search": 0, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "track_seen": 0 +} \ No newline at end of file diff --git a/erpnext/stock/doctype/variant_field/variant_field.py b/erpnext/stock/doctype/variant_field/variant_field.py new file mode 100644 index 0000000000..a77301e0e5 --- /dev/null +++ b/erpnext/stock/doctype/variant_field/variant_field.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +from frappe.model.document import Document + +class VariantField(Document): + pass From 5b05335e89a581ddb74176effd956ba9084fef7c Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Thu, 7 Sep 2017 15:14:09 +0530 Subject: [PATCH 8/9] added patch and set default fields after completion of setup wizard --- erpnext/patches.txt | 1 + .../v8_9/set_default_fields_in_variant_settings.py | 13 +++++++++++++ erpnext/setup/setup_wizard/setup_wizard.py | 7 +++++++ .../item_variant_settings/item_variant_settings.js | 5 ++++- .../item_variant_settings.json | 8 ++++---- .../item_variant_settings/item_variant_settings.py | 13 ++++++++++++- .../test_item_variant_settings.py | 9 +++++++++ 7 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 erpnext/patches/v8_9/set_default_fields_in_variant_settings.py create mode 100644 erpnext/stock/doctype/item_variant_settings/test_item_variant_settings.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index f961a5b81e..855358e716 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -446,3 +446,4 @@ erpnext.patches.v8_9.set_print_zero_amount_taxes erpnext.patches.v8_9.set_default_customer_group erpnext.patches.v8_9.remove_employee_from_salary_structure_parent erpnext.patches.v8_9.delete_gst_doctypes_for_outside_india_accounts +erpnext.patches.v8_9.set_default_fields_in_variant_settings \ No newline at end of file diff --git a/erpnext/patches/v8_9/set_default_fields_in_variant_settings.py b/erpnext/patches/v8_9/set_default_fields_in_variant_settings.py new file mode 100644 index 0000000000..a550d093fa --- /dev/null +++ b/erpnext/patches/v8_9/set_default_fields_in_variant_settings.py @@ -0,0 +1,13 @@ +# Copyright (c) 2017, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + frappe.reload_doc('stock', 'doctype', 'item_variant_settings') + frappe.reload_doc('stock', 'doctype', 'variant_field') + + doc = frappe.get_doc('Item Variant Settings') + doc.set_default_fields() + doc.save() \ No newline at end of file diff --git a/erpnext/setup/setup_wizard/setup_wizard.py b/erpnext/setup/setup_wizard/setup_wizard.py index d3e4a084f5..bf9221784c 100644 --- a/erpnext/setup/setup_wizard/setup_wizard.py +++ b/erpnext/setup/setup_wizard/setup_wizard.py @@ -33,6 +33,7 @@ def setup_complete(args=None): create_feed_and_todo() create_email_digest() create_letter_head(args) + set_no_copy_fields_in_variant_settings() if args.get('domain').lower() == 'education': create_academic_year() @@ -354,6 +355,12 @@ def create_letter_head(args): fileurl = save_file(filename, content, "Letter Head", _("Standard"), decode=True).file_url frappe.db.set_value("Letter Head", _("Standard"), "content", "" % fileurl) +def set_no_copy_fields_in_variant_settings(): + # set no copy fields of an item doctype to item variant settings + doc = frappe.get_doc('Item Variant Settings') + doc.set_default_fields() + doc.save() + def create_logo(args): if args.get("attach_logo"): attach_logo = args.get("attach_logo").split(",") diff --git a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js index cd7d8a4085..77ccfd0cc0 100644 --- a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js +++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js @@ -4,9 +4,12 @@ frappe.ui.form.on('Item Variant Settings', { setup: function(frm) { const allow_fields = []; + const exclude_fields = ["item_code", "item_name", "show_in_website", "show_variant_in_website", "standard_rate"]; + frappe.model.with_doctype('Item', () => { frappe.get_meta('Item').fields.forEach(d => { - if(!in_list(['HTML', 'Section Break', 'Column Break', 'Button'], d.fieldtype) && !d.no_copy) { + if(!in_list(['HTML', 'Section Break', 'Column Break', 'Button'], d.fieldtype) + && !d.no_copy && !in_list(exclude_fields, d.fieldname)) { allow_fields.push(d.fieldname); } }); diff --git a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.json b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.json index 226a07ca6f..a29137c762 100644 --- a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.json +++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.json @@ -18,7 +18,7 @@ "bold": 0, "collapsible": 0, "columns": 0, - "fieldname": "variant_fields", + "fieldname": "copy_fields_to_variant", "fieldtype": "Section Break", "hidden": 0, "ignore_user_permissions": 0, @@ -27,7 +27,7 @@ "in_global_search": 0, "in_list_view": 0, "in_standard_filter": 0, - "label": "Variant Fields", + "label": "Copy Fields to Variant", "length": 0, "no_copy": 0, "permlevel": 0, @@ -84,8 +84,8 @@ "issingle": 1, "istable": 0, "max_attachments": 0, - "modified": "2017-08-29 16:38:49.467749", - "modified_by": "Administrator", + "modified": "2017-09-11 12:05:16.288601", + "modified_by": "rohit@erpnext.com", "module": "Stock", "name": "Item Variant Settings", "name_case": "", diff --git a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py index 26e08d119d..1cc7c21520 100644 --- a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py +++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py @@ -3,7 +3,18 @@ # For license information, please see license.txt from __future__ import unicode_literals +import frappe from frappe.model.document import Document class ItemVariantSettings(Document): - pass + def set_default_fields(self): + self.fields = [] + fields = frappe.get_meta('Item').fields + exclude_fields = ["item_code", "item_name", "show_in_website", "show_variant_in_website", "standard_rate"] + + for d in fields: + if not d.no_copy and d.fieldname not in exclude_fields and \ + d.fieldtype not in ['HTML', 'Section Break', 'Column Break', 'Button']: + self.append('fields', { + 'field_name': d.fieldname + }) \ No newline at end of file diff --git a/erpnext/stock/doctype/item_variant_settings/test_item_variant_settings.py b/erpnext/stock/doctype/item_variant_settings/test_item_variant_settings.py new file mode 100644 index 0000000000..9a800c07fc --- /dev/null +++ b/erpnext/stock/doctype/item_variant_settings/test_item_variant_settings.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import unittest + +class TestItemVariantSettings(unittest.TestCase): + pass From 2851dfad9974c9a49feee8f05a4adb6971c5cceb Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 21 Sep 2017 15:41:15 +0530 Subject: [PATCH 9/9] Set fields in Item Variant Settings which should be copied from template to variant --- .../doctype/item_variant_settings/item_variant_settings.js | 5 +++-- .../doctype/item_variant_settings/item_variant_settings.py | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js index 77ccfd0cc0..f3404cc78b 100644 --- a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js +++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js @@ -4,11 +4,12 @@ frappe.ui.form.on('Item Variant Settings', { setup: function(frm) { const allow_fields = []; - const exclude_fields = ["item_code", "item_name", "show_in_website", "show_variant_in_website", "standard_rate"]; + const exclude_fields = ["item_code", "item_name", "show_in_website", "show_variant_in_website", + "opening_stock", "variant_of", "valuation_rate", "variant_based_on"]; frappe.model.with_doctype('Item', () => { frappe.get_meta('Item').fields.forEach(d => { - if(!in_list(['HTML', 'Section Break', 'Column Break', 'Button'], d.fieldtype) + if(!in_list(['HTML', 'Section Break', 'Column Break', 'Button', 'Read Only'], d.fieldtype) && !d.no_copy && !in_list(exclude_fields, d.fieldname)) { allow_fields.push(d.fieldname); } diff --git a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py index 1cc7c21520..80462d1ab8 100644 --- a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py +++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py @@ -10,11 +10,14 @@ class ItemVariantSettings(Document): def set_default_fields(self): self.fields = [] fields = frappe.get_meta('Item').fields - exclude_fields = ["item_code", "item_name", "show_in_website", "show_variant_in_website", "standard_rate"] + exclude_fields = ["item_code", "item_name", "show_in_website", "show_variant_in_website", + "standard_rate", "opening_stock", "image", "description", + "variant_of", "valuation_rate", "description", "variant_based_on", + "website_image", "thumbnail", "website_specifiations", "web_long_description"] for d in fields: if not d.no_copy and d.fieldname not in exclude_fields and \ - d.fieldtype not in ['HTML', 'Section Break', 'Column Break', 'Button']: + d.fieldtype not in ['HTML', 'Section Break', 'Column Break', 'Button', 'Read Only']: self.append('fields', { 'field_name': d.fieldname }) \ No newline at end of file