Merge pull request #30886 from frappe/version-13-pre-release

chore: weekly release for version-13
This commit is contained in:
Deepesh Garg
2022-05-03 14:19:46 +05:30
committed by GitHub
48 changed files with 1003 additions and 271 deletions

View File

@@ -2,6 +2,13 @@
set -e
# Check for merge conflicts before proceeding
python -m compileall -f "${GITHUB_WORKSPACE}"
if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}"
then echo "Found merge conflicts"
exit 1
fi
cd ~ || exit
sudo apt-get install redis-server libcups2-dev

View File

@@ -6,6 +6,7 @@ from erpnext.hooks import regional_overrides
__version__ = "13.27.1"
def get_default_company(user=None):
"""Get default company for user"""
from frappe.defaults import get_user_default_as_list

View File

@@ -114,10 +114,13 @@ class OpeningInvoiceCreationTool(Document):
)
or {}
)
default_currency = frappe.db.get_value(row.party_type, row.party, "default_currency")
if company_details:
invoice.update(
{
"currency": company_details.get("default_currency"),
"currency": default_currency or company_details.get("default_currency"),
"letter_head": company_details.get("default_letter_head"),
}
)

View File

@@ -38,6 +38,15 @@ erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.ext
]
};
});
this.frm.set_query("cost_center", () => {
return {
"filters": {
"company": this.frm.doc.company,
"is_group": 0
}
}
});
},
refresh: function() {

View File

@@ -24,6 +24,7 @@
"invoice_limit",
"payment_limit",
"bank_cash_account",
"cost_center",
"sec_break1",
"invoices",
"column_break_15",
@@ -178,13 +179,19 @@
{
"fieldname": "column_break_11",
"fieldtype": "Column Break"
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
}
],
"hide_toolbar": 1,
"icon": "icon-resize-horizontal",
"issingle": 1,
"links": [],
"modified": "2021-10-04 20:27:11.114194",
"modified": "2022-04-29 15:37:10.246831",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Reconciliation",
@@ -209,5 +216,6 @@
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -332,6 +332,9 @@ class PaymentReconciliation(Document):
def get_conditions(self, get_invoices=False, get_payments=False, get_return_invoices=False):
condition = " and company = '{0}' ".format(self.company)
if self.get("cost_center") and (get_invoices or get_payments or get_return_invoices):
condition = " and cost_center = '{0}' ".format(self.cost_center)
if get_invoices:
condition += (
" and posting_date >= {0}".format(frappe.db.escape(self.from_invoice_date))

View File

@@ -1,9 +1,96 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import unittest
import frappe
from frappe.utils import add_days, getdate
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
class TestPaymentReconciliation(unittest.TestCase):
pass
@classmethod
def setUpClass(cls):
make_customer()
make_invoice_and_payment()
def test_payment_reconciliation(self):
payment_reco = frappe.get_doc("Payment Reconciliation")
payment_reco.company = "_Test Company"
payment_reco.party_type = "Customer"
payment_reco.party = "_Test Payment Reco Customer"
payment_reco.receivable_payable_account = "Debtors - _TC"
payment_reco.from_invoice_date = add_days(getdate(), -1)
payment_reco.to_invoice_date = getdate()
payment_reco.from_payment_date = add_days(getdate(), -1)
payment_reco.to_payment_date = getdate()
payment_reco.maximum_invoice_amount = 1000
payment_reco.maximum_payment_amount = 1000
payment_reco.invoice_limit = 10
payment_reco.payment_limit = 10
payment_reco.bank_cash_account = "_Test Bank - _TC"
payment_reco.cost_center = "_Test Cost Center - _TC"
payment_reco.get_unreconciled_entries()
self.assertEqual(len(payment_reco.get("invoices")), 1)
self.assertEqual(len(payment_reco.get("payments")), 1)
payment_entry = payment_reco.get("payments")[0].reference_name
invoice = payment_reco.get("invoices")[0].invoice_number
payment_reco.allocate_entries(
{
"payments": [payment_reco.get("payments")[0].as_dict()],
"invoices": [payment_reco.get("invoices")[0].as_dict()],
}
)
payment_reco.reconcile()
payment_entry_doc = frappe.get_doc("Payment Entry", payment_entry)
self.assertEqual(payment_entry_doc.get("references")[0].reference_name, invoice)
def make_customer():
if not frappe.db.get_value("Customer", "_Test Payment Reco Customer"):
frappe.get_doc(
{
"doctype": "Customer",
"customer_name": "_Test Payment Reco Customer",
"customer_type": "Individual",
"customer_group": "_Test Customer Group",
"territory": "_Test Territory",
}
).insert()
def make_invoice_and_payment():
si = create_sales_invoice(
customer="_Test Payment Reco Customer", qty=1, rate=690, do_not_save=True
)
si.cost_center = "_Test Cost Center - _TC"
si.save()
si.submit()
pe = frappe.get_doc(
{
"doctype": "Payment Entry",
"payment_type": "Receive",
"party_type": "Customer",
"party": "_Test Payment Reco Customer",
"company": "_Test Company",
"paid_from_account_currency": "INR",
"paid_to_account_currency": "INR",
"source_exchange_rate": 1,
"target_exchange_rate": 1,
"reference_no": "1",
"reference_date": getdate(),
"received_amount": 690,
"paid_amount": 690,
"paid_from": "Debtors - _TC",
"paid_to": "_Test Bank - _TC",
"cost_center": "_Test Cost Center - _TC",
}
)
pe.insert()
pe.submit()

View File

@@ -166,7 +166,7 @@ class PeriodClosingVoucher(AccountsController):
sum(t1.debit_in_account_currency) - sum(t1.credit_in_account_currency) as bal_in_account_currency,
sum(t1.debit) - sum(t1.credit) as bal_in_company_currency
from `tabGL Entry` t1, `tabAccount` t2
where t1.account = t2.name and t2.report_type = 'Profit and Loss'
where t1.is_cancelled = 0 and t1.account = t2.name and t2.report_type = 'Profit and Loss'
and t2.docstatus < 2 and t2.company = %s
and t1.posting_date between %s and %s
group by t1.account, {dimension_fields}

View File

@@ -23,6 +23,10 @@
"order_confirmation_no",
"order_confirmation_date",
"amended_from",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
"drop_ship",
"customer",
"customer_name",
@@ -1138,16 +1142,39 @@
"fieldtype": "Link",
"label": "Tax Withholding Category",
"options": "Tax Withholding Category"
},
{
"collapsible": 1,
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions "
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
}
],
"icon": "fa fa-file-text",
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2021-09-28 13:10:47.955401",
"modified": "2022-04-26 12:16:38.694276",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
@@ -1194,6 +1221,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"timeline_field": "supplier",
"title_field": "supplier_name",
"track_changes": 1

View File

