Merge branch 'develop' into variance-report-fix

This commit is contained in:
Anupam Kumar
2020-09-01 09:31:22 +05:30
committed by GitHub
680 changed files with 26959 additions and 20594 deletions

View File

@@ -0,0 +1,24 @@
{
"chart_name": "Item-wise Annual Sales",
"chart_type": "Report",
"creation": "2020-07-20 20:17:16.474566",
"custom_options": "",
"docstatus": 0,
"doctype": "Dashboard Chart",
"dynamic_filters_json": "{\"company\":\"frappe.defaults.get_user_default(\\\"Company\\\")\",\"to_date\":\"frappe.datetime.nowdate()\"}",
"filters_json": "{\"from_date\":\"2020-06-22\"}",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"modified": "2020-07-22 14:42:25.512675",
"modified_by": "Administrator",
"module": "Selling",
"name": "Item-wise Annual Sales",
"number_of_groups": 0,
"owner": "Administrator",
"report_name": "Item-wise Sales History",
"timeseries": 0,
"type": "Bar",
"use_report_chart": 1,
"y_axis": []
}

View File

@@ -0,0 +1,24 @@
{
"chart_name": "Sales Order Analysis",
"chart_type": "Report",
"creation": "2020-07-20 20:17:16.440393",
"custom_options": "{\"type\": \"donut\", \"height\": 300, \"axisOptions\": {\"shortenYAxisNumbers\": 1}}",
"docstatus": 0,
"doctype": "Dashboard Chart",
"dynamic_filters_json": "{\"company\":\"frappe.defaults.get_user_default(\\\"Company\\\")\",\"to_date\":\"frappe.datetime.nowdate()\"}",
"filters_json": "{\"status\":[\"To Bill\",\"To Deliver\"],\"group_by_so\":0,\"from_date\":\"2020-06-22\"}",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"modified": "2020-07-22 17:06:05.750660",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Analysis",
"number_of_groups": 0,
"owner": "Administrator",
"report_name": "Sales Order Analysis",
"timeseries": 0,
"type": "Donut",
"use_report_chart": 1,
"y_axis": []
}

View File

@@ -0,0 +1,24 @@
{
"chart_name": "Sales Order Trends",
"chart_type": "Report",
"creation": "2020-07-20 20:17:16.508240",
"custom_options": "{\"type\": \"line\", \"axisOptions\": {\"shortenYAxisNumbers\": 1}, \"tooltipOptions\": {}, \"lineOptions\": {\"regionFill\": 1}}",
"docstatus": 0,
"doctype": "Dashboard Chart",
"dynamic_filters_json": "{\"company\":\"frappe.defaults.get_user_default(\\\"Company\\\")\",\"fiscal_year\":\"frappe.sys_defaults.fiscal_year\"}",
"filters_json": "{\"period\":\"Monthly\",\"based_on\":\"Item\"}",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"modified": "2020-07-22 16:24:45.726270",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Trends",
"number_of_groups": 0,
"owner": "Administrator",
"report_name": "Sales Order Trends",
"timeseries": 0,
"type": "Line",
"use_report_chart": 1,
"y_axis": []
}

View File

@@ -0,0 +1,24 @@
{
"chart_name": "Top Customers",
"chart_type": "Report",
"creation": "2020-07-20 20:17:16.539281",
"custom_options": "",
"docstatus": 0,
"doctype": "Dashboard Chart",
"dynamic_filters_json": "{\"company\":\"frappe.defaults.get_user_default(\\\"Company\\\")\",\"fiscal_year\":\"frappe.sys_defaults.fiscal_year\"}",
"filters_json": "{\"period\":\"Yearly\",\"based_on\":\"Customer\"}",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"modified": "2020-07-22 17:03:10.320147",
"modified_by": "Administrator",
"module": "Selling",
"name": "Top Customers",
"number_of_groups": 0,
"owner": "Administrator",
"report_name": "Delivery Note Trends",
"timeseries": 0,
"type": "Bar",
"use_report_chart": 1,
"y_axis": []
}

View File

@@ -1,198 +0,0 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
import json
from frappe import _
from frappe.utils import nowdate
from erpnext.accounts.utils import get_fiscal_year
def get_data():
return frappe._dict({
"dashboards": get_dashboards(),
"charts": get_charts(),
"number_cards": get_number_cards(),
})
def get_company_for_dashboards():
company = frappe.defaults.get_defaults().company
if company:
return company
else:
company_list = frappe.get_list("Company")
if company_list:
return company_list[0].name
return None
company = frappe.get_doc("Company", get_company_for_dashboards())
fiscal_year = get_fiscal_year(nowdate(), as_dict=1)
fiscal_year_name = fiscal_year.get("name")
start_date = str(fiscal_year.get("year_start_date"))
end_date = str(fiscal_year.get("year_end_date"))
def get_dashboards():
return [{
"name": "Selling",
"dashboard_name": "Selling",
"charts": [
{ "chart": "Sales Order Trends", "width": "Full"},
{ "chart": "Top Customers", "width": "Half"},
{ "chart": "Sales Order Analysis", "width": "Half"},
{ "chart": "Item-wise Annual Sales", "width": "Full"}
],
"cards": [
{ "card": "Annual Sales"},
{ "card": "Sales Orders to Deliver"},
{ "card": "Sales Orders to Bill"},
{ "card": "Active Customers"}
]
}]
def get_charts():
return [
{
"name": "Sales Order Analysis",
"chart_name": _("Sales Order Analysis"),
"chart_type": "Report",
"custom_options": json.dumps({
"type": "donut",
"height": 300,
"axisOptions": {"shortenYAxisNumbers": 1}
}),
"doctype": "Dashboard Chart",
"filters_json": json.dumps({
"company": company.name,
"from_date": start_date,
"to_date": end_date
}),
"is_custom": 1,
"is_public": 1,
"owner": "Administrator",
"report_name": "Sales Order Analysis",
"type": "Donut"
},
{
"name": "Item-wise Annual Sales",
"chart_name": _("Item-wise Annual Sales"),
"chart_type": "Report",
"doctype": "Dashboard Chart",
"filters_json": json.dumps({
"company": company.name,
"from_date": start_date,
"to_date": end_date
}),
"is_custom": 1,
"is_public": 1,
"owner": "Administrator",
"report_name": "Item-wise Sales History",
"type": "Bar"
},
{
"name": "Sales Order Trends",
"chart_name": _("Sales Order Trends"),
"chart_type": "Report",
"custom_options": json.dumps({
"type": "line",
"axisOptions": {"shortenYAxisNumbers": 1},
"tooltipOptions": {},
"lineOptions": {
"regionFill": 1
}
}),
"doctype": "Dashboard Chart",
"filters_json": json.dumps({
"company": company.name,
"period": "Monthly",
"fiscal_year": fiscal_year_name,
"based_on": "Item"
}),
"is_custom": 1,
"is_public": 1,
"owner": "Administrator",
"report_name": "Sales Order Trends",
"type": "Line"
},
{
"name": "Top Customers",
"chart_name": _("Top Customers"),
"chart_type": "Report",
"doctype": "Dashboard Chart",
"filters_json": json.dumps({
"company": company.name,
"period": "Monthly",
"fiscal_year": fiscal_year_name,
"based_on": "Customer"
}),
"is_custom": 1,
"is_public": 1,
"owner": "Administrator",
"report_name": "Delivery Note Trends",
"type": "Bar"
}
]
def get_number_cards():
return [
{
"name": "Annual Sales",
"aggregate_function_based_on": "base_net_total",
"doctype": "Number Card",
"document_type": "Sales Order",
"filters_json": json.dumps([
["Sales Order", "transaction_date", "Between", [start_date, end_date], False],
["Sales Order", "status", "not in", ["Draft", "Cancelled", "Closed", None], False],
["Sales Order", "docstatus", "=", 1, False],
["Sales Order", "company", "=", company.name, False]
]),
"function": "Sum",
"is_public": 1,
"label": _("Annual Sales"),
"owner": "Administrator",
"show_percentage_stats": 1,
"stats_time_interval": "Monthly"
},
{
"name": "Sales Orders to Deliver",
"doctype": "Number Card",
"document_type": "Sales Order",
"filters_json": json.dumps([
["Sales Order", "status", "in", ["To Deliver and Bill", "To Deliver", None], False],
["Sales Order", "docstatus", "=", 1, False],
["Sales Order", "company", "=", company.name, False]
]),
"function": "Count",
"is_public": 1,
"label": _("Sales Orders to Deliver"),
"owner": "Administrator",
"show_percentage_stats": 1,
"stats_time_interval": "Weekly"
},
{
"name": "Sales Orders to Bill",
"doctype": "Number Card",
"document_type": "Sales Order",
"filters_json": json.dumps([
["Sales Order", "status", "in", ["To Deliver and Bill", "To Bill", None], False],
["Sales Order", "docstatus", "=", 1, False],
["Sales Order", "company", "=", company.name, False]
]),
"function": "Count",
"is_public": 1,
"label": _("Sales Orders to Bill"),
"owner": "Administrator",
"show_percentage_stats": 1,
"stats_time_interval": "Weekly"
},
{
"name": "Active Customers",
"doctype": "Number Card",
"document_type": "Customer",
"filters_json": json.dumps([["Customer", "disabled", "=", "0"]]),
"function": "Count",
"is_public": 1,
"label": "Active Customers",
"owner": "Administrator",
"show_percentage_stats": 1,
"stats_time_interval": "Monthly"
}
]

View File

@@ -3,7 +3,7 @@
{
"hidden": 0,
"label": "Retail Operations",
"links": "[\n {\n \"description\": \"Setup default values for POS Invoices\",\n \"label\": \"Point-of-Sale Profile\",\n \"name\": \"POS Profile\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"POS Profile\"\n ],\n \"description\": \"Point of Sale\",\n \"label\": \"POS\",\n \"name\": \"pos\",\n \"onboard\": 1,\n \"type\": \"page\"\n },\n {\n \"description\": \"Cashier Closing\",\n \"label\": \"Cashier Closing\",\n \"name\": \"Cashier Closing\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Setup mode of POS (Online / Offline)\",\n \"label\": \"POS Settings\",\n \"name\": \"POS Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"To make Customer based incentive schemes.\",\n \"label\": \"Loyalty Program\",\n \"name\": \"Loyalty Program\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"To view logs of Loyalty Points assigned to a Customer.\",\n \"label\": \"Loyalty Point Entry\",\n \"name\": \"Loyalty Point Entry\",\n \"type\": \"doctype\"\n }\n]"
"links": "[\n {\n \"description\": \"Setup default values for POS Invoices\",\n \"label\": \"Point of Sale Profile\",\n \"name\": \"POS Profile\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"POS Profile\"\n ],\n \"description\": \"Point of Sale\",\n \"label\": \"Point of Sale\",\n \"name\": \"point-of-sale\",\n \"onboard\": 1,\n \"type\": \"page\"\n },\n {\n \"description\": \"Setup mode of POS (Online / Offline)\",\n \"label\": \"POS Settings\",\n \"name\": \"POS Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Cashier Closing\",\n \"label\": \"Cashier Closing\",\n \"name\": \"Cashier Closing\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"To make Customer based incentive schemes.\",\n \"label\": \"Loyalty Program\",\n \"name\": \"Loyalty Program\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"To view logs of Loyalty Points assigned to a Customer.\",\n \"label\": \"Loyalty Point Entry\",\n \"name\": \"Loyalty Point Entry\",\n \"type\": \"doctype\"\n }\n]"
}
],
"category": "Domains",
@@ -14,10 +14,11 @@
"docstatus": 0,
"doctype": "Desk Page",
"extends_another_page": 0,
"hide_custom": 0,
"idx": 0,
"is_standard": 1,
"label": "Retail",
"modified": "2020-04-26 22:42:39.346750",
"modified": "2020-08-20 18:00:07.515691",
"modified_by": "Administrator",
"module": "Selling",
"name": "Retail",
@@ -25,5 +26,27 @@
"pin_to_bottom": 0,
"pin_to_top": 0,
"restrict_to_domain": "Retail",
"shortcuts": []
"shortcuts": [
{
"color": "#9deca2",
"doc_view": "",
"format": "{} Active",
"label": "Point of Sale Profile",
"link_to": "POS Profile",
"stats_filter": "{\n \"disabled\": 0\n}",
"type": "DocType"
},
{
"doc_view": "",
"label": "Point of Sale",
"link_to": "point-of-sale",
"type": "Page"
},
{
"doc_view": "",
"label": "POS Settings",
"link_to": "POS Settings",
"type": "DocType"
}
]
}

View File

