diff --git a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py index 1923f78cf89..63317c52d80 100644 --- a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py +++ b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py @@ -58,8 +58,7 @@ def get_columns(): { "fieldname": "payment_document", "label": _("Payment Document Type"), - "fieldtype": "Link", - "options": "DocType", + "fieldtype": "Data", "width": 220 }, { diff --git a/erpnext/config/crm.py b/erpnext/config/crm.py index e49fc60f63a..70784f3d5f7 100644 --- a/erpnext/config/crm.py +++ b/erpnext/config/crm.py @@ -141,6 +141,11 @@ def get_data(): "name": "Campaign", "description": _("Sales campaigns."), }, + { + "type": "doctype", + "name": "Email Campaign", + "description": _("Sends Mails to lead or contact based on a Campaign schedule"), + }, { "type": "doctype", "name": "SMS Center", diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index d8c50b2622f..b2057ca40f9 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -40,7 +40,6 @@ status_map = { ["To Bill", "eval:self.per_delivered == 100 and self.per_billed < 100 and self.docstatus == 1"], ["To Deliver", "eval:self.per_delivered < 100 and self.per_billed == 100 and self.docstatus == 1"], ["Completed", "eval:self.per_delivered == 100 and self.per_billed == 100 and self.docstatus == 1"], - ["Completed", "eval:self.order_type == 'Maintenance' and self.per_billed == 100 and self.docstatus == 1"], ["Cancelled", "eval:self.docstatus==2"], ["Closed", "eval:self.status=='Closed'"], ["On Hold", "eval:self.status=='On Hold'"], diff --git a/erpnext/crm/doctype/campaign_email_schedule/__init__.py b/erpnext/crm/doctype/campaign_email_schedule/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/crm/doctype/campaign_email_schedule/campaign_email_schedule.json b/erpnext/crm/doctype/campaign_email_schedule/campaign_email_schedule.json new file mode 100644 index 00000000000..1481a32d5b0 --- /dev/null +++ b/erpnext/crm/doctype/campaign_email_schedule/campaign_email_schedule.json @@ -0,0 +1,38 @@ +{ + "creation": "2019-06-30 15:56:20.306901", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "email_template", + "send_after_days" + ], + "fields": [ + { + "fieldname": "send_after_days", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Send After (days)", + "reqd": 1 + }, + { + "fieldname": "email_template", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Email Template", + "options": "Email Template", + "reqd": 1 + } + ], + "istable": 1, + "modified": "2019-07-12 11:46:43.184123", + "modified_by": "Administrator", + "module": "CRM", + "name": "Campaign Email Schedule", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/crm/doctype/campaign_email_schedule/campaign_email_schedule.py b/erpnext/crm/doctype/campaign_email_schedule/campaign_email_schedule.py new file mode 100644 index 00000000000..8445b8a397e --- /dev/null +++ b/erpnext/crm/doctype/campaign_email_schedule/campaign_email_schedule.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class CampaignEmailSchedule(Document): + pass diff --git a/erpnext/crm/doctype/email_campaign/__init__.py b/erpnext/crm/doctype/email_campaign/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/crm/doctype/email_campaign/email_campaign.js b/erpnext/crm/doctype/email_campaign/email_campaign.js new file mode 100644 index 00000000000..b0e93536094 --- /dev/null +++ b/erpnext/crm/doctype/email_campaign/email_campaign.js @@ -0,0 +1,8 @@ +// Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Email Campaign', { + email_campaign_for: function(frm) { + frm.set_value('recipient', ''); + } +}); diff --git a/erpnext/crm/doctype/email_campaign/email_campaign.json b/erpnext/crm/doctype/email_campaign/email_campaign.json new file mode 100644 index 00000000000..32591362753 --- /dev/null +++ b/erpnext/crm/doctype/email_campaign/email_campaign.json @@ -0,0 +1,95 @@ +{ + "autoname": "format:MAIL-CAMP-{YYYY}-{#####}", + "creation": "2019-06-30 16:05:30.015615", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "campaign_name", + "email_campaign_for", + "recipient", + "sender", + "column_break_4", + "start_date", + "end_date", + "status" + ], + "fields": [ + { + "fieldname": "campaign_name", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Campaign", + "options": "Campaign", + "reqd": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "\nScheduled\nIn Progress\nCompleted\nUnsubscribed", + "read_only": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "start_date", + "fieldtype": "Date", + "label": "Start Date", + "reqd": 1 + }, + { + "fieldname": "end_date", + "fieldtype": "Date", + "label": "End Date", + "read_only": 1 + }, + { + "default": "Lead", + "fieldname": "email_campaign_for", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Email Campaign For ", + "options": "\nLead\nContact" + }, + { + "fieldname": "recipient", + "fieldtype": "Dynamic Link", + "label": "Recipient", + "options": "email_campaign_for", + "reqd": 1 + }, + { + "default": "__user", + "fieldname": "sender", + "fieldtype": "Link", + "label": "Sender", + "options": "User" + } + ], + "modified": "2019-07-12 13:47:37.261213", + "modified_by": "Administrator", + "module": "CRM", + "name": "Email Campaign", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/crm/doctype/email_campaign/email_campaign.py b/erpnext/crm/doctype/email_campaign/email_campaign.py new file mode 100644 index 00000000000..98e4927beb6 --- /dev/null +++ b/erpnext/crm/doctype/email_campaign/email_campaign.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.utils import getdate, add_days, today, nowdate, cstr +from frappe.model.document import Document +from frappe.core.doctype.communication.email import make + +class EmailCampaign(Document): + def validate(self): + self.set_date() + #checking if email is set for lead. Not checking for contact as email is a mandatory field for contact. + if self.email_campaign_for == "Lead": + self.validate_lead() + self.validate_email_campaign_already_exists() + self.update_status() + + def set_date(self): + if getdate(self.start_date) < getdate(today()): + frappe.throw(_("Start Date cannot be before the current date")) + #set the end date as start date + max(send after days) in campaign schedule + send_after_days = [] + campaign = frappe.get_doc("Campaign", self.campaign_name) + for entry in campaign.get("campaign_schedules"): + send_after_days.append(entry.send_after_days) + try: + end_date = add_days(getdate(self.start_date), max(send_after_days)) + except ValueError: + frappe.throw(_("Please set up the Campaign Schedule in the Campaign {0}").format(self.campaign_name)) + + def validate_lead(self): + lead_email_id = frappe.db.get_value("Lead", self.recipient, 'email_id') + if not lead_email_id: + lead_name = frappe.db.get_value("Lead", self.recipient, 'lead_name') + frappe.throw(_("Please set an email id for the Lead {0}").format(lead_name)) + + def validate_email_campaign_already_exists(self): + email_campaign_exists = frappe.db.exists("Email Campaign", { + "campaign_name": self.campaign_name, + "recipient": self.recipient, + "status": ("in", ["In Progress", "Scheduled"]) + }) + if email_campaign_exists: + frappe.throw(_("The Campaign '{0}' already exists for the {1} '{2}'").format(self.campaign_name, self.email_campaign_for, self.recipient)) + + def update_status(self): + start_date = getdate(self.start_date) + end_date = getdate(self.end_date) + today_date = getdate(today()) + if start_date > today_date: + self.status = "Scheduled" + elif end_date >= today_date: + self.status = "In Progress" + elif end_date < today_date: + self.status = "Completed" + +#called through hooks to send campaign mails to leads +def send_email_to_leads_or_contacts(): + email_campaigns = frappe.get_all("Email Campaign", filters = { 'status': ('not in', ['Unsubscribed', 'Completed', 'Scheduled']) }) + for camp in email_campaigns: + email_campaign = frappe.get_doc("Email Campaign", camp.name) + campaign = frappe.get_cached_doc("Campaign", email_campaign.campaign_name) + for entry in campaign.get("campaign_schedules"): + scheduled_date = add_days(email_campaign.get('start_date'), entry.get('send_after_days')) + if scheduled_date == getdate(today()): + send_mail(entry, email_campaign) + +def send_mail(entry, email_campaign): + recipient = frappe.db.get_value(email_campaign.email_campaign_for, email_campaign.get("recipient"), 'email_id') + + email_template = frappe.get_doc("Email Template", entry.get("email_template")) + sender = frappe.db.get_value("User", email_campaign.get("sender"), 'email') + + # send mail and link communication to document + comm = make( + doctype = "Email Campaign", + name = email_campaign.name, + subject = email_template.get("subject"), + content = email_template.get("response"), + sender = sender, + recipients = recipient, + communication_medium = "Email", + sent_or_received = "Sent", + send_email = True, + email_template = email_template.name + ) + return comm + +#called from hooks on doc_event Email Unsubscribe +def unsubscribe_recipient(unsubscribe, method): + if unsubscribe.reference_doctype == 'Email Campaign': + frappe.db.set_value("Email Campaign", unsubscribe.reference_name, "status", "Unsubscribed") + +#called through hooks to update email campaign status daily +def set_email_campaign_status(): + email_campaigns = frappe.get_all("Email Campaign", filters = { 'status': ('!=', 'Unsubscribed')}) + for entry in email_campaigns: + email_campaign = frappe.get_doc("Email Campaign", entry.name) + email_campaign.update_status() diff --git a/erpnext/crm/doctype/email_campaign/email_campaign_list.js b/erpnext/crm/doctype/email_campaign/email_campaign_list.js new file mode 100644 index 00000000000..adc399da0f0 --- /dev/null +++ b/erpnext/crm/doctype/email_campaign/email_campaign_list.js @@ -0,0 +1,11 @@ +frappe.listview_settings['Email Campaign'] = { + get_indicator: function(doc) { + var colors = { + "Unsubscribed": "red", + "Scheduled": "blue", + "In Progress": "orange", + "Completed": "green" + }; + return [__(doc.status), colors[doc.status], "status,=," + doc.status]; + } +}; diff --git a/erpnext/crm/doctype/email_campaign/test_email_campaign.py b/erpnext/crm/doctype/email_campaign/test_email_campaign.py new file mode 100644 index 00000000000..f5eab483330 --- /dev/null +++ b/erpnext/crm/doctype/email_campaign/test_email_campaign.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestEmailCampaign(unittest.TestCase): + pass diff --git a/erpnext/hooks.py b/erpnext/hooks.py index d814700a458..47d1a68efcb 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -233,6 +233,9 @@ doc_events = { }, "Contact":{ "on_trash": "erpnext.support.doctype.issue.issue.update_issue" + }, + "Email Unsubscribe": { + "after_insert": "erpnext.crm.doctype.email_campaign.email_campaign.unsubscribe_recipient" } } @@ -272,6 +275,8 @@ scheduler_events = { "erpnext.projects.doctype.project.project.send_project_status_email_to_users", "erpnext.quality_management.doctype.quality_review.quality_review.review", "erpnext.support.doctype.service_level_agreement.service_level_agreement.check_agreement_status", + "erpnext.crm.doctype.email_campaign.email_campaign.send_email_to_leads_or_contacts", + "erpnext.crm.doctype.email_campaign.email_campaign.set_email_campaign_status" ], "daily_long": [ "erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.update_latest_price_in_all_boms" diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py index e8c170e7215..2da10857324 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.py +++ b/erpnext/regional/report/gstr_1/gstr_1.py @@ -161,8 +161,9 @@ class Gstr1Report(object): "gst_category": ["in", ["Registered Regular", "Deemed Export", "SEZ"]] }) - conditions += """ and ifnull(gst_category, '') != 'Overseas' and is_return != 1 - and customer in ({0})""".format(", ".join([frappe.db.escape(c.name) for c in customers])) + if customers: + conditions += """ and ifnull(gst_category, '') != 'Overseas' and is_return != 1 + and customer in ({0})""".format(", ".join([frappe.db.escape(c.name) for c in customers])) if self.filters.get("type_of_business") in ("B2C Large", "B2C Small"): b2c_limit = frappe.db.get_single_value('GST Settings', 'b2c_limit') @@ -174,11 +175,11 @@ class Gstr1Report(object): "gst_category": ["in", ["Unregistered"]] }) - if self.filters.get("type_of_business") == "B2C Large": + if self.filters.get("type_of_business") == "B2C Large" and customers: conditions += """ and SUBSTR(place_of_supply, 1, 2) != SUBSTR(company_gstin, 1, 2) and grand_total > {0} and is_return != 1 and customer in ({1})""".\ format(flt(b2c_limit), ", ".join([frappe.db.escape(c.name) for c in customers])) - elif self.filters.get("type_of_business") == "B2C Small": + elif self.filters.get("type_of_business") == "B2C Small" and customers: conditions += """ and ( SUBSTR(place_of_supply, 1, 2) = SUBSTR(company_gstin, 1, 2) or grand_total <= {0}) and is_return != 1 and customer in ({1})""".\ diff --git a/erpnext/selling/doctype/campaign/campaign.json b/erpnext/selling/doctype/campaign/campaign.json index d12069959cc..986ac1306cd 100644 --- a/erpnext/selling/doctype/campaign/campaign.json +++ b/erpnext/selling/doctype/campaign/campaign.json @@ -6,18 +6,13 @@ "description": "Keep Track of Sales Campaigns. Keep track of Leads, Quotations, Sales Order etc from Campaigns to gauge Return on Investment. ", "doctype": "DocType", "document_type": "Setup", + "engine": "InnoDB", "field_order": [ "campaign", "campaign_name", "naming_series", - "from_date", - "column_break1", - "status", - "to_date", - "budget_section", - "currency", - "column_break2", - "budget", + "campaign_schedules_section", + "campaign_schedules", "description_section", "description" ], @@ -52,57 +47,25 @@ "oldfieldtype": "Text", "width": "300px" }, - { - "fieldname": "status", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Status", - "options": "\nPlanned\nIn Progress\nCompleted\nCancelled", - "reqd": 1, - "default": "Planned" - }, - { - "fieldname": "from_date", - "fieldtype": "Date", - "label": "From Date" - }, - { - "fieldname": "to_date", - "fieldtype": "Date", - "label": "To Date" - }, - { - "fieldname": "column_break1", - "fieldtype": "Column Break" - }, - { - "fieldname": "budget", - "fieldtype": "Currency", - "label": "Budget" - }, { "fieldname": "description_section", "fieldtype": "Section Break" }, { - "fieldname": "currency", - "fieldtype": "Link", - "label": "Currency", - "options": "Currency" + "fieldname": "campaign_schedules", + "fieldtype": "Table", + "label": "Campaign Schedules", + "options": "Campaign Email Schedule" }, { - "fieldname": "column_break2", - "fieldtype": "Column Break" - }, - { - "fieldname": "budget_section", + "fieldname": "campaign_schedules_section", "fieldtype": "Section Break", - "label": "BUDGET" + "label": "Campaign Schedules" } ], "icon": "fa fa-bullhorn", "idx": 1, - "modified": "2019-04-29 22:09:39.251884", + "modified": "2019-07-22 12:03:39.832342", "modified_by": "Administrator", "module": "Selling", "name": "Campaign", @@ -140,5 +103,7 @@ "write": 1 } ], - "quick_entry": 1 -} + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/erpnext/selling/doctype/campaign/campaign_dashboard.py b/erpnext/selling/doctype/campaign/campaign_dashboard.py new file mode 100644 index 00000000000..a9d8eca38c2 --- /dev/null +++ b/erpnext/selling/doctype/campaign/campaign_dashboard.py @@ -0,0 +1,13 @@ +from __future__ import unicode_literals +from frappe import _ + +def get_data(): + return { + 'fieldname': 'campaign_name', + 'transactions': [ + { + 'label': _('Email Campaigns'), + 'items': ['Email Campaign'] + } + ], + } diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py index 8fbeac81382..cab21162c76 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -69,8 +69,7 @@ class ItemGroup(NestedSet, WebsiteGenerator): "items": get_product_list_for_group(product_group = self.name, start=start, limit=context.page_length + 1, search=frappe.form_dict.get("search")), "parents": get_parent_item_groups(self.parent_item_group), - "title": self.name, - "products_as_list": cint(frappe.db.get_single_value('Products Settings', 'products_as_list')) + "title": self.name }) if self.slideshow: diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index bd242570650..f609a0be7dc 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -122,8 +122,8 @@ class Batch(Document): self.expiry_date = add_days(self.manufacturing_date, shelf_life_in_days) if has_expiry_date and not self.expiry_date: - frappe.throw(_('Expiry date is mandatory for selected item')) - frappe.msgprint(_('Set items shelf life in days, to set expiry based on manufacturing_date plus self life')) + frappe.msgprint(_('Expiry date is mandatory for selected item.')) + frappe.throw(_("Set item's shelf life in days, to set expiry based on manufacturing date plus shelf-life.")) def get_name_from_naming_series(self): """ diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index 2df3d984e68..c203f8baa3d 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -222,7 +222,7 @@ def validate_serial_no(sle, item_det): frappe.throw(_("Serial No {0} has already been received").format(serial_no), SerialNoDuplicateError) - if (sr.delivery_document_no and sle.voucher_type != 'Stock Entry' + if (sr.delivery_document_no and sle.voucher_type not in ['Stock Entry', 'Stock Reconciliation'] and sle.voucher_type == sr.delivery_document_type): return_against = frappe.db.get_value(sle.voucher_type, sle.voucher_no, 'return_against') if return_against and return_against != sr.delivery_document_no: @@ -299,7 +299,7 @@ def validate_so_serial_no(sr, sales_order,): be delivered""").format(sales_order, sr.item_code, sr.name)) def has_duplicate_serial_no(sn, sle): - if sn.warehouse: + if sn.warehouse and sle.voucher_type != 'Stock Reconciliation': return True if sn.company != sle.company: @@ -415,16 +415,20 @@ def update_serial_nos_after_submit(controller, parentfield): if not stock_ledger_entries: return for d in controller.get(parentfield): + if d.serial_no: + continue + update_rejected_serial_nos = True if (controller.doctype in ("Purchase Receipt", "Purchase Invoice") and d.rejected_qty) else False accepted_serial_nos_updated = False + if controller.doctype == "Stock Entry": warehouse = d.t_warehouse qty = d.transfer_qty else: warehouse = d.warehouse - qty = d.stock_qty - + qty = (d.qty if controller.doctype == "Stock Reconciliation" + else d.stock_qty) for sle in stock_ledger_entries: if sle.voucher_detail_no==d.name: if not accepted_serial_nos_updated and qty and abs(sle.actual_qty)==qty \ diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 0abcbb328aa..f40560a57f7 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -359,7 +359,7 @@ class StockEntry(StockController): d.basic_rate = 0.0 elif d.t_warehouse and not d.basic_rate: d.basic_rate = get_valuation_rate(d.item_code, d.t_warehouse, - self.doctype, d.name, d.allow_zero_valuation_rate, + self.doctype, self.name, d.allow_zero_valuation_rate, currency=erpnext.get_company_currency(self.company)) def set_actual_qty(self): diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index 79da70e3138..5fe89d6e227 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -38,7 +38,7 @@ class StockLedgerEntry(Document): self.check_stock_frozen_date() self.actual_amt_check() - if not self.get("via_landed_cost_voucher") and self.voucher_type != 'Stock Reconciliation': + if not self.get("via_landed_cost_voucher"): from erpnext.stock.doctype.serial_no.serial_no import process_serial_no process_serial_no(self) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js index ed9d77092ae..5ac0b098fe6 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js @@ -12,8 +12,7 @@ frappe.ui.form.on("Stock Reconciliation", { return { query: "erpnext.controllers.queries.item_query", filters:{ - "is_stock_item": 1, - "has_serial_no": 0 + "is_stock_item": 1 } } }); @@ -77,6 +76,7 @@ frappe.ui.form.on("Stock Reconciliation", { set_valuation_rate_and_qty: function(frm, cdt, cdn) { var d = frappe.model.get_doc(cdt, cdn); + if(d.item_code && d.warehouse) { frappe.call({ method: "erpnext.stock.doctype.stock_reconciliation.stock_reconciliation.get_stock_balance_for", @@ -84,7 +84,8 @@ frappe.ui.form.on("Stock Reconciliation", { item_code: d.item_code, warehouse: d.warehouse, posting_date: frm.doc.posting_date, - posting_time: frm.doc.posting_time + posting_time: frm.doc.posting_time, + batch_no: d.batch_no }, callback: function(r) { frappe.model.set_value(cdt, cdn, "qty", r.message.qty); @@ -93,7 +94,7 @@ frappe.ui.form.on("Stock Reconciliation", { frappe.model.set_value(cdt, cdn, "current_valuation_rate", r.message.rate); frappe.model.set_value(cdt, cdn, "current_amount", r.message.rate * r.message.qty); frappe.model.set_value(cdt, cdn, "amount", r.message.rate * r.message.qty); - + frappe.model.set_value(cdt, cdn, "current_serial_no", r.message.serial_nos); } }); } @@ -152,17 +153,44 @@ frappe.ui.form.on("Stock Reconciliation Item", { barcode: function(frm, cdt, cdn) { frm.events.set_item_code(frm, cdt, cdn); }, + warehouse: function(frm, cdt, cdn) { + var child = locals[cdt][cdn]; + if (child.batch_no) { + frappe.model.set_value(child.cdt, child.cdn, "batch_no", ""); + } + frm.events.set_valuation_rate_and_qty(frm, cdt, cdn); }, + item_code: function(frm, cdt, cdn) { + var child = locals[cdt][cdn]; + if (child.batch_no) { + frappe.model.set_value(cdt, cdn, "batch_no", ""); + } + frm.events.set_valuation_rate_and_qty(frm, cdt, cdn); }, + + batch_no: function(frm, cdt, cdn) { + frm.events.set_valuation_rate_and_qty(frm, cdt, cdn); + }, + qty: function(frm, cdt, cdn) { frm.events.set_amount_quantity(frm, cdt, cdn); }, + valuation_rate: function(frm, cdt, cdn) { frm.events.set_amount_quantity(frm, cdt, cdn); + }, + + serial_no: function(frm, cdt, cdn) { + var child = locals[cdt][cdn]; + + if (child.serial_no) { + const serial_nos = child.serial_no.trim().split('\n'); + frappe.model.set_value(cdt, cdn, "qty", serial_nos.length); + } } }); diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 205beed7443..d9e62c79318 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -9,7 +9,9 @@ from frappe.utils import cstr, flt, cint from erpnext.stock.stock_ledger import update_entries_after from erpnext.controllers.stock_controller import StockController from erpnext.accounts.utils import get_company_default -from erpnext.stock.utils import get_stock_balance +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos +from erpnext.stock.utils import get_stock_balance, get_incoming_rate, get_available_serial_nos +from erpnext.stock.doctype.batch.batch import get_batch_qty class OpeningEntryAccountError(frappe.ValidationError): pass class EmptyStockReconciliationItemsError(frappe.ValidationError): pass @@ -30,10 +32,16 @@ class StockReconciliation(StockController): self.validate_expense_account() self.set_total_qty_and_amount() + if self._action=="submit": + self.make_batches('warehouse') + def on_submit(self): self.update_stock_ledger() self.make_gl_entries() + from erpnext.stock.doctype.serial_no.serial_no import update_serial_nos_after_submit + update_serial_nos_after_submit(self, "items") + def on_cancel(self): self.delete_and_repost_sle() self.make_gl_entries_on_cancel() @@ -42,23 +50,28 @@ class StockReconciliation(StockController): """Remove items if qty or rate is not changed""" self.difference_amount = 0.0 def _changed(item): - qty, rate = get_stock_balance(item.item_code, item.warehouse, - self.posting_date, self.posting_time, with_valuation_rate=True) - if (item.qty==None or item.qty==qty) and (item.valuation_rate==None or item.valuation_rate==rate): + item_dict = get_stock_balance_for(item.item_code, item.warehouse, + self.posting_date, self.posting_time, batch_no=item.batch_no) + if (((item.qty is None or item.qty==item_dict.get("qty")) and + (item.valuation_rate is None or item.valuation_rate==item_dict.get("rate")) and not item.serial_no) + or (item.serial_no and item.serial_no == item_dict.get("serial_nos"))): return False else: # set default as current rates - if item.qty==None: - item.qty = qty + if item.qty is None: + item.qty = item_dict.get("qty") - if item.valuation_rate==None: - item.valuation_rate = rate + if item.valuation_rate is None: + item.valuation_rate = item_dict.get("rate") - item.current_qty = qty - item.current_valuation_rate = rate + if item_dict.get("serial_nos"): + item.current_serial_no = item_dict.get("serial_nos") + + item.current_qty = item_dict.get("qty") + item.current_valuation_rate = item_dict.get("rate") self.difference_amount += (flt(item.qty, item.precision("qty")) * \ - flt(item.valuation_rate or rate, item.precision("valuation_rate")) \ - - flt(qty, item.precision("qty")) * flt(rate, item.precision("valuation_rate"))) + flt(item.valuation_rate or item_dict.get("rate"), item.precision("valuation_rate")) \ + - flt(item_dict.get("qty"), item.precision("qty")) * flt(item_dict.get("rate"), item.precision("valuation_rate"))) return True items = list(filter(lambda d: _changed(d), self.items)) @@ -84,12 +97,17 @@ class StockReconciliation(StockController): for row_num, row in enumerate(self.items): # find duplicates - if [row.item_code, row.warehouse] in item_warehouse_combinations: + key = [row.item_code, row.warehouse] + for field in ['serial_no', 'batch_no']: + if row.get(field): + key.append(row.get(field)) + + if key in item_warehouse_combinations: self.validation_messages.append(_get_msg(row_num, _("Duplicate entry"))) else: - item_warehouse_combinations.append([row.item_code, row.warehouse]) + item_warehouse_combinations.append(key) - self.validate_item(row.item_code, row_num+1) + self.validate_item(row.item_code, row) # validate warehouse if not frappe.db.get_value("Warehouse", row.warehouse): @@ -131,7 +149,7 @@ class StockReconciliation(StockController): raise frappe.ValidationError(self.validation_messages) - def validate_item(self, item_code, row_num): + def validate_item(self, item_code, row): from erpnext.stock.doctype.item.item import validate_end_of_life, \ validate_is_stock_item, validate_cancelled_item @@ -145,51 +163,130 @@ class StockReconciliation(StockController): validate_is_stock_item(item_code, item.is_stock_item, verbose=0) # item should not be serialized - if item.has_serial_no == 1: - raise frappe.ValidationError(_("Serialized Item {0} cannot be updated using Stock Reconciliation, please use Stock Entry").format(item_code)) + if item.has_serial_no and not row.serial_no and not item.serial_no_series: + raise frappe.ValidationError(_("Serial no(s) required for serialized item {0}").format(item_code)) # item managed batch-wise not allowed - if item.has_batch_no == 1: - raise frappe.ValidationError(_("Batched Item {0} cannot be updated using Stock Reconciliation, instead use Stock Entry").format(item_code)) + if item.has_batch_no and not row.batch_no and not item.create_new_batch: + raise frappe.ValidationError(_("Batch no is required for batched item {0}").format(item_code)) # docstatus should be < 2 validate_cancelled_item(item_code, item.docstatus, verbose=0) except Exception as e: - self.validation_messages.append(_("Row # ") + ("%d: " % (row_num)) + cstr(e)) + self.validation_messages.append(_("Row # ") + ("%d: " % (row.idx)) + cstr(e)) def update_stock_ledger(self): """ find difference between current and expected entries and create stock ledger entries based on the difference""" from erpnext.stock.stock_ledger import get_previous_sle + sl_entries = [] for row in self.items: + item = frappe.get_doc("Item", row.item_code) + if item.has_serial_no or item.has_batch_no: + self.get_sle_for_serialized_items(row, sl_entries) + else: + previous_sle = get_previous_sle({ + "item_code": row.item_code, + "warehouse": row.warehouse, + "posting_date": self.posting_date, + "posting_time": self.posting_time + }) + + if previous_sle: + if row.qty in ("", None): + row.qty = previous_sle.get("qty_after_transaction", 0) + + if row.valuation_rate in ("", None): + row.valuation_rate = previous_sle.get("valuation_rate", 0) + + if row.qty and not row.valuation_rate: + frappe.throw(_("Valuation Rate required for Item {0} at row {1}").format(row.item_code, row.idx)) + + if ((previous_sle and row.qty == previous_sle.get("qty_after_transaction") + and (row.valuation_rate == previous_sle.get("valuation_rate") or row.qty == 0)) + or (not previous_sle and not row.qty)): + continue + + sl_entries.append(self.get_sle_for_items(row)) + + if sl_entries: + self.make_sl_entries(sl_entries) + + def get_sle_for_serialized_items(self, row, sl_entries): + from erpnext.stock.stock_ledger import get_previous_sle + + serial_nos = get_serial_nos(row.serial_no) + + + # To issue existing serial nos + if row.current_qty and (row.current_serial_no or row.batch_no): + args = self.get_sle_for_items(row) + args.update({ + 'actual_qty': -1 * row.current_qty, + 'serial_no': row.current_serial_no, + 'batch_no': row.batch_no, + 'valuation_rate': row.current_valuation_rate + }) + + if row.current_serial_no: + args.update({ + 'qty_after_transaction': 0, + }) + + sl_entries.append(args) + + for serial_no in serial_nos: + args = self.get_sle_for_items(row, [serial_no]) + previous_sle = get_previous_sle({ "item_code": row.item_code, - "warehouse": row.warehouse, "posting_date": self.posting_date, - "posting_time": self.posting_time + "posting_time": self.posting_time, + "serial_no": serial_no }) - if previous_sle: - if row.qty in ("", None): - row.qty = previous_sle.get("qty_after_transaction", 0) - if row.valuation_rate in ("", None): - row.valuation_rate = previous_sle.get("valuation_rate", 0) + if previous_sle and row.warehouse != previous_sle.get("warehouse"): + # If serial no exists in different warehouse - if row.qty and not row.valuation_rate: - frappe.throw(_("Valuation Rate required for Item in row {0}").format(row.idx)) + new_args = args.copy() + new_args.update({ + 'actual_qty': -1, + 'qty_after_transaction': cint(previous_sle.get('qty_after_transaction')) - 1, + 'warehouse': previous_sle.get("warehouse", '') or row.warehouse, + 'valuation_rate': previous_sle.get("valuation_rate") + }) - if ((previous_sle and row.qty == previous_sle.get("qty_after_transaction") - and (row.valuation_rate == previous_sle.get("valuation_rate") or row.qty == 0)) - or (not previous_sle and not row.qty)): - continue + sl_entries.append(new_args) - self.insert_entries(row) + if row.qty: + args = self.get_sle_for_items(row) - def insert_entries(self, row): + args.update({ + 'actual_qty': row.qty, + 'incoming_rate': row.valuation_rate, + 'valuation_rate': row.valuation_rate + }) + + sl_entries.append(args) + + if serial_nos == get_serial_nos(row.current_serial_no): + # update valuation rate + self.update_valuation_rate_for_serial_nos(row, serial_nos) + + def update_valuation_rate_for_serial_nos(self, row, serial_nos): + valuation_rate = row.valuation_rate if self.docstatus == 1 else row.current_valuation_rate + for d in serial_nos: + frappe.db.set_value("Serial No", d, 'purchase_rate', valuation_rate) + + def get_sle_for_items(self, row, serial_nos=None): """Insert Stock Ledger Entries""" - args = frappe._dict({ + + if not serial_nos and row.serial_no: + serial_nos = get_serial_nos(row.serial_no) + + data = frappe._dict({ "doctype": "Stock Ledger Entry", "item_code": row.item_code, "warehouse": row.warehouse, @@ -197,13 +294,19 @@ class StockReconciliation(StockController): "posting_time": self.posting_time, "voucher_type": self.doctype, "voucher_no": self.name, + "voucher_detail_no": row.name, "company": self.company, "stock_uom": frappe.db.get_value("Item", row.item_code, "stock_uom"), - "is_cancelled": "No", - "qty_after_transaction": flt(row.qty, row.precision("qty")), + "is_cancelled": "No" if self.docstatus != 2 else "Yes", + "serial_no": '\n'.join(serial_nos) if serial_nos else '', + "batch_no": row.batch_no, "valuation_rate": flt(row.valuation_rate, row.precision("valuation_rate")) }) - self.make_sl_entries([args]) + + if not row.batch_no: + data.qty_after_transaction = flt(row.qty, row.precision("qty")) + + return data def delete_and_repost_sle(self): """ Delete Stock Ledger Entries related to this voucher @@ -217,6 +320,15 @@ class StockReconciliation(StockController): frappe.db.sql("""delete from `tabStock Ledger Entry` where voucher_type=%s and voucher_no=%s""", (self.doctype, self.name)) + sl_entries = [] + for row in self.items: + if row.serial_no or row.batch_no or row.current_serial_no: + self.get_sle_for_serialized_items(row, sl_entries) + + if sl_entries: + sl_entries.reverse() + self.make_sl_entries(sl_entries) + # repost future entries for selected item_code, warehouse for entries in existing_entries: update_entries_after({ @@ -310,17 +422,52 @@ def get_items(warehouse, posting_date, posting_time, company): return res @frappe.whitelist() -def get_stock_balance_for(item_code, warehouse, posting_date, posting_time): +def get_stock_balance_for(item_code, warehouse, + posting_date, posting_time, batch_no=None, with_valuation_rate= True): frappe.has_permission("Stock Reconciliation", "write", throw = True) - qty, rate = get_stock_balance(item_code, warehouse, - posting_date, posting_time, with_valuation_rate=True) + item_dict = frappe.db.get_value("Item", item_code, + ["has_serial_no", "has_batch_no"], as_dict=1) + + serial_nos = "" + if item_dict.get("has_serial_no"): + qty, rate, serial_nos = get_qty_rate_for_serial_nos(item_code, + warehouse, posting_date, posting_time, item_dict) + else: + qty, rate = get_stock_balance(item_code, warehouse, + posting_date, posting_time, with_valuation_rate=with_valuation_rate) + + if item_dict.get("has_batch_no"): + qty = get_batch_qty(batch_no, warehouse) or 0 return { 'qty': qty, - 'rate': rate + 'rate': rate, + 'serial_nos': serial_nos } +def get_qty_rate_for_serial_nos(item_code, warehouse, posting_date, posting_time, item_dict): + args = { + "item_code": item_code, + "warehouse": warehouse, + "posting_date": posting_date, + "posting_time": posting_time, + } + + serial_nos_list = [serial_no.get("name") + for serial_no in get_available_serial_nos(item_code, warehouse)] + + qty = len(serial_nos_list) + serial_nos = '\n'.join(serial_nos_list) + args.update({ + 'qty': qty, + "serial_nos": serial_nos + }) + + rate = get_incoming_rate(args, raise_error_if_no_rate=False) or 0 + + return qty, rate, serial_nos + @frappe.whitelist() def get_difference_account(purpose, company): if purpose == 'Stock Reconciliation': diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 2dc585b8d63..f0c71cf39aa 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -13,9 +13,12 @@ from erpnext.stock.stock_ledger import get_previous_sle, update_entries_after from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import EmptyStockReconciliationItemsError, get_items from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.doctype.item.test_item import create_item +from erpnext.stock.utils import get_stock_balance, get_incoming_rate, get_available_serial_nos, get_stock_value_on +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos class TestStockReconciliation(unittest.TestCase): def setUp(self): + create_batch_or_serial_no_items() frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1) self.insert_existing_sle() @@ -106,6 +109,135 @@ class TestStockReconciliation(unittest.TestCase): make_stock_entry(posting_date="2013-01-05", posting_time="07:00", item_code="_Test Item", target="_Test Warehouse - _TC", qty=15, basic_rate=1200) + def test_stock_reco_for_serialized_item(self): + set_perpetual_inventory() + + to_delete_records = [] + to_delete_serial_nos = [] + + # Add new serial nos + serial_item_code = "Stock-Reco-Serial-Item-1" + serial_warehouse = "_Test Warehouse for Stock Reco1 - _TC" + + sr = create_stock_reconciliation(item_code=serial_item_code, + warehouse = serial_warehouse, qty=5, rate=200) + + # print(sr.name) + serial_nos = get_serial_nos(sr.items[0].serial_no) + self.assertEqual(len(serial_nos), 5) + + args = { + "item_code": serial_item_code, + "warehouse": serial_warehouse, + "posting_date": nowdate(), + "posting_time": nowtime(), + "serial_no": sr.items[0].serial_no + } + + valuation_rate = get_incoming_rate(args) + self.assertEqual(valuation_rate, 200) + + to_delete_records.append(sr.name) + + sr = create_stock_reconciliation(item_code=serial_item_code, + warehouse = serial_warehouse, qty=5, rate=300, serial_no = '\n'.join(serial_nos)) + + # print(sr.name) + serial_nos1 = get_serial_nos(sr.items[0].serial_no) + self.assertEqual(len(serial_nos1), 5) + + args = { + "item_code": serial_item_code, + "warehouse": serial_warehouse, + "posting_date": nowdate(), + "posting_time": nowtime(), + "serial_no": sr.items[0].serial_no + } + + valuation_rate = get_incoming_rate(args) + self.assertEqual(valuation_rate, 300) + + to_delete_records.append(sr.name) + to_delete_records.reverse() + + for d in to_delete_records: + stock_doc = frappe.get_doc("Stock Reconciliation", d) + stock_doc.cancel() + frappe.delete_doc("Stock Reconciliation", stock_doc.name) + + for d in serial_nos + serial_nos1: + if frappe.db.exists("Serial No", d): + frappe.delete_doc("Serial No", d) + + def test_stock_reco_for_batch_item(self): + set_perpetual_inventory() + + to_delete_records = [] + to_delete_serial_nos = [] + + # Add new serial nos + item_code = "Stock-Reco-batch-Item-1" + warehouse = "_Test Warehouse for Stock Reco2 - _TC" + + sr = create_stock_reconciliation(item_code=item_code, + warehouse = warehouse, qty=5, rate=200, do_not_submit=1) + sr.save(ignore_permissions=True) + sr.submit() + + self.assertTrue(sr.items[0].batch_no) + to_delete_records.append(sr.name) + + sr1 = create_stock_reconciliation(item_code=item_code, + warehouse = warehouse, qty=6, rate=300, batch_no=sr.items[0].batch_no) + + args = { + "item_code": item_code, + "warehouse": warehouse, + "posting_date": nowdate(), + "posting_time": nowtime(), + } + + valuation_rate = get_incoming_rate(args) + self.assertEqual(valuation_rate, 300) + to_delete_records.append(sr1.name) + + + sr2 = create_stock_reconciliation(item_code=item_code, + warehouse = warehouse, qty=0, rate=0, batch_no=sr.items[0].batch_no) + + stock_value = get_stock_value_on(warehouse, nowdate(), item_code) + self.assertEqual(stock_value, 0) + to_delete_records.append(sr2.name) + + to_delete_records.reverse() + for d in to_delete_records: + stock_doc = frappe.get_doc("Stock Reconciliation", d) + stock_doc.cancel() + + frappe.delete_doc("Batch", sr.items[0].batch_no) + for d in to_delete_records: + frappe.delete_doc("Stock Reconciliation", d) + +def create_batch_or_serial_no_items(): + create_warehouse("_Test Warehouse for Stock Reco1", + {"is_group": 0, "parent_warehouse": "_Test Warehouse Group - _TC"}) + + create_warehouse("_Test Warehouse for Stock Reco2", + {"is_group": 0, "parent_warehouse": "_Test Warehouse Group - _TC"}) + + serial_item_doc = create_item("Stock-Reco-Serial-Item-1", is_stock_item=1) + if not serial_item_doc.has_serial_no: + serial_item_doc.has_serial_no = 1 + serial_item_doc.serial_no_series = "SRSI.####" + serial_item_doc.save(ignore_permissions=True) + + batch_item_doc = create_item("Stock-Reco-batch-Item-1", is_stock_item=1) + if not batch_item_doc.has_batch_no: + batch_item_doc.has_batch_no = 1 + batch_item_doc.create_new_batch = 1 + serial_item_doc.batch_number_series = "BASR.#####" + batch_item_doc.save(ignore_permissions=True) + def create_stock_reconciliation(**args): args = frappe._dict(args) sr = frappe.new_doc("Stock Reconciliation") @@ -120,11 +252,14 @@ def create_stock_reconciliation(**args): "item_code": args.item_code or "_Test Item", "warehouse": args.warehouse or "_Test Warehouse - _TC", "qty": args.qty, - "valuation_rate": args.rate + "valuation_rate": args.rate, + "serial_no": args.serial_no, + "batch_no": args.batch_no }) try: - sr.submit() + if not args.do_not_submit: + sr.submit() except EmptyStockReconciliationItemsError: pass return sr @@ -140,3 +275,4 @@ def set_valuation_method(item_code, valuation_method): }, allow_negative_stock=1) test_dependencies = ["Item", "Warehouse"] + diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json index 0fafe8306ce..e53db0772b4 100644 --- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json +++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json @@ -1,560 +1,182 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2015-02-17 01:06:05.072764", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Other", - "editable_grid": 1, - "engine": "InnoDB", + "creation": "2015-02-17 01:06:05.072764", + "doctype": "DocType", + "document_type": "Other", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "barcode", + "item_code", + "item_name", + "warehouse", + "column_break_6", + "qty", + "valuation_rate", + "amount", + "serial_no_and_batch_section", + "serial_no", + "column_break_11", + "batch_no", + "section_break_3", + "current_qty", + "current_serial_no", + "column_break_9", + "current_valuation_rate", + "current_amount", + "section_break_14", + "quantity_difference", + "column_break_16", + "amount_difference" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "barcode", - "fieldtype": "Data", - "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": "Barcode", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 1, - "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 - }, + "fieldname": "barcode", + "fieldtype": "Data", + "label": "Barcode", + "print_hide": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 3, - "fieldname": "item_code", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Item Code", - "length": 0, - "no_copy": 0, - "options": "Item", - "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 - }, + "columns": 3, + "fieldname": "item_code", + "fieldtype": "Link", + "in_global_search": 1, + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "item_name", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Item Name", - "length": 0, - "no_copy": 1, - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "item_name", + "fieldtype": "Data", + "in_global_search": 1, + "label": "Item Name", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 3, - "fieldname": "warehouse", - "fieldtype": "Link", - "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": "Warehouse", - "length": 0, - "no_copy": 0, - "options": "Warehouse", - "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 - }, + "columns": 3, + "fieldname": "warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Warehouse", + "options": "Warehouse", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_6", - "fieldtype": "Column 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, - "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 - }, + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 2, - "description": "", - "fieldname": "qty", - "fieldtype": "Float", - "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": "Quantity", - "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 - }, + "columns": 2, + "fieldname": "qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Quantity" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 2, - "description": "", - "fieldname": "valuation_rate", - "fieldtype": "Currency", - "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": "Valuation Rate", - "length": 0, - "no_copy": 0, - "options": "Company:company:default_currency", - "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 - }, + "columns": 2, + "fieldname": "valuation_rate", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Valuation Rate", + "options": "Company:company:default_currency" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "amount", - "fieldtype": "Currency", - "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": "Amount", - "length": 0, - "no_copy": 0, - "options": "Company:company:default_currency", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "amount", + "fieldtype": "Currency", + "label": "Amount", + "options": "Company:company:default_currency", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_3", - "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": "Before reconciliation", - "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 - }, + "fieldname": "serial_no_and_batch_section", + "fieldtype": "Section Break", + "label": "Serial No and Batch" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "", - "fieldname": "current_qty", - "fieldtype": "Float", - "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": "Current Qty", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "serial_no", + "fieldtype": "Small Text", + "label": "Serial No" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_9", - "fieldtype": "Column 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, - "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 - }, + "fieldname": "column_break_11", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "", - "fieldname": "current_valuation_rate", - "fieldtype": "Currency", - "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": "Current Valuation Rate", - "length": 0, - "no_copy": 0, - "options": "Company:company:default_currency", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "section_break_3", + "fieldtype": "Section Break", + "label": "Before reconciliation" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "", - "fieldname": "current_amount", - "fieldtype": "Currency", - "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": "Current Amount", - "length": 0, - "no_copy": 0, - "options": "Company:company:default_currency", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "0", + "fieldname": "current_qty", + "fieldtype": "Float", + "label": "Current Qty", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_14", - "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, - "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 - }, + "fieldname": "current_serial_no", + "fieldtype": "Small Text", + "label": "Current Serial No", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "quantity_difference", - "fieldtype": "Read Only", - "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": "Quantity Difference", - "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 - }, + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_16", - "fieldtype": "Column 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, - "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 - }, + "fieldname": "current_valuation_rate", + "fieldtype": "Currency", + "label": "Current Valuation Rate", + "options": "Company:company:default_currency", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "amount_difference", - "fieldtype": "Currency", - "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": "Amount Difference", - "length": 0, - "no_copy": 0, - "options": "Company:company:default_currency", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "current_amount", + "fieldtype": "Currency", + "label": "Current Amount", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "section_break_14", + "fieldtype": "Section Break" + }, + { + "fieldname": "quantity_difference", + "fieldtype": "Read Only", + "label": "Quantity Difference" + }, + { + "fieldname": "column_break_16", + "fieldtype": "Column Break" + }, + { + "fieldname": "amount_difference", + "fieldtype": "Currency", + "label": "Amount Difference", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "batch_no", + "fieldtype": "Link", + "label": "Batch No", + "options": "Batch" } - ], - "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, - "menu_index": 0, - "modified": "2017-08-03 00:03:40.412071", - "modified_by": "Administrator", - "module": "Stock", - "name": "Stock Reconciliation Item", - "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 + ], + "istable": 1, + "modified": "2019-06-14 17:10:53.188305", + "modified_by": "Administrator", + "module": "Stock", + "name": "Stock Reconciliation Item", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py index e7cb9ad060c..7f7835f74ee 100644 --- a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py +++ b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py @@ -55,10 +55,12 @@ def get_conditions(filters): #get all details def get_stock_ledger_entries(filters): conditions = get_conditions(filters) - return frappe.db.sql("""select item_code, batch_no, warehouse, - posting_date, actual_qty + return frappe.db.sql(""" + select item_code, batch_no, warehouse, posting_date, sum(actual_qty) as actual_qty from `tabStock Ledger Entry` - where docstatus < 2 and ifnull(batch_no, '') != '' %s order by item_code, warehouse""" % + where docstatus < 2 and ifnull(batch_no, '') != '' %s + group by voucher_no, batch_no, item_code, warehouse + order by item_code, warehouse""" % conditions, as_dict=1) def get_item_warehouse_batch_map(filters, float_precision): diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index c8706b291e0..ff5b026695a 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -157,9 +157,12 @@ class update_entries_after(object): if sle.serial_no: self.get_serialized_values(sle) self.qty_after_transaction += flt(sle.actual_qty) + if sle.voucher_type == "Stock Reconciliation": + self.qty_after_transaction = sle.qty_after_transaction + self.stock_value = flt(self.qty_after_transaction) * flt(self.valuation_rate) else: - if sle.voucher_type=="Stock Reconciliation": + if sle.voucher_type=="Stock Reconciliation" and not sle.batch_no: # assert self.valuation_rate = sle.valuation_rate self.qty_after_transaction = sle.qty_after_transaction @@ -371,7 +374,7 @@ class update_entries_after(object): """get Stock Ledger Entries after a particular datetime, for reposting""" return get_stock_ledger_entries(self.previous_sle or frappe._dict({ "item_code": self.args.get("item_code"), "warehouse": self.args.get("warehouse") }), - ">", "asc", for_update=True) + ">", "asc", for_update=True, check_serial_no=False) def raise_exceptions(self): deficiency = min(e["diff"] for e in self.exceptions) @@ -412,7 +415,8 @@ def get_previous_sle(args, for_update=False): sle = get_stock_ledger_entries(args, "<=", "desc", "limit 1", for_update=for_update) return sle and sle[0] or {} -def get_stock_ledger_entries(previous_sle, operator=None, order="desc", limit=None, for_update=False, debug=False): +def get_stock_ledger_entries(previous_sle, operator=None, + order="desc", limit=None, for_update=False, debug=False, check_serial_no=True): """get stock ledger entries filtered by specific posting datetime conditions""" conditions = " and timestamp(posting_date, posting_time) {0} timestamp(%(posting_date)s, %(posting_time)s)".format(operator) if previous_sle.get("warehouse"): @@ -420,6 +424,9 @@ def get_stock_ledger_entries(previous_sle, operator=None, order="desc", limit=No elif previous_sle.get("warehouse_condition"): conditions += " and " + previous_sle.get("warehouse_condition") + if check_serial_no and previous_sle.get("serial_no"): + conditions += " and serial_no like {}".format(frappe.db.escape('%{0}%'.format(previous_sle.get("serial_no")))) + if not previous_sle.get("posting_date"): previous_sle["posting_date"] = "1900-01-01" if not previous_sle.get("posting_time"): @@ -479,6 +486,7 @@ def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no, if not allow_zero_rate and not valuation_rate and raise_error_if_no_rate \ and cint(erpnext.is_perpetual_inventory_enabled(company)): frappe.local.message_log = [] - frappe.throw(_("Valuation rate not found for the Item {0}, which is required to do accounting entries for {1} {2}. If the item is transacting as a zero valuation rate item in the {1}, please mention that in the {1} Item table. Otherwise, please create an incoming stock transaction for the item or mention valuation rate in the Item record, and then try submiting/cancelling this entry").format(item_code, voucher_type, voucher_no)) + frappe.throw(_("Valuation rate not found for the Item {0}, which is required to do accounting entries for {1} {2}. If the item is transacting as a zero valuation rate item in the {1}, please mention that in the {1} Item table. Otherwise, please create an incoming stock transaction for the item or mention valuation rate in the Item record, and then try submiting / cancelling this entry.") + .format(item_code, voucher_type, voucher_no)) return valuation_rate diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 76631fad649..6ea322872ef 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -173,7 +173,7 @@ def get_incoming_rate(args, raise_error_if_no_rate=True): in_rate = get_valuation_rate(args.get('item_code'), args.get('warehouse'), args.get('voucher_type'), voucher_no, args.get('allow_zero_valuation'), currency=erpnext.get_company_currency(args.get('company')), company=args.get('company'), - raise_error_if_no_rate=True) + raise_error_if_no_rate=raise_error_if_no_rate) return in_rate @@ -277,3 +277,7 @@ def update_included_uom_in_report(columns, result, include_uom, conversion_facto new_row.append(None) result[row_idx] = new_row + +def get_available_serial_nos(item_code, warehouse): + return frappe.get_all("Serial No", filters = {'item_code': item_code, + 'warehouse': warehouse, 'delivery_document_no': ''}) or [] \ No newline at end of file