@@ -592,6 +592,9 @@ accounting_dimension_doctypes = [
"Subscription Plan",
"POS Invoice",
"POS Invoice Item",
"Purchase Order",
"Purchase Receipt",
"Sales Order",
]
regional_overrides = {

View File

@@ -5,7 +5,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import cint, get_datetime
from frappe.utils import cint, get_datetime, get_link_to_form
from erpnext.hr.doctype.shift_assignment.shift_assignment import (
get_actual_start_end_datetime_of_shift,
@@ -127,19 +127,17 @@ def mark_attendance_and_link_log(
log_names = [x.name for x in logs]
employee = logs[0].employee
if attendance_status == "Skip":
frappe.db.sql(
"""update `tabEmployee Checkin`
set skip_auto_attendance = %s
where name in %s""",
("1", log_names),
)
skip_attendance_in_checkins(log_names)
return None
elif attendance_status in ("Present", "Absent", "Half Day"):
employee_doc = frappe.get_doc("Employee", employee)
if not frappe.db.exists(
duplicate = frappe.db.exists(
"Attendance",
{"employee": employee, "attendance_date": attendance_date, "docstatus": ("!=", "2")},
):
)
if not duplicate:
doc_dict = {
"doctype": "Attendance",
"employee": employee,
@@ -155,6 +153,12 @@ def mark_attendance_and_link_log(
}
attendance = frappe.get_doc(doc_dict).insert()
attendance.submit()
if attendance_status == "Absent":
attendance.add_comment(
text=_("Employee was marked Absent for not meeting the working hours threshold.")
)
frappe.db.sql(
"""update `tabEmployee Checkin`
set attendance = %s
@@ -163,12 +167,10 @@ def mark_attendance_and_link_log(
)
return attendance
else:
frappe.db.sql(
"""update `tabEmployee Checkin`
set skip_auto_attendance = %s
where name in %s""",
("1", log_names),
)
skip_attendance_in_checkins(log_names)
if duplicate:
add_comment_in_checkins(log_names, duplicate)
return None
else:
frappe.throw(_("{} is an invalid Attendance Status.").format(attendance_status))
@@ -237,3 +239,29 @@ def time_diff_in_hours(start, end):
def find_index_in_dict(dict_list, key, value):
return next((index for (index, d) in enumerate(dict_list) if d[key] == value), None)
def add_comment_in_checkins(log_names, duplicate):
text = _("Auto Attendance skipped due to duplicate attendance record: {}").format(
get_link_to_form("Attendance", duplicate)
)
for name in log_names:
frappe.get_doc(
{
"doctype": "Comment",
"comment_type": "Comment",
"reference_doctype": "Employee Checkin",
"reference_name": name,
"content": text,
}
).insert(ignore_permissions=True)
def skip_attendance_in_checkins(log_names):
EmployeeCheckin = frappe.qb.DocType("Employee Checkin")
(
frappe.qb.update(EmployeeCheckin)
.set("skip_auto_attendance", 1)
.where(EmployeeCheckin.name.isin(log_names))
).run()

View File

@@ -84,7 +84,7 @@ class ShiftAssignment(Document):
@frappe.whitelist()
def get_events(start, end, filters=None):
events = []
from frappe.desk.calendar import get_event_conditions
employee = frappe.db.get_value(
"Employee", {"user_id": frappe.session.user}, ["name", "company"], as_dict=True
@@ -95,20 +95,22 @@ def get_events(start, end, filters=None):
employee = ""
company = frappe.db.get_value("Global Defaults", None, "default_company")
from frappe.desk.reportview import get_filters_cond
conditions = get_filters_cond("Shift Assignment", filters, [])
add_assignments(events, start, end, conditions=conditions)
conditions = get_event_conditions("Shift Assignment", filters)
events = add_assignments(start, end, conditions=conditions)
return events
def add_assignments(events, start, end, conditions=None):
def add_assignments(start, end, conditions=None):
events = []
query = """select name, start_date, end_date, employee_name,
employee, docstatus, shift_type
from `tabShift Assignment` where
start_date >= %(start_date)s
or end_date <= %(end_date)s
or (%(start_date)s between start_date and end_date and %(end_date)s between start_date and end_date)
(
start_date >= %(start_date)s
or end_date <= %(end_date)s
or (%(start_date)s between start_date and end_date and %(end_date)s between start_date and end_date)
)
and docstatus = 1"""
if conditions:
query += conditions

View File

@@ -4,14 +4,22 @@
import unittest
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, nowdate
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.shift_assignment.shift_assignment import get_events
test_dependencies = ["Shift Type"]
class TestShiftAssignment(unittest.TestCase):
class TestShiftAssignment(FrappeTestCase):
def setUp(self):
frappe.db.sql("delete from `tabShift Assignment`")
frappe.db.delete("Shift Assignment")
if not frappe.db.exists("Shift Type", "Day Shift"):
frappe.get_doc(
{"doctype": "Shift Type", "name": "Day Shift", "start_time": "9:00:00", "end_time": "18:00:00"}
).insert()
def test_make_shift_assignment(self):
shift_assignment = frappe.get_doc(
@@ -86,3 +94,36 @@ class TestShiftAssignment(unittest.TestCase):
)
self.assertRaises(frappe.ValidationError, shift_assignment_3.save)
def test_shift_assignment_calendar(self):
employee1 = make_employee("test_shift_assignment1@example.com", company="_Test Company")
employee2 = make_employee("test_shift_assignment2@example.com", company="_Test Company")
date = nowdate()
shift_1 = frappe.get_doc(
{
"doctype": "Shift Assignment",
"shift_type": "Day Shift",
"company": "_Test Company",
"employee": employee1,
"start_date": date,
"status": "Active",
}
).submit()
frappe.get_doc(
{
"doctype": "Shift Assignment",
"shift_type": "Day Shift",
"company": "_Test Company",
"employee": employee2,
"start_date": date,
"status": "Active",
}
).submit()
events = get_events(
start=date, end=date, filters=[["Shift Assignment", "employee", "=", employee1, False]]
)
self.assertEqual(len(events), 1)
self.assertEqual(events[0]["name"], shift_1.name)

View File

@@ -139,7 +139,17 @@ class ShiftType(Document):
for date in dates:
shift_details = get_employee_shift(employee, date, True)
if shift_details and shift_details.shift_type.name == self.name:
mark_attendance(employee, date, "Absent", self.name)
attendance = mark_attendance(employee, date, "Absent", self.name)
if attendance:
frappe.get_doc(
{
"doctype": "Comment",
"comment_type": "Comment",
"reference_doctype": "Attendance",
"reference_name": attendance,
"content": frappe._("Employee was marked Absent due to missing Employee Checkins."),
}
).insert(ignore_permissions=True)
def get_assigned_employee(self, from_date=None, consider_default_shift=False):
filters = {"start_date": (">", from_date), "shift_type": self.name, "docstatus": "1"}

View File

@@ -358,5 +358,8 @@ erpnext.patches.v13_0.rename_non_profit_fields
erpnext.patches.v13_0.enable_ksa_vat_docs #1
erpnext.patches.v13_0.create_gst_custom_fields_in_quotation
erpnext.patches.v13_0.update_expense_claim_status_for_paid_advances
erpnext.patches.v13_0.change_default_item_manufacturer_fieldtype
erpnext.patches.v13_0.set_return_against_in_pos_invoice_references
erpnext.patches.v13_0.copy_custom_field_filters_to_website_item
erpnext.patches.v13_0.copy_custom_field_filters_to_website_item
erpnext.patches.v13_0.education_deprecation_warning
erpnext.patches.v13_0.create_accounting_dimensions_in_orders

View File

@@ -0,0 +1,16 @@
import frappe
def execute():
# Erase all default item manufacturers that dont exist.
item = frappe.qb.DocType("Item")
manufacturer = frappe.qb.DocType("Manufacturer")
(
frappe.qb.update(item)
.set(item.default_item_manufacturer, None)
.left_join(manufacturer)
.on(item.default_item_manufacturer == manufacturer.name)
.where(manufacturer.name.isnull() & item.default_item_manufacturer.isnotnull())
).run()

View File

@@ -0,0 +1,39 @@
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
def execute():
accounting_dimensions = frappe.db.get_all(
"Accounting Dimension", fields=["fieldname", "label", "document_type", "disabled"]
)
if not accounting_dimensions:
return
count = 1
for d in accounting_dimensions:
if count % 2 == 0:
insert_after_field = "dimension_col_break"
else:
insert_after_field = "accounting_dimensions_section"
for doctype in ["Purchase Order", "Purchase Receipt", "Sales Order"]:
field = frappe.db.get_value("Custom Field", {"dt": doctype, "fieldname": d.fieldname})
if field:
continue
df = {
"fieldname": d.fieldname,
"label": d.label,
"fieldtype": "Link",
"options": d.document_type,
"insert_after": insert_after_field,
}
create_custom_field(doctype, df, ignore_validate=False)
frappe.clear_cache(doctype=doctype)
count += 1

View File

@@ -0,0 +1,10 @@
import click
def execute():
click.secho(
"Education Domain is moved to a separate app and will be removed from ERPNext in version-14.\n"
"When upgrading to ERPNext version-14, please install the app to continue using the Education domain: https://github.com/frappe/education",
fg="yellow",
)

View File

@@ -376,13 +376,19 @@ class SalarySlip(TransactionBase):
if joining_date and (getdate(self.start_date) < joining_date <= getdate(self.end_date)):
start_date = joining_date
unmarked_days = self.get_unmarked_days_based_on_doj_or_relieving(
unmarked_days, include_holidays_in_total_working_days, self.start_date, joining_date
unmarked_days,
include_holidays_in_total_working_days,
self.start_date,
add_days(joining_date, -1),
)
if relieving_date and (getdate(self.start_date) <= relieving_date < getdate(self.end_date)):
end_date = relieving_date
unmarked_days = self.get_unmarked_days_based_on_doj_or_relieving(
unmarked_days, include_holidays_in_total_working_days, relieving_date, self.end_date
unmarked_days,
include_holidays_in_total_working_days,
add_days(relieving_date, 1),
self.end_date,
)
# exclude days for which attendance has been marked
@@ -408,10 +414,10 @@ class SalarySlip(TransactionBase):
from erpnext.hr.doctype.employee.employee import is_holiday
if include_holidays_in_total_working_days:
unmarked_days -= date_diff(end_date, start_date)
unmarked_days -= date_diff(end_date, start_date) + 1
else:
# exclude only if not holidays
for days in range(date_diff(end_date, start_date)):
for days in range(date_diff(end_date, start_date) + 1):
date = add_days(end_date, -days)
if not is_holiday(self.employee, date):
unmarked_days -= 1

View File

@@ -128,6 +128,72 @@ class TestSalarySlip(unittest.TestCase):
},
)
def test_payment_days_for_mid_joinee_including_holidays(self):
no_of_days = self.get_no_of_days()
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com")
joining_date, relieving_date = add_days(month_start_date, 3), add_days(month_end_date, -5)
for days in range(date_diff(month_end_date, month_start_date) + 1):
date = add_days(month_start_date, days)
mark_attendance(new_emp_id, date, "Present", ignore_validate=True)
# Case 1: relieving in mid month
frappe.db.set_value(
"Employee",
new_emp_id,
{"date_of_joining": month_start_date, "relieving_date": relieving_date, "status": "Active"},
)
new_ss = make_employee_salary_slip(
"test_payment_days_based_on_joining_date@salary.com",
"Monthly",
"Test Payment Based On Attendence",
)
self.assertEqual(new_ss.payment_days, no_of_days[0] - 5)
# Case 2: joining in mid month
frappe.db.set_value(
"Employee",
new_emp_id,
{"date_of_joining": joining_date, "relieving_date": month_end_date, "status": "Active"},
)
frappe.delete_doc("Salary Slip", new_ss.name, force=True)
new_ss = make_employee_salary_slip(
"test_payment_days_based_on_joining_date@salary.com",
"Monthly",
"Test Payment Based On Attendence",
)
self.assertEqual(new_ss.payment_days, no_of_days[0] - 3)
# Case 3: joining and relieving in mid-month
frappe.db.set_value(
"Employee",
new_emp_id,
{"date_of_joining": joining_date, "relieving_date": relieving_date, "status": "Left"},
)
frappe.delete_doc("Salary Slip", new_ss.name, force=True)
new_ss = make_employee_salary_slip(
"test_payment_days_based_on_joining_date@salary.com",
"Monthly",
"Test Payment Based On Attendence",
)
self.assertEqual(new_ss.total_working_days, no_of_days[0])
self.assertEqual(new_ss.payment_days, no_of_days[0] - 8)
@change_settings(
"Payroll Settings",
{
"payroll_based_on": "Attendance",
"consider_unmarked_attendance_as": "Absent",
"include_holidays_in_total_working_days": True,
},
)
def test_payment_days_for_mid_joinee_including_holidays_and_unmarked_days(self):
# tests mid month joining and relieving along with unmarked days
from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
no_of_days = self.get_no_of_days()
@@ -135,12 +201,6 @@ class TestSalarySlip(unittest.TestCase):
new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com")
joining_date, relieving_date = add_days(month_start_date, 3), add_days(month_end_date, -5)
frappe.db.set_value(
"Employee",
new_emp_id,
{"date_of_joining": joining_date, "relieving_date": relieving_date, "status": "Left"},
)
holidays = 0
for days in range(date_diff(relieving_date, joining_date) + 1):
@@ -150,6 +210,12 @@ class TestSalarySlip(unittest.TestCase):
else:
holidays += 1
frappe.db.set_value(
"Employee",
new_emp_id,
{"date_of_joining": joining_date, "relieving_date": relieving_date, "status": "Left"},
)
new_ss = make_employee_salary_slip(
"test_payment_days_based_on_joining_date@salary.com",
"Monthly",

View File

@@ -84,6 +84,7 @@ class Project(Document):
type=task_details.type,
issue=task_details.issue,
is_group=task_details.is_group,
color=task_details.color,
)
).insert()

View File

@@ -58,6 +58,7 @@ def validate_eligibility(doc):
invalid_company = not frappe.db.get_value("E Invoice User", {"company": doc.get("company")})
invalid_supply_type = doc.get("gst_category") not in [
"Registered Regular",
"Registered Composition",
"SEZ",
"Overseas",
"Deemed Export",
@@ -125,24 +126,33 @@ def read_json(name):
def get_transaction_details(invoice):
supply_type = ""
if invoice.gst_category == "Registered Regular":
if (
invoice.gst_category == "Registered Regular" or invoice.gst_category == "Registered Composition"
):
supply_type = "B2B"
elif invoice.gst_category == "SEZ":
supply_type = "SEZWOP"
if invoice.export_type == "Without Payment of Tax":
supply_type = "SEZWOP"
else:
supply_type = "SEZWP"
elif invoice.gst_category == "Overseas":
supply_type = "EXPWOP"
if invoice.export_type == "Without Payment of Tax":
supply_type = "EXPWOP"
else:
supply_type = "EXPWP"
elif invoice.gst_category == "Deemed Export":
supply_type = "DEXP"
if not supply_type:
rr, sez, overseas, export = (
rr, rc, sez, overseas, export = (
bold("Registered Regular"),
bold("Registered Composition"),
bold("SEZ"),
bold("Overseas"),
bold("Deemed Export"),
)
frappe.throw(
_("GST category should be one of {}, {}, {}, {}").format(rr, sez, overseas, export),
_("GST category should be one of {}, {}, {}, {}, {}").format(rr, rc, sez, overseas, export),
title=_("Invalid Supply Type"),
)

View File

@@ -714,7 +714,7 @@ def get_custom_fields():
insert_after="customer",
no_copy=1,
print_hide=1,
depends_on='eval:in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category) && doc.irn_cancelled === 0',
depends_on='eval:in_list(["Registered Regular", "Registered Composition", "SEZ", "Overseas", "Deemed Export"], doc.gst_category) && doc.irn_cancelled === 0',
),
dict(
fieldname="irn_cancelled",

View File

@@ -95,10 +95,9 @@ class VATAuditReport(object):
as_dict=1,
)
for d in items:
if d.item_code not in self.invoice_items.get(d.parent, {}):
self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, {"net_amount": 0.0})
self.invoice_items[d.parent][d.item_code]["net_amount"] += d.get("base_net_amount", 0)
self.invoice_items[d.parent][d.item_code]["is_zero_rated"] = d.is_zero_rated
self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, {"net_amount": 0.0})
self.invoice_items[d.parent][d.item_code]["net_amount"] += d.get("base_net_amount", 0)
self.invoice_items[d.parent][d.item_code]["is_zero_rated"] = d.is_zero_rated
def get_items_based_on_tax_rate(self, doctype):
self.items_based_on_tax_rate = frappe._dict()
@@ -110,7 +109,7 @@ class VATAuditReport(object):
self.tax_details = frappe.db.sql(
"""
SELECT
parent, account_head, item_wise_tax_detail, base_tax_amount_after_discount_amount
parent, account_head, item_wise_tax_detail
FROM
`tab%s`
WHERE
@@ -123,7 +122,7 @@ class VATAuditReport(object):
tuple([doctype] + list(self.invoices.keys())),
)
for parent, account, item_wise_tax_detail, tax_amount in self.tax_details:
for parent, account, item_wise_tax_detail in self.tax_details:
if item_wise_tax_detail:
try:
if account in self.sa_vat_accounts:
@@ -135,7 +134,7 @@ class VATAuditReport(object):
# to skip items with non-zero tax rate in multiple rows
if taxes[0] == 0 and not is_zero_rated:
continue
tax_rate, item_amount_map = self.get_item_amount_map(parent, item_code, taxes)
tax_rate = self.get_item_amount_map(parent, item_code, taxes)
if tax_rate is not None:
rate_based_dict = self.items_based_on_tax_rate.setdefault(parent, {}).setdefault(
@@ -151,16 +150,22 @@ class VATAuditReport(object):
tax_rate = taxes[0]
tax_amount = taxes[1]
gross_amount = net_amount + tax_amount
item_amount_map = self.item_tax_rate.setdefault(parent, {}).setdefault(item_code, [])
amount_dict = {
"tax_rate": tax_rate,
"gross_amount": gross_amount,
"tax_amount": tax_amount,
"net_amount": net_amount,
}
item_amount_map.append(amount_dict)
return tax_rate, item_amount_map
self.item_tax_rate.setdefault(parent, {}).setdefault(
item_code,
{
"tax_rate": tax_rate,
"gross_amount": 0.0,
"tax_amount": 0.0,
"net_amount": 0.0,
},
)
self.item_tax_rate[parent][item_code]["net_amount"] += net_amount
self.item_tax_rate[parent][item_code]["tax_amount"] += tax_amount
self.item_tax_rate[parent][item_code]["gross_amount"] += gross_amount
return tax_rate
def get_conditions(self):
conditions = ""
@@ -205,9 +210,10 @@ class VATAuditReport(object):
for inv, inv_data in self.invoices.items():
if self.items_based_on_tax_rate.get(inv):
for rate, items in self.items_based_on_tax_rate.get(inv).items():
row = {"tax_amount": 0.0, "gross_amount": 0.0, "net_amount": 0.0}
consolidated_data_map.setdefault(rate, {"data": []})
for item in items:
row = {}
item_details = self.item_tax_rate.get(inv).get(item)
row["account"] = inv_data.get("account")
row["posting_date"] = formatdate(inv_data.get("posting_date"), "dd-mm-yyyy")
@@ -216,10 +222,11 @@ class VATAuditReport(object):
row["party_type"] = "Customer" if doctype == "Sales Invoice" else "Supplier"
row["party"] = inv_data.get("party")
row["remarks"] = inv_data.get("remarks")
row["gross_amount"] = item_details[0].get("gross_amount")
row["tax_amount"] = item_details[0].get("tax_amount")
row["net_amount"] = item_details[0].get("net_amount")
consolidated_data_map[rate]["data"].append(row)
row["gross_amount"] += item_details.get("gross_amount")
row["tax_amount"] += item_details.get("tax_amount")
row["net_amount"] += item_details.get("net_amount")
consolidated_data_map[rate]["data"].append(row)
return consolidated_data_map

View File

@@ -245,7 +245,7 @@ def make_custom_fields():
"Supplier Quotation Item": invoice_item_fields,
}
create_custom_fields(custom_fields)
create_custom_fields(custom_fields, ignore_validate=True)
def add_print_formats():

View File

@@ -152,7 +152,9 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend(
}
}
this.frm.add_custom_button(__('Pick List'), () => this.create_pick_list(), __('Create'));
if (flt(doc.per_picked, 6) < 100 && flt(doc.per_delivered, 6) < 100) {
this.frm.add_custom_button(__('Pick List'), () => this.create_pick_list(), __('Create'));
}
const order_is_a_sale = ["Sales", "Shopping Cart"].indexOf(doc.order_type) !== -1;
const order_is_maintenance = ["Maintenance"].indexOf(doc.order_type) !== -1;

View File

@@ -25,6 +25,10 @@
"po_no",
"po_date",
"tax_id",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
"contact_info",
"customer_address",
"address_display",
@@ -113,7 +117,6 @@
"is_internal_customer",
"represents_company",
"inter_company_order_reference",
"project",
"party_account_currency",
"column_break_77",
"source",
@@ -1520,14 +1523,31 @@
"fieldname": "per_picked",
"fieldtype": "Percent",
"label": "% Picked",
"no_copy": 1,
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
}
],
"icon": "fa fa-file-text",
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2022-03-15 21:38:31.437586",
"modified": "2022-04-26 14:38:18.350207",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",
@@ -1606,4 +1626,4 @@
"title_field": "customer_name",
"track_changes": 1,
"track_seen": 1
}
}