@@ -18,7 +18,7 @@
{
"hidden": 0,
"label": "Key Reports",
"links": "[\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Sales Analytics\",\n \"name\": \"Sales Analytics\",\n \"onboard\": 1,\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Sales Order Analysis\",\n \"name\": \"Sales Order Analysis\",\n \"onboard\": 1,\n \"type\": \"report\"\n },\n {\n \"icon\": \"fa fa-bar-chart\",\n \"label\": \"Sales Funnel\",\n \"name\": \"sales-funnel\",\n \"onboard\": 1,\n \"type\": \"page\"\n },\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Sales Order Trends\",\n \"name\": \"Sales Order Trends\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Quotation\"\n ],\n \"doctype\": \"Quotation\",\n \"is_query_report\": true,\n \"label\": \"Quotation Trends\",\n \"name\": \"Quotation Trends\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Customer\"\n ],\n \"doctype\": \"Customer\",\n \"icon\": \"fa fa-bar-chart\",\n \"is_query_report\": true,\n \"label\": \"Customer Acquisition and Loyalty\",\n \"name\": \"Customer Acquisition and Loyalty\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Inactive Customers\",\n \"name\": \"Inactive Customers\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Sales Person-wise Transaction Summary\",\n \"name\": \"Sales Person-wise Transaction Summary\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Item\"\n ],\n \"doctype\": \"Item\",\n \"is_query_report\": true,\n \"label\": \"Item-wise Sales History\",\n \"name\": \"Item-wise Sales History\",\n \"type\": \"report\"\n }\n]"
"links": "[\n {\n \n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Sales Analytics\",\n \"name\": \"Sales Analytics\",\n \"onboard\": 1,\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Sales Order Analysis\",\n \"name\": \"Sales Order Analysis\",\n \"onboard\": 1,\n \"type\": \"report\"\n },\n {\n \"icon\": \"fa fa-bar-chart\",\n \"label\": \"Sales Funnel\",\n \"name\": \"sales-funnel\",\n \"onboard\": 1,\n \"type\": \"page\"\n },\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Sales Order Trends\",\n \"name\": \"Sales Order Trends\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Quotation\"\n ],\n \"doctype\": \"Quotation\",\n \"is_query_report\": true,\n \"label\": \"Quotation Trends\",\n \"name\": \"Quotation Trends\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Customer\"\n ],\n \"doctype\": \"Customer\",\n \"icon\": \"fa fa-bar-chart\",\n \"is_query_report\": true,\n \"label\": \"Customer Acquisition and Loyalty\",\n \"name\": \"Customer Acquisition and Loyalty\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Inactive Customers\",\n \"name\": \"Inactive Customers\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Sales Person-wise Transaction Summary\",\n \"name\": \"Sales Person-wise Transaction Summary\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Item\"\n ],\n \"doctype\": \"Item\",\n \"is_query_report\": true,\n \"label\": \"Item-wise Sales History\",\n \"name\": \"Item-wise Sales History\",\n \"type\": \"report\"\n }\n]"
},
{
"hidden": 0,
@@ -44,7 +44,7 @@
"idx": 0,
"is_standard": 1,
"label": "Selling",
"modified": "2020-06-29 19:26:35.139097",
"modified": "2020-08-15 10:12:53.131621",
"modified_by": "Administrator",
"module": "Selling",
"name": "Selling",

View File

@@ -184,6 +184,14 @@ class Customer(TransactionBase):
def validate_credit_limit_on_change(self):
if self.get("__islocal") or not self.credit_limits:
return
past_credit_limits = [d.credit_limit
for d in frappe.db.get_all("Customer Credit Limit", filters={'parent': self.name}, fields=["credit_limit"], order_by="company")]
current_credit_limits = [d.credit_limit for d in sorted(self.credit_limits, key=lambda k: k.company)]
if past_credit_limits == current_credit_limits:
return
company_record = []
for limit in self.credit_limits:
@@ -340,6 +348,7 @@ def get_loyalty_programs(doc):
return lp_details
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_customer_list(doctype, txt, searchfield, start, page_len, filters=None):
from erpnext.controllers.queries import get_fields
fields = ["name", "customer_name", "customer_group", "territory"]
@@ -542,6 +551,7 @@ def make_address(args, is_primary_address=1):
return address
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_customer_primary_contact(doctype, txt, searchfield, start, page_len, filters):
customer = filters.get('customer')
return frappe.db.sql("""

View File

@@ -12,7 +12,8 @@ def get_data():
'Payment Entry': 'party',
'Quotation': 'party_name',
'Opportunity': 'party_name',
'Bank Account': 'party'
'Bank Account': 'party',
'Subscription': 'party'
},
'dynamic_links': {
'party_name': ['Customer', 'quotation_to']
@@ -32,7 +33,7 @@ def get_data():
},
{
'label': _('Support'),
'items': ['Issue']
'items': ['Issue', 'Maintenance Visit', 'Installation Note', 'Warranty Claim']
},
{
'label': _('Projects'),

View File

@@ -1,7 +1,7 @@
{
"allow_copy": 0,
"allow_import": 0,
"allow_rename": 0,
"allow_rename": 1,
"autoname": "field:source_name",
"beta": 0,
"creation": "2016-09-16 01:47:47.382372",
@@ -74,7 +74,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2016-09-16 02:03:01.441622",
"modified": "2020-09-16 02:03:01.441622",
"modified_by": "Administrator",
"module": "Selling",
"name": "Lead Source",
@@ -128,4 +128,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_seen": 0
}
}

View File

@@ -1,84 +0,0 @@
<div class="clearfix"></div>
<div class="box">
<div class="grid-body">
<div class="rows text-center">
<!-- Sales summary section -->
<div>
<h6 class="text-center uppercase" style="color: #8D99A6">{{ _("Sales Summary") }}</h6>
<div class="tax-break-up" style="overflow-x: auto;">
<table class="table table-bordered table-hover">
<thead>
</thead>
<tbody>
<tr>
<td class="text-left">{{ _('Grand Total') }}</td>
<td class='text-right'>{{ data.grand_total or '' }} {{ currency.symbol }}</td>
</tr>
<tr>
<td class="text-left">{{ _('Net Total') }}</td>
<td class='text-right'>{{ data.net_total or '' }} {{ currency.symbol }}</td>
</tr>
<tr>
<td class="text-left">{{ _('Total Quantity') }}</td>
<td class='text-right'>{{ data.total_quantity or '' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Section end -->
<!-- Mode of payment section -->
<div>
<h6 class="text-center uppercase" style="color: #8D99A6">{{ _("Mode of Payments") }}</h6>
<div class="tax-break-up" style="overflow-x: auto;">
<table class="table table-bordered table-hover">
<thead>
<tr>
<th class="text-left">{{ _("Mode of Payment") }}</th>
<th class="text-right">{{ _("Amount") }}</th>
</tr>
</thead>
<tbody>
{% for d in data.payment_reconciliation %}
<tr>
<td class="text-left">{{ d.mode_of_payment }}</td>
<td class='text-right'>{{ d.expected_amount }} {{ currency.symbol }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Section end -->
<!-- Taxes section -->
<div>
<h6 class="text-center uppercase" style="color: #8D99A6">{{ _("Taxes") }}</h6>
<div class="tax-break-up" style="overflow-x: auto;">
<table class="table table-bordered table-hover">
<thead>
<tr>
<th class="text-left">{{ _("Rate") }}</th>
<th class="text-right">{{ _("Amount") }}</th>
</tr>
</thead>
<tbody>
{% for d in data.taxes %}
<tr>
<td class="text-left">{{ d.rate }} %</td>
<td class='text-right'>{{ d.amount }} {{ currency.symbol }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Section end -->
</div>
</div>
</div>
</div>

View File

@@ -1,87 +0,0 @@
// Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on('POS Closing Voucher', {
onload: function(frm) {
frm.set_query("pos_profile", function(doc) {
return {
filters: {
'user': doc.user
}
};
});
frm.set_query("user", function(doc) {
return {
query: "erpnext.selling.doctype.pos_closing_voucher.pos_closing_voucher.get_cashiers",
filters: {
'parent': doc.pos_profile
}
};
});
},
total_amount: function(frm) {
get_difference_amount(frm);
},
custody_amount: function(frm){
get_difference_amount(frm);
},
expense_amount: function(frm){
get_difference_amount(frm);
},
refresh: function(frm) {
get_closing_voucher_details(frm);
},
period_start_date: function(frm) {
get_closing_voucher_details(frm);
},
period_end_date: function(frm) {
get_closing_voucher_details(frm);
},
company: function(frm) {
get_closing_voucher_details(frm);
},
pos_profile: function(frm) {
get_closing_voucher_details(frm);
},
user: function(frm) {
get_closing_voucher_details(frm);
},
});
frappe.ui.form.on('POS Closing Voucher Details', {
collected_amount: function(doc, cdt, cdn) {
var row = locals[cdt][cdn];
frappe.model.set_value(cdt, cdn, "difference", row.collected_amount - row.expected_amount);
}
});
var get_difference_amount = function(frm){
frm.doc.difference = frm.doc.total_amount - frm.doc.custody_amount - frm.doc.expense_amount;
refresh_field("difference");
};
var get_closing_voucher_details = function(frm) {
if (frm.doc.period_end_date && frm.doc.period_start_date && frm.doc.company && frm.doc.pos_profile && frm.doc.user) {
frappe.call({
method: "get_closing_voucher_details",
doc: frm.doc,
callback: function(r) {
if (r.message) {
refresh_field("payment_reconciliation");
refresh_field("sales_invoices_summary");
refresh_field("taxes");
refresh_field("grand_total");
refresh_field("net_total");
refresh_field("total_quantity");
refresh_field("total_amount");
frm.get_field("payment_reconciliation_details").$wrapper.html(r.message);
}
}
});
}
};

View File

@@ -1,188 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, 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.model.document import Document
from collections import defaultdict
from erpnext.controllers.taxes_and_totals import get_itemised_tax_breakup_data
import json
class POSClosingVoucher(Document):
def get_closing_voucher_details(self):
filters = {
'doc': self.name,
'from_date': self.period_start_date,
'to_date': self.period_end_date,
'company': self.company,
'pos_profile': self.pos_profile,
'user': self.user,
'is_pos': 1
}
invoice_list = get_invoices(filters)
self.set_invoice_list(invoice_list)
sales_summary = get_sales_summary(invoice_list)
self.set_sales_summary_values(sales_summary)
self.total_amount = sales_summary['grand_total']
if not self.get('payment_reconciliation'):
mop = get_mode_of_payment_details(invoice_list)
self.set_mode_of_payments(mop)
taxes = get_tax_details(invoice_list)
self.set_taxes(taxes)
return self.get_payment_reconciliation_details()
def validate(self):
user = frappe.get_all('POS Closing Voucher',
filters = {
'user': self.user,
'docstatus': 1
},
or_filters = {
'period_start_date': ('between', [self.period_start_date, self.period_end_date]),
'period_end_date': ('between', [self.period_start_date, self.period_end_date])
})
if user:
frappe.throw(_("POS Closing Voucher alreday exists for {0} between date {1} and {2}")
.format(self.user, self.period_start_date, self.period_end_date))
def set_invoice_list(self, invoice_list):
self.sales_invoices_summary = []
for invoice in invoice_list:
self.append('sales_invoices_summary', {
'invoice': invoice['name'],
'qty_of_items': invoice['pos_total_qty'],
'grand_total': invoice['grand_total']
})
def set_sales_summary_values(self, sales_summary):
self.grand_total = sales_summary['grand_total']
self.net_total = sales_summary['net_total']
self.total_quantity = sales_summary['total_qty']
def set_mode_of_payments(self, mop):
self.payment_reconciliation = []
for m in mop:
self.append('payment_reconciliation', {
'mode_of_payment': m['name'],
'expected_amount': m['amount']
})
def set_taxes(self, taxes):
self.taxes = []
for tax in taxes:
self.append('taxes', {
'rate': tax['rate'],
'amount': tax['amount']
})
def get_payment_reconciliation_details(self):
currency = get_company_currency(self)
return frappe.render_template("erpnext/selling/doctype/pos_closing_voucher/closing_voucher_details.html",
{"data": self, "currency": currency})
@frappe.whitelist()
def get_cashiers(doctype, txt, searchfield, start, page_len, filters):
cashiers_list = frappe.get_all("POS Profile User", filters=filters, fields=['user'])
cashiers = [cashier for cashier in set(c['user'] for c in cashiers_list)]
return [[c] for c in cashiers]
def get_mode_of_payment_details(invoice_list):
mode_of_payment_details = []
invoice_list_names = ",".join(['"' + invoice['name'] + '"' for invoice in invoice_list])
if invoice_list:
inv_mop_detail = frappe.db.sql("""select a.owner, a.posting_date,
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(b.base_amount) as paid_amount
from `tabSales Invoice` a, `tabSales Invoice Payment` b
where a.name = b.parent
and a.name in ({invoice_list_names})
group by a.owner, a.posting_date, mode_of_payment
union
select a.owner,a.posting_date,
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(b.base_paid_amount) as paid_amount
from `tabSales Invoice` a, `tabPayment Entry` b,`tabPayment Entry Reference` c
where a.name = c.reference_name
and b.name = c.parent
and a.name in ({invoice_list_names})
group by a.owner, a.posting_date, mode_of_payment
union
select a.owner, a.posting_date,
ifnull(a.voucher_type,'') as mode_of_payment, sum(b.credit)
from `tabJournal Entry` a, `tabJournal Entry Account` b
where a.name = b.parent
and a.docstatus = 1
and b.reference_type = "Sales Invoice"
and b.reference_name in ({invoice_list_names})
group by a.owner, a.posting_date, mode_of_payment
""".format(invoice_list_names=invoice_list_names), as_dict=1)
inv_change_amount = frappe.db.sql("""select a.owner, a.posting_date,
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(a.base_change_amount) as change_amount
from `tabSales Invoice` a, `tabSales Invoice Payment` b
where a.name = b.parent
and a.name in ({invoice_list_names})
and b.mode_of_payment = 'Cash'
and a.base_change_amount > 0
group by a.owner, a.posting_date, mode_of_payment""".format(invoice_list_names=invoice_list_names), as_dict=1)
for d in inv_change_amount:
for det in inv_mop_detail:
if det["owner"] == d["owner"] and det["posting_date"] == d["posting_date"] and det["mode_of_payment"] == d["mode_of_payment"]:
paid_amount = det["paid_amount"] - d["change_amount"]
det["paid_amount"] = paid_amount
payment_details = defaultdict(int)
for d in inv_mop_detail:
payment_details[d.mode_of_payment] += d.paid_amount
for m in payment_details:
mode_of_payment_details.append({'name': m, 'amount': payment_details[m]})
return mode_of_payment_details
def get_tax_details(invoice_list):
tax_breakup = []
tax_details = defaultdict(int)
for invoice in invoice_list:
doc = frappe.get_doc("Sales Invoice", invoice.name)
itemised_tax, itemised_taxable_amount = get_itemised_tax_breakup_data(doc)
if itemised_tax:
for a in itemised_tax:
for b in itemised_tax[a]:
for c in itemised_tax[a][b]:
if c == 'tax_rate':
tax_details[itemised_tax[a][b][c]] += itemised_tax[a][b]['tax_amount']
for t in tax_details:
tax_breakup.append({'rate': t, 'amount': tax_details[t]})
return tax_breakup
def get_sales_summary(invoice_list):
net_total = sum(item['net_total'] for item in invoice_list)
grand_total = sum(item['grand_total'] for item in invoice_list)
total_qty = sum(item['pos_total_qty'] for item in invoice_list)
return {'net_total': net_total, 'grand_total': grand_total, 'total_qty': total_qty}
def get_company_currency(doc):
currency = frappe.get_cached_value('Company', doc.company, "default_currency")
return frappe.get_doc('Currency', currency)
def get_invoices(filters):
return frappe.db.sql("""select a.name, a.base_grand_total as grand_total,
a.base_net_total as net_total, a.pos_total_qty
from `tabSales Invoice` a
where a.docstatus = 1 and a.posting_date >= %(from_date)s
and a.posting_date <= %(to_date)s and a.company=%(company)s
and a.pos_profile = %(pos_profile)s and a.is_pos = %(is_pos)s
and a.owner = %(user)s""",
filters, as_dict=1)

View File

@@ -1,23 +0,0 @@
/* eslint-disable */
// rename this file from _test_[name] to test_[name] to activate
// and remove above this line
QUnit.test("test: POS Closing Voucher", function (assert) {
let done = assert.async();
// number of asserts
assert.expect(1);
frappe.run_serially([
// insert a new POS Closing Voucher
() => frappe.tests.make('POS Closing Voucher', [
// values to be set
{key: 'value'}
]),
() => {
assert.equal(cur_frm.doc.key, 'value');
},
() => done()
]);
});

View File

@@ -1,83 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from __future__ import unicode_literals
import frappe
import unittest
from frappe.utils import nowdate
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
class TestPOSClosingVoucher(unittest.TestCase):
def test_pos_closing_voucher(self):
old_user = frappe.session.user
user = 'test@example.com'
test_user = frappe.get_doc('User', user)
roles = ("Accounts Manager", "Accounts User", "Sales Manager")
test_user.add_roles(*roles)
frappe.set_user(user)
pos_profile = make_pos_profile()
pos_profile.append('applicable_for_users', {
'default': 1,
'user': user
})
pos_profile.save()
si1 = create_sales_invoice(is_pos=1, rate=3500, do_not_submit=1)
si1.append('payments', {
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 3500
})
si1.submit()
si2 = create_sales_invoice(is_pos=1, rate=3200, do_not_submit=1)
si2.append('payments', {
'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 3200
})
si2.submit()
pcv_doc = create_pos_closing_voucher(user=user,
pos_profile=pos_profile.name, collected_amount=6700)
pcv_doc.get_closing_voucher_details()
self.assertEqual(pcv_doc.total_quantity, 2)
self.assertEqual(pcv_doc.net_total, 6700)
payment = pcv_doc.payment_reconciliation[0]
self.assertEqual(payment.mode_of_payment, 'Cash')
si1.load_from_db()
si1.cancel()
si2.load_from_db()
si2.cancel()
test_user.load_from_db()
test_user.remove_roles(*roles)
frappe.set_user(old_user)
frappe.db.sql("delete from `tabPOS Profile`")
def create_pos_closing_voucher(**args):
args = frappe._dict(args)
doc = frappe.get_doc({
'doctype': 'POS Closing Voucher',
'period_start_date': args.period_start_date or nowdate(),
'period_end_date': args.period_end_date or nowdate(),
'posting_date': args.posting_date or nowdate(),
'company': args.company or "_Test Company",
'pos_profile': args.pos_profile,
'user': args.user or "Administrator",
})
doc.get_closing_voucher_details()
if doc.get('payment_reconciliation'):
doc.payment_reconciliation[0].collected_amount = (args.collected_amount or
doc.payment_reconciliation[0].expected_amount)
doc.save()
return doc

View File

@@ -1,172 +0,0 @@
{
"allow_copy": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"beta": 0,
"creation": "2018-05-28 19:10:47.580174",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
"fields": [
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "mode_of_payment",
"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": "Mode of Payment",
"length": 0,
"no_copy": 0,
"options": "Mode of Payment",
"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,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "0.0",
"fieldname": "collected_amount",
"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": "Collected Amount",
"length": 0,
"no_copy": 0,
"options": "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": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "expected_amount",
"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": "Expected Amount",
"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,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "difference",
"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": "Difference",
"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,
"translatable": 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": "2018-05-29 17:47:16.311557",
"modified_by": "Administrator",
"module": "Selling",
"name": "POS Closing Voucher Details",
"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
}

View File

@@ -1,9 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, 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 POSClosingVoucherDetails(Document):
pass

View File

@@ -1,138 +0,0 @@
{
"allow_copy": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"beta": 0,
"creation": "2018-05-29 14:50:08.687453",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
"fields": [
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "invoice",
"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": "Invoices",
"length": 0,
"no_copy": 0,
"options": "Sales Invoice",
"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,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "qty_of_items",
"fieldtype": "Data",
"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 of Items",
"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,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "grand_total",
"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": "Grand Total",
"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,
"translatable": 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": "2018-05-29 17:46:46.539993",
"modified_by": "Administrator",
"module": "Selling",
"name": "POS Closing Voucher Invoices",
"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
}

View File

@@ -1,9 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, 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 POSClosingVoucherInvoices(Document):
pass

View File

@@ -1,106 +0,0 @@
{
"allow_copy": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"beta": 0,
"creation": "2018-05-30 09:11:22.535470",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
"fields": [
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "rate",
"fieldtype": "Percent",
"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": "Rate",
"length": 0,
"no_copy": 0,
"options": "",
"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,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 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": 1,
"in_standard_filter": 0,
"label": "Amount",
"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,
"translatable": 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": "2018-05-30 09:11:22.535470",
"modified_by": "Administrator",
"module": "Selling",
"name": "POS Closing Voucher Taxes",
"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
}

View File

@@ -1,9 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, 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 POSClosingVoucherTaxes(Document):
pass

View File

@@ -29,6 +29,7 @@ class ProductBundle(Document):
frappe.throw(_("Row #{0}: Child Item should not be a Product Bundle. Please remove Item {1} and Save").format(item.idx, frappe.bold(item.item_code)))
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_new_item_code(doctype, txt, searchfield, start, page_len, filters):
from erpnext.controllers.queries import get_match_cond

View File

@@ -654,6 +654,7 @@
"fieldname": "base_in_words",
"fieldtype": "Data",
"label": "In Words (Company Currency)",
"length": 240,
"oldfieldname": "in_words",
"oldfieldtype": "Data",
"print_hide": 1,
@@ -713,6 +714,7 @@
"fieldname": "in_words",
"fieldtype": "Data",
"label": "In Words",
"length": 240,
"oldfieldname": "in_words_export",
"oldfieldtype": "Data",
"print_hide": 1,
@@ -921,7 +923,7 @@
"fieldname": "lost_reasons",
"fieldtype": "Table MultiSelect",
"label": "Lost Reasons",
"options": "Lost Reason Detail",
"options": "Quotation Lost Reason Detail",
"read_only": 1
}
],
@@ -930,7 +932,7 @@
"is_submittable": 1,
"links": [],
"max_attachments": 1,
"modified": "2019-12-30 19:14:56.630270",
"modified": "2020-07-26 17:46:19.951223",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation",

View File

@@ -68,7 +68,7 @@ class Quotation(SellingController):
def declare_enquiry_lost(self, lost_reasons_list, detailed_reason=None):
if not self.has_sales_order():
get_lost_reasons = frappe.get_list('Opportunity Lost Reason',
get_lost_reasons = frappe.get_list('Quotation Lost Reason',
fields = ["name"])
lost_reasons_lst = [reason.get('name') for reason in get_lost_reasons]
frappe.db.set(self, 'status', 'Lost')
@@ -285,9 +285,17 @@ def _make_customer(source_name, ignore_permissions=False):
return customer
else:
raise
except frappe.MandatoryError:
except frappe.MandatoryError as e:
mandatory_fields = e.args[0].split(':')[1].split(',')
mandatory_fields = [customer.meta.get_label(field.strip()) for field in mandatory_fields]
frappe.local.message_log = []
frappe.throw(_("Please create Customer from Lead {0}").format(lead_name))
lead_link = frappe.utils.get_link_to_form("Lead", lead_name)
message = _("Could not auto create Customer due to the following missing mandatory field(s):") + "<br>"
message += "<br><ul><li>" + "</li><li>".join(mandatory_fields) + "</li></ul>"
message += _("Please create Customer from Lead {0}.").format(lead_link)
frappe.throw(message, title=_("Mandatory Missing"))
else:
return customer_name
else:

View File

@@ -928,6 +928,7 @@
"hide_days": 1,
"hide_seconds": 1,
"label": "In Words (Company Currency)",
"length": 240,
"oldfieldname": "in_words",
"oldfieldtype": "Data",
"print_hide": 1,
@@ -986,6 +987,7 @@
"hide_days": 1,
"hide_seconds": 1,
"label": "In Words",
"length": 240,
"oldfieldname": "in_words_export",
"oldfieldtype": "Data",
"print_hide": 1,
@@ -1458,7 +1460,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2020-06-30 11:56:42.301317",
"modified": "2020-07-31 14:13:17.962015",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",
@@ -1532,7 +1534,7 @@
"sort_field": "modified",
"sort_order": "DESC",
"timeline_field": "customer",
"title_field": "title",
"title_field": "customer",
"track_changes": 1,
"track_seen": 1
}

View File

@@ -888,6 +888,7 @@ def make_purchase_order(source_name, for_supplier=None, selected_items=[], targe
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_supplier(doctype, txt, searchfield, start, page_len, filters):
supp_master_name = frappe.defaults.get_user_default("supp_master_name")
if supp_master_name == "Supplier Name":

View File

@@ -10,6 +10,7 @@ def get_data():
'Payment Entry': 'reference_name',
'Payment Request': 'reference_name',
'Auto Repeat': 'reference_document',
'Maintenance Visit': 'prevdoc_docname'
},
'internal_links': {
'Quotation': ['items', 'prevdoc_docname']
@@ -17,7 +18,7 @@ def get_data():
'transactions': [
{
'label': _('Fulfillment'),
'items': ['Sales Invoice', 'Pick List', 'Delivery Note']
'items': ['Sales Invoice', 'Pick List', 'Delivery Note', 'Maintenance Visit']
},
{
'label': _('Purchasing'),

View File

@@ -0,0 +1,21 @@
{
"creation": "2020-07-20 20:17:16.653866",
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Customer",
"dynamic_filters_json": "",
"filters_json": "[[\"Customer\",\"disabled\",\"=\",\"0\"]]",
"function": "Count",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Active Customers",
"modified": "2020-07-22 14:20:32.268103",
"modified_by": "Administrator",
"module": "Selling",
"name": "Active Customers",
"owner": "Administrator",
"show_percentage_stats": 1,
"stats_time_interval": "Monthly",
"type": "Document Type"
}

View File

@@ -0,0 +1,22 @@
{
"aggregate_function_based_on": "base_net_total",
"creation": "2020-07-20 20:17:16.568132",
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Sales Order",
"dynamic_filters_json": "[[\"Sales Order\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Sales Order\",\"status\",\"not in\",[\"Draft\",\"Cancelled\",\"Closed\",null],false],[\"Sales Order\",\"docstatus\",\"=\",\"1\",false],[\"Sales Order\",\"modified\",\"Timespan\",\"this year\",false]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Annual Sales",
"modified": "2020-07-22 16:56:33.747156",
"modified_by": "Administrator",
"module": "Selling",
"name": "Annual Sales",
"owner": "Administrator",
"show_percentage_stats": 1,
"stats_time_interval": "Monthly",
"type": "Document Type"
}

View File

@@ -0,0 +1,21 @@
{
"creation": "2020-07-20 20:17:16.625001",
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Sales Order",
"dynamic_filters_json": "[[\"Sales Order\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Sales Order\",\"status\",\"in\",[\"To Deliver and Bill\",\"To Bill\",null],false],[\"Sales Order\",\"docstatus\",\"=\",\"1\",false]]",
"function": "Count",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Sales Orders to Bill",
"modified": "2020-07-22 14:20:09.918626",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Orders to Bill",
"owner": "Administrator",
"show_percentage_stats": 1,
"stats_time_interval": "Weekly",
"type": "Document Type"
}

View File

@@ -0,0 +1,21 @@
{
"creation": "2020-07-20 20:17:16.596857",
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Sales Order",
"dynamic_filters_json": "[[\"Sales Order\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Sales Order\",\"status\",\"in\",[\"To Deliver and Bill\",\"To Deliver\",null],false],[\"Sales Order\",\"docstatus\",\"=\",\"1\",false]]",
"function": "Count",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Sales Orders to Deliver",
"modified": "2020-07-22 14:19:28.833784",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Orders to Deliver",
"owner": "Administrator",
"show_percentage_stats": 1,
"stats_time_interval": "Weekly",
"type": "Document Type"
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,33 @@
{
"content": null,
"creation": "2017-08-07 17:08:56.737947",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"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",
"content": null,
"creation": "2020-01-28 22:05:44.819140",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2020-06-01 15:41:06.348380",
"modified_by": "Administrator",
"module": "Selling",
"name": "point-of-sale",
"owner": "Administrator",
"page_name": "Point of Sale",
"restrict_to_domain": "Retail",
"roles": [
{
"role": "Accounts User"
},
},
{
"role": "Accounts Manager"
},
},
{
"role": "Sales User"
},
},
{
"role": "Sales Manager"
}
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "Point of Sale"
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "Point Of Sale"
}

View File

@@ -6,6 +6,7 @@ import frappe, json
from frappe.utils.nestedset import get_root_of
from frappe.utils import cint
from erpnext.accounts.doctype.pos_profile.pos_profile import get_item_groups
from erpnext.accounts.doctype.pos_invoice.pos_invoice import get_stock_availability
from six import string_types
@@ -13,10 +14,9 @@ from six import string_types
def get_items(start, page_length, price_list, item_group, search_value="", pos_profile=None):
data = dict()
warehouse = ""
display_items_in_stock = 0
if pos_profile:
warehouse, display_items_in_stock = frappe.db.get_value('POS Profile', pos_profile, ['warehouse', 'display_items_in_stock'])
warehouse = frappe.db.get_value('POS Profile', pos_profile, ['warehouse'])
if not frappe.db.exists('Item Group', item_group):
item_group = get_root_of('Item Group')
@@ -43,6 +43,7 @@ def get_items(start, page_length, price_list, item_group, search_value="", pos_p
SELECT
name AS item_code,
item_name,
description,
stock_uom,
image AS item_image,
idx AS idx,
@@ -53,10 +54,11 @@ def get_items(start, page_length, price_list, item_group, search_value="", pos_p
disabled = 0
AND has_variants = 0
AND is_sales_item = 1
AND is_fixed_asset = 0
AND item_group in (SELECT name FROM `tabItem Group` WHERE lft >= {lft} AND rgt <= {rgt})
AND {condition}
ORDER BY
idx desc
name asc
LIMIT
{start}, {page_length}"""
.format(
@@ -73,34 +75,16 @@ def get_items(start, page_length, price_list, item_group, search_value="", pos_p
fields = ["item_code", "price_list_rate", "currency"],
filters = {'price_list': price_list, 'item_code': ['in', items]})
item_prices, bin_data = {}, {}
item_prices = {}
for d in item_prices_data:
item_prices[d.item_code] = d
# prepare filter for bin query
bin_filters = {'item_code': ['in', items]}
if warehouse:
bin_filters['warehouse'] = warehouse
if display_items_in_stock:
bin_filters['actual_qty'] = [">", 0]
# query item bin
bin_data = frappe.get_all(
'Bin', fields=['item_code', 'sum(actual_qty) as actual_qty'],
filters=bin_filters, group_by='item_code'
)
# convert list of dict into dict as {item_code: actual_qty}
bin_dict = {}
for b in bin_data:
bin_dict[b.get('item_code')] = b.get('actual_qty')
for item in items_data:
item_code = item.item_code
item_price = item_prices.get(item_code) or {}
item_stock_qty = bin_dict.get(item_code)
item_stock_qty = get_stock_availability(item_code, warehouse)
if display_items_in_stock and not item_stock_qty:
if not item_stock_qty:
pass
else:
row = {}
@@ -116,6 +100,13 @@ def get_items(start, page_length, price_list, item_group, search_value="", pos_p
'items': result
}
if len(res['items']) == 1:
res['items'][0].setdefault('serial_no', serial_no)
res['items'][0].setdefault('batch_no', batch_no)
res['items'][0].setdefault('barcode', barcode)
return res
if serial_no:
res.update({
'serial_no': serial_no
@@ -168,6 +159,7 @@ def get_item_group_condition(pos_profile):
return cond % tuple(item_groups)
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def item_group_query(doctype, txt, searchfield, start, page_len, filters):
item_groups = []
cond = "1=1"
@@ -186,6 +178,73 @@ def item_group_query(doctype, txt, searchfield, start, page_len, filters):
{'txt': '%%%s%%' % txt})
@frappe.whitelist()
def get_pos_fields():
return frappe.get_all("POS Field", fields=["label", "fieldname",
"fieldtype", "default_value", "reqd", "read_only", "options"])
def check_opening_entry(user):
open_vouchers = frappe.db.get_all("POS Opening Entry",
filters = {
"user": user,
"pos_closing_entry": ["in", ["", None]],
"docstatus": 1
},
fields = ["name", "company", "pos_profile", "period_start_date"],
order_by = "period_start_date desc"
)
return open_vouchers
@frappe.whitelist()
def create_opening_voucher(pos_profile, company, balance_details):
import json
balance_details = json.loads(balance_details)
new_pos_opening = frappe.get_doc({
'doctype': 'POS Opening Entry',
"period_start_date": frappe.utils.get_datetime(),
"posting_date": frappe.utils.getdate(),
"user": frappe.session.user,
"pos_profile": pos_profile,
"company": company,
})
new_pos_opening.set("balance_details", balance_details)
new_pos_opening.submit()
return new_pos_opening.as_dict()
@frappe.whitelist()
def get_past_order_list(search_term, status, limit=20):
fields = ['name', 'grand_total', 'currency', 'customer', 'posting_time', 'posting_date']
invoice_list = []
if search_term and status:
invoices_by_customer = frappe.db.get_all('POS Invoice', filters={
'customer': ['like', '%{}%'.format(search_term)],
'status': status
}, fields=fields)
invoices_by_name = frappe.db.get_all('POS Invoice', filters={
'name': ['like', '%{}%'.format(search_term)],
'status': status
}, fields=fields)
invoice_list = invoices_by_customer + invoices_by_name
elif status:
invoice_list = frappe.db.get_all('POS Invoice', filters={
'status': status
}, fields=fields)
return invoice_list
@frappe.whitelist()
def set_customer_info(fieldname, customer, value=""):
if fieldname == 'loyalty_program':
frappe.db.set_value('Customer', customer, 'loyalty_program', value)
contact = frappe.get_cached_value('Customer', customer, 'customer_primary_contact')
if contact:
contact_doc = frappe.get_doc('Contact', contact)
if fieldname == 'email_id':
contact_doc.set('email_ids', [{ 'email_id': value, 'is_primary': 1}])
frappe.db.set_value('Customer', customer, 'email_id', value)
elif fieldname == 'mobile_no':
contact_doc.set('phone_nos', [{ 'phone': value, 'is_primary_mobile_no': 1}])
frappe.db.set_value('Customer', customer, 'mobile_no', value)
contact_doc.save()

View File

@@ -0,0 +1,715 @@
{% include "erpnext/selling/page/point_of_sale/onscan.js" %}
{% include "erpnext/selling/page/point_of_sale/pos_item_selector.js" %}
{% include "erpnext/selling/page/point_of_sale/pos_item_cart.js" %}
{% include "erpnext/selling/page/point_of_sale/pos_item_details.js" %}
{% include "erpnext/selling/page/point_of_sale/pos_payment.js" %}
{% include "erpnext/selling/page/point_of_sale/pos_number_pad.js" %}
{% include "erpnext/selling/page/point_of_sale/pos_past_order_list.js" %}
{% include "erpnext/selling/page/point_of_sale/pos_past_order_summary.js" %}
erpnext.PointOfSale.Controller = class {
constructor(wrapper) {
this.wrapper = $(wrapper).find('.layout-main-section');
this.page = wrapper.page;
this.load_assets();
}
load_assets() {
// after loading assets first check if opening entry has been made
frappe.require(['assets/erpnext/css/pos.css'], this.check_opening_entry.bind(this));
}
check_opening_entry() {
return frappe.call("erpnext.selling.page.point_of_sale.point_of_sale.check_opening_entry", { "user": frappe.session.user })
.then((r) => {
if (r.message.length) {
// assuming only one opening voucher is available for the current user
this.prepare_app_defaults(r.message[0]);
} else {
this.create_opening_voucher();
}
});
}
create_opening_voucher() {
const table_fields = [
{ fieldname: "mode_of_payment", fieldtype: "Link", in_list_view: 1, label: "Mode of Payment", options: "Mode of Payment", reqd: 1 },
{ fieldname: "opening_amount", fieldtype: "Currency", default: 0, in_list_view: 1, label: "Opening Amount",
options: "company:company_currency", reqd: 1 }
];
const dialog = new frappe.ui.Dialog({
title: __('Create POS Opening Entry'),
fields: [
{
fieldtype: 'Link', label: __('Company'), default: frappe.defaults.get_default('company'),
options: 'Company', fieldname: 'company', reqd: 1
},
{
fieldtype: 'Link', label: __('POS Profile'),
options: 'POS Profile', fieldname: 'pos_profile', reqd: 1,
onchange: () => {
const pos_profile = dialog.fields_dict.pos_profile.get_value();
const company = dialog.fields_dict.company.get_value();
const user = frappe.session.user
if (!pos_profile || !company || !user) return;
// auto fetch last closing entry's balance details
frappe.db.get_list("POS Closing Entry", {
filters: { company, pos_profile, user },
limit: 1,
order_by: 'period_end_date desc'
}).then((res) => {
if (!res.length) return;
const pos_closing_entry = res[0];
frappe.db.get_doc("POS Closing Entry", pos_closing_entry.name).then(({ payment_reconciliation }) => {
dialog.fields_dict.balance_details.df.data = [];
payment_reconciliation.forEach(pay => {
const { mode_of_payment } = pay;
dialog.fields_dict.balance_details.df.data.push({
mode_of_payment: mode_of_payment
});
});
dialog.fields_dict.balance_details.grid.refresh();
});
});
}
},
{
fieldname: "balance_details",
fieldtype: "Table",
label: "Opening Balance Details",
cannot_add_rows: false,
in_place_edit: true,
reqd: 1,
data: [],
fields: table_fields
}
],
primary_action: ({ company, pos_profile, balance_details }) => {
if (!balance_details.length) {
frappe.show_alert({
message: __("Please add Mode of payments and opening balance details."),
indicator: 'red'
})
frappe.utils.play_sound("error");
return;
}
frappe.dom.freeze();
return frappe.call("erpnext.selling.page.point_of_sale.point_of_sale.create_opening_voucher",
{ pos_profile, company, balance_details })
.then((r) => {
frappe.dom.unfreeze();
dialog.hide();
if (r.message) {
this.prepare_app_defaults(r.message);
}
})
},
primary_action_label: __('Submit')
});
dialog.show();
}
prepare_app_defaults(data) {
this.pos_opening = data.name;
this.company = data.company;
this.pos_profile = data.pos_profile;
this.pos_opening_time = data.period_start_date;
frappe.db.get_value('Stock Settings', undefined, 'allow_negative_stock').then(({ message }) => {
this.allow_negative_stock = flt(message.allow_negative_stock) || false;
});
frappe.db.get_doc("POS Profile", this.pos_profile).then((profile) => {
this.customer_groups = profile.customer_groups.map(group => group.customer_group);
this.cart.make_customer_selector();
});
this.item_stock_map = {};
this.make_app();
}
set_opening_entry_status() {
this.page.set_title_sub(
`<span class="indicator orange">
<a class="text-muted" href="#Form/POS%20Opening%20Entry/${this.pos_opening}">
Opened at ${moment(this.pos_opening_time).format("Do MMMM, h:mma")}
</a>
</span>`);
}
make_app() {
return frappe.run_serially([
() => frappe.dom.freeze(),
() => {
this.set_opening_entry_status();
this.prepare_dom();
this.prepare_components();
this.prepare_menu();
},
() => this.make_new_invoice(),
() => frappe.dom.unfreeze(),
() => this.page.set_title(__('Point of Sale')),
]);
}
prepare_dom() {
this.wrapper.append(`
<div class="app grid grid-cols-10 pt-8 gap-6"></div>`
);
this.$components_wrapper = this.wrapper.find('.app');
}
prepare_components() {
this.init_item_selector();
this.init_item_details();
this.init_item_cart();
this.init_payments();
this.init_recent_order_list();
this.init_order_summary();
}
prepare_menu() {
var me = this;
this.page.clear_menu();
this.page.add_menu_item(__("Form View"), function () {
frappe.model.sync(me.frm.doc);
frappe.set_route("Form", me.frm.doc.doctype, me.frm.doc.name);
});
this.page.add_menu_item(__("Toggle Recent Orders"), () => {
const show = this.recent_order_list.$component.hasClass('d-none');
this.toggle_recent_order_list(show);
});
this.page.add_menu_item(__("Save as Draft"), this.save_draft_invoice.bind(this));
frappe.ui.keys.on("ctrl+s", this.save_draft_invoice.bind(this));
this.page.add_menu_item(__('Close the POS'), this.close_pos.bind(this));
frappe.ui.keys.on("shift+ctrl+s", this.close_pos.bind(this));
}
save_draft_invoice() {
if (!this.$components_wrapper.is(":visible")) return;
if (this.frm.doc.items.length == 0) {
frappe.show_alert({
message:__("You must add atleast one item to save it as draft."),
indicator:'red'
});
frappe.utils.play_sound("error");
return;
}
this.frm.save(undefined, undefined, undefined, () => {
frappe.show_alert({
message:__("There was an error saving the document."),
indicator:'red'
});
frappe.utils.play_sound("error");
}).then(() => {
frappe.run_serially([
() => frappe.dom.freeze(),
() => this.make_new_invoice(),
() => frappe.dom.unfreeze(),
]);
})
}
close_pos() {
if (!this.$components_wrapper.is(":visible")) return;
let voucher = frappe.model.get_new_doc('POS Closing Entry');
voucher.pos_profile = this.frm.doc.pos_profile;
voucher.user = frappe.session.user;
voucher.company = this.frm.doc.company;
voucher.pos_opening_entry = this.pos_opening;
voucher.period_end_date = frappe.datetime.now_datetime();
voucher.posting_date = frappe.datetime.now_date();
frappe.set_route('Form', 'POS Closing Entry', voucher.name);
}
init_item_selector() {
this.item_selector = new erpnext.PointOfSale.ItemSelector({
wrapper: this.$components_wrapper,
pos_profile: this.pos_profile,
events: {
item_selected: args => this.on_cart_update(args),
get_frm: () => this.frm || {},
get_allowed_item_group: () => this.item_groups
}
})
}
init_item_cart() {
this.cart = new erpnext.PointOfSale.ItemCart({
wrapper: this.$components_wrapper,
events: {
get_frm: () => this.frm,
cart_item_clicked: (item_code, batch_no, uom) => {
const item_row = this.frm.doc.items.find(
i => i.item_code === item_code
&& i.uom === uom
&& (!batch_no || (batch_no && i.batch_no === batch_no))
);
this.item_details.toggle_item_details_section(item_row);
},
numpad_event: (value, action) => this.update_item_field(value, action),
checkout: () => this.payment.checkout(),
edit_cart: () => this.payment.edit_cart(),
customer_details_updated: (details) => {
this.customer_details = details;
// will add/remove LP payment method
this.payment.render_loyalty_points_payment_mode();
},
get_allowed_customer_group: () => this.customer_groups
}
})
}
init_item_details() {
this.item_details = new erpnext.PointOfSale.ItemDetails({
wrapper: this.$components_wrapper,
events: {
get_frm: () => this.frm,
toggle_item_selector: (minimize) => {
this.item_selector.resize_selector(minimize);
this.cart.toggle_numpad(minimize);
},
form_updated: async (cdt, cdn, fieldname, value) => {
const item_row = frappe.model.get_doc(cdt, cdn);
if (item_row && item_row[fieldname] != value) {
if (fieldname === 'qty' && flt(value) == 0) {
this.remove_item_from_cart();
return;
}
const { item_code, batch_no, uom } = this.item_details.current_item;
const event = {
field: fieldname,
value,
item: { item_code, batch_no, uom }
}
return this.on_cart_update(event)
}
},
item_field_focused: (fieldname) => {
this.cart.toggle_numpad_field_edit(fieldname);
},
set_value_in_current_cart_item: (selector, value) => {
this.cart.update_selector_value_in_cart_item(selector, value, this.item_details.current_item);
},
clone_new_batch_item_in_frm: (batch_serial_map, current_item) => {
// called if serial nos are 'auto_selected' and if those serial nos belongs to multiple batches
// for each unique batch new item row is added in the form & cart
Object.keys(batch_serial_map).forEach(batch => {
const { item_code, batch_no } = current_item;
const item_to_clone = this.frm.doc.items.find(i => i.item_code === item_code && i.batch_no === batch_no);
const new_row = this.frm.add_child("items", { ...item_to_clone });
// update new serialno and batch
new_row.batch_no = batch;
new_row.serial_no = batch_serial_map[batch].join(`\n`);
new_row.qty = batch_serial_map[batch].length;
this.frm.doc.items.forEach(row => {
if (item_code === row.item_code) {
this.update_cart_html(row);
}
});
})
},
remove_item_from_cart: () => this.remove_item_from_cart(),
get_item_stock_map: () => this.item_stock_map,
close_item_details: () => {
this.item_details.toggle_item_details_section(undefined);
this.cart.prev_action = undefined;
this.cart.toggle_item_highlight();
},
get_available_stock: (item_code, warehouse) => this.get_available_stock(item_code, warehouse)
}
});
}
init_payments() {
this.payment = new erpnext.PointOfSale.Payment({
wrapper: this.$components_wrapper,
events: {
get_frm: () => this.frm || {},
get_customer_details: () => this.customer_details || {},
toggle_other_sections: (show) => {
if (show) {
this.item_details.$component.hasClass('d-none') ? '' : this.item_details.$component.addClass('d-none');
this.item_selector.$component.addClass('d-none');
} else {
this.item_selector.$component.removeClass('d-none');
}
},
submit_invoice: () => {
this.frm.savesubmit()
.then((r) => {
// this.set_invoice_status();
this.toggle_components(false);
this.order_summary.toggle_component(true);
this.order_summary.load_summary_of(this.frm.doc, true);
frappe.show_alert({
indicator: 'green',
message: __(`POS invoice ${r.doc.name} created succesfully`)
});
});
}
}
});
}
init_recent_order_list() {
this.recent_order_list = new erpnext.PointOfSale.PastOrderList({
wrapper: this.$components_wrapper,
events: {
open_invoice_data: (name) => {
frappe.db.get_doc('POS Invoice', name).then((doc) => {
this.order_summary.load_summary_of(doc);
});
},
reset_summary: () => this.order_summary.show_summary_placeholder()
}
})
}
init_order_summary() {
this.order_summary = new erpnext.PointOfSale.PastOrderSummary({
wrapper: this.$components_wrapper,
events: {
get_frm: () => this.frm,
process_return: (name) => {
this.recent_order_list.toggle_component(false);
frappe.db.get_doc('POS Invoice', name).then((doc) => {
frappe.run_serially([
() => this.make_return_invoice(doc),
() => this.cart.load_invoice(),
() => this.item_selector.toggle_component(true)
]);
});
},
edit_order: (name) => {
this.recent_order_list.toggle_component(false);
frappe.run_serially([
() => this.frm.refresh(name),
() => this.cart.load_invoice(),
() => this.item_selector.toggle_component(true)
]);
},
new_order: () => {
frappe.run_serially([
() => frappe.dom.freeze(),
() => this.make_new_invoice(),
() => this.item_selector.toggle_component(true),
() => frappe.dom.unfreeze(),
]);
}
}
})
}
toggle_recent_order_list(show) {
this.toggle_components(!show);
this.recent_order_list.toggle_component(show);
this.order_summary.toggle_component(show);
}
toggle_components(show) {
this.cart.toggle_component(show);
this.item_selector.toggle_component(show);
// do not show item details or payment if recent order is toggled off
!show ? (this.item_details.toggle_component(false) || this.payment.toggle_component(false)) : '';
}
make_new_invoice() {
return frappe.run_serially([
() => this.make_sales_invoice_frm(),
() => this.set_pos_profile_data(),
() => this.set_pos_profile_status(),
() => this.cart.load_invoice(),
]);
}
make_sales_invoice_frm() {
const doctype = 'POS Invoice';
return new Promise(resolve => {
if (this.frm) {
this.frm = this.get_new_frm(this.frm);
this.frm.doc.items = [];
this.frm.doc.is_pos = 1
resolve();
} else {
frappe.model.with_doctype(doctype, () => {
this.frm = this.get_new_frm();
this.frm.doc.items = [];
this.frm.doc.is_pos = 1
resolve();
});
}
});
}
get_new_frm(_frm) {
const doctype = 'POS Invoice';
const page = $('<div>');
const frm = _frm || new frappe.ui.form.Form(doctype, page, false);
const name = frappe.model.make_new_doc_and_get_name(doctype, true);
frm.refresh(name);
return frm;
}
async make_return_invoice(doc) {
frappe.dom.freeze();
this.frm = this.get_new_frm(this.frm);
this.frm.doc.items = [];
const res = await frappe.call({
method: "erpnext.accounts.doctype.pos_invoice.pos_invoice.make_sales_return",
args: {
'source_name': doc.name,
'target_doc': this.frm.doc
}
});
frappe.model.sync(res.message);
await this.set_pos_profile_data();
frappe.dom.unfreeze();
}
set_pos_profile_data() {
if (this.company && !this.frm.doc.company) this.frm.doc.company = this.company;
if (this.pos_profile && !this.frm.doc.pos_profile) this.frm.doc.pos_profile = this.pos_profile;
if (!this.frm.doc.company) return;
return new Promise(resolve => {
return this.frm.call({
doc: this.frm.doc,
method: "set_missing_values",
}).then((r) => {
if(!r.exc) {
if (!this.frm.doc.pos_profile) {
frappe.dom.unfreeze();
this.raise_exception_for_pos_profile();
}
this.frm.trigger("update_stock");
this.frm.trigger('calculate_taxes_and_totals');
if(this.frm.doc.taxes_and_charges) this.frm.script_manager.trigger("taxes_and_charges");
frappe.model.set_default_values(this.frm.doc);
if (r.message) {
this.frm.pos_print_format = r.message.print_format || "";
this.frm.meta.default_print_format = r.message.print_format || "";
this.frm.allow_edit_rate = r.message.allow_edit_rate;
this.frm.allow_edit_discount = r.message.allow_edit_discount;
this.frm.doc.campaign = r.message.campaign;
}
}
resolve();
});
});
}
raise_exception_for_pos_profile() {
setTimeout(() => frappe.set_route('List', 'POS Profile'), 2000);
frappe.throw(__("POS Profile is required to use Point-of-Sale"));
}
set_invoice_status() {
const [status, indicator] = frappe.listview_settings["POS Invoice"].get_indicator(this.frm.doc);
this.page.set_indicator(__(`${status}`), indicator);
}
set_pos_profile_status() {
this.page.set_indicator(__(`${this.pos_profile}`), "blue");
}
async on_cart_update(args) {
frappe.dom.freeze();
try {
let { field, value, item } = args;
const { item_code, batch_no, serial_no, uom } = item;
let item_row = this.get_item_from_frm(item_code, batch_no, uom);
const item_selected_from_selector = field === 'qty' && value === "+1"
if (item_row) {
item_selected_from_selector && (value = item_row.qty + flt(value))
field === 'qty' && (value = flt(value));
if (field === 'qty' && value > 0 && !this.allow_negative_stock)
await this.check_stock_availability(item_row, value, this.frm.doc.set_warehouse);
if (this.is_current_item_being_edited(item_row) || item_selected_from_selector) {
await frappe.model.set_value(item_row.doctype, item_row.name, field, value);
this.update_cart_html(item_row);
}
} else {
if (!this.frm.doc.customer) {
frappe.dom.unfreeze();
frappe.show_alert({
message: __('You must select a customer before adding an item.'),
indicator: 'orange'
});
frappe.utils.play_sound("error");
return;
}
item_selected_from_selector && (value = flt(value))
const args = { item_code, batch_no, [field]: value };
if (serial_no) args['serial_no'] = serial_no;
if (field === 'serial_no') args['qty'] = value.split(`\n`).length || 0;
item_row = this.frm.add_child('items', args);
if (field === 'qty' && value !== 0 && !this.allow_negative_stock)
await this.check_stock_availability(item_row, value, this.frm.doc.set_warehouse);
await this.trigger_new_item_events(item_row);
this.check_serial_batch_selection_needed(item_row) && this.edit_item_details_of(item_row);
this.update_cart_html(item_row);
}
} catch (error) {
console.log(error);
} finally {
frappe.dom.unfreeze();
}
}
get_item_from_frm(item_code, batch_no, uom) {
const has_batch_no = batch_no;
return this.frm.doc.items.find(
i => i.item_code === item_code
&& (!has_batch_no || (has_batch_no && i.batch_no === batch_no))
&& (i.uom === uom)
);
}
edit_item_details_of(item_row) {
this.item_details.toggle_item_details_section(item_row);
}
is_current_item_being_edited(item_row) {
const { item_code, batch_no } = this.item_details.current_item;
return item_code !== item_row.item_code || batch_no != item_row.batch_no ? false : true;
}
update_cart_html(item_row, remove_item) {
this.cart.update_item_html(item_row, remove_item);
this.cart.update_totals_section(this.frm);
}
check_serial_batch_selection_needed(item_row) {
// right now item details is shown for every type of item.
// if item details is not shown for every item then this fn will be needed
const serialized = item_row.has_serial_no;
const batched = item_row.has_batch_no;
const no_serial_selected = !item_row.serial_no;
const no_batch_selected = !item_row.batch_no;
if ((serialized && no_serial_selected) || (batched && no_batch_selected) ||
(serialized && batched && (no_batch_selected || no_serial_selected))) {
return true;
}
return false;
}
async trigger_new_item_events(item_row) {
await this.frm.script_manager.trigger('item_code', item_row.doctype, item_row.name)
await this.frm.script_manager.trigger('qty', item_row.doctype, item_row.name)
}
async check_stock_availability(item_row, qty_needed, warehouse) {
const available_qty = (await this.get_available_stock(item_row.item_code, warehouse)).message;
frappe.dom.unfreeze();
if (!(available_qty > 0)) {
frappe.model.clear_doc(item_row.doctype, item_row.name);
frappe.throw(__(`Item Code: ${item_row.item_code.bold()} is not available under warehouse ${warehouse.bold()}.`))
} else if (available_qty < qty_needed) {
frappe.show_alert({
message: __(`Stock quantity not enough for Item Code: ${item_row.item_code.bold()} under warehouse ${warehouse.bold()}.
Available quantity ${available_qty.toString().bold()}.`),
indicator: 'orange'
});
frappe.utils.play_sound("error");
this.item_details.qty_control.set_value(flt(available_qty));
}
frappe.dom.freeze();
}
get_available_stock(item_code, warehouse) {
const me = this;
return frappe.call({
method: "erpnext.accounts.doctype.pos_invoice.pos_invoice.get_stock_availability",
args: {
'item_code': item_code,
'warehouse': warehouse,
},
callback(res) {
if (!me.item_stock_map[item_code])
me.item_stock_map[item_code] = {}
me.item_stock_map[item_code][warehouse] = res.message;
}
});
}
update_item_field(value, field_or_action) {
if (field_or_action === 'checkout') {
this.item_details.toggle_item_details_section(undefined);
} else if (field_or_action === 'remove') {
this.remove_item_from_cart();
} else {
const field_control = this.item_details[`${field_or_action}_control`];
if (!field_control) return;
field_control.set_focus();
value != "" && field_control.set_value(value);
}
}
remove_item_from_cart() {
frappe.dom.freeze();
const { doctype, name, current_item } = this.item_details;
frappe.model.set_value(doctype, name, 'qty', 0);
this.frm.script_manager.trigger('qty', doctype, name).then(() => {
frappe.model.clear_doc(doctype, name);
this.update_cart_html(current_item, true);
this.item_details.toggle_item_details_section(undefined);
frappe.dom.unfreeze();
})
}
}

View File

@@ -0,0 +1,951 @@
erpnext.PointOfSale.ItemCart = class {
constructor({ wrapper, events }) {
this.wrapper = wrapper;
this.events = events;
this.customer_info = undefined;
this.init_component();
}
init_component() {
this.prepare_dom();
this.init_child_components();
this.bind_events();
this.attach_shortcuts();
}
prepare_dom() {
this.wrapper.append(
`<section class="col-span-4 flex flex-col shadow rounded item-cart bg-white mx-h-70 h-100"></section>`
)
this.$component = this.wrapper.find('.item-cart');
}
init_child_components() {
this.init_customer_selector();
this.init_cart_components();
}
init_customer_selector() {
this.$component.append(
`<div class="customer-section rounded flex flex-col m-8 mb-0"></div>`
)
this.$customer_section = this.$component.find('.customer-section');
}
reset_customer_selector() {
const frm = this.events.get_frm();
frm.set_value('customer', '');
this.$customer_section.removeClass('border pr-4 pl-4');
this.make_customer_selector();
this.customer_field.set_focus();
}
init_cart_components() {
this.$component.append(
`<div class="cart-container flex flex-col items-center rounded flex-1 relative">
<div class="absolute flex flex-col p-8 pt-0 w-full h-full">
<div class="flex text-grey cart-header pt-2 pb-2 p-4 mt-2 mb-2 w-full f-shrink-0">
<div class="flex-1">Item</div>
<div class="mr-4">Qty</div>
<div class="rate-list-header mr-1 text-right">Amount</div>
</div>
<div class="cart-items-section flex flex-col flex-1 scroll-y rounded w-full"></div>
<div class="cart-totals-section flex flex-col w-full mt-4 f-shrink-0"></div>
<div class="numpad-section flex flex-col mt-4 d-none w-full p-8 pt-0 pb-0 f-shrink-0"></div>
</div>
</div>`
);
this.$cart_container = this.$component.find('.cart-container');
this.make_cart_totals_section();
this.make_cart_items_section();
this.make_cart_numpad();
}
make_cart_items_section() {
this.$cart_header = this.$component.find('.cart-header');
this.$cart_items_wrapper = this.$component.find('.cart-items-section');
this.make_no_items_placeholder();
}
make_no_items_placeholder() {
this.$cart_header.addClass('d-none');
this.$cart_items_wrapper.html(
`<div class="no-item-wrapper flex items-center h-18">
<div class="flex-1 text-center text-grey">No items in cart</div>
</div>`
)
this.$cart_items_wrapper.addClass('mt-4 border-grey border-dashed');
}
make_cart_totals_section() {
this.$totals_section = this.$component.find('.cart-totals-section');
this.$totals_section.append(
`<div class="add-discount flex items-center pt-4 pb-4 pr-4 pl-4 text-grey pointer no-select d-none">
+ Add Discount
</div>
<div class="border border-grey rounded">
<div class="net-total flex justify-between items-center h-16 pr-8 pl-8 border-b-grey">
<div class="flex flex-col">
<div class="text-md text-dark-grey text-bold">Net Total</div>
</div>
<div class="flex flex-col text-right">
<div class="text-md text-dark-grey text-bold">0.00</div>
</div>
</div>
<div class="taxes"></div>
<div class="grand-total flex justify-between items-center h-16 pr-8 pl-8 border-b-grey">
<div class="flex flex-col">
<div class="text-md text-dark-grey text-bold">Grand Total</div>
</div>
<div class="flex flex-col text-right">
<div class="text-md text-dark-grey text-bold">0.00</div>
</div>
</div>
<div class="checkout-btn flex items-center justify-center h-16 pr-8 pl-8 text-center text-grey no-select pointer rounded-b text-md text-bold">
Checkout
</div>
<div class="edit-cart-btn flex items-center justify-center h-16 pr-8 pl-8 text-center text-grey no-select pointer d-none text-md text-bold">
Edit Cart
</div>
</div>`
)
this.$add_discount_elem = this.$component.find(".add-discount");
}
make_cart_numpad() {
this.$numpad_section = this.$component.find('.numpad-section');
this.number_pad = new erpnext.PointOfSale.NumberPad({
wrapper: this.$numpad_section,
events: {
numpad_event: this.on_numpad_event.bind(this)
},
cols: 5,
keys: [
[ 1, 2, 3, 'Quantity' ],
[ 4, 5, 6, 'Discount' ],
[ 7, 8, 9, 'Rate' ],
[ '.', 0, 'Delete', 'Remove' ]
],
css_classes: [
[ '', '', '', 'col-span-2' ],
[ '', '', '', 'col-span-2' ],
[ '', '', '', 'col-span-2' ],
[ '', '', '', 'col-span-2 text-bold text-danger' ]
],
fieldnames_map: { 'Quantity': 'qty', 'Discount': 'discount_percentage' }
})
this.$numpad_section.prepend(
`<div class="flex mb-2 justify-between">
<span class="numpad-net-total"></span>
<span class="numpad-grand-total"></span>
</div>`
)
this.$numpad_section.append(
`<div class="numpad-btn checkout-btn flex items-center justify-center h-16 pr-8 pl-8 bg-primary
text-center text-white no-select pointer rounded text-md text-bold mt-4" data-button-value="checkout">
Checkout
</div>`
)
}
bind_events() {
const me = this;
this.$customer_section.on('click', '.add-remove-customer', function (e) {
const customer_info_is_visible = me.$cart_container.hasClass('d-none');
customer_info_is_visible ?
me.toggle_customer_info(false) : me.reset_customer_selector();
});
this.$customer_section.on('click', '.customer-header', function(e) {
// don't triggger the event if .add-remove-customer btn is clicked which is under .customer-header
if ($(e.target).closest('.add-remove-customer').length) return;
const show = !me.$cart_container.hasClass('d-none');
me.toggle_customer_info(show);
});
this.$cart_items_wrapper.on('click', '.cart-item-wrapper', function() {
const $cart_item = $(this);
me.toggle_item_highlight(this);
const payment_section_hidden = me.$totals_section.find('.edit-cart-btn').hasClass('d-none');
if (!payment_section_hidden) {
// payment section is visible
// edit cart first and then open item details section
me.$totals_section.find(".edit-cart-btn").click();
}
const item_code = unescape($cart_item.attr('data-item-code'));
const batch_no = unescape($cart_item.attr('data-batch-no'));
const uom = unescape($cart_item.attr('data-uom'));
me.events.cart_item_clicked(item_code, batch_no, uom);
this.numpad_value = '';
});
this.$component.on('click', '.checkout-btn', function() {
if (!$(this).hasClass('bg-primary')) return;
me.events.checkout();
me.toggle_checkout_btn(false);
me.$add_discount_elem.removeClass("d-none");
});
this.$totals_section.on('click', '.edit-cart-btn', () => {
this.events.edit_cart();
this.toggle_checkout_btn(true);
this.$add_discount_elem.addClass("d-none");
});
this.$component.on('click', '.add-discount', () => {
const can_edit_discount = this.$add_discount_elem.find('.edit-discount').length;
if(!this.discount_field || can_edit_discount) this.show_discount_control();
});
frappe.ui.form.on("POS Invoice", "paid_amount", frm => {
// called when discount is applied
this.update_totals_section(frm);
});
}
attach_shortcuts() {
for (let row of this.number_pad.keys) {
for (let btn of row) {
let shortcut_key = `ctrl+${frappe.scrub(String(btn))[0]}`;
if (btn === 'Delete') shortcut_key = 'ctrl+backspace';
if (btn === 'Remove') shortcut_key = 'shift+ctrl+backspace'
if (btn === '.') shortcut_key = 'ctrl+>';
// to account for fieldname map
const fieldname = this.number_pad.fieldnames[btn] ? this.number_pad.fieldnames[btn] :
typeof btn === 'string' ? frappe.scrub(btn) : btn;
frappe.ui.keys.on(`${shortcut_key}`, () => {
const cart_is_visible = this.$component.is(":visible");
if (cart_is_visible && this.item_is_selected && this.$numpad_section.is(":visible")) {
this.$numpad_section.find(`.numpad-btn[data-button-value="${fieldname}"]`).click();
}
})
}
}
frappe.ui.keys.on("ctrl+enter", () => {
const cart_is_visible = this.$component.is(":visible");
const payment_section_hidden = this.$totals_section.find('.edit-cart-btn').hasClass('d-none');
if (cart_is_visible && payment_section_hidden) {
this.$component.find(".checkout-btn").click();
}
});
}
toggle_item_highlight(item) {
const $cart_item = $(item);
const item_is_highlighted = $cart_item.hasClass("shadow");
if (!item || item_is_highlighted) {
this.item_is_selected = false;
this.$cart_container.find('.cart-item-wrapper').removeClass("shadow").css("opacity", "1");
} else {
$cart_item.addClass("shadow");
this.item_is_selected = true;
this.$cart_container.find('.cart-item-wrapper').css("opacity", "1");
this.$cart_container.find('.cart-item-wrapper').not(item).removeClass("shadow").css("opacity", "0.65");
}
// highlight with inner shadow
// $cart_item.addClass("shadow-inner bg-selected");
// me.$cart_container.find('.cart-item-wrapper').not(this).removeClass("shadow-inner bg-selected");
}
make_customer_selector() {
this.$customer_section.html(`<div class="customer-search-field flex flex-1 items-center"></div>`);
const me = this;
const query = { query: 'erpnext.controllers.queries.customer_query' };
const allowed_customer_group = this.events.get_allowed_customer_group() || [];
if (allowed_customer_group.length) {
query.filters = {
customer_group: ['in', allowed_customer_group]
}
}
this.customer_field = frappe.ui.form.make_control({
df: {
label: __('Customer'),
fieldtype: 'Link',
options: 'Customer',
placeholder: __('Search by customer name, phone, email.'),
get_query: () => query,
onchange: function() {
if (this.value) {
const frm = me.events.get_frm();
frappe.dom.freeze();
frappe.model.set_value(frm.doc.doctype, frm.doc.name, 'customer', this.value);
frm.script_manager.trigger('customer', frm.doc.doctype, frm.doc.name).then(() => {
frappe.run_serially([
() => me.fetch_customer_details(this.value),
() => me.events.customer_details_updated(me.customer_info),
() => me.update_customer_section(),
() => me.update_totals_section(),
() => frappe.dom.unfreeze()
]);
})
}
},
},
parent: this.$customer_section.find('.customer-search-field'),
render_input: true,
});
this.customer_field.toggle_label(false);
}
fetch_customer_details(customer) {
if (customer) {
return new Promise((resolve) => {
frappe.db.get_value('Customer', customer, ["email_id", "mobile_no", "image", "loyalty_program"]).then(({ message }) => {
const { loyalty_program } = message;
// if loyalty program then fetch loyalty points too
if (loyalty_program) {
frappe.call({
method: "erpnext.accounts.doctype.loyalty_program.loyalty_program.get_loyalty_program_details_with_points",
args: { customer, loyalty_program, "silent": true },
callback: (r) => {
const { loyalty_points, conversion_factor } = r.message;
if (!r.exc) {
this.customer_info = { ...message, customer, loyalty_points, conversion_factor };
resolve();
}
}
});
} else {
this.customer_info = { ...message, customer };
resolve();
}
});
});
} else {
return new Promise((resolve) => {
this.customer_info = {}
resolve();
});
}
}
show_discount_control() {
this.$add_discount_elem.removeClass("pr-4 pl-4");
this.$add_discount_elem.html(
`<div class="add-dicount-field flex flex-1 items-center"></div>
<div class="submit-field flex items-center"></div>`
);
const me = this;
this.discount_field = frappe.ui.form.make_control({
df: {
label: __('Discount'),
fieldtype: 'Data',
placeholder: __('Enter discount percentage.'),
onchange: function() {
if (this.value || this.value == 0) {
const frm = me.events.get_frm();
frappe.model.set_value(frm.doc.doctype, frm.doc.name, 'additional_discount_percentage', flt(this.value));
me.hide_discount_control(this.value);
}
},
},
parent: this.$add_discount_elem.find('.add-dicount-field'),
render_input: true,
});
this.discount_field.toggle_label(false);
this.discount_field.set_focus();
}
hide_discount_control(discount) {
this.$add_discount_elem.addClass('pr-4 pl-4');
this.$add_discount_elem.html(
`<svg class="mr-2" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
<div class="edit-discount p-1 pr-3 pl-3 text-dark-grey rounded w-fit bg-green-200 mb-2">
${String(discount).bold()}% off
</div>
`
);
}
update_customer_section() {
const { customer, email_id='', mobile_no='', image } = this.customer_info || {};
if (customer) {
this.$customer_section.addClass('border pr-4 pl-4').html(
`<div class="customer-details flex flex-col">
<div class="customer-header flex items-center rounded h-18 pointer">
${get_customer_image()}
<div class="customer-name flex flex-col flex-1 f-shrink-1 overflow-hidden whitespace-nowrap">
<div class="text-md text-dark-grey text-bold">${customer}</div>
${get_customer_description()}
</div>
<div class="f-shrink-0 add-remove-customer flex items-center pointer" data-customer="${escape(customer)}">
<svg width="32" height="32" viewBox="0 0 14 14" fill="none">
<path d="M4.93764 4.93759L7.00003 6.99998M9.06243 9.06238L7.00003 6.99998M7.00003 6.99998L4.93764 9.06238L9.06243 4.93759" stroke="#8D99A6"/>
</svg>
</div>
</div>
</div>`
);
} else {
// reset customer selector
this.reset_customer_selector();
}
function get_customer_description() {
if (!email_id && !mobile_no) {
return `<div class="text-grey-200 italic">Click to add email / phone</div>`
} else if (email_id && !mobile_no) {
return `<div class="text-grey">${email_id}</div>`
} else if (mobile_no && !email_id) {
return `<div class="text-grey">${mobile_no}</div>`
} else {
return `<div class="text-grey">${email_id} | ${mobile_no}</div>`
}
}
function get_customer_image() {
if (image) {
return `<div class="icon flex items-center justify-center w-12 h-12 rounded bg-light-grey mr-4 text-grey-200">
<img class="h-full" src="${image}" alt="${image}" style="object-fit: cover;">
</div>`
} else {
return `<div class="icon flex items-center justify-center w-12 h-12 rounded bg-light-grey mr-4 text-grey-200 text-md">
${frappe.get_abbr(customer)}
</div>`
}
}
}
update_totals_section(frm) {
if (!frm) frm = this.events.get_frm();
this.render_net_total(frm.doc.base_net_total);
this.render_grand_total(frm.doc.base_grand_total);
const taxes = frm.doc.taxes.map(t => { return { description: t.description, rate: t.rate }})
this.render_taxes(frm.doc.base_total_taxes_and_charges, taxes);
}
render_net_total(value) {
const currency = this.events.get_frm().doc.currency;
this.$totals_section.find('.net-total').html(
`<div class="flex flex-col">
<div class="text-md text-dark-grey text-bold">Net Total</div>
</div>
<div class="flex flex-col text-right">
<div class="text-md text-dark-grey text-bold">${format_currency(value, currency)}</div>
</div>`
)
this.$numpad_section.find('.numpad-net-total').html(`Net Total: <span class="text-bold">${format_currency(value, currency)}</span>`)
}
render_grand_total(value) {
const currency = this.events.get_frm().doc.currency;
this.$totals_section.find('.grand-total').html(
`<div class="flex flex-col">
<div class="text-md text-dark-grey text-bold">Grand Total</div>
</div>
<div class="flex flex-col text-right">
<div class="text-md text-dark-grey text-bold">${format_currency(value, currency)}</div>
</div>`
)
this.$numpad_section.find('.numpad-grand-total').html(`Grand Total: <span class="text-bold">${format_currency(value, currency)}</span>`)
}
render_taxes(value, taxes) {
if (taxes.length) {
const currency = this.events.get_frm().doc.currency;
this.$totals_section.find('.taxes').html(
`<div class="flex items-center justify-between h-16 pr-8 pl-8 border-b-grey">
<div class="flex">
<div class="text-md text-dark-grey text-bold w-fit">Tax Charges</div>
<div class="flex ml-6 text-dark-grey">
${
taxes.map((t, i) => {
let margin_left = '';
if (i !== 0) margin_left = 'ml-2';
return `<span class="border-grey p-1 pl-2 pr-2 rounded ${margin_left}">${t.description}</span>`
}).join('')
}
</div>
</div>
<div class="flex flex-col text-right">
<div class="text-md text-dark-grey text-bold">${format_currency(value, currency)}</div>
</div>
</div>`
)
} else {
this.$totals_section.find('.taxes').html('')
}
}
get_cart_item({ item_code, batch_no, uom }) {
const batch_attr = `[data-batch-no="${escape(batch_no)}"]`;
const item_code_attr = `[data-item-code="${escape(item_code)}"]`;
const uom_attr = `[data-uom=${escape(uom)}]`;
const item_selector = batch_no ?
`.cart-item-wrapper${batch_attr}${uom_attr}` : `.cart-item-wrapper${item_code_attr}${uom_attr}`;
return this.$cart_items_wrapper.find(item_selector);
}
update_item_html(item, remove_item) {
const $item = this.get_cart_item(item);
if (remove_item) {
$item && $item.remove();
} else {
const { item_code, batch_no, uom } = item;
const search_field = batch_no ? 'batch_no' : 'item_code';
const search_value = batch_no || item_code;
const item_row = this.events.get_frm().doc.items.find(i => i[search_field] === search_value && i.uom === uom);
this.render_cart_item(item_row, $item);
}
const no_of_cart_items = this.$cart_items_wrapper.children().length;
no_of_cart_items > 0 && this.highlight_checkout_btn(no_of_cart_items > 0);
this.update_empty_cart_section(no_of_cart_items);
}
render_cart_item(item_data, $item_to_update) {
const currency = this.events.get_frm().doc.currency;
const me = this;
if (!$item_to_update.length) {
this.$cart_items_wrapper.append(
`<div class="cart-item-wrapper flex items-center h-18 pr-4 pl-4 rounded border-grey pointer no-select"
data-item-code="${escape(item_data.item_code)}" data-uom="${escape(item_data.uom)}"
data-batch-no="${escape(item_data.batch_no || '')}">
</div>`
)
$item_to_update = this.get_cart_item(item_data);
}
$item_to_update.html(
`<div class="flex flex-col flex-1 f-shrink-1 overflow-hidden whitespace-nowrap">
<div class="text-md text-dark-grey text-bold">
${item_data.item_name}
</div>
${get_description_html()}
</div>
${get_rate_discount_html()}
</div>`
)
set_dynamic_rate_header_width();
this.scroll_to_item($item_to_update);
function set_dynamic_rate_header_width() {
const rate_cols = Array.from(me.$cart_items_wrapper.find(".rate-col"));
me.$cart_header.find(".rate-list-header").css("width", "");
me.$cart_items_wrapper.find(".rate-col").css("width", "");
let max_width = rate_cols.reduce((max_width, elm) => {
if ($(elm).width() > max_width)
max_width = $(elm).width();
return max_width;
}, 0);
max_width += 1;
if (max_width == 1) max_width = "";
me.$cart_header.find(".rate-list-header").css("width", max_width);
me.$cart_items_wrapper.find(".rate-col").css("width", max_width);
}
function get_rate_discount_html() {
if (item_data.rate && item_data.amount && item_data.rate !== item_data.amount) {
return `
<div class="flex f-shrink-0 ml-4 items-center">
<div class="flex w-8 h-8 rounded bg-light-grey mr-4 items-center justify-center font-bold f-shrink-0">
<span>${item_data.qty || 0}</span>
</div>
<div class="rate-col flex flex-col f-shrink-0 text-right">
<div class="text-md text-dark-grey text-bold">${format_currency(item_data.amount, currency)}</div>
<div class="text-md-0 text-dark-grey">${format_currency(item_data.rate, currency)}</div>
</div>
</div>`
} else {
return `
<div class="flex f-shrink-0 ml-4 text-right">
<div class="flex w-8 h-8 rounded bg-light-grey mr-4 items-center justify-center font-bold f-shrink-0">
<span>${item_data.qty || 0}</span>
</div>
<div class="rate-col flex flex-col f-shrink-0 text-right">
<div class="text-md text-dark-grey text-bold">${format_currency(item_data.rate, currency)}</div>
</div>
</div>`
}
}
function get_description_html() {
if (item_data.description) {
if (item_data.description.indexOf('<div>') != -1) {
try {
item_data.description = $(item_data.description).text();
} catch (error) {
item_data.description = item_data.description.replace(/<div>/g, ' ').replace(/<\/div>/g, ' ').replace(/ +/g, ' ');
}
}
item_data.description = frappe.ellipsis(item_data.description, 45);
return `<div class="text-grey">${item_data.description}</div>`
}
return ``;
}
}
scroll_to_item($item) {
if ($item.length === 0) return;
const scrollTop = $item.offset().top - this.$cart_items_wrapper.offset().top + this.$cart_items_wrapper.scrollTop();
this.$cart_items_wrapper.animate({ scrollTop });
}
update_selector_value_in_cart_item(selector, value, item) {
const $item_to_update = this.get_cart_item(item);
$item_to_update.attr(`data-${selector}`, value);
}
toggle_checkout_btn(show_checkout) {
if (show_checkout) {
this.$totals_section.find('.checkout-btn').removeClass('d-none');
this.$totals_section.find('.edit-cart-btn').addClass('d-none');
} else {
this.$totals_section.find('.checkout-btn').addClass('d-none');
this.$totals_section.find('.edit-cart-btn').removeClass('d-none');
}
}
highlight_checkout_btn(toggle) {
const has_primary_class = this.$totals_section.find('.checkout-btn').hasClass('bg-primary');
if (toggle && !has_primary_class) {
this.$totals_section.find('.checkout-btn').addClass('bg-primary text-white text-lg');
} else if (!toggle && has_primary_class) {
this.$totals_section.find('.checkout-btn').removeClass('bg-primary text-white text-lg');
}
}
update_empty_cart_section(no_of_cart_items) {
const $no_item_element = this.$cart_items_wrapper.find('.no-item-wrapper');
// if cart has items and no item is present
no_of_cart_items > 0 && $no_item_element && $no_item_element.remove()
&& this.$cart_items_wrapper.removeClass('mt-4 border-grey border-dashed') && this.$cart_header.removeClass('d-none');
no_of_cart_items === 0 && !$no_item_element.length && this.make_no_items_placeholder();
}
on_numpad_event($btn) {
const current_action = $btn.attr('data-button-value');
const action_is_field_edit = ['qty', 'discount_percentage', 'rate'].includes(current_action);
this.highlight_numpad_btn($btn, current_action);
const action_is_pressed_twice = this.prev_action === current_action;
const first_click_event = !this.prev_action;
const field_to_edit_changed = this.prev_action && this.prev_action != current_action;
if (action_is_field_edit) {
if (first_click_event || field_to_edit_changed) {
this.prev_action = current_action;
} else if (action_is_pressed_twice) {
this.prev_action = undefined;
}
this.numpad_value = '';
} else if (current_action === 'checkout') {
this.prev_action = undefined;
this.toggle_item_highlight();
this.events.numpad_event(undefined, current_action);
return;
} else if (current_action === 'remove') {
this.prev_action = undefined;
this.toggle_item_highlight();
this.events.numpad_event(undefined, current_action);
return;
} else {
this.numpad_value = current_action === 'delete' ? this.numpad_value.slice(0, -1) : this.numpad_value + current_action;
this.numpad_value = this.numpad_value || 0;
}
const first_click_event_is_not_field_edit = !action_is_field_edit && first_click_event;
if (first_click_event_is_not_field_edit) {
frappe.show_alert({
indicator: 'red',
message: __('Please select a field to edit from numpad')
});
frappe.utils.play_sound("error");
return;
}
if (flt(this.numpad_value) > 100 && this.prev_action === 'discount_percentage') {
frappe.show_alert({
message: __('Discount cannot be greater than 100%'),
indicator: 'orange'
});
frappe.utils.play_sound("error");
this.numpad_value = current_action;
}
this.events.numpad_event(this.numpad_value, this.prev_action);
}
highlight_numpad_btn($btn, curr_action) {
const curr_action_is_highlighted = $btn.hasClass('shadow-inner');
const curr_action_is_action = ['qty', 'discount_percentage', 'rate', 'done'].includes(curr_action);
if (!curr_action_is_highlighted) {
$btn.addClass('shadow-inner bg-selected');
}
if (this.prev_action === curr_action && curr_action_is_highlighted) {
// if Qty is pressed twice
$btn.removeClass('shadow-inner bg-selected');
}
if (this.prev_action && this.prev_action !== curr_action && curr_action_is_action) {
// Order: Qty -> Rate then remove Qty highlight
const prev_btn = $(`[data-button-value='${this.prev_action}']`);
prev_btn.removeClass('shadow-inner bg-selected');
}
if (!curr_action_is_action || curr_action === 'done') {
// if numbers are clicked
setTimeout(() => {
$btn.removeClass('shadow-inner bg-selected');
}, 100);
}
}
toggle_numpad(show) {
if (show) {
this.$totals_section.addClass('d-none');
this.$numpad_section.removeClass('d-none');
} else {
this.$totals_section.removeClass('d-none');
this.$numpad_section.addClass('d-none');
}
this.reset_numpad();
}
reset_numpad() {
this.numpad_value = '';
this.prev_action = undefined;
this.$numpad_section.find('.shadow-inner').removeClass('shadow-inner bg-selected');
}
toggle_numpad_field_edit(fieldname) {
if (['qty', 'discount_percentage', 'rate'].includes(fieldname)) {
this.$numpad_section.find(`[data-button-value="${fieldname}"]`).click();
}
}
toggle_customer_info(show) {
if (show) {
this.$cart_container.addClass('d-none')
this.$customer_section.addClass('flex-1 scroll-y').removeClass('mb-0 border pr-4 pl-4')
this.$customer_section.find('.icon').addClass('w-24 h-24 text-2xl').removeClass('w-12 h-12 text-md')
this.$customer_section.find('.customer-header').removeClass('h-18');
this.$customer_section.find('.customer-details').addClass('sticky z-100 bg-white');
this.$customer_section.find('.customer-name').html(
`<div class="text-md text-dark-grey text-bold">${this.customer_info.customer}</div>
<div class="last-transacted-on text-grey-200"></div>`
)
this.$customer_section.find('.customer-details').append(
`<div class="customer-form">
<div class="text-grey mt-4 mb-6">CONTACT DETAILS</div>
<div class="grid grid-cols-2 gap-4">
<div class="email_id-field"></div>
<div class="mobile_no-field"></div>
<div class="loyalty_program-field"></div>
<div class="loyalty_points-field"></div>
</div>
<div class="text-grey mt-4 mb-6">RECENT TRANSACTIONS</div>
</div>`
)
// transactions need to be in diff div from sticky elem for scrolling
this.$customer_section.append(`<div class="customer-transactions flex-1 rounded"></div>`)
this.render_customer_info_form();
this.fetch_customer_transactions();
} else {
this.$cart_container.removeClass('d-none');
this.$customer_section.removeClass('flex-1 scroll-y').addClass('mb-0 border pr-4 pl-4');
this.$customer_section.find('.icon').addClass('w-12 h-12 text-md').removeClass('w-24 h-24 text-2xl');
this.$customer_section.find('.customer-header').addClass('h-18')
this.$customer_section.find('.customer-details').removeClass('sticky z-100 bg-white');
this.update_customer_section();
}
}
render_customer_info_form() {
const $customer_form = this.$customer_section.find('.customer-form');
const dfs = [{
fieldname: 'email_id',
label: __('Email'),
fieldtype: 'Data',
options: 'email',
placeholder: __("Enter customer's email")
},{
fieldname: 'mobile_no',
label: __('Phone Number'),
fieldtype: 'Data',
placeholder: __("Enter customer's phone number")
},{
fieldname: 'loyalty_program',
label: __('Loyalty Program'),
fieldtype: 'Link',
options: 'Loyalty Program',
placeholder: __("Select Loyalty Program")
},{
fieldname: 'loyalty_points',
label: __('Loyalty Points'),
fieldtype: 'Int',
read_only: 1
}];
const me = this;
dfs.forEach(df => {
this[`customer_${df.fieldname}_field`] = frappe.ui.form.make_control({
df: { ...df,
onchange: handle_customer_field_change,
},
parent: $customer_form.find(`.${df.fieldname}-field`),
render_input: true,
});
this[`customer_${df.fieldname}_field`].set_value(this.customer_info[df.fieldname]);
})
function handle_customer_field_change() {
const current_value = me.customer_info[this.df.fieldname];
const current_customer = me.customer_info.customer;
if (this.value && current_value != this.value && this.df.fieldname != 'loyalty_points') {
frappe.call({
method: 'erpnext.selling.page.point_of_sale.point_of_sale.set_customer_info',
args: {
fieldname: this.df.fieldname,
customer: current_customer,
value: this.value
},
callback: (r) => {
if(!r.exc) {
me.customer_info[this.df.fieldname] = this.value;
frappe.show_alert({
message: __("Customer contact updated successfully."),
indicator: 'green'
});
frappe.utils.play_sound("submit");
}
}
});
}
}
}
fetch_customer_transactions() {
frappe.db.get_list('POS Invoice', {
filters: { customer: this.customer_info.customer, docstatus: 1 },
fields: ['name', 'grand_total', 'status', 'posting_date', 'posting_time', 'currency'],
limit: 20
}).then((res) => {
const transaction_container = this.$customer_section.find('.customer-transactions');
if (!res.length) {
transaction_container.removeClass('flex-1 border rounded').html(
`<div class="text-grey text-center">No recent transactions found</div>`
)
return;
};
const elapsed_time = moment(res[0].posting_date+" "+res[0].posting_time).fromNow();
this.$customer_section.find('.last-transacted-on').html(`Last transacted ${elapsed_time}`);
res.forEach(invoice => {
const posting_datetime = moment(invoice.posting_date+" "+invoice.posting_time).format("Do MMMM, h:mma");
let indicator_color = '';
if (in_list(['Paid', 'Consolidated'], invoice.status)) (indicator_color = 'green');
if (invoice.status === 'Draft') (indicator_color = 'red');
if (invoice.status === 'Return') (indicator_color = 'grey');
transaction_container.append(
`<div class="invoice-wrapper flex p-3 justify-between border-grey rounded pointer no-select" data-invoice-name="${escape(invoice.name)}">
<div class="flex flex-col justify-end">
<div class="text-dark-grey text-bold overflow-hidden whitespace-nowrap mb-2">${invoice.name}</div>
<div class="flex items-center f-shrink-1 text-dark-grey overflow-hidden whitespace-nowrap">
${posting_datetime}
</div>
</div>
<div class="flex flex-col text-right">
<div class="f-shrink-0 text-md text-dark-grey text-bold ml-4">
${format_currency(invoice.grand_total, invoice.currency, 0) || 0}
</div>
<div class="f-shrink-0 text-grey ml-4 text-bold indicator ${indicator_color}">${invoice.status}</div>
</div>
</div>`
)
});
})
}
load_invoice() {
const frm = this.events.get_frm();
this.fetch_customer_details(frm.doc.customer).then(() => {
this.events.customer_details_updated(this.customer_info);
this.update_customer_section();
})
this.$cart_items_wrapper.html('');
if (frm.doc.items.length) {
frm.doc.items.forEach(item => {
this.update_item_html(item);
});
} else {
this.make_no_items_placeholder();
this.highlight_checkout_btn(false);
}
this.update_totals_section(frm);
if(frm.doc.docstatus === 1) {
this.$totals_section.find('.checkout-btn').addClass('d-none');
this.$totals_section.find('.edit-cart-btn').addClass('d-none');
this.$totals_section.find('.grand-total').removeClass('border-b-grey');
} else {
this.$totals_section.find('.checkout-btn').removeClass('d-none');
this.$totals_section.find('.edit-cart-btn').addClass('d-none');
this.$totals_section.find('.grand-total').addClass('border-b-grey');
}
this.toggle_component(true);
}
toggle_component(show) {
show ? this.$component.removeClass('d-none') : this.$component.addClass('d-none');
}
}

View File

@@ -0,0 +1,394 @@
erpnext.PointOfSale.ItemDetails = class {
constructor({ wrapper, events }) {
this.wrapper = wrapper;
this.events = events;
this.current_item = {};
this.init_component();
}
init_component() {
this.prepare_dom();
this.init_child_components();
this.bind_events();
this.attach_shortcuts();
}
prepare_dom() {
this.wrapper.append(
`<section class="col-span-4 flex shadow rounded item-details bg-white mx-h-70 h-100 d-none"></section>`
)
this.$component = this.wrapper.find('.item-details');
}
init_child_components() {
this.$component.html(
`<div class="details-container flex flex-col p-8 rounded w-full">
<div class="flex justify-between mb-2">
<div class="text-grey">ITEM DETAILS</div>
<div class="close-btn text-grey hover-underline pointer no-select">Close</div>
</div>
<div class="item-defaults flex">
<div class="flex-1 flex flex-col justify-end mr-4 mb-2">
<div class="item-name text-xl font-weight-450"></div>
<div class="item-description text-md-0 text-grey-200"></div>
<div class="item-price text-xl font-bold"></div>
</div>
<div class="item-image flex items-center justify-center w-46 h-46 bg-light-grey rounded ml-4 text-6xl text-grey-100"></div>
</div>
<div class="discount-section flex items-center"></div>
<div class="text-grey mt-4 mb-6">STOCK DETAILS</div>
<div class="form-container grid grid-cols-2 row-gap-2 col-gap-4 grid-auto-row"></div>
</div>`
)
this.$item_name = this.$component.find('.item-name');
this.$item_description = this.$component.find('.item-description');
this.$item_price = this.$component.find('.item-price');
this.$item_image = this.$component.find('.item-image');
this.$form_container = this.$component.find('.form-container');
this.$dicount_section = this.$component.find('.discount-section');
}
toggle_item_details_section(item) {
const { item_code, batch_no, uom } = this.current_item;
const item_code_is_same = item && item_code === item.item_code;
const batch_is_same = item && batch_no == item.batch_no;
const uom_is_same = item && uom === item.uom;
this.item_has_changed = !item ? false : item_code_is_same && batch_is_same && uom_is_same ? false : true;
this.events.toggle_item_selector(this.item_has_changed);
this.toggle_component(this.item_has_changed);
if (this.item_has_changed) {
this.doctype = item.doctype;
this.item_meta = frappe.get_meta(this.doctype);
this.name = item.name;
this.item_row = item;
this.currency = this.events.get_frm().doc.currency;
this.current_item = { item_code: item.item_code, batch_no: item.batch_no, uom: item.uom };
this.render_dom(item);
this.render_discount_dom(item);
this.render_form(item);
} else {
this.validate_serial_batch_item();
this.current_item = {};
}
}
validate_serial_batch_item() {
const doc = this.events.get_frm().doc;
const item_row = doc.items.find(item => item.name === this.name);
if (!item_row) return;
const serialized = item_row.has_serial_no;
const batched = item_row.has_batch_no;
const no_serial_selected = !item_row.serial_no;
const no_batch_selected = !item_row.batch_no;
if ((serialized && no_serial_selected) || (batched && no_batch_selected) ||
(serialized && batched && (no_batch_selected || no_serial_selected))) {
frappe.show_alert({
message: __("Item will be removed since no serial / batch no selected."),
indicator: 'orange'
});
frappe.utils.play_sound("cancel");
this.events.remove_item_from_cart();
}
}
render_dom(item) {
let { item_code ,item_name, description, image, price_list_rate } = item;
function get_description_html() {
if (description) {
description = description.indexOf('...') === -1 && description.length > 75 ? description.substr(0, 73) + '...' : description;
return description;
}
return ``;
}
this.$item_name.html(item_name);
this.$item_description.html(get_description_html());
this.$item_price.html(format_currency(price_list_rate, this.currency));
if (image) {
this.$item_image.html(
`<img class="h-full" src="${image}" alt="${image}" style="object-fit: cover;">`
);
} else {
this.$item_image.html(frappe.get_abbr(item_code));
}
}
render_discount_dom(item) {
if (item.discount_percentage) {
this.$dicount_section.html(
`<div class="text-grey line-through mr-4 text-md mb-2">
${format_currency(item.price_list_rate, this.currency)}
</div>
<div class="p-1 pr-3 pl-3 rounded w-fit text-bold bg-green-200 mb-2">
${item.discount_percentage}% off
</div>`
)
this.$item_price.html(format_currency(item.rate, this.currency));
} else {
this.$dicount_section.html(``)
}
}
render_form(item) {
const fields_to_display = this.get_form_fields(item);
this.$form_container.html('');
fields_to_display.forEach((fieldname, idx) => {
this.$form_container.append(
`<div class="">
<div class="item_detail_field ${fieldname}-control" data-fieldname="${fieldname}"></div>
</div>`
)
const field_meta = this.item_meta.fields.find(df => df.fieldname === fieldname);
fieldname === 'discount_percentage' ? (field_meta.label = __('Discount (%)')) : '';
const me = this;
this[`${fieldname}_control`] = frappe.ui.form.make_control({
df: {
...field_meta,
onchange: function() {
me.events.form_updated(me.doctype, me.name, fieldname, this.value);
}
},
parent: this.$form_container.find(`.${fieldname}-control`),
render_input: true,
})
this[`${fieldname}_control`].set_value(item[fieldname]);
});
this.make_auto_serial_selection_btn(item);
this.bind_custom_control_change_event();
}
get_form_fields(item) {
const fields = ['qty', 'uom', 'rate', 'price_list_rate', 'discount_percentage', 'warehouse', 'actual_qty'];
if (item.has_serial_no) fields.push('serial_no');
if (item.has_batch_no) fields.push('batch_no');
return fields;
}
make_auto_serial_selection_btn(item) {
if (item.has_serial_no) {
this.$form_container.append(
`<div class="grid-filler no-select"></div>`
)
if (!item.has_batch_no) {
this.$form_container.append(
`<div class="grid-filler no-select"></div>`
)
}
this.$form_container.append(
`<div class="auto-fetch-btn bg-grey-100 border border-grey text-bold rounded pt-3 pb-3 pl-6 pr-8 text-grey pointer no-select mt-2"
style="height: 3.3rem">
Auto Fetch Serial Numbers
</div>`
)
this.$form_container.find('.serial_no-control').find('textarea').css('height', '9rem');
this.$form_container.find('.serial_no-control').parent().addClass('row-span-2');
}
}
bind_custom_control_change_event() {
const me = this;
if (this.rate_control) {
this.rate_control.df.onchange = function() {
if (this.value) {
me.events.form_updated(me.doctype, me.name, 'rate', this.value).then(() => {
const item_row = frappe.get_doc(me.doctype, me.name);
const doc = me.events.get_frm().doc;
me.$item_price.html(format_currency(item_row.rate, doc.currency));
me.render_discount_dom(item_row);
});
}
}
}
if (this.warehouse_control) {
this.warehouse_control.df.reqd = 1;
this.warehouse_control.df.onchange = function() {
if (this.value) {
me.events.form_updated(me.doctype, me.name, 'warehouse', this.value).then(() => {
me.item_stock_map = me.events.get_item_stock_map();
const available_qty = me.item_stock_map[me.item_row.item_code][this.value];
if (available_qty === undefined) {
me.events.get_available_stock(me.item_row.item_code, this.value).then(() => {
// item stock map is updated now reset warehouse
me.warehouse_control.set_value(this.value);
})
} else if (available_qty === 0) {
me.warehouse_control.set_value('');
frappe.throw(__(`Item Code: ${me.item_row.item_code.bold()} is not available under warehouse ${this.value.bold()}.`));
}
me.actual_qty_control.set_value(available_qty);
});
}
}
this.warehouse_control.refresh();
}
if (this.discount_percentage_control) {
this.discount_percentage_control.df.onchange = function() {
if (this.value) {
me.events.form_updated(me.doctype, me.name, 'discount_percentage', this.value).then(() => {
const item_row = frappe.get_doc(me.doctype, me.name);
me.rate_control.set_value(item_row.rate);
});
}
}
}
if (this.serial_no_control) {
this.serial_no_control.df.reqd = 1;
this.serial_no_control.df.onchange = async function() {
!me.current_item.batch_no && await me.auto_update_batch_no();
me.events.form_updated(me.doctype, me.name, 'serial_no', this.value);
}
this.serial_no_control.refresh();
}
if (this.batch_no_control) {
this.batch_no_control.df.reqd = 1;
this.batch_no_control.df.get_query = () => {
return {
query: 'erpnext.controllers.queries.get_batch_no',
filters: {
item_code: me.item_row.item_code,
warehouse: me.item_row.warehouse
}
}
};
this.batch_no_control.df.onchange = function() {
me.events.set_value_in_current_cart_item('batch-no', this.value);
me.events.form_updated(me.doctype, me.name, 'batch_no', this.value);
me.current_item.batch_no = this.value;
}
this.batch_no_control.refresh();
}
if (this.uom_control) {
this.uom_control.df.onchange = function() {
me.events.set_value_in_current_cart_item('uom', this.value);
me.events.form_updated(me.doctype, me.name, 'uom', this.value);
me.current_item.uom = this.value;
}
}
}
async auto_update_batch_no() {
if (this.serial_no_control && this.batch_no_control) {
const selected_serial_nos = this.serial_no_control.get_value().split(`\n`).filter(s => s);
if (!selected_serial_nos.length) return;
// find batch nos of the selected serial no
const serials_with_batch_no = await frappe.db.get_list("Serial No", {
filters: { 'name': ["in", selected_serial_nos]},
fields: ["batch_no", "name"]
});
const batch_serial_map = serials_with_batch_no.reduce((acc, r) => {
acc[r.batch_no] || (acc[r.batch_no] = []);
acc[r.batch_no] = [...acc[r.batch_no], r.name];
return acc;
}, {});
// set current item's batch no and serial no
const batch_no = Object.keys(batch_serial_map)[0];
const batch_serial_nos = batch_serial_map[batch_no].join(`\n`);
// eg. 10 selected serial no. -> 5 belongs to first batch other 5 belongs to second batch
const serial_nos_belongs_to_other_batch = selected_serial_nos.length !== batch_serial_map[batch_no].length;
const current_batch_no = this.batch_no_control.get_value();
current_batch_no != batch_no && await this.batch_no_control.set_value(batch_no);
if (serial_nos_belongs_to_other_batch) {
this.serial_no_control.set_value(batch_serial_nos);
this.qty_control.set_value(batch_serial_map[batch_no].length);
}
delete batch_serial_map[batch_no];
if (serial_nos_belongs_to_other_batch)
this.events.clone_new_batch_item_in_frm(batch_serial_map, this.current_item);
}
}
bind_events() {
this.bind_auto_serial_fetch_event();
this.bind_fields_to_numpad_fields();
this.$component.on('click', '.close-btn', () => {
this.events.close_item_details();
});
}
attach_shortcuts() {
frappe.ui.keys.on("escape", () => {
const item_details_visible = this.$component.is(":visible");
if (item_details_visible) {
this.events.close_item_details();
}
});
}
bind_fields_to_numpad_fields() {
const me = this;
this.$form_container.on('click', '.input-with-feedback', function() {
const fieldname = $(this).attr('data-fieldname');
if (this.last_field_focused != fieldname) {
me.events.item_field_focused(fieldname);
this.last_field_focused = fieldname;
}
});
}
bind_auto_serial_fetch_event() {
this.$form_container.on('click', '.auto-fetch-btn', () => {
this.batch_no_control.set_value('');
let qty = this.qty_control.get_value();
let numbers = frappe.call({
method: "erpnext.stock.doctype.serial_no.serial_no.auto_fetch_serial_number",
args: {
qty,
item_code: this.current_item.item_code,
warehouse: this.warehouse_control.get_value() || '',
batch_nos: this.current_item.batch_no || '',
for_doctype: 'POS Invoice'
}
});
numbers.then((data) => {
let auto_fetched_serial_numbers = data.message;
let records_length = auto_fetched_serial_numbers.length;
if (!records_length) {
const warehouse = this.warehouse_control.get_value().bold();
frappe.msgprint(__(`Serial numbers unavailable for Item ${this.current_item.item_code.bold()}
under warehouse ${warehouse}. Please try changing warehouse.`));
} else if (records_length < qty) {
frappe.msgprint(`Fetched only ${records_length} available serial numbers.`);
this.qty_control.set_value(records_length);
}
numbers = auto_fetched_serial_numbers.join(`\n`);
this.serial_no_control.set_value(numbers);
});
})
}
toggle_component(show) {
show ? this.$component.removeClass('d-none') : this.$component.addClass('d-none');
}
}

View File

@@ -0,0 +1,265 @@
erpnext.PointOfSale.ItemSelector = class {
constructor({ frm, wrapper, events, pos_profile }) {
this.wrapper = wrapper;
this.events = events;
this.pos_profile = pos_profile;
this.inti_component();
}
inti_component() {
this.prepare_dom();
this.make_search_bar();
this.load_items_data();
this.bind_events();
this.attach_shortcuts();
}
prepare_dom() {
this.wrapper.append(
`<section class="col-span-6 flex shadow rounded items-selector bg-white mx-h-70 h-100">
<div class="flex flex-col rounded w-full scroll-y">
<div class="filter-section flex p-8 pb-2 bg-white sticky z-100">
<div class="search-field flex f-grow-3 mr-8 items-center text-grey"></div>
<div class="item-group-field flex f-grow-1 items-center text-grey text-bold"></div>
</div>
<div class="flex flex-1 flex-col p-8 pt-2">
<div class="text-grey mb-6">ALL ITEMS</div>
<div class="items-container grid grid-cols-4 gap-8">
</div>
</div>
</div>
</section>`
);
this.$component = this.wrapper.find('.items-selector');
}
async load_items_data() {
if (!this.item_group) {
const res = await frappe.db.get_value("Item Group", {lft: 1, is_group: 1}, "name");
this.parent_item_group = res.message.name;
};
if (!this.price_list) {
const res = await frappe.db.get_value("POS Profile", this.pos_profile, "selling_price_list");
this.price_list = res.message.selling_price_list;
}
this.get_items({}).then(({message}) => {
this.render_item_list(message.items);
});
}
get_items({start = 0, page_length = 40, search_value=''}) {
const price_list = this.events.get_frm().doc?.selling_price_list || this.price_list;
let { item_group, pos_profile } = this;
!item_group && (item_group = this.parent_item_group);
return frappe.call({
method: "erpnext.selling.page.point_of_sale.point_of_sale.get_items",
freeze: true,
args: { start, page_length, price_list, item_group, search_value, pos_profile },
});
}
render_item_list(items) {
this.$items_container = this.$component.find('.items-container');
this.$items_container.html('');
items.forEach(item => {
const item_html = this.get_item_html(item);
this.$items_container.append(item_html);
})
}
get_item_html(item) {
const { item_image, serial_no, batch_no, barcode, actual_qty, stock_uom } = item;
const indicator_color = actual_qty > 10 ? "green" : actual_qty !== 0 ? "orange" : "red";
function get_item_image_html() {
if (item_image) {
return `<div class="flex items-center justify-center h-32 border-b-grey text-6xl text-grey-100">
<img class="h-full" src="${item_image}" alt="${item_image}" style="object-fit: cover;">
</div>`
} else {
return `<div class="flex items-center justify-center h-32 bg-light-grey text-6xl text-grey-100">
${frappe.get_abbr(item.item_name)}
</div>`
}
}
return (
`<div class="item-wrapper rounded shadow pointer no-select" data-item-code="${escape(item.item_code)}"
data-serial-no="${escape(serial_no)}" data-batch-no="${escape(batch_no)}" data-uom="${escape(stock_uom)}"
title="Avaiable Qty: ${actual_qty}">
${get_item_image_html()}
<div class="flex items-center pr-4 pl-4 h-10 justify-between">
<div class="flex items-center f-shrink-1 text-dark-grey overflow-hidden whitespace-nowrap">
<span class="indicator ${indicator_color}"></span>
${frappe.ellipsis(item.item_name, 18)}
</div>
<div class="f-shrink-0 text-dark-grey text-bold ml-4">${format_currency(item.price_list_rate, item.currency, 0) || 0}</div>
</div>
</div>`
)
}
make_search_bar() {
const me = this;
this.$component.find('.search-field').html('');
this.$component.find('.item-group-field').html('');
this.search_field = frappe.ui.form.make_control({
df: {
label: __('Search'),
fieldtype: 'Data',
placeholder: __('Search by item code, serial number, batch no or barcode')
},
parent: this.$component.find('.search-field'),
render_input: true,
});
this.item_group_field = frappe.ui.form.make_control({
df: {
label: __('Item Group'),
fieldtype: 'Link',
options: 'Item Group',
placeholder: __('Select item group'),
onchange: function() {
me.item_group = this.value;
!me.item_group && (me.item_group = me.parent_item_group);
me.filter_items();
},
get_query: function () {
return {
query: 'erpnext.selling.page.point_of_sale.point_of_sale.item_group_query',
filters: {
pos_profile: me.events.get_frm().doc?.pos_profile
}
}
},
},
parent: this.$component.find('.item-group-field'),
render_input: true,
});
this.search_field.toggle_label(false);
this.item_group_field.toggle_label(false);
}
bind_events() {
const me = this;
onScan.attachTo(document, {
onScan: (sScancode) => {
if (this.search_field && this.$component.is(':visible')) {
this.search_field.set_focus();
$(this.search_field.$input[0]).val(sScancode).trigger("input");
this.barcode_scanned = true;
}
}
});
this.$component.on('click', '.item-wrapper', function() {
const $item = $(this);
const item_code = unescape($item.attr('data-item-code'));
let batch_no = unescape($item.attr('data-batch-no'));
let serial_no = unescape($item.attr('data-serial-no'));
let uom = unescape($item.attr('data-uom'));
// escape(undefined) returns "undefined" then unescape returns "undefined"
batch_no = batch_no === "undefined" ? undefined : batch_no;
serial_no = serial_no === "undefined" ? undefined : serial_no;
uom = uom === "undefined" ? undefined : uom;
me.events.item_selected({ field: 'qty', value: "+1", item: { item_code, batch_no, serial_no, uom }});
})
this.search_field.$input.on('input', (e) => {
clearTimeout(this.last_search);
this.last_search = setTimeout(() => {
const search_term = e.target.value;
this.filter_items({ search_term });
}, 300);
});
}
attach_shortcuts() {
frappe.ui.keys.on("ctrl+i", () => {
const selector_is_visible = this.$component.is(':visible');
if (!selector_is_visible) return;
this.search_field.set_focus();
});
frappe.ui.keys.on("ctrl+g", () => {
const selector_is_visible = this.$component.is(':visible');
if (!selector_is_visible) return;
this.item_group_field.set_focus();
});
// for selecting the last filtered item on search
frappe.ui.keys.on("enter", () => {
const selector_is_visible = this.$component.is(':visible');
if (!selector_is_visible || this.search_field.get_value() === "") return;
if (this.items.length == 1) {
this.$items_container.find(".item-wrapper").click();
frappe.utils.play_sound("submit");
$(this.search_field.$input[0]).val("").trigger("input");
} else if (this.items.length == 0 && this.barcode_scanned) {
// only show alert of barcode is scanned and enter is pressed
frappe.show_alert({
message: __("No items found. Scan barcode again."),
indicator: 'orange'
});
frappe.utils.play_sound("error");
this.barcode_scanned = false;
$(this.search_field.$input[0]).val("").trigger("input");
}
});
}
filter_items({ search_term='' }={}) {
if (search_term) {
search_term = search_term.toLowerCase();
// memoize
this.search_index = this.search_index || {};
if (this.search_index[search_term]) {
const items = this.search_index[search_term];
this.items = items;
this.render_item_list(items);
return;
}
}
this.get_items({ search_value: search_term })
.then(({ message }) => {
const { items, serial_no, batch_no, barcode } = message;
if (search_term && !barcode) {
this.search_index[search_term] = items;
}
this.items = items;
this.render_item_list(items);
});
}
resize_selector(minimize) {
minimize ?
this.$component.find('.search-field').removeClass('mr-8') :
this.$component.find('.search-field').addClass('mr-8');
minimize ?
this.$component.find('.filter-section').addClass('flex-col') :
this.$component.find('.filter-section').removeClass('flex-col');
minimize ?
this.$component.removeClass('col-span-6').addClass('col-span-2') :
this.$component.removeClass('col-span-2').addClass('col-span-6')
minimize ?
this.$items_container.removeClass('grid-cols-4').addClass('grid-cols-1') :
this.$items_container.removeClass('grid-cols-1').addClass('grid-cols-4')
}
toggle_component(show) {
show ? this.$component.removeClass('d-none') : this.$component.addClass('d-none');
}
}

View File

@@ -0,0 +1,49 @@
erpnext.PointOfSale.NumberPad = class {
constructor({ wrapper, events, cols, keys, css_classes, fieldnames_map }) {
this.wrapper = wrapper;
this.events = events;
this.cols = cols;
this.keys = keys;
this.css_classes = css_classes || [];
this.fieldnames = fieldnames_map || {};
this.init_component();
}
init_component() {
this.prepare_dom();
this.bind_events();
}
prepare_dom() {
const { cols, keys, css_classes, fieldnames } = this;
function get_keys() {
return keys.reduce((a, row, i) => {
return a + row.reduce((a2, number, j) => {
const class_to_append = css_classes && css_classes[i] ? css_classes[i][j] : '';
const fieldname = fieldnames && fieldnames[number] ?
fieldnames[number] :
typeof number === 'string' ? frappe.scrub(number) : number;
return a2 + `<div class="numpad-btn pointer no-select rounded ${class_to_append}
flex items-center justify-center h-16 text-md border-grey border" data-button-value="${fieldname}">${number}</div>`
}, '')
}, '');
}
this.wrapper.html(
`<div class="grid grid-cols-${cols} gap-4">
${get_keys()}
</div>`
)
}
bind_events() {
const me = this;
this.wrapper.on('click', '.numpad-btn', function() {
const $btn = $(this);
me.events.numpad_event($btn);
})
}
}

View File

@@ -0,0 +1,130 @@
erpnext.PointOfSale.PastOrderList = class {
constructor({ wrapper, events }) {
this.wrapper = wrapper;
this.events = events;
this.init_component();
}
init_component() {
this.prepare_dom();
this.make_filter_section();
this.bind_events();
}
prepare_dom() {
this.wrapper.append(
`<section class="col-span-4 flex flex-col shadow rounded past-order-list bg-white mx-h-70 h-100 d-none">
<div class="flex flex-col rounded w-full scroll-y">
<div class="filter-section flex flex-col p-8 pb-2 bg-white sticky z-100">
<div class="search-field flex items-center text-grey"></div>
<div class="status-field flex items-center text-grey text-bold"></div>
</div>
<div class="flex flex-1 flex-col p-8 pt-2">
<div class="text-grey mb-6">RECENT ORDERS</div>
<div class="invoices-container rounded border grid grid-cols-1"></div>
</div>
</div>
</section>`
)
this.$component = this.wrapper.find('.past-order-list');
this.$invoices_container = this.$component.find('.invoices-container');
}
bind_events() {
this.search_field.$input.on('input', (e) => {
clearTimeout(this.last_search);
this.last_search = setTimeout(() => {
const search_term = e.target.value;
this.refresh_list(search_term, this.status_field.get_value());
}, 300);
});
const me = this;
this.$invoices_container.on('click', '.invoice-wrapper', function() {
const invoice_name = unescape($(this).attr('data-invoice-name'));
me.events.open_invoice_data(invoice_name);
})
}
make_filter_section() {
const me = this;
this.search_field = frappe.ui.form.make_control({
df: {
label: __('Search'),
fieldtype: 'Data',
placeholder: __('Search by invoice id or customer name')
},
parent: this.$component.find('.search-field'),
render_input: true,
});
this.status_field = frappe.ui.form.make_control({
df: {
label: __('Invoice Status'),
fieldtype: 'Select',
options: `Draft\nPaid\nConsolidated\nReturn`,
placeholder: __('Filter by invoice status'),
onchange: function() {
me.refresh_list(me.search_field.get_value(), this.value);
}
},
parent: this.$component.find('.status-field'),
render_input: true,
});
this.search_field.toggle_label(false);
this.status_field.toggle_label(false);
this.status_field.set_value('Paid');
}
toggle_component(show) {
show ?
this.$component.removeClass('d-none') && this.refresh_list() :
this.$component.addClass('d-none');
}
refresh_list() {
frappe.dom.freeze();
this.events.reset_summary();
const search_term = this.search_field.get_value();
const status = this.status_field.get_value();
this.$invoices_container.html('');
return frappe.call({
method: "erpnext.selling.page.point_of_sale.point_of_sale.get_past_order_list",
freeze: true,
args: { search_term, status },
callback: (response) => {
frappe.dom.unfreeze();
response.message.forEach(invoice => {
const invoice_html = this.get_invoice_html(invoice);
this.$invoices_container.append(invoice_html);
});
}
});
}
get_invoice_html(invoice) {
const posting_datetime = moment(invoice.posting_date+" "+invoice.posting_time).format("Do MMMM, h:mma");
return (
`<div class="invoice-wrapper flex p-4 justify-between border-b-grey pointer no-select" data-invoice-name="${escape(invoice.name)}">
<div class="flex flex-col justify-end">
<div class="text-dark-grey text-bold overflow-hidden whitespace-nowrap mb-2">${invoice.name}</div>
<div class="flex items-center">
<div class="flex items-center f-shrink-1 text-dark-grey overflow-hidden whitespace-nowrap">
<svg class="mr-2" width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
</svg>
${invoice.customer}
</div>
</div>
</div>
<div class="flex flex-col text-right">
<div class="f-shrink-0 text-lg text-dark-grey text-bold ml-4">${format_currency(invoice.grand_total, invoice.currency, 0) || 0}</div>
<div class="f-shrink-0 text-grey ml-4">${posting_datetime}</div>
</div>
</div>`
)
}
}

View File

@@ -0,0 +1,456 @@
erpnext.PointOfSale.PastOrderSummary = class {
constructor({ wrapper, events }) {
this.wrapper = wrapper;
this.events = events;
this.init_component();
}
init_component() {
this.prepare_dom();
this.init_child_components();
this.bind_events();
this.attach_shortcuts();
}
prepare_dom() {
this.wrapper.append(
`<section class="col-span-6 flex flex-col items-center shadow rounded past-order-summary bg-white mx-h-70 h-100 d-none">
<div class="no-summary-placeholder flex flex-1 items-center justify-center p-16">
<div class="no-item-wrapper flex items-center h-18 pr-4 pl-4">
<div class="flex-1 text-center text-grey">Select an invoice to load summary data</div>
</div>
</div>
<div class="summary-wrapper d-none flex-1 w-66 text-dark-grey relative">
<div class="summary-container absolute flex flex-col pt-16 pb-16 pr-8 pl-8 w-full h-full"></div>
</div>
</section>`
)
this.$component = this.wrapper.find('.past-order-summary');
this.$summary_wrapper = this.$component.find('.summary-wrapper');
this.$summary_container = this.$component.find('.summary-container');
}
init_child_components() {
this.init_upper_section();
this.init_items_summary();
this.init_totals_summary();
this.init_payments_summary();
this.init_summary_buttons();
this.init_email_print_dialog();
}
init_upper_section() {
this.$summary_container.append(
`<div class="flex upper-section justify-between w-full h-24"></div>`
);
this.$upper_section = this.$summary_container.find('.upper-section');
}
init_items_summary() {
this.$summary_container.append(
`<div class="flex flex-col flex-1 mt-6 w-full scroll-y">
<div class="text-grey mb-4 sticky bg-white">ITEMS</div>
<div class="items-summary-container border rounded flex flex-col w-full"></div>
</div>`
)
this.$items_summary_container = this.$summary_container.find('.items-summary-container');
}
init_totals_summary() {
this.$summary_container.append(
`<div class="flex flex-col mt-6 w-full f-shrink-0">
<div class="text-grey mb-4">TOTALS</div>
<div class="summary-totals-container border rounded flex flex-col w-full"></div>
</div>`
)
this.$totals_summary_container = this.$summary_container.find('.summary-totals-container');
}
init_payments_summary() {
this.$summary_container.append(
`<div class="flex flex-col mt-6 w-full f-shrink-0">
<div class="text-grey mb-4">PAYMENTS</div>
<div class="payments-summary-container border rounded flex flex-col w-full mb-4"></div>
</div>`
)
this.$payment_summary_container = this.$summary_container.find('.payments-summary-container');
}
init_summary_buttons() {
this.$summary_container.append(
`<div class="summary-btns flex summary-btns justify-between w-full f-shrink-0"></div>`
)
this.$summary_btns = this.$summary_container.find('.summary-btns');
}
init_email_print_dialog() {
const email_dialog = new frappe.ui.Dialog({
title: 'Email Receipt',
fields: [
{fieldname:'email_id', fieldtype:'Data', options: 'Email', label:'Email ID'},
// {fieldname:'remarks', fieldtype:'Text', label:'Remarks (if any)'}
],
primary_action: () => {
this.send_email();
},
primary_action_label: __('Send'),
});
this.email_dialog = email_dialog;
const print_dialog = new frappe.ui.Dialog({
title: 'Print Receipt',
fields: [
{fieldname:'print', fieldtype:'Data', label:'Print Preview'}
],
primary_action: () => {
const frm = this.events.get_frm();
frm.doc = this.doc;
frm.print_preview.lang_code = frm.doc.language;
frm.print_preview.printit(true);
},
primary_action_label: __('Print'),
});
this.print_dialog = print_dialog;
}
get_upper_section_html(doc) {
const { status } = doc; let indicator_color = '';
in_list(['Paid', 'Consolidated'], status) && (indicator_color = 'green');
status === 'Draft' && (indicator_color = 'red');
status === 'Return' && (indicator_color = 'grey');
return `<div class="flex flex-col items-start justify-end pr-4">
<div class="text-lg text-bold pt-2">${doc.customer}</div>
<div class="text-grey">${this.customer_email}</div>
<div class="text-grey mt-auto">Sold by: ${doc.owner}</div>
</div>
<div class="flex flex-col flex-1 items-end justify-between">
<div class="text-2-5xl text-bold">${format_currency(doc.paid_amount, doc.currency)}</div>
<div class="flex justify-between">
<div class="text-grey mr-4">${doc.name}</div>
<div class="text-grey text-bold indicator ${indicator_color}">${doc.status}</div>
</div>
</div>`
}
get_discount_html(doc) {
if (doc.discount_amount) {
return `<div class="total-summary-wrapper flex items-center h-12 pr-4 pl-4 pointer border-b-grey no-select">
<div class="flex f-shrink-1 items-center">
<div class="text-md-0 text-dark-grey text-bold overflow-hidden whitespace-nowrap mr-2">
Discount
</div>
<span class="text-grey">(${doc.additional_discount_percentage} %)</span>
</div>
<div class="flex flex-col f-shrink-0 ml-auto text-right">
<div class="text-md-0 text-dark-grey text-bold">${format_currency(doc.discount_amount, doc.currency)}</div>
</div>
</div>`;
} else {
return ``;
}
}
get_net_total_html(doc) {
return `<div class="total-summary-wrapper flex items-center h-12 pr-4 pl-4 pointer border-b-grey no-select">
<div class="flex f-shrink-1 items-center">
<div class="text-md-0 text-dark-grey text-bold overflow-hidden whitespace-nowrap">
Net Total
</div>
</div>
<div class="flex flex-col f-shrink-0 ml-auto text-right">
<div class="text-md-0 text-dark-grey text-bold">${format_currency(doc.net_total, doc.currency)}</div>
</div>
</div>`
}
get_taxes_html(doc) {
return `<div class="total-summary-wrapper flex items-center justify-between h-12 pr-4 pl-4 border-b-grey">
<div class="flex">
<div class="text-md-0 text-dark-grey text-bold w-fit">Tax Charges</div>
<div class="flex ml-6 text-dark-grey">
${
doc.taxes.map((t, i) => {
let margin_left = '';
if (i !== 0) margin_left = 'ml-2';
return `<span class="pl-2 pr-2 ${margin_left}">${t.description} @${t.rate}%</span>`
}).join('')
}
</div>
</div>
<div class="flex flex-col text-right">
<div class="text-md-0 text-dark-grey text-bold">${format_currency(doc.base_total_taxes_and_charges, doc.currency)}</div>
</div>
</div>`
}
get_grand_total_html(doc) {
return `<div class="total-summary-wrapper flex items-center h-12 pr-4 pl-4 pointer border-b-grey no-select">
<div class="flex f-shrink-1 items-center">
<div class="text-md-0 text-dark-grey text-bold overflow-hidden whitespace-nowrap">
Grand Total
</div>
</div>
<div class="flex flex-col f-shrink-0 ml-auto text-right">
<div class="text-md-0 text-dark-grey text-bold">${format_currency(doc.grand_total, doc.currency)}</div>
</div>
</div>`
}
get_item_html(doc, item_data) {
return `<div class="item-summary-wrapper flex items-center h-12 pr-4 pl-4 border-b-grey pointer no-select">
<div class="flex w-6 h-6 rounded bg-light-grey mr-4 items-center justify-center font-bold f-shrink-0">
<span>${item_data.qty || 0}</span>
</div>
<div class="flex flex-col f-shrink-1">
<div class="text-md text-dark-grey text-bold overflow-hidden whitespace-nowrap">
${item_data.item_name}
</div>
</div>
<div class="flex f-shrink-0 ml-auto text-right">
${get_rate_discount_html()}
</div>
</div>`
function get_rate_discount_html() {
if (item_data.rate && item_data.price_list_rate && item_data.rate !== item_data.price_list_rate) {
return `<span class="text-grey mr-2">(${item_data.discount_percentage}% off)</span>
<div class="text-md-0 text-dark-grey text-bold">${format_currency(item_data.rate, doc.currency)}</div>`
} else {
return `<div class="text-md-0 text-dark-grey text-bold">${format_currency(item_data.price_list_rate || item_data.rate, doc.currency)}</div>`
}
}
}
get_payment_html(doc, payment) {
return `<div class="payment-summary-wrapper flex items-center h-12 pr-4 pl-4 pointer border-b-grey no-select">
<div class="flex f-shrink-1 items-center">
<div class="text-md-0 text-dark-grey text-bold overflow-hidden whitespace-nowrap">
${payment.mode_of_payment}
</div>
</div>
<div class="flex flex-col f-shrink-0 ml-auto text-right">
<div class="text-md-0 text-dark-grey text-bold">${format_currency(payment.amount, doc.currency)}</div>
</div>
</div>`
}
bind_events() {
this.$summary_container.on('click', '.return-btn', () => {
this.events.process_return(this.doc.name);
this.toggle_component(false);
this.$component.find('.no-summary-placeholder').removeClass('d-none');
this.$summary_wrapper.addClass('d-none');
});
this.$summary_container.on('click', '.edit-btn', () => {
this.events.edit_order(this.doc.name);
this.toggle_component(false);
this.$component.find('.no-summary-placeholder').removeClass('d-none');
this.$summary_wrapper.addClass('d-none');
});
this.$summary_container.on('click', '.new-btn', () => {
this.events.new_order();
this.toggle_component(false);
this.$component.find('.no-summary-placeholder').removeClass('d-none');
this.$summary_wrapper.addClass('d-none');
});
this.$summary_container.on('click', '.email-btn', () => {
this.email_dialog.fields_dict.email_id.set_value(this.customer_email);
this.email_dialog.show();
});
this.$summary_container.on('click', '.print-btn', () => {
// this.print_dialog.show();
const frm = this.events.get_frm();
frm.doc = this.doc;
frm.print_preview.lang_code = frm.doc.language;
frm.print_preview.printit(true);
});
}
attach_shortcuts() {
frappe.ui.keys.on("ctrl+p", () => {
const print_btn_visible = this.$summary_container.find('.print-btn').is(":visible");
const summary_visible = this.$component.is(":visible");
if (!summary_visible || !print_btn_visible) return;
this.$summary_container.find('.print-btn').click();
});
}
toggle_component(show) {
show ?
this.$component.removeClass('d-none') :
this.$component.addClass('d-none');
}
send_email() {
const frm = this.events.get_frm();
const recipients = this.email_dialog.get_values().recipients;
const doc = this.doc || frm.doc;
const print_format = frm.pos_print_format;
frappe.call({
method:"frappe.core.doctype.communication.email.make",
args: {
recipients: recipients,
subject: __(frm.meta.name) + ': ' + doc.name,
doctype: doc.doctype,
name: doc.name,
send_email: 1,
print_format,
sender_full_name: frappe.user.full_name(),
_lang : doc.language
},
callback: r => {
if(!r.exc) {
frappe.utils.play_sound("email");
if(r.message["emails_not_sent_to"]) {
frappe.msgprint(__("Email not sent to {0} (unsubscribed / disabled)",
[ frappe.utils.escape_html(r.message["emails_not_sent_to"]) ]) );
} else {
frappe.show_alert({
message: __('Email sent successfully.'),
indicator: 'green'
});
}
this.email_dialog.hide();
} else {
frappe.msgprint(__("There were errors while sending email. Please try again."));
}
}
});
}
add_summary_btns(map) {
this.$summary_btns.html('');
map.forEach(m => {
if (m.condition) {
m.visible_btns.forEach(b => {
const class_name = b.split(' ')[0].toLowerCase();
this.$summary_btns.append(
`<div class="${class_name}-btn border rounded h-14 flex flex-1 items-center mr-4 justify-center text-md text-bold no-select pointer">
${b}
</div>`
)
});
}
});
this.$summary_btns.children().last().removeClass('mr-4');
}
show_summary_placeholder() {
this.$summary_wrapper.addClass("d-none");
this.$component.find('.no-summary-placeholder').removeClass('d-none');
}
switch_to_post_submit_summary() {
// switch to full width view
this.$component.removeClass('col-span-6').addClass('col-span-10');
this.$summary_wrapper.removeClass('w-66').addClass('w-40');
// switch place holder with summary container
this.$component.find('.no-summary-placeholder').addClass('d-none');
this.$summary_wrapper.removeClass('d-none');
}
switch_to_recent_invoice_summary() {
// switch full width view with 60% view
this.$component.removeClass('col-span-10').addClass('col-span-6');
this.$summary_wrapper.removeClass('w-40').addClass('w-66');
// switch place holder with summary container
this.$component.find('.no-summary-placeholder').addClass('d-none');
this.$summary_wrapper.removeClass('d-none');
}
get_condition_btn_map(after_submission) {
if (after_submission)
return [{ condition: true, visible_btns: ['Print Receipt', 'Email Receipt', 'New Order'] }];
return [
{ condition: this.doc.docstatus === 0, visible_btns: ['Edit Order'] },
{ condition: !this.doc.is_return && this.doc.docstatus === 1, visible_btns: ['Print Receipt', 'Email Receipt', 'Return']},
{ condition: this.doc.is_return && this.doc.docstatus === 1, visible_btns: ['Print Receipt', 'Email Receipt']}
];
}
load_summary_of(doc, after_submission=false) {
this.$summary_wrapper.removeClass("d-none");
after_submission ?
this.switch_to_post_submit_summary() : this.switch_to_recent_invoice_summary();
this.doc = doc;
this.attach_basic_info(doc);
this.attach_items_info(doc);
this.attach_totals_info(doc);
this.attach_payments_info(doc);
const condition_btns_map = this.get_condition_btn_map(after_submission);
this.add_summary_btns(condition_btns_map);
}
attach_basic_info(doc) {
frappe.db.get_value('Customer', this.doc.customer, 'email_id').then(({ message }) => {
this.customer_email = message.email_id || '';
const upper_section_dom = this.get_upper_section_html(doc);
this.$upper_section.html(upper_section_dom);
});
}
attach_items_info(doc) {
this.$items_summary_container.html('');
doc.items.forEach(item => {
const item_dom = this.get_item_html(doc, item);
this.$items_summary_container.append(item_dom);
});
}
attach_payments_info(doc) {
this.$payment_summary_container.html('');
doc.payments.forEach(p => {
if (p.amount) {
const payment_dom = this.get_payment_html(doc, p);
this.$payment_summary_container.append(payment_dom);
}
});
if (doc.redeem_loyalty_points && doc.loyalty_amount) {
const payment_dom = this.get_payment_html(doc, {
mode_of_payment: 'Loyalty Points',
amount: doc.loyalty_amount,
});
this.$payment_summary_container.append(payment_dom);
}
}
attach_totals_info(doc) {
this.$totals_summary_container.html('');
const discount_dom = this.get_discount_html(doc);
const net_total_dom = this.get_net_total_html(doc);
const taxes_dom = this.get_taxes_html(doc);
const grand_total_dom = this.get_grand_total_html(doc);
this.$totals_summary_container.append(discount_dom);
this.$totals_summary_container.append(net_total_dom);
this.$totals_summary_container.append(taxes_dom);
this.$totals_summary_container.append(grand_total_dom);
}
}

View File

@@ -0,0 +1,503 @@
{% include "erpnext/selling/page/point_of_sale/pos_number_pad.js" %}
erpnext.PointOfSale.Payment = class {
constructor({ events, wrapper }) {
this.wrapper = wrapper;
this.events = events;
this.init_component();
}
init_component() {
this.prepare_dom();
this.initialize_numpad();
this.bind_events();
this.attach_shortcuts();
}
prepare_dom() {
this.wrapper.append(
`<section class="col-span-6 flex shadow rounded payment-section bg-white mx-h-70 h-100 d-none">
<div class="flex flex-col p-16 pt-8 pb-8 w-full">
<div class="text-grey mb-6 payment-section no-select pointer">
PAYMENT METHOD<span class="octicon octicon-chevron-down collapse-indicator"></span>
</div>
<div class="payment-modes flex flex-wrap"></div>
<div class="invoice-details-section"></div>
<div class="flex mt-auto justify-center w-full">
<div class="flex flex-col justify-center flex-1 ml-4">
<div class="flex w-full">
<div class="totals-remarks items-end justify-end flex flex-1">
<div class="remarks text-md-0 text-grey mr-auto"></div>
<div class="totals flex justify-end pt-4"></div>
</div>
<div class="number-pad w-40 mb-4 ml-8 d-none"></div>
</div>
<div class="flex items-center justify-center mt-4 submit-order h-16 w-full rounded bg-primary text-md text-white no-select pointer text-bold">
Complete Order
</div>
<div class="order-time flex items-center justify-end mt-2 pt-2 pb-2 w-full text-md-0 text-grey no-select pointer d-none"></div>
</div>
</div>
</div>
</section>`
)
this.$component = this.wrapper.find('.payment-section');
this.$payment_modes = this.$component.find('.payment-modes');
this.$totals_remarks = this.$component.find('.totals-remarks');
this.$totals = this.$component.find('.totals');
this.$remarks = this.$component.find('.remarks');
this.$numpad = this.$component.find('.number-pad');
this.$invoice_details_section = this.$component.find('.invoice-details-section');
}
make_invoice_fields_control() {
frappe.db.get_doc("POS Settings", undefined).then((doc) => {
const fields = doc.invoice_fields;
if (!fields.length) return;
this.$invoice_details_section.html(
`<div class="text-grey pb-6 mt-2 pointer no-select">
ADDITIONAL INFORMATION<span class="octicon octicon-chevron-down collapse-indicator"></span>
</div>
<div class="invoice-fields grid grid-cols-2 gap-4 mb-6 d-none"></div>`
);
this.$invoice_fields = this.$invoice_details_section.find('.invoice-fields');
const frm = this.events.get_frm();
fields.forEach(df => {
this.$invoice_fields.append(
`<div class="invoice_detail_field ${df.fieldname}-field" data-fieldname="${df.fieldname}"></div>`
);
this[`${df.fieldname}_field`] = frappe.ui.form.make_control({
df: {
...df,
onchange: function() {
frm.set_value(this.df.fieldname, this.value);
}
},
parent: this.$invoice_fields.find(`.${df.fieldname}-field`),
render_input: true,
});
this[`${df.fieldname}_field`].set_value(frm.doc[df.fieldname]);
})
});
}
initialize_numpad() {
const me = this;
this.number_pad = new erpnext.PointOfSale.NumberPad({
wrapper: this.$numpad,
events: {
numpad_event: function($btn) {
me.on_numpad_clicked($btn);
}
},
cols: 3,
keys: [
[ 1, 2, 3 ],
[ 4, 5, 6 ],
[ 7, 8, 9 ],
[ '.', 0, 'Delete' ]
],
})
this.numpad_value = '';
}
on_numpad_clicked($btn) {
const me = this;
const button_value = $btn.attr('data-button-value');
highlight_numpad_btn($btn);
this.numpad_value = button_value === 'delete' ? this.numpad_value.slice(0, -1) : this.numpad_value + button_value;
this.selected_mode.$input.get(0).focus();
this.selected_mode.set_value(this.numpad_value);
function highlight_numpad_btn($btn) {
$btn.addClass('shadow-inner bg-selected');
setTimeout(() => {
$btn.removeClass('shadow-inner bg-selected');
}, 100);
}
}
bind_events() {
const me = this;
this.$payment_modes.on('click', '.mode-of-payment', function(e) {
const mode_clicked = $(this);
// if clicked element doesn't have .mode-of-payment class then return
if (!$(e.target).is(mode_clicked)) return;
const mode = mode_clicked.attr('data-mode');
// hide all control fields and shortcuts
$(`.mode-of-payment-control`).addClass('d-none');
$(`.cash-shortcuts`).addClass('d-none');
me.$payment_modes.find(`.pay-amount`).removeClass('d-none');
me.$payment_modes.find(`.loyalty-amount-name`).addClass('d-none');
// remove highlight from all mode-of-payments
$('.mode-of-payment').removeClass('border-primary');
if (mode_clicked.hasClass('border-primary')) {
// clicked one is selected then unselect it
mode_clicked.removeClass('border-primary');
me.selected_mode = '';
me.toggle_numpad(false);
} else {
// clicked one is not selected then select it
mode_clicked.addClass('border-primary');
mode_clicked.find('.mode-of-payment-control').removeClass('d-none');
mode_clicked.find('.cash-shortcuts').removeClass('d-none');
me.$payment_modes.find(`.${mode}-amount`).addClass('d-none');
me.$payment_modes.find(`.${mode}-name`).removeClass('d-none');
me.toggle_numpad(true);
me.selected_mode = me[`${mode}_control`];
const doc = me.events.get_frm().doc;
me.selected_mode?.$input?.get(0).focus();
!me.selected_mode?.get_value() ? me.selected_mode?.set_value(doc.grand_total - doc.paid_amount) : '';
}
})
this.$payment_modes.on('click', '.shortcut', function(e) {
const value = $(this).attr('data-value');
me.selected_mode.set_value(value);
})
// this.$totals_remarks.on('click', '.remarks', () => {
// this.toggle_remarks_control();
// })
this.$component.on('click', '.submit-order', () => {
const doc = this.events.get_frm().doc;
const paid_amount = doc.paid_amount;
const items = doc.items;
if (paid_amount == 0 || !items.length) {
const message = items.length ? __("You cannot submit the order without payment.") : __("You cannot submit empty order.")
frappe.show_alert({ message, indicator: "orange" });
frappe.utils.play_sound("error");
return;
}
this.events.submit_invoice();
})
frappe.ui.form.on('POS Invoice', 'paid_amount', (frm) => {
this.update_totals_section(frm.doc);
// need to re calculate cash shortcuts after discount is applied
const is_cash_shortcuts_invisible = this.$payment_modes.find('.cash-shortcuts').hasClass('d-none');
this.attach_cash_shortcuts(frm.doc);
!is_cash_shortcuts_invisible && this.$payment_modes.find('.cash-shortcuts').removeClass('d-none');
})
frappe.ui.form.on('POS Invoice', 'loyalty_amount', (frm) => {
const formatted_currency = format_currency(frm.doc.loyalty_amount, frm.doc.currency);
this.$payment_modes.find(`.loyalty-amount-amount`).html(formatted_currency);
});
frappe.ui.form.on("Sales Invoice Payment", "amount", (frm, cdt, cdn) => {
// for setting correct amount after loyalty points are redeemed
const default_mop = locals[cdt][cdn];
const mode = default_mop.mode_of_payment.replace(' ', '_').toLowerCase();
if (this[`${mode}_control`] && this[`${mode}_control`].get_value() != default_mop.amount) {
this[`${mode}_control`].set_value(default_mop.amount);
}
});
this.$component.on('click', '.invoice-details-section', function(e) {
if ($(e.target).closest('.invoice-fields').length) return;
me.$payment_modes.addClass('d-none');
me.$invoice_fields.toggleClass("d-none");
me.toggle_numpad(false);
});
this.$component.on('click', '.payment-section', () => {
this.$invoice_fields.addClass("d-none");
this.$payment_modes.toggleClass('d-none');
this.toggle_numpad(true);
})
}
attach_shortcuts() {
frappe.ui.keys.on("ctrl+enter", () => {
const payment_is_visible = this.$component.is(":visible");
const active_mode = this.$payment_modes.find(".border-primary");
if (payment_is_visible && active_mode.length) {
this.$component.find('.submit-order').click();
}
});
frappe.ui.keys.on("tab", () => {
const payment_is_visible = this.$component.is(":visible");
const mode_of_payments = Array.from(this.$payment_modes.find(".mode-of-payment")).map(m => $(m).attr("data-mode"));
let active_mode = this.$payment_modes.find(".border-primary");
active_mode = active_mode.length ? active_mode.attr("data-mode") : undefined;
if (!active_mode) return;
const mode_index = mode_of_payments.indexOf(active_mode);
const next_mode_index = (mode_index + 1) % mode_of_payments.length;
const next_mode_to_be_clicked = this.$payment_modes.find(`.mode-of-payment[data-mode="${mode_of_payments[next_mode_index]}"]`);
if (payment_is_visible && mode_index != next_mode_index) {
next_mode_to_be_clicked.click();
}
});
}
toggle_numpad(show) {
if (show) {
this.$numpad.removeClass('d-none');
this.$remarks.addClass('d-none');
this.$totals_remarks.addClass('w-60 justify-center').removeClass('justify-end w-full');
} else {
this.$numpad.addClass('d-none');
this.$remarks.removeClass('d-none');
this.$totals_remarks.removeClass('w-60 justify-center').addClass('justify-end w-full');
}
}
render_payment_section() {
this.render_payment_mode_dom();
this.make_invoice_fields_control();
this.update_totals_section();
}
edit_cart() {
this.events.toggle_other_sections(false);
this.toggle_component(false);
}
checkout() {
this.events.toggle_other_sections(true);
this.toggle_component(true);
this.render_payment_section();
}
toggle_remarks_control() {
if (this.$remarks.find('.frappe-control').length) {
this.$remarks.html('+ Add Remark');
} else {
this.$remarks.html('');
this[`remark_control`] = frappe.ui.form.make_control({
df: {
label: __('Remark'),
fieldtype: 'Data',
onchange: function() {}
},
parent: this.$totals_remarks.find(`.remarks`),
render_input: true,
});
this[`remark_control`].set_value('');
}
}
render_payment_mode_dom() {
const doc = this.events.get_frm().doc;
const payments = doc.payments;
const currency = doc.currency;
this.$payment_modes.html(
`${
payments.map((p, i) => {
const mode = p.mode_of_payment.replace(' ', '_').toLowerCase();
const payment_type = p.type;
const margin = i % 2 === 0 ? 'pr-2' : 'pl-2';
const amount = p.amount > 0 ? format_currency(p.amount, currency) : '';
return (
`<div class="w-half ${margin} bg-white">
<div class="mode-of-payment rounded border border-grey text-grey text-md
mb-4 p-8 pt-4 pb-4 no-select pointer" data-mode="${mode}" data-payment-type="${payment_type}">
${p.mode_of_payment}
<div class="${mode}-amount pay-amount inline float-right text-bold">${amount}</div>
<div class="${mode} mode-of-payment-control mt-4 flex flex-1 items-center d-none"></div>
</div>
</div>`
)
}).join('')
}`
)
payments.forEach(p => {
const mode = p.mode_of_payment.replace(' ', '_').toLowerCase();
const me = this;
this[`${mode}_control`] = frappe.ui.form.make_control({
df: {
label: __(`${p.mode_of_payment}`),
fieldtype: 'Currency',
placeholder: __(`Enter ${p.mode_of_payment} amount.`),
onchange: function() {
if (this.value || this.value == 0) {
frappe.model.set_value(p.doctype, p.name, 'amount', flt(this.value))
.then(() => me.update_totals_section());
const formatted_currency = format_currency(this.value, currency);
me.$payment_modes.find(`.${mode}-amount`).html(formatted_currency);
}
}
},
parent: this.$payment_modes.find(`.${mode}.mode-of-payment-control`),
render_input: true,
});
this[`${mode}_control`].toggle_label(false);
this[`${mode}_control`].set_value(p.amount);
if (p.default) {
setTimeout(() => {
this.$payment_modes.find(`.${mode}.mode-of-payment-control`).parent().click();
}, 500);
}
})
this.render_loyalty_points_payment_mode();
this.attach_cash_shortcuts(doc);
}
attach_cash_shortcuts(doc) {
const grand_total = doc.grand_total;
const currency = doc.currency;
const shortcuts = this.get_cash_shortcuts(flt(grand_total));
this.$payment_modes.find('.cash-shortcuts').remove();
this.$payment_modes.find('[data-payment-type="Cash"]').find('.mode-of-payment-control').after(
`<div class="cash-shortcuts grid grid-cols-3 gap-2 flex-1 text-center text-md-0 mb-2 d-none">
${
shortcuts.map(s => {
return `<div class="shortcut rounded bg-light-grey text-dark-grey pt-2 pb-2 no-select pointer" data-value="${s}">
${format_currency(s, currency)}
</div>`
}).join('')
}
</div>`
)
}
get_cash_shortcuts(grand_total) {
let steps = [1, 5, 10];
const digits = String(Math.round(grand_total)).length;
steps = steps.map(x => x * (10 ** (digits - 2)));
const get_nearest = (amount, x) => {
let nearest_x = Math.ceil((amount / x)) * x;
return nearest_x === amount ? nearest_x + x : nearest_x;
}
return steps.reduce((finalArr, x) => {
let nearest_x = get_nearest(grand_total, x);
nearest_x = finalArr.indexOf(nearest_x) != -1 ? nearest_x + x : nearest_x;
return [...finalArr, nearest_x];
}, []);
}
render_loyalty_points_payment_mode() {
const me = this;
const doc = this.events.get_frm().doc;
const { loyalty_program, loyalty_points, conversion_factor } = this.events.get_customer_details();
this.$payment_modes.find(`.mode-of-payment[data-mode="loyalty-amount"]`).parent().remove();
if (!loyalty_program) return;
let description, read_only, max_redeemable_amount;
if (!loyalty_points) {
description = __(`You don't have enough points to redeem.`);
read_only = true;
} else {
max_redeemable_amount = flt(flt(loyalty_points) * flt(conversion_factor), precision("loyalty_amount", doc))
description = __(`You can redeem upto ${format_currency(max_redeemable_amount)}.`);
read_only = false;
}
const margin = this.$payment_modes.children().length % 2 === 0 ? 'pr-2' : 'pl-2';
const amount = doc.loyalty_amount > 0 ? format_currency(doc.loyalty_amount, doc.currency) : '';
this.$payment_modes.append(
`<div class="w-half ${margin} bg-white">
<div class="mode-of-payment rounded border border-grey text-grey text-md
mb-4 p-8 pt-4 pb-4 no-select pointer" data-mode="loyalty-amount" data-payment-type="loyalty-amount">
Redeem Loyalty Points
<div class="loyalty-amount-amount pay-amount inline float-right text-bold">${amount}</div>
<div class="loyalty-amount-name inline float-right text-bold text-md-0 d-none">${loyalty_program}</div>
<div class="loyalty-amount mode-of-payment-control mt-4 flex flex-1 items-center d-none"></div>
</div>
</div>`
)
this['loyalty-amount_control'] = frappe.ui.form.make_control({
df: {
label: __('Redeem Loyalty Points'),
fieldtype: 'Currency',
placeholder: __(`Enter amount to be redeemed.`),
options: 'company:currency',
read_only,
onchange: async function() {
if (!loyalty_points) return;
if (this.value > max_redeemable_amount) {
frappe.show_alert({
message: __(`You cannot redeem more than ${format_currency(max_redeemable_amount)}.`),
indicator: "red"
});
frappe.utils.play_sound("submit");
me['loyalty-amount_control'].set_value(0);
return;
}
const redeem_loyalty_points = this.value > 0 ? 1 : 0;
await frappe.model.set_value(doc.doctype, doc.name, 'redeem_loyalty_points', redeem_loyalty_points);
frappe.model.set_value(doc.doctype, doc.name, 'loyalty_points', parseInt(this.value / conversion_factor));
},
description
},
parent: this.$payment_modes.find(`.loyalty-amount.mode-of-payment-control`),
render_input: true,
});
this['loyalty-amount_control'].toggle_label(false);
// this.render_add_payment_method_dom();
}
render_add_payment_method_dom() {
const docstatus = this.events.get_frm().doc.docstatus;
if (docstatus === 0)
this.$payment_modes.append(
`<div class="w-full pr-2">
<div class="add-mode-of-payment w-half text-grey mb-4 no-select pointer">+ Add Payment Method</div>
</div>`
)
}
update_totals_section(doc) {
if (!doc) doc = this.events.get_frm().doc;
const paid_amount = doc.paid_amount;
const remaining = doc.grand_total - doc.paid_amount;
const change = doc.change_amount || remaining <= 0 ? -1 * remaining : undefined;
const currency = doc.currency
const label = change ? __('Change') : __('To Be Paid');
this.$totals.html(
`<div>
<div class="pr-8 border-r-grey">Paid Amount</div>
<div class="pr-8 border-r-grey text-bold text-2xl">${format_currency(paid_amount, currency)}</div>
</div>
<div>
<div class="pl-8">${label}</div>
<div class="pl-8 text-green-400 text-bold text-2xl">${format_currency(change || remaining, currency)}</div>
</div>`
)
}
toggle_component(show) {
show ? this.$component.removeClass('d-none') : this.$component.addClass('d-none');
}
}

View File

@@ -1,38 +0,0 @@
QUnit.test("test:Point of Sales", function(assert) {
assert.expect(1);
let done = assert.async();
frappe.run_serially([
() => frappe.set_route('point-of-sale'),
() => frappe.timeout(3),
() => frappe.set_control('customer', 'Test Customer 1'),
() => frappe.timeout(0.2),
() => cur_frm.set_value('customer', 'Test Customer 1'),
() => frappe.timeout(2),
() => frappe.click_link('Test Product 2'),
() => frappe.timeout(0.2),
() => frappe.click_element(`.cart-items [data-item-code="Test Product 2"]`),
() => frappe.timeout(0.2),
() => frappe.click_element(`.number-pad [data-value="Rate"]`),
() => frappe.timeout(0.2),
() => frappe.click_element(`.number-pad [data-value="2"]`),
() => frappe.timeout(0.2),
() => frappe.click_element(`.number-pad [data-value="5"]`),
() => frappe.timeout(0.2),
() => frappe.click_element(`.number-pad [data-value="0"]`),
() => frappe.timeout(0.2),
() => frappe.click_element(`.number-pad [data-value="Pay"]`),
() => frappe.timeout(0.2),
() => frappe.click_element(`.frappe-control [data-value="4"]`),
() => frappe.timeout(0.2),
() => frappe.click_element(`.frappe-control [data-value="5"]`),
() => frappe.timeout(0.2),
() => frappe.click_element(`.frappe-control [data-value="0"]`),
() => frappe.timeout(0.2),
() => frappe.click_button('Submit'),
() => frappe.click_button('Yes'),
() => frappe.timeout(3),
() => assert.ok(cur_frm.doc.docstatus==1, "Sales invoice created successfully"),
() => done()
]);
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,22 @@
{
"align_labels_right": 0,
"creation": "2011-12-21 11:08:55",
"custom_format": 1,
"disabled": 0,
"doc_type": "POS Invoice",
"docstatus": 0,
"doctype": "Print Format",
"html": "<style>\n\t.print-format table, .print-format tr, \n\t.print-format td, .print-format div, .print-format p {\n\t\tfont-family: Tahoma, sans-serif;\n\t\tline-height: 150%;\n\t\tvertical-align: middle;\n\t}\n\t@media screen {\n\t\t.print-format {\n\t\t\twidth: 4in;\n\t\t\tpadding: 0.25in;\n\t\t\tmin-height: 8in;\n\t\t}\n\t}\n</style>\n\n{% if letter_head %}\n {{ letter_head }}\n{% endif %}\n\n<p class=\"text-center\" style=\"margin-bottom: 1rem\">\n\t{{ doc.company }}<br>\n\t{{ doc.select_print_heading or _(\"Invoice\") }}<br>\n</p>\n<p>\n\t<b>{{ _(\"Receipt No\") }}:</b> {{ doc.name }}<br>\n\t<b>{{ _(\"Date\") }}:</b> {{ doc.get_formatted(\"posting_date\") }}<br>\n\t<b>{{ _(\"Customer\") }}:</b> {{ doc.customer_name }}\n</p>\n\n<hr>\n<table class=\"table table-condensed cart no-border\">\n\t<thead>\n\t\t<tr>\n\t\t\t<th width=\"50%\">{{ _(\"Item\") }}</th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Qty\") }}</th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Amount\") }}</th>\n\t\t</tr>\n\t</thead>\n\t<tbody>\n\t\t{%- for item in doc.items -%}\n\t\t<tr>\n\t\t\t<td>\n\t\t\t\t{{ item.item_code }}\n\t\t\t\t{%- if item.item_name != item.item_code -%}\n\t\t\t\t\t<br>{{ item.item_name }}\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- if item.serial_no -%}\n\t\t\t\t\t<br><b>{{ _(\"SR.No\") }}:</b><br>\n\t\t\t\t\t{{ item.serial_no | replace(\"\\n\", \", \") }}\n\t\t\t\t{%- endif -%}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">{{ item.qty }}<br>@ {{ item.get_formatted(\"rate\") }}</td>\n\t\t\t<td class=\"text-right\">{{ item.get_formatted(\"amount\") }}</td>\n\t\t</tr>\n\t\t{%- endfor -%}\n\t</tbody>\n</table>\n<table class=\"table table-condensed no-border\">\n\t<tbody>\n\t\t<tr>\n\t\t\t{% if doc.flags.show_inclusive_tax_in_print %}\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ _(\"Total Excl. Tax\") }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"net_total\", doc) }}\n\t\t\t\t</td>\n\t\t\t{% else %}\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ _(\"Total\") }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"total\", doc) }}\n\t\t\t\t</td>\n\t\t\t{% endif %}\n\t\t</tr>\n\t\t{%- for row in doc.taxes -%}\n\t\t {%- if not row.included_in_print_rate or doc.flags.show_inclusive_tax_in_print -%}\n\t\t\t<tr>\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t {% if '%' in row.description %}\n\t\t\t\t\t {{ row.description }}\n\t\t\t\t\t{% else %}\n\t\t\t\t\t {{ row.description }}@{{ row.rate }}%\n\t\t\t\t\t{% endif %}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ row.get_formatted(\"tax_amount\", doc) }}\n\t\t\t\t</td>\n\t\t\t<tr>\n\t\t {%- endif -%}\n\t\t{%- endfor -%}\n\n\t\t{%- if doc.discount_amount -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t{{ _(\"Discount\") }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"discount_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Grand Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"grand_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- if doc.rounded_total -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Rounded Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"rounded_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Paid Amount\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"paid_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- if doc.change_amount -%}\n\t\t\t<tr>\n\t\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t\t<b>{{ _(\"Change Amount\") }}</b>\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"change_amount\") }}\n\t\t\t\t</td>\n\t\t\t</tr>\n\t\t{%- endif -%}\n\t</tbody>\n</table>\n<hr>\n<p>{{ doc.terms or \"\" }}</p>\n<p class=\"text-center\">{{ _(\"Thank you, please visit again.\") }}</p>",
"idx": 1,
"line_breaks": 0,
"modified": "2020-04-29 16:45:58.942375",
"modified_by": "Administrator",
"module": "Selling",
"name": "POS Invoice",
"owner": "Administrator",
"print_format_builder": 0,
"print_format_type": "Jinja",
"raw_printing": 0,
"show_section_headings": 0,
"standard": "Yes"
}

View File

@@ -0,0 +1,24 @@
{
"align_labels_right": 0,
"creation": "2020-05-14 17:02:44.207166",
"custom_format": 1,
"default_print_language": "en",
"disabled": 0,
"doc_type": "POS Invoice",
"docstatus": 0,
"doctype": "Print Format",
"font": "Default",
"html": "<style>\n\t.print-format table, .print-format tr, \n\t.print-format td, .print-format div, .print-format p {\n\t\tfont-family: Tahoma, sans-serif;\n\t\tline-height: 150%;\n\t\tvertical-align: middle;\n\t}\n\t@media screen {\n\t\t.print-format {\n\t\t\twidth: 4in;\n\t\t\tpadding: 0.25in;\n\t\t\tmin-height: 8in;\n\t\t}\n\t}\n</style>\n\n{% if letter_head %}\n {{ letter_head }}\n{% endif %}\n\n<p class=\"text-center\" style=\"margin-bottom: 1rem\">\n\t{{ doc.company }}<br>\n\t{{ doc.select_print_heading or _(\"Return Invoice\") }}<br>\n</p>\n<p>\n\t<b>{{ _(\"Receipt No\") }}:</b> {{ doc.name }}<br>\n\t<b>{{ _(\"Original Invoice\") }}:</b> {{ doc.return_against }}<br>\n\t<b>{{ _(\"Date\") }}:</b> {{ doc.get_formatted(\"posting_date\") }}<br>\n\t<b>{{ _(\"Customer\") }}:</b> {{ doc.customer_name }}\n</p>\n\n<hr>\n<table class=\"table table-condensed cart no-border\">\n\t<thead>\n\t\t<tr>\n\t\t\t<th width=\"50%\">{{ _(\"Item\") }}</th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Qty\") }}</th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Amount\") }}</th>\n\t\t</tr>\n\t</thead>\n\t<tbody>\n\t\t{%- for item in doc.items -%}\n\t\t<tr>\n\t\t\t<td>\n\t\t\t\t{{ item.item_code }}\n\t\t\t\t{%- if item.item_name != item.item_code -%}\n\t\t\t\t\t<br>{{ item.item_name }}\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- if item.serial_no -%}\n\t\t\t\t\t<br><b>{{ _(\"SR.No\") }}:</b><br>\n\t\t\t\t\t{{ item.serial_no | replace(\"\\n\", \", \") }}\n\t\t\t\t{%- endif -%}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">{{ item.qty }}<br>@ {{ item.get_formatted(\"rate\") }}</td>\n\t\t\t<td class=\"text-right\">{{ item.get_formatted(\"amount\") }}</td>\n\t\t</tr>\n\t\t{%- endfor -%}\n\t</tbody>\n</table>\n<table class=\"table table-condensed no-border\">\n\t<tbody>\n\t\t<tr>\n\t\t\t{% if doc.flags.show_inclusive_tax_in_print %}\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ _(\"Total Excl. Tax\") }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"net_total\", doc) }}\n\t\t\t\t</td>\n\t\t\t{% else %}\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ _(\"Total\") }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"total\", doc) }}\n\t\t\t\t</td>\n\t\t\t{% endif %}\n\t\t</tr>\n\t\t{%- for row in doc.taxes -%}\n\t\t {%- if not row.included_in_print_rate or doc.flags.show_inclusive_tax_in_print -%}\n\t\t\t<tr>\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t {% if '%' in row.description %}\n\t\t\t\t\t {{ row.description }}\n\t\t\t\t\t{% else %}\n\t\t\t\t\t {{ row.description }}@{{ row.rate }}%\n\t\t\t\t\t{% endif %}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ row.get_formatted(\"tax_amount\", doc)}}\n\t\t\t\t</td>\n\t\t\t<tr>\n\t\t {%- endif -%}\n\t\t{%- endfor -%}\n\n\t\t{%- if doc.discount_amount -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t{{ _(\"Discount\") }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"discount_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Grand Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"grand_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- if doc.rounded_total -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Rounded Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"rounded_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Paid Amount\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"paid_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- if doc.change_amount -%}\n\t\t\t<tr>\n\t\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t\t<b>{{ _(\"Change Amount\") }}</b>\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"change_amount\")}}\n\t\t\t\t</td>\n\t\t\t</tr>\n\t\t{%- endif -%}\n\t</tbody>\n</table>\n<hr>\n<p>{{ doc.terms or \"\" }}</p>\n<p class=\"text-center\">{{ _(\"Thank you, please visit again.\") }}</p>",
"idx": 0,
"line_breaks": 0,
"modified": "2020-05-14 17:13:29.354015",
"modified_by": "Administrator",
"module": "Selling",
"name": "Return POS Invoice",
"owner": "Administrator",
"print_format_builder": 0,
"print_format_type": "Jinja",
"raw_printing": 0,
"show_section_headings": 0,
"standard": "Yes"
}

View File

@@ -191,7 +191,7 @@ def get_conditions(filters):
conditions += "AND so_item.item_code = '%s'" %frappe.db.escape(filters.item_code)
if filters.get("customer"):
conditions += "AND so.customer = '%s'" %frappe.db.escape(filters.customer)
conditions += "AND so.customer = %s" %frappe.db.escape(filters.customer)
return conditions

View File

@@ -96,7 +96,7 @@ def prepare_data(data, filters):
# prepare data for report view
row["qty_to_bill"] = flt(row["qty"]) - flt(row["billed_qty"])
row["delay"] = 0 if row["delay"] < 0 else row["delay"]
row["delay"] = 0 if row["delay"] and row["delay"] < 0 else row["delay"]
if filters.get("group_by_so"):
so_name = row["sales_order"]

View File

@@ -142,7 +142,7 @@ erpnext.selling.SellingController = erpnext.TransactionController.extend({
frappe.model.round_floats_in(item, ["price_list_rate", "discount_percentage"]);
// check if child doctype is Sales Order Item/Qutation Item and calculate the rate
if(in_list(["Quotation Item", "Sales Order Item", "Delivery Note Item", "Sales Invoice Item"]), cdt)
if(in_list(["Quotation Item", "Sales Order Item", "Delivery Note Item", "Sales Invoice Item", "POS Invoice Item"]), cdt)
this.apply_pricing_rule_on_item(item);
else
item.rate = flt(item.price_list_rate * (1 - item.discount_percentage / 100.0),
@@ -312,6 +312,11 @@ erpnext.selling.SellingController = erpnext.TransactionController.extend({
batch_no: function(doc, cdt, cdn) {
var me = this;
var item = frappe.get_doc(cdt, cdn);
if (item.serial_no) {
return;
}
item.serial_no = null;
var has_serial_no;
frappe.db.get_value('Item', {'item_code': item.item_code}, 'has_serial_no', (r) => {
@@ -489,13 +494,18 @@ frappe.ui.form.on(cur_frm.doctype, {
var dialog = new frappe.ui.Dialog({
title: __("Set as Lost"),
fields: [
{"fieldtype": "Table MultiSelect",
"label": __("Lost Reasons"),
"fieldname": "lost_reason",
"options": "Lost Reason Detail",
"reqd": 1},
{"fieldtype": "Text", "label": __("Detailed Reason"), "fieldname": "detailed_reason"},
{
"fieldtype": "Table MultiSelect",
"label": __("Lost Reasons"),
"fieldname": "lost_reason",
"options": frm.doctype === 'Opportunity' ? 'Opportunity Lost Reason Detail': 'Quotation Lost Reason Detail',
"reqd": 1
},
{
"fieldtype": "Text",
"label": __("Detailed Reason"),
"fieldname": "detailed_reason"
},
],
primary_action: function() {
var values = dialog.get_values();

View File

@@ -0,0 +1,46 @@
{
"cards": [
{
"card": "Annual Sales"
},
{
"card": "Sales Orders to Deliver"
},
{
"card": "Sales Orders to Bill"
},
{
"card": "Active Customers"
}
],
"charts": [
{
"chart": "Sales Order Trends",
"width": "Full"
},
{
"chart": "Top Customers",
"width": "Half"
},
{
"chart": "Sales Order Analysis",
"width": "Half"
},
{
"chart": "Item-wise Annual Sales",
"width": "Full"
}
],
"creation": "2020-07-20 20:17:16.688162",
"dashboard_name": "Selling",
"docstatus": 0,
"doctype": "Dashboard",
"idx": 0,
"is_default": 0,
"is_standard": 1,
"modified": "2020-07-22 15:31:22.299903",
"modified_by": "Administrator",
"module": "Selling",
"name": "Selling",
"owner": "Administrator"
}