View File

@@ -455,6 +455,16 @@ class SalesOrder(SellingController):
if tot_qty != 0:
self.db_set("per_delivered", flt(delivered_qty / tot_qty) * 100, update_modified=False)
def update_picking_status(self):
total_picked_qty = 0.0
total_qty = 0.0
for so_item in self.items:
total_picked_qty += flt(so_item.picked_qty)
total_qty += flt(so_item.stock_qty)
per_picked = total_picked_qty / total_qty * 100
self.db_set("per_picked", flt(per_picked), update_modified=False)
def set_indicator(self):
"""Set indicator for portal"""
if self.per_billed < 100 and self.per_delivered < 100:
@@ -1302,9 +1312,30 @@ def make_inter_company_purchase_order(source_name, target_doc=None):
@frappe.whitelist()
def create_pick_list(source_name, target_doc=None):
def update_item_quantity(source, target, source_parent):
target.qty = flt(source.qty) - flt(source.delivered_qty)
target.stock_qty = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.conversion_factor)
from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle
def update_item_quantity(source, target, source_parent) -> None:
picked_qty = flt(source.picked_qty) / (flt(source.conversion_factor) or 1)
qty_to_be_picked = flt(source.qty) - max(picked_qty, flt(source.delivered_qty))
target.qty = qty_to_be_picked
target.stock_qty = qty_to_be_picked * flt(source.conversion_factor)
def update_packed_item_qty(source, target, source_parent) -> None:
qty = flt(source.qty)
for item in source_parent.items:
if source.parent_detail_docname == item.name:
picked_qty = flt(item.picked_qty) / (flt(item.conversion_factor) or 1)
pending_percent = (item.qty - max(picked_qty, item.delivered_qty)) / item.qty
target.qty = target.stock_qty = qty * pending_percent
return
def should_pick_order_item(item) -> bool:
return (
abs(item.delivered_qty) < abs(item.qty)
and item.delivered_by_supplier != 1
and not is_product_bundle(item.item_code)
)
doc = get_mapped_doc(
"Sales Order",
@@ -1315,8 +1346,17 @@ def create_pick_list(source_name, target_doc=None):
"doctype": "Pick List Item",
"field_map": {"parent": "sales_order", "name": "sales_order_item"},
"postprocess": update_item_quantity,
"condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty)
and doc.delivered_by_supplier != 1,
"condition": should_pick_order_item,
},
"Packed Item": {
"doctype": "Pick List Item",
"field_map": {
"parent": "sales_order",
"name": "sales_order_item",
"parent_detail_docname": "product_bundle_item",
},
"field_no_map": ["picked_qty"],
"postprocess": update_packed_item_qty,
},
},
target_doc,

View File

@@ -801,13 +801,15 @@
{
"fieldname": "picked_qty",
"fieldtype": "Float",
"label": "Picked Qty"
"label": "Picked Qty (in Stock UOM)",
"no_copy": 1,
"read_only": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
"modified": "2022-03-15 20:17:33.984799",
"modified": "2022-04-27 03:15:34.366563",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Item",

View File

@@ -343,9 +343,9 @@ erpnext.PointOfSale.Controller = class {
toggle_other_sections: (show) => {
if (show) {
this.item_details.$component.is(':visible') ? this.item_details.$component.css('display', 'none') : '';
this.item_selector.$component.css('display', 'none');
this.item_selector.toggle_component(false);
} else {
this.item_selector.$component.css('display', 'flex');
this.item_selector.toggle_component(true);
}
},

View File

@@ -130,10 +130,10 @@ erpnext.PointOfSale.ItemCart = class {
},
cols: 5,
keys: [
[ 1, 2, 3, __('Quantity') ],
[ 4, 5, 6, __('Discount') ],
[ 7, 8, 9, __('Rate') ],
[ '.', 0, __('Delete'), __('Remove') ]
[ 1, 2, 3, 'Quantity' ],
[ 4, 5, 6, 'Discount' ],
[ 7, 8, 9, 'Rate' ],
[ '.', 0, 'Delete', 'Remove' ]
],
css_classes: [
[ '', '', '', 'col-span-2' ],

View File

@@ -179,6 +179,25 @@ erpnext.PointOfSale.ItemSelector = class {
});
this.search_field.toggle_label(false);
this.item_group_field.toggle_label(false);
this.attach_clear_btn();
}
attach_clear_btn() {
this.search_field.$wrapper.find('.control-input').append(
`<span class="link-btn" style="top: 2px;">
<a class="btn-open no-decoration" title="${__("Clear")}">
${frappe.utils.icon('close', 'sm')}
</a>
</span>`
);
this.$clear_search_btn = this.search_field.$wrapper.find('.link-btn');
this.$clear_search_btn.on('click', 'a', () => {
this.set_search_value('');
this.search_field.set_focus();
});
}
set_search_value(value) {
@@ -252,6 +271,16 @@ erpnext.PointOfSale.ItemSelector = class {
const search_term = e.target.value;
this.filter_items({ search_term });
}, 300);
this.$clear_search_btn.toggle(
Boolean(this.search_field.$input.val())
);
});
this.search_field.$input.on('focus', () => {
this.$clear_search_btn.toggle(
Boolean(this.search_field.$input.val())
);
});
}
@@ -284,7 +313,7 @@ erpnext.PointOfSale.ItemSelector = class {
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");
this.set_search_value('');
} else if (this.items.length == 0 && this.barcode_scanned) {
// only show alert of barcode is scanned and enter is pressed
frappe.show_alert({
@@ -293,7 +322,7 @@ erpnext.PointOfSale.ItemSelector = class {
});
frappe.utils.play_sound("error");
this.barcode_scanned = false;
$(this.search_field.$input[0]).val("").trigger("input");
this.set_search_value('');
}
});
}
@@ -350,6 +379,7 @@ erpnext.PointOfSale.ItemSelector = class {
}
toggle_component(show) {
show ? this.$component.css('display', 'flex') : this.$component.css('display', 'none');
this.set_search_value('');
this.$component.css('display', show ? 'flex': 'none');
}
};

View File

@@ -25,7 +25,7 @@ erpnext.PointOfSale.NumberPad = class {
const fieldname = fieldnames && fieldnames[number] ?
fieldnames[number] : typeof number === 'string' ? frappe.scrub(number) : number;
return a2 + `<div class="numpad-btn ${class_to_append}" data-button-value="${fieldname}">${number}</div>`;
return a2 + `<div class="numpad-btn ${class_to_append}" data-button-value="${fieldname}">${__(number)}</div>`;
}, '');
}, '');
}

View File

@@ -23,6 +23,10 @@
"is_return",
"issue_credit_note",
"return_against",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
"customer_po_details",
"po_no",
"column_break_17",
@@ -115,7 +119,6 @@
"driver_name",
"lr_date",
"more_info",
"project",
"campaign",
"source",
"column_break5",
@@ -1309,13 +1312,29 @@
"fieldtype": "Currency",
"label": "Amount Eligible for Commission",
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
}
],
"icon": "fa fa-truck",
"idx": 146,
"is_submittable": 1,
"links": [],
"modified": "2022-03-10 14:29:13.428984",
"modified": "2022-04-26 14:48:08.781837",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note",
@@ -1380,6 +1399,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"timeline_field": "customer",
"title_field": "title",
"track_changes": 1,

View File

@@ -78,56 +78,6 @@ class TestDeliveryNote(FrappeTestCase):
self.assertFalse(get_gl_entries("Delivery Note", dn.name))
# def test_delivery_note_gl_entry(self):
# company = frappe.db.get_value('Warehouse', 'Stores - TCP1', 'company')
# set_valuation_method("_Test Item", "FIFO")
# make_stock_entry(target="Stores - TCP1", qty=5, basic_rate=100)
# stock_in_hand_account = get_inventory_account('_Test Company with perpetual inventory')
# prev_bal = get_balance_on(stock_in_hand_account)
# dn = create_delivery_note(company='_Test Company with perpetual inventory', warehouse='Stores - TCP1', cost_center = 'Main - TCP1', expense_account = "Cost of Goods Sold - TCP1")
# gl_entries = get_gl_entries("Delivery Note", dn.name)
# self.assertTrue(gl_entries)
# stock_value_difference = abs(frappe.db.get_value("Stock Ledger Entry",
# {"voucher_type": "Delivery Note", "voucher_no": dn.name}, "stock_value_difference"))
# expected_values = {
# stock_in_hand_account: [0.0, stock_value_difference],
# "Cost of Goods Sold - TCP1": [stock_value_difference, 0.0]
# }
# for i, gle in enumerate(gl_entries):
# self.assertEqual([gle.debit, gle.credit], expected_values.get(gle.account))
# # check stock in hand balance
# bal = get_balance_on(stock_in_hand_account)
# self.assertEqual(bal, prev_bal - stock_value_difference)
# # back dated incoming entry
# make_stock_entry(posting_date=add_days(nowdate(), -2), target="Stores - TCP1",
# qty=5, basic_rate=100)
# gl_entries = get_gl_entries("Delivery Note", dn.name)
# self.assertTrue(gl_entries)
# stock_value_difference = abs(frappe.db.get_value("Stock Ledger Entry",
# {"voucher_type": "Delivery Note", "voucher_no": dn.name}, "stock_value_difference"))
# expected_values = {
# stock_in_hand_account: [0.0, stock_value_difference],
# "Cost of Goods Sold - TCP1": [stock_value_difference, 0.0]
# }
# for i, gle in enumerate(gl_entries):
# self.assertEqual([gle.debit, gle.credit], expected_values.get(gle.account))
# dn.cancel()
# self.assertTrue(get_gl_entries("Delivery Note", dn.name))
# set_perpetual_inventory(0, company)
def test_delivery_note_gl_entry_packing_item(self):
company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")
@@ -854,8 +804,6 @@ class TestDeliveryNote(FrappeTestCase):
company="_Test Company with perpetual inventory",
)
company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")
set_valuation_method("_Test Item", "FIFO")
make_stock_entry(target="Stores - TCP1", qty=5, basic_rate=100)
@@ -881,8 +829,6 @@ class TestDeliveryNote(FrappeTestCase):
def test_delivery_note_cost_center_with_balance_sheet_account(self):
cost_center = "Main - TCP1"
company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")
set_valuation_method("_Test Item", "FIFO")
make_stock_entry(target="Stores - TCP1", qty=5, basic_rate=100)

View File

@@ -918,8 +918,9 @@
},
{
"fieldname": "default_item_manufacturer",
"fieldtype": "Data",
"fieldtype": "Link",
"label": "Default Item Manufacturer",
"options": "Manufacturer",
"read_only": 1
},
{
@@ -954,7 +955,7 @@
"image_field": "image",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-12-14 04:13:16.857534",
"modified": "2022-04-28 04:52:10.272256",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",

View File

@@ -29,6 +29,7 @@
"ordered_qty",
"column_break_16",
"incoming_rate",
"picked_qty",
"page_break",
"prevdoc_doctype",
"parent_detail_docname"
@@ -234,13 +235,20 @@
"label": "Ordered Qty",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "picked_qty",
"fieldtype": "Float",
"label": "Picked Qty",
"no_copy": 1,
"read_only": 1
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2022-03-10 15:42:00.265915",
"modified": "2022-04-27 05:23:08.683245",
"modified_by": "Administrator",
"module": "Stock",
"name": "Packed Item",

View File

@@ -32,7 +32,7 @@ def make_packing_list(doc):
reset = reset_packing_list(doc)
for item_row in doc.get("items"):
if frappe.db.exists("Product Bundle", {"new_item_code": item_row.item_code}):
if is_product_bundle(item_row.item_code):
for bundle_item in get_product_bundle_items(item_row.item_code):
pi_row = add_packed_item_row(
doc=doc,
@@ -54,6 +54,10 @@ def make_packing_list(doc):
set_product_bundle_rate_amount(doc, parent_items_price) # set price in bundle item
def is_product_bundle(item_code: str) -> bool:
return bool(frappe.db.exists("Product Bundle", {"new_item_code": item_code}))
def get_indexed_packed_items_table(doc):
"""
Create dict from stale packed items table like:

View File

@@ -1,10 +1,12 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from typing import List, Optional, Tuple
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_to_date, nowdate
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
@@ -12,6 +14,33 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
def create_product_bundle(
quantities: Optional[List[int]] = None, warehouse: Optional[str] = None
) -> Tuple[str, List[str]]:
"""Get a new product_bundle for use in tests.
Create 10x required stock if warehouse is specified.
"""
if not quantities:
quantities = [2, 2]
bundle = make_item(properties={"is_stock_item": 0}).name
bundle_doc = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": bundle})
components = []
for qty in quantities:
compoenent = make_item().name
components.append(compoenent)
bundle_doc.append("items", {"item_code": compoenent, "qty": qty})
if warehouse:
make_stock_entry(item=compoenent, to_warehouse=warehouse, qty=10 * qty, rate=100)
bundle_doc.insert()
return bundle, components
class TestPackedItem(FrappeTestCase):
"Test impact on Packed Items table in various scenarios."
@@ -19,24 +48,11 @@ class TestPackedItem(FrappeTestCase):
def setUpClass(cls) -> None:
super().setUpClass()
cls.warehouse = "_Test Warehouse - _TC"
cls.bundle = "_Test Product Bundle X"
cls.bundle_items = ["_Test Bundle Item 1", "_Test Bundle Item 2"]
cls.bundle2 = "_Test Product Bundle Y"
cls.bundle2_items = ["_Test Bundle Item 3", "_Test Bundle Item 4"]
cls.bundle, cls.bundle_items = create_product_bundle(warehouse=cls.warehouse)
cls.bundle2, cls.bundle2_items = create_product_bundle(warehouse=cls.warehouse)
make_item(cls.bundle, {"is_stock_item": 0})
make_item(cls.bundle2, {"is_stock_item": 0})
for item in cls.bundle_items + cls.bundle2_items:
make_item(item, {"is_stock_item": 1})
make_item("_Test Normal Stock Item", {"is_stock_item": 1})
make_product_bundle(cls.bundle, cls.bundle_items, qty=2)
make_product_bundle(cls.bundle2, cls.bundle2_items, qty=2)
for item in cls.bundle_items + cls.bundle2_items:
make_stock_entry(item_code=item, to_warehouse=cls.warehouse, qty=100, rate=100)
cls.normal_item = make_item().name
def test_adding_bundle_item(self):
"Test impact on packed items if bundle item row is added."
@@ -58,7 +74,7 @@ class TestPackedItem(FrappeTestCase):
self.assertEqual(so.packed_items[1].qty, 4)
# change item code to non bundle item
so.items[0].item_code = "_Test Normal Stock Item"
so.items[0].item_code = self.normal_item
so.save()
self.assertEqual(len(so.packed_items), 0)

View File

@@ -114,6 +114,7 @@
"set_only_once": 1
},
{
"collapsible": 1,
"fieldname": "print_settings_section",
"fieldtype": "Section Break",
"label": "Print Settings"
@@ -129,7 +130,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2021-10-05 15:08:40.369957",
"modified": "2022-04-21 07:56:40.646473",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List",
@@ -199,5 +200,6 @@
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -4,13 +4,14 @@
import json
from collections import OrderedDict, defaultdict
from itertools import groupby
from operator import itemgetter
from typing import Dict, List, Set
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.model.mapper import map_child_doc
from frappe.utils import cint, floor, flt, today
from frappe.utils.nestedset import get_descendants_of
from erpnext.selling.doctype.sales_order.sales_order import (
make_delivery_note as create_delivery_note_from_sales_order,
@@ -36,6 +37,7 @@ class PickList(Document):
frappe.throw("Row " + str(location.idx) + " has been picked already!")
def before_submit(self):
update_sales_orders = set()
for item in self.locations:
# if the user has not entered any picked qty, set it to stock_qty, before submit
if item.picked_qty == 0:
@@ -43,7 +45,8 @@ class PickList(Document):
if item.sales_order_item:
# update the picked_qty in SO Item
self.update_so(item.sales_order_item, item.picked_qty, item.item_code)
self.update_sales_order_item(item, item.picked_qty, item.item_code)
update_sales_orders.add(item.sales_order)
if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"):
continue
@@ -63,18 +66,29 @@ class PickList(Document):
title=_("Quantity Mismatch"),
)
self.update_bundle_picked_qty()
self.update_sales_order_picking_status(update_sales_orders)
def before_cancel(self):
# update picked_qty in SO Item on cancel of PL
"""Deduct picked qty on cancelling pick list"""
updated_sales_orders = set()
for item in self.get("locations"):
if item.sales_order_item:
self.update_so(item.sales_order_item, -1 * item.picked_qty, item.item_code)
self.update_sales_order_item(item, -1 * item.picked_qty, item.item_code)
updated_sales_orders.add(item.sales_order)
self.update_bundle_picked_qty()
self.update_sales_order_picking_status(updated_sales_orders)
def update_sales_order_item(self, item, picked_qty, item_code):
item_table = "Sales Order Item" if not item.product_bundle_item else "Packed Item"
stock_qty_field = "stock_qty" if not item.product_bundle_item else "qty"
def update_so(self, so_item, picked_qty, item_code):
so_doc = frappe.get_doc(
"Sales Order", frappe.db.get_value("Sales Order Item", so_item, "parent")
)
already_picked, actual_qty = frappe.db.get_value(
"Sales Order Item", so_item, ["picked_qty", "qty"]
item_table,
item.sales_order_item,
["picked_qty", stock_qty_field],
)
if self.docstatus == 1:
@@ -82,23 +96,18 @@ class PickList(Document):
100 + flt(frappe.db.get_single_value("Stock Settings", "over_delivery_receipt_allowance"))
):
frappe.throw(
"You are picking more than required quantity for "
+ item_code
+ ". Check if there is any other pick list created for "
+ so_doc.name
_(
"You are picking more than required quantity for {}. Check if there is any other pick list created for {}"
).format(item_code, item.sales_order)
)
frappe.db.set_value("Sales Order Item", so_item, "picked_qty", already_picked + picked_qty)
frappe.db.set_value(item_table, item.sales_order_item, "picked_qty", already_picked + picked_qty)
total_picked_qty = 0
total_so_qty = 0
for item in so_doc.get("items"):
total_picked_qty += flt(item.picked_qty)
total_so_qty += flt(item.stock_qty)
total_picked_qty = total_picked_qty + picked_qty
per_picked = total_picked_qty / total_so_qty * 100
so_doc.db_set("per_picked", flt(per_picked), update_modified=False)
@staticmethod
def update_sales_order_picking_status(sales_orders: Set[str]) -> None:
for sales_order in sales_orders:
if sales_order:
frappe.get_doc("Sales Order", sales_order).update_picking_status()
@frappe.whitelist()
def set_item_locations(self, save=False):
@@ -108,7 +117,7 @@ class PickList(Document):
from_warehouses = None
if self.parent_warehouse:
from_warehouses = frappe.db.get_descendants("Warehouse", self.parent_warehouse)
from_warehouses = get_descendants_of("Warehouse", self.parent_warehouse)
# Create replica before resetting, to handle empty table on update after submit.
locations_replica = self.get("locations")
@@ -189,8 +198,7 @@ class PickList(Document):
frappe.throw(_("Qty of Finished Goods Item should be greater than 0."))
def before_print(self, settings=None):
if self.get("group_same_items"):
self.group_similar_items()
self.group_similar_items()
def group_similar_items(self):
group_item_qty = defaultdict(float)
@@ -216,6 +224,58 @@ class PickList(Document):
for idx, item in enumerate(self.locations, start=1):
item.idx = idx
def update_bundle_picked_qty(self):
product_bundles = self._get_product_bundles()
product_bundle_qty_map = self._get_product_bundle_qty_map(product_bundles.values())
for so_row, item_code in product_bundles.items():
picked_qty = self._compute_picked_qty_for_bundle(so_row, product_bundle_qty_map[item_code])
item_table = "Sales Order Item"
already_picked = frappe.db.get_value(item_table, so_row, "picked_qty")
frappe.db.set_value(
item_table,
so_row,
"picked_qty",
already_picked + (picked_qty * (1 if self.docstatus == 1 else -1)),
)
def _get_product_bundles(self) -> Dict[str, str]:
# Dict[so_item_row: item_code]
product_bundles = {}
for item in self.locations:
if not item.product_bundle_item:
continue
product_bundles[item.product_bundle_item] = frappe.db.get_value(
"Sales Order Item",
item.product_bundle_item,
"item_code",
)
return product_bundles
def _get_product_bundle_qty_map(self, bundles: List[str]) -> Dict[str, Dict[str, float]]:
# bundle_item_code: Dict[component, qty]
product_bundle_qty_map = {}
for bundle_item_code in bundles:
bundle = frappe.get_last_doc("Product Bundle", {"new_item_code": bundle_item_code})
product_bundle_qty_map[bundle_item_code] = {item.item_code: item.qty for item in bundle.items}
return product_bundle_qty_map
def _compute_picked_qty_for_bundle(self, bundle_row, bundle_items) -> int:
"""Compute how many full bundles can be created from picked items."""
precision = frappe.get_precision("Stock Ledger Entry", "qty_after_transaction")
possible_bundles = []
for item in self.locations:
if item.product_bundle_item != bundle_row:
continue
qty_in_bundle = bundle_items.get(item.item_code)
if qty_in_bundle:
possible_bundles.append(item.picked_qty / qty_in_bundle)
else:
possible_bundles.append(0)
return int(flt(min(possible_bundles), precision or 6))
def validate_item_locations(pick_list):
if not pick_list.locations:
@@ -449,22 +509,18 @@ def create_delivery_note(source_name, target_doc=None):
for location in pick_list.locations:
if location.sales_order:
sales_orders.append(
[frappe.db.get_value("Sales Order", location.sales_order, "customer"), location.sales_order]
frappe.db.get_value(
"Sales Order", location.sales_order, ["customer", "name as sales_order"], as_dict=True
)
)
# Group sales orders by customer
for key, keydata in groupby(sales_orders, key=itemgetter(0)):
sales_dict[key] = set([d[1] for d in keydata])
for customer, rows in groupby(sales_orders, key=lambda so: so["customer"]):
sales_dict[customer] = {row.sales_order for row in rows}
if sales_dict:
delivery_note = create_dn_with_so(sales_dict, pick_list)
is_item_wo_so = 0
for location in pick_list.locations:
if not location.sales_order:
is_item_wo_so = 1
break
if is_item_wo_so == 1:
# Create a DN for items without sales orders as well
if not all(item.sales_order for item in pick_list.locations):
delivery_note = create_dn_wo_so(pick_list)
frappe.msgprint(_("Delivery Note(s) created for the Pick List"))
@@ -491,27 +547,30 @@ def create_dn_wo_so(pick_list):
def create_dn_with_so(sales_dict, pick_list):
delivery_note = None
item_table_mapper = {
"doctype": "Delivery Note Item",
"field_map": {
"rate": "rate",
"name": "so_detail",
"parent": "against_sales_order",
},
"condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty)
and doc.delivered_by_supplier != 1,
}
for customer in sales_dict:
for so in sales_dict[customer]:
delivery_note = None
delivery_note = create_delivery_note_from_sales_order(so, delivery_note, skip_item_mapping=True)
item_table_mapper = {
"doctype": "Delivery Note Item",
"field_map": {
"rate": "rate",
"name": "so_detail",
"parent": "against_sales_order",
},
"condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty)
and doc.delivered_by_supplier != 1,
}
break
if delivery_note:
# map all items of all sales orders of that customer
for so in sales_dict[customer]:
map_pl_locations(pick_list, item_table_mapper, delivery_note, so)
delivery_note.insert(ignore_mandatory=True)
delivery_note.flags.ignore_mandatory = True
delivery_note.insert()
update_packed_item_details(pick_list, delivery_note)
delivery_note.save()
return delivery_note
@@ -519,28 +578,28 @@ def create_dn_with_so(sales_dict, pick_list):
def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None):
for location in pick_list.locations:
if location.sales_order == sales_order:
if location.sales_order_item:
sales_order_item = frappe.get_cached_doc(
"Sales Order Item", {"name": location.sales_order_item}
)
else:
sales_order_item = None
if location.sales_order != sales_order or location.product_bundle_item:
continue
source_doc, table_mapper = (
[sales_order_item, item_mapper] if sales_order_item else [location, item_mapper]
)
if location.sales_order_item:
sales_order_item = frappe.get_doc("Sales Order Item", location.sales_order_item)
else:
sales_order_item = None
dn_item = map_child_doc(source_doc, delivery_note, table_mapper)
source_doc = sales_order_item or location
if dn_item:
dn_item.pick_list_item = location.name
dn_item.warehouse = location.warehouse
dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1)
dn_item.batch_no = location.batch_no
dn_item.serial_no = location.serial_no
dn_item = map_child_doc(source_doc, delivery_note, item_mapper)
update_delivery_note_item(source_doc, dn_item, delivery_note)
if dn_item:
dn_item.pick_list_item = location.name
dn_item.warehouse = location.warehouse
dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1)
dn_item.batch_no = location.batch_no
dn_item.serial_no = location.serial_no
update_delivery_note_item(source_doc, dn_item, delivery_note)
add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper)
set_delivery_note_missing_values(delivery_note)
delivery_note.pick_list = pick_list.name
@@ -548,6 +607,50 @@ def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None):
delivery_note.customer = frappe.get_value("Sales Order", sales_order, "customer")
def add_product_bundles_to_delivery_note(
pick_list: "PickList", delivery_note, item_mapper
) -> None:
"""Add product bundles found in pick list to delivery note.
When mapping pick list items, the bundle item itself isn't part of the
locations. Dynamically fetch and add parent bundle item into DN."""
product_bundles = pick_list._get_product_bundles()
product_bundle_qty_map = pick_list._get_product_bundle_qty_map(product_bundles.values())
for so_row, item_code in product_bundles.items():
sales_order_item = frappe.get_doc("Sales Order Item", so_row)
dn_bundle_item = map_child_doc(sales_order_item, delivery_note, item_mapper)
dn_bundle_item.qty = pick_list._compute_picked_qty_for_bundle(
so_row, product_bundle_qty_map[item_code]
)
update_delivery_note_item(sales_order_item, dn_bundle_item, delivery_note)
def update_packed_item_details(pick_list: "PickList", delivery_note) -> None:
"""Update stock details on packed items table of delivery note."""
def _find_so_row(packed_item):
for item in delivery_note.items:
if packed_item.parent_detail_docname == item.name:
return item.so_detail
def _find_pick_list_location(bundle_row, packed_item):
if not bundle_row:
return
for loc in pick_list.locations:
if loc.product_bundle_item == bundle_row and loc.item_code == packed_item.item_code:
return loc
for packed_item in delivery_note.packed_items:
so_row = _find_so_row(packed_item)
location = _find_pick_list_location(so_row, packed_item)
if not location:
continue
packed_item.warehouse = location.warehouse
packed_item.batch_no = location.batch_no
packed_item.serial_no = location.serial_no
@frappe.whitelist()
def create_stock_entry(pick_list):
pick_list = frappe.get_doc(json.loads(pick_list))

View File

@@ -3,18 +3,21 @@
import frappe
from frappe import _dict
test_dependencies = ["Item", "Sales Invoice", "Stock Entry", "Batch"]
from frappe.tests.utils import FrappeTestCase
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.selling.doctype.sales_order.sales_order import create_pick_list
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import create_item, make_item
from erpnext.stock.doctype.packed_item.test_packed_item import create_product_bundle
from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
EmptyStockReconciliationItemsError,
)
test_dependencies = ["Item", "Sales Invoice", "Stock Entry", "Batch"]
class TestPickList(FrappeTestCase):
def test_pick_list_picks_warehouse_for_each_item(self):
@@ -566,14 +569,79 @@ class TestPickList(FrappeTestCase):
if dn_item.item_code == "_Test Item 2":
self.assertEqual(dn_item.qty, 2)
# def test_pick_list_skips_items_in_expired_batch(self):
# pass
def test_picklist_with_multi_uom(self):
warehouse = "_Test Warehouse - _TC"
item = make_item(properties={"uoms": [dict(uom="Box", conversion_factor=24)]}).name
make_stock_entry(item=item, to_warehouse=warehouse, qty=1000)
# def test_pick_list_from_sales_order(self):
# pass
so = make_sales_order(item_code=item, qty=10, rate=42, uom="Box")
pl = create_pick_list(so.name)
# pick half the qty
for loc in pl.locations:
loc.picked_qty = loc.stock_qty / 2
pl.save()
pl.submit()
# def test_pick_list_from_work_order(self):
# pass
so.reload()
self.assertEqual(so.per_picked, 50)
# def test_pick_list_from_material_request(self):
# pass
def test_picklist_with_bundles(self):
warehouse = "_Test Warehouse - _TC"
quantities = [5, 2]
bundle, components = create_product_bundle(quantities, warehouse=warehouse)
bundle_items = dict(zip(components, quantities))
so = make_sales_order(item_code=bundle, qty=3, rate=42)
pl = create_pick_list(so.name)
pl.save()
self.assertEqual(len(pl.locations), 2)
for item in pl.locations:
self.assertEqual(item.stock_qty, bundle_items[item.item_code] * 3)
# check picking status on sales order
pl.submit()
so.reload()
self.assertEqual(so.per_picked, 100)
# deliver
dn = create_delivery_note(pl.name).submit()
self.assertEqual(dn.items[0].rate, 42)
self.assertEqual(dn.packed_items[0].warehouse, warehouse)
so.reload()
self.assertEqual(so.per_delivered, 100)
def test_picklist_with_partial_bundles(self):
# from test_records.json
warehouse = "_Test Warehouse - _TC"
quantities = [5, 2]
bundle, components = create_product_bundle(quantities, warehouse=warehouse)
so = make_sales_order(item_code=bundle, qty=4, rate=42)
pl = create_pick_list(so.name)
for loc in pl.locations:
loc.picked_qty = loc.qty / 2
pl.save().submit()
so.reload()
self.assertEqual(so.per_picked, 50)
# deliver half qty
dn = create_delivery_note(pl.name).submit()
self.assertEqual(dn.items[0].rate, 42)
so.reload()
self.assertEqual(so.per_delivered, 50)
pl = create_pick_list(so.name)
pl.save().submit()
so.reload()
self.assertEqual(so.per_picked, 100)
# deliver remaining
dn = create_delivery_note(pl.name).submit()
self.assertEqual(dn.items[0].rate, 42)
so.reload()
self.assertEqual(so.per_delivered, 100)

View File

@@ -27,6 +27,7 @@
"column_break_15",
"sales_order",
"sales_order_item",
"product_bundle_item",
"material_request",
"material_request_item"
],
@@ -146,6 +147,7 @@
{
"fieldname": "sales_order_item",
"fieldtype": "Data",
"hidden": 1,
"label": "Sales Order Item",
"read_only": 1
},
@@ -177,11 +179,19 @@
"fieldtype": "Data",
"label": "Item Group",
"read_only": 1
},
{
"description": "product bundle item row's name in sales order. Also indicates that picked item is to be used for a product bundle",
"fieldname": "product_bundle_item",
"fieldtype": "Data",
"hidden": 1,
"label": "Product Bundle Item",
"read_only": 1
}
],
"istable": 1,
"links": [],
"modified": "2021-09-28 12:02:16.923056",
"modified": "2022-04-22 05:27:38.497997",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List Item",
@@ -190,5 +200,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -24,6 +24,10 @@
"apply_putaway_rule",
"is_return",
"return_against",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
"section_addresses",
"supplier_address",
"contact_person",
@@ -107,7 +111,6 @@
"bill_no",
"bill_date",
"more_info",
"project",
"status",
"amended_from",
"range",
@@ -1144,13 +1147,29 @@
"label": "Represents Company",
"options": "Company",
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
}
],
"icon": "fa fa-truck",
"idx": 261,
"is_submittable": 1,
"links": [],
"modified": "2022-04-10 22:50:37.761362",
"modified": "2022-04-26 13:41:32.625197",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt",

View File

@@ -46,9 +46,9 @@
"items",
"get_stock_and_rate",
"section_break_19",
"total_incoming_value",
"column_break_22",
"total_outgoing_value",
"column_break_22",
"total_incoming_value",
"value_difference",
"additional_costs_section",
"additional_costs",
@@ -374,7 +374,7 @@
{
"fieldname": "total_incoming_value",
"fieldtype": "Currency",
"label": "Total Incoming Value",
"label": "Total Incoming Value (Receipt)",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
@@ -386,7 +386,7 @@
{
"fieldname": "total_outgoing_value",
"fieldtype": "Currency",
"label": "Total Outgoing Value",
"label": "Total Outgoing Value (Consumption)",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
@@ -394,7 +394,7 @@
{
"fieldname": "value_difference",
"fieldtype": "Currency",
"label": "Total Value Difference (Out - In)",
"label": "Total Value Difference (Incoming - Outgoing)",
"options": "Company:company:default_currency",
"print_hide_if_no_value": 1,
"read_only": 1
@@ -619,7 +619,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2022-02-07 12:55:14.614077",
"modified": "2022-05-02 05:21:39.060501",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry",

View File

@@ -557,7 +557,7 @@ class StockEntry(StockController):
)
def set_actual_qty(self):
allow_negative_stock = cint(frappe.db.get_value("Stock Settings", None, "allow_negative_stock"))
allow_negative_stock = cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
for d in self.get("items"):
previous_sle = get_previous_sle(

View File

@@ -30,7 +30,6 @@ class TestStockReconciliation(FrappeTestCase):
frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1)
def tearDown(self):
frappe.flags.dont_execute_stock_reposts = None
frappe.local.future_sle = {}
def test_reco_for_fifo(self):
@@ -40,7 +39,9 @@ class TestStockReconciliation(FrappeTestCase):
self._test_reco_sle_gle("Moving Average")
def _test_reco_sle_gle(self, valuation_method):
se1, se2, se3 = insert_existing_sle(warehouse="Stores - TCP1")
item_code = make_item(properties={"valuation_method": valuation_method}).name
se1, se2, se3 = insert_existing_sle(warehouse="Stores - TCP1", item_code=item_code)
company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")
# [[qty, valuation_rate, posting_date,
# posting_time, expected_stock_value, bin_qty, bin_valuation]]
@@ -54,11 +55,9 @@ class TestStockReconciliation(FrappeTestCase):
]
for d in input_data:
set_valuation_method("_Test Item", valuation_method)
last_sle = get_previous_sle(
{
"item_code": "_Test Item",
"item_code": item_code,
"warehouse": "Stores - TCP1",
"posting_date": d[2],
"posting_time": d[3],
@@ -67,6 +66,7 @@ class TestStockReconciliation(FrappeTestCase):
# submit stock reconciliation
stock_reco = create_stock_reconciliation(
item_code=item_code,
qty=d[0],
rate=d[1],
posting_date=d[2],
@@ -480,9 +480,11 @@ class TestStockReconciliation(FrappeTestCase):
"""
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
frappe.db.rollback()
# repost will make this test useless, qty should update in realtime without reposts
frappe.flags.dont_execute_stock_reposts = True
frappe.db.rollback()
self.addCleanup(frappe.flags.pop, "dont_execute_stock_reposts")
item_code = make_item().name
warehouse = "_Test Warehouse - _TC"
@@ -593,26 +595,26 @@ def create_batch_item_with_batch(item_name, batch_id):
b.save()
def insert_existing_sle(warehouse):
def insert_existing_sle(warehouse, item_code="_Test Item"):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
se1 = make_stock_entry(
posting_date="2012-12-15",
posting_time="02:00",
item_code="_Test Item",
item_code=item_code,
target=warehouse,
qty=10,
basic_rate=700,
)
se2 = make_stock_entry(
posting_date="2012-12-25", posting_time="03:00", item_code="_Test Item", source=warehouse, qty=15
posting_date="2012-12-25", posting_time="03:00", item_code=item_code, source=warehouse, qty=15
)
se3 = make_stock_entry(
posting_date="2013-01-05",
posting_time="07:00",
item_code="_Test Item",
item_code=item_code,
target=warehouse,
qty=15,
basic_rate=1200,

View File

@@ -36,6 +36,9 @@ class Warehouse(NestedSet):
self.set_onload("account", account)
load_address_and_contact(self)
def validate(self):
self.warn_about_multiple_warehouse_account()
def on_update(self):
self.update_nsm_model()
@@ -70,6 +73,53 @@ class Warehouse(NestedSet):
self.update_nsm_model()
self.unlink_from_items()
def warn_about_multiple_warehouse_account(self):
"If Warehouse value is split across multiple accounts, warn."
def get_accounts_where_value_is_booked(name):
sle = frappe.qb.DocType("Stock Ledger Entry")
gle = frappe.qb.DocType("GL Entry")
ac = frappe.qb.DocType("Account")
return (
frappe.qb.from_(sle)
.join(gle)
.on(sle.voucher_no == gle.voucher_no)
.join(ac)
.on(ac.name == gle.account)
.select(gle.account)
.distinct()
.where((sle.warehouse == name) & (ac.account_type == "Stock"))
.orderby(sle.creation)
.run(as_dict=True)
)
if self.is_new():
return
old_wh_account = frappe.db.get_value("Warehouse", self.name, "account")
# WH account is being changed or set get all accounts against which wh value is booked
if self.account != old_wh_account:
accounts = get_accounts_where_value_is_booked(self.name)
accounts = [d.account for d in accounts]
if not accounts or (len(accounts) == 1 and self.account in accounts):
# if same singular account has stock value booked ignore
return
warning = _("Warehouse's Stock Value has already been booked in the following accounts:")
account_str = "<br>" + ", ".join(frappe.bold(ac) for ac in accounts)
reason = "<br><br>" + _(
"Booking stock value across multiple accounts will make it harder to track stock and account value."
)
frappe.msgprint(
warning + account_str + reason,
title=_("Multiple Warehouse Accounts"),
indicator="orange",
)
def check_if_sle_exists(self):
return frappe.db.exists("Stock Ledger Entry", {"warehouse": self.name})