Merge branch 'develop' into repack-entry-stock-ageing

This commit is contained in:
Marica 2022-02-18 18:54:23 +05:30 committed by GitHub
commit d1a283fd79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 860 additions and 148 deletions

View File

@ -40,10 +40,14 @@ if [ "$DB" == "postgres" ];then
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
fi
wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz
tar -xf /tmp/wkhtmltox.tar.xz -C /tmp
sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf
sudo chmod o+x /usr/local/bin/wkhtmltopdf
install_whktml() {
wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz
tar -xf /tmp/wkhtmltox.tar.xz -C /tmp
sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf
sudo chmod o+x /usr/local/bin/wkhtmltopdf
}
install_whktml &
cd ~/frappe-bench || exit
@ -57,5 +61,5 @@ bench get-app erpnext "${GITHUB_WORKSPACE}"
if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
bench start &> bench_run_logs.txt &
CI=Yes bench build --app frappe &
bench --site test_site reinstall --yes
bench build --app frappe

View File

@ -64,6 +64,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
"account_currency",
(r) => {
frm.currency = r.account_currency;
frm.trigger("render_chart");
}
);
}
@ -128,7 +129,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
}
},
render_chart(frm) {
render_chart: frappe.utils.debounce((frm) => {
frm.cards_manager = new erpnext.accounts.bank_reconciliation.NumberCardManager(
{
$reconciliation_tool_cards: frm.get_field(
@ -140,7 +141,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
currency: frm.currency,
}
);
},
}, 500),
render(frm) {
if (frm.doc.bank_account) {

View File

@ -1955,7 +1955,8 @@ def update_bin_on_delete(row, doctype):
qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse)
update_bin_qty(row.item_code, row.warehouse, qty_dict)
if row.warehouse:
update_bin_qty(row.item_code, row.warehouse, qty_dict)
def validate_and_delete_children(parent, data):
deleted_children = []

View File

@ -3,7 +3,7 @@
"allow_events_in_timeline": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"allow_rename": 1,
"autoname": "field:lost_reason",
"beta": 0,
"creation": "2018-12-28 14:48:51.044975",
@ -57,7 +57,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2018-12-28 14:49:43.336437",
"modified": "2022-02-16 10:49:43.336437",
"modified_by": "Administrator",
"module": "CRM",
"name": "Opportunity Lost Reason",
@ -150,4 +150,4 @@
"track_changes": 0,
"track_seen": 0,
"track_views": 0
}
}

View File

@ -66,26 +66,24 @@ class ItemVariantsCacheManager:
)
]
# join with Website Item
item_variants_data = frappe.get_all(
'Item Variant Attribute',
{'variant_of': parent_item_code},
['parent', 'attribute', 'attribute_value'],
order_by='name',
as_list=1
)
disabled_items = set(
[i.name for i in frappe.db.get_all('Item', {'disabled': 1})]
# Get Variants and tehir Attributes that are not disabled
iva = frappe.qb.DocType("Item Variant Attribute")
item = frappe.qb.DocType("Item")
query = (
frappe.qb.from_(iva)
.join(item).on(item.name == iva.parent)
.select(
iva.parent, iva.attribute, iva.attribute_value
).where(
(iva.variant_of == parent_item_code)
& (item.disabled == 0)
).orderby(iva.name)
)
item_variants_data = query.run()
attribute_value_item_map = frappe._dict()
item_attribute_value_map = frappe._dict()
# dont consider variants that are disabled
# pull all other variants
item_variants_data = [r for r in item_variants_data if r[0] not in disabled_items]
for row in item_variants_data:
item_code, attribute, attribute_value = row
# (attr, value) => [item1, item2]
@ -124,4 +122,7 @@ def build_cache(item_code):
def enqueue_build_cache(item_code):
if frappe.cache().hget('item_cache_build_in_progress', item_code):
return
frappe.enqueue(build_cache, item_code=item_code, queue='long')
frappe.enqueue(
"erpnext.e_commerce.variant_selector.item_variants_cache.build_cache",
item_code=item_code, queue='long'
)

View File

@ -104,6 +104,8 @@ class TestVariantSelector(ERPNextTestCase):
})
make_web_item_price(item_code="Test-Tshirt-Temp-S-R", price_list_rate=100)
frappe.local.shopping_cart_settings = None # clear cached settings values
next_values = get_next_attribute_and_values(
"Test-Tshirt-Temp",
selected_attributes={"Test Size": "Small", "Test Colour": "Red"}

View File

@ -13,7 +13,7 @@ from frappe.utils import call_hook_method, cint, flt, get_url
class GoCardlessSettings(Document):
supported_currencies = ["EUR", "DKK", "GBP", "SEK"]
supported_currencies = ["EUR", "DKK", "GBP", "SEK", "AUD", "NZD", "CAD", "USD"]
def validate(self):
self.initialize_client()
@ -80,7 +80,7 @@ class GoCardlessSettings(Document):
def validate_transaction_currency(self, currency):
if currency not in self.supported_currencies:
frappe.throw(_("Please select another payment method. Stripe does not support transactions in currency '{0}'").format(currency))
frappe.throw(_("Please select another payment method. Go Cardless does not support transactions in currency '{0}'").format(currency))
def get_payment_url(self, **kwargs):
return get_url("./integrations/gocardless_checkout?{0}".format(urlencode(kwargs)))

View File

@ -319,7 +319,7 @@ class ProductionPlan(Document):
if self.total_produced_qty > 0:
self.status = "In Process"
if self.check_have_work_orders_completed():
if self.all_items_completed():
self.status = "Completed"
if self.status != 'Completed':
@ -591,21 +591,32 @@ class ProductionPlan(Document):
self.append("sub_assembly_items", data)
def check_have_work_orders_completed(self):
wo_status = frappe.db.get_list(
def all_items_completed(self):
all_items_produced = all(flt(d.planned_qty) - flt(d.produced_qty) < 0.000001
for d in self.po_items)
if not all_items_produced:
return False
wo_status = frappe.get_all(
"Work Order",
filters={"production_plan": self.name},
filters={
"production_plan": self.name,
"status": ("not in", ["Closed", "Stopped"]),
"docstatus": ("<", 2),
},
fields="status",
pluck="status"
pluck="status",
)
return all(s == "Completed" for s in wo_status)
all_work_orders_completed = all(s == "Completed" for s in wo_status)
return all_work_orders_completed
@frappe.whitelist()
def download_raw_materials(doc, warehouses=None):
if isinstance(doc, str):
doc = frappe._dict(json.loads(doc))
item_list = [['Item Code', 'Description', 'Stock UOM', 'Warehouse', 'Required Qty as per BOM',
item_list = [['Item Code', 'Item Name', 'Description',
'Stock UOM', 'Warehouse', 'Required Qty as per BOM',
'Projected Qty', 'Available Qty In Hand', 'Ordered Qty', 'Planned Qty',
'Reserved Qty for Production', 'Safety Stock', 'Required Qty']]
@ -614,7 +625,8 @@ def download_raw_materials(doc, warehouses=None):
items = get_items_for_material_requests(doc, warehouses=warehouses, get_parent_warehouse_data=True)
for d in items:
item_list.append([d.get('item_code'), d.get('description'), d.get('stock_uom'), d.get('warehouse'),
item_list.append([d.get('item_code'), d.get('item_name'),
d.get('description'), d.get('stock_uom'), d.get('warehouse'),
d.get('required_bom_qty'), d.get('projected_qty'), d.get('actual_qty'), d.get('ordered_qty'),
d.get('planned_qty'), d.get('reserved_qty_for_production'), d.get('safety_stock'), d.get('quantity')])
@ -1044,4 +1056,4 @@ def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, indent=0):
def set_default_warehouses(row, default_warehouses):
for field in ['wip_warehouse', 'fg_warehouse']:
if not row.get(field):
row[field] = default_warehouses.get(field)
row[field] = default_warehouses.get(field)

View File

@ -409,9 +409,6 @@ class TestProductionPlan(ERPNextTestCase):
boms = {
"Assembly": {
"SubAssembly1": {"ChildPart1": {}, "ChildPart2": {},},
"SubAssembly2": {"ChildPart3": {}},
"SubAssembly3": {"SubSubAssy1": {"ChildPart4": {}}},
"ChildPart5": {},
"ChildPart6": {},
"SubAssembly4": {"SubSubAssy2": {"ChildPart7": {}}},
},
@ -591,6 +588,20 @@ class TestProductionPlan(ERPNextTestCase):
pln.reload()
self.assertEqual(pln.po_items[0].pending_qty, 1)
def test_qty_based_status(self):
pp = frappe.new_doc("Production Plan")
pp.po_items = [
frappe._dict(planned_qty=5, produce_qty=4)
]
self.assertFalse(pp.all_items_completed())
pp.po_items = [
frappe._dict(planned_qty=5, produce_qty=10),
frappe._dict(planned_qty=5, produce_qty=4)
]
self.assertFalse(pp.all_items_completed())
def create_production_plan(**args):
"""
sales_order (obj): Sales Order Doc Object

View File

@ -329,7 +329,6 @@ execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings'
erpnext.patches.v14_0.set_payroll_cost_centers
erpnext.patches.v13_0.agriculture_deprecation_warning
erpnext.patches.v13_0.hospitality_deprecation_warning
erpnext.patches.v13_0.update_exchange_rate_settings
erpnext.patches.v13_0.update_asset_quantity_field
erpnext.patches.v13_0.delete_bank_reconciliation_detail
erpnext.patches.v13_0.enable_provisional_accounting
@ -351,4 +350,6 @@ erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template
erpnext.patches.v13_0.shopping_cart_to_ecommerce
erpnext.patches.v13_0.update_disbursement_account
erpnext.patches.v13_0.update_reserved_qty_closed_wo
erpnext.patches.v13_0.update_exchange_rate_settings
erpnext.patches.v14_0.delete_amazon_mws_doctype
erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr

View File

@ -9,6 +9,8 @@ def execute():
FROM `tabBin`""",as_dict=1)
for entry in bin_details:
if not (entry.item_code and entry.warehouse):
continue
update_bin_qty(entry.get("item_code"), entry.get("warehouse"), {
"indented_qty": get_indented_qty(entry.get("item_code"), entry.get("warehouse"))
})

View File

@ -0,0 +1,36 @@
import frappe
def execute():
"""
1. Get submitted Work Orders with MR, MR Item and SO set
2. Get SO Item detail from MR Item detail in WO, and set in WO
3. Update work_order_qty in SO
"""
work_order = frappe.qb.DocType("Work Order")
query = (
frappe.qb.from_(work_order)
.select(
work_order.name, work_order.produced_qty,
work_order.material_request,
work_order.material_request_item,
work_order.sales_order
).where(
(work_order.material_request.isnotnull())
& (work_order.material_request_item.isnotnull())
& (work_order.sales_order.isnotnull())
& (work_order.docstatus == 1)
& (work_order.produced_qty > 0)
)
)
results = query.run(as_dict=True)
for row in results:
so_item = frappe.get_value(
"Material Request Item", row.material_request_item, "sales_order_item"
)
frappe.db.set_value("Work Order", row.name, "sales_order_item", so_item)
if so_item:
wo = frappe.get_doc("Work Order", row.name)
wo.update_work_order_qty_in_so()

View File

@ -6,9 +6,6 @@ from erpnext.setup.utils import get_exchange_rate
def execute():
frappe.reload_doc('crm', 'doctype', 'opportunity')
frappe.reload_doc('crm', 'doctype', 'opportunity_item')
opportunities = frappe.db.get_list('Opportunity', filters={
'opportunity_amount': ['>', 0]
}, fields=['name', 'company', 'currency', 'opportunity_amount'])
@ -20,15 +17,11 @@ def execute():
if opportunity.currency != company_currency:
conversion_rate = get_exchange_rate(opportunity.currency, company_currency)
base_opportunity_amount = flt(conversion_rate) * flt(opportunity.opportunity_amount)
grand_total = flt(opportunity.opportunity_amount)
base_grand_total = flt(conversion_rate) * flt(opportunity.opportunity_amount)
else:
conversion_rate = 1
base_opportunity_amount = grand_total = base_grand_total = flt(opportunity.opportunity_amount)
base_opportunity_amount = flt(opportunity.opportunity_amount)
frappe.db.set_value('Opportunity', opportunity.name, {
'conversion_rate': conversion_rate,
'base_opportunity_amount': base_opportunity_amount,
'grand_total': grand_total,
'base_grand_total': base_grand_total
'base_opportunity_amount': base_opportunity_amount
}, update_modified=False)

View File

@ -29,9 +29,11 @@ def execute():
""")
for item_code, warehouse in repost_for:
update_bin_qty(item_code, warehouse, {
"reserved_qty": get_reserved_qty(item_code, warehouse)
})
if not (item_code and warehouse):
continue
update_bin_qty(item_code, warehouse, {
"reserved_qty": get_reserved_qty(item_code, warehouse)
})
frappe.db.sql("""delete from tabBin
where exists(

View File

@ -14,6 +14,8 @@ def execute():
union
select item_code, warehouse from `tabStock Ledger Entry`) a"""):
try:
if not (item_code and warehouse):
continue
count += 1
update_bin_qty(item_code, warehouse, {
"indented_qty": get_indented_qty(item_code, warehouse),

View File

@ -151,6 +151,35 @@ class TestTimesheet(unittest.TestCase):
settings.ignore_employee_time_overlap = initial_setting
settings.save()
def test_timesheet_not_overlapping_with_continuous_timelogs(self):
emp = make_employee("test_employee_6@salary.com")
update_activity_type("_Test Activity Type")
timesheet = frappe.new_doc("Timesheet")
timesheet.employee = emp
timesheet.append(
'time_logs',
{
"billable": 1,
"activity_type": "_Test Activity Type",
"from_time": now_datetime(),
"to_time": now_datetime() + datetime.timedelta(hours=3),
"company": "_Test Company"
}
)
timesheet.append(
'time_logs',
{
"billable": 1,
"activity_type": "_Test Activity Type",
"from_time": now_datetime() + datetime.timedelta(hours=3),
"to_time": now_datetime() + datetime.timedelta(hours=4),
"company": "_Test Company"
}
)
timesheet.save() # should not throw an error
def test_to_time(self):
emp = make_employee("test_employee_6@salary.com")
from_time = now_datetime()

View File

@ -7,7 +7,7 @@ import json
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import add_to_date, flt, getdate, time_diff_in_hours
from frappe.utils import add_to_date, flt, get_datetime, getdate, time_diff_in_hours
from erpnext.controllers.queries import get_match_cond
from erpnext.hr.utils import validate_active_employee
@ -145,7 +145,7 @@ class Timesheet(Document):
if not (data.from_time and data.hours):
return
_to_time = add_to_date(data.from_time, hours=data.hours, as_datetime=True)
_to_time = get_datetime(add_to_date(data.from_time, hours=data.hours, as_datetime=True))
if data.to_time != _to_time:
data.to_time = _to_time
@ -171,39 +171,54 @@ class Timesheet(Document):
.format(args.idx, self.name, existing.name), OverlapError)
def get_overlap_for(self, fieldname, args, value):
cond = "ts.`{0}`".format(fieldname)
if fieldname == 'workstation':
cond = "tsd.`{0}`".format(fieldname)
timesheet = frappe.qb.DocType("Timesheet")
timelog = frappe.qb.DocType("Timesheet Detail")
existing = frappe.db.sql("""select ts.name as name, tsd.from_time as from_time, tsd.to_time as to_time from
`tabTimesheet Detail` tsd, `tabTimesheet` ts where {0}=%(val)s and tsd.parent = ts.name and
(
(%(from_time)s > tsd.from_time and %(from_time)s < tsd.to_time) or
(%(to_time)s > tsd.from_time and %(to_time)s < tsd.to_time) or
(%(from_time)s <= tsd.from_time and %(to_time)s >= tsd.to_time))
and tsd.name!=%(name)s
and ts.name!=%(parent)s
and ts.docstatus < 2""".format(cond),
{
"val": value,
"from_time": args.from_time,
"to_time": args.to_time,
"name": args.name or "No Name",
"parent": args.parent or "No Name"
}, as_dict=True)
# check internal overlap
for time_log in self.time_logs:
if not (time_log.from_time and time_log.to_time
and args.from_time and args.to_time): continue
from_time = get_datetime(args.from_time)
to_time = get_datetime(args.to_time)
if (fieldname != 'workstation' or args.get(fieldname) == time_log.get(fieldname)) and \
args.idx != time_log.idx and ((args.from_time > time_log.from_time and args.from_time < time_log.to_time) or
(args.to_time > time_log.from_time and args.to_time < time_log.to_time) or
(args.from_time <= time_log.from_time and args.to_time >= time_log.to_time)):
return self
existing = (
frappe.qb.from_(timesheet)
.join(timelog)
.on(timelog.parent == timesheet.name)
.select(timesheet.name.as_('name'), timelog.from_time.as_('from_time'), timelog.to_time.as_('to_time'))
.where(
(timelog.name != (args.name or "No Name"))
& (timesheet.name != (args.parent or "No Name"))
& (timesheet.docstatus < 2)
& (timesheet[fieldname] == value)
& (
((from_time > timelog.from_time) & (from_time < timelog.to_time))
| ((to_time > timelog.from_time) & (to_time < timelog.to_time))
| ((from_time <= timelog.from_time) & (to_time >= timelog.to_time))
)
)
).run(as_dict=True)
if self.check_internal_overlap(fieldname, args):
return self
return existing[0] if existing else None
def check_internal_overlap(self, fieldname, args):
for time_log in self.time_logs:
if not (time_log.from_time and time_log.to_time
and args.from_time and args.to_time):
continue
from_time = get_datetime(time_log.from_time)
to_time = get_datetime(time_log.to_time)
args_from_time = get_datetime(args.from_time)
args_to_time = get_datetime(args.to_time)
if (args.get(fieldname) == time_log.get(fieldname)) and (args.idx != time_log.idx) and (
(args_from_time > from_time and args_from_time < to_time)
or (args_to_time > from_time and args_to_time < to_time)
or (args_from_time <= from_time and args_to_time >= to_time)
):
return True
return False
def update_cost(self):
for data in self.time_logs:
if data.activity_type or data.is_billable:

View File

@ -14,12 +14,6 @@
"to_time",
"hours",
"completed",
"section_break_7",
"completed_qty",
"workstation",
"column_break_12",
"operation",
"operation_id",
"project_details",
"project",
"project_name",
@ -83,43 +77,6 @@
"fieldtype": "Check",
"label": "Completed"
},
{
"fieldname": "section_break_7",
"fieldtype": "Section Break"
},
{
"depends_on": "eval:parent.work_order",
"fieldname": "completed_qty",
"fieldtype": "Float",
"label": "Completed Qty"
},
{
"depends_on": "eval:parent.work_order",
"fieldname": "workstation",
"fieldtype": "Link",
"label": "Workstation",
"options": "Workstation",
"read_only": 1
},
{
"fieldname": "column_break_12",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:parent.work_order",
"fieldname": "operation",
"fieldtype": "Link",
"label": "Operation",
"options": "Operation",
"read_only": 1
},
{
"depends_on": "eval:parent.work_order",
"fieldname": "operation_id",
"fieldtype": "Data",
"hidden": 1,
"label": "Operation Id"
},
{
"fieldname": "project_details",
"fieldtype": "Section Break"
@ -267,7 +224,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2021-05-18 12:19:33.205940",
"modified": "2022-02-17 16:53:34.878798",
"modified_by": "Administrator",
"module": "Projects",
"name": "Timesheet Detail",
@ -275,5 +232,6 @@
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "ASC"
"sort_order": "ASC",
"states": []
}

View File

@ -2284,20 +2284,12 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
coupon_code() {
var me = this;
if (this.frm.doc.coupon_code) {
frappe.run_serially([
() => this.frm.doc.ignore_pricing_rule=1,
() => me.ignore_pricing_rule(),
() => this.frm.doc.ignore_pricing_rule=0,
() => me.apply_pricing_rule(),
() => this.frm.save()
]);
} else {
frappe.run_serially([
() => this.frm.doc.ignore_pricing_rule=1,
() => me.ignore_pricing_rule()
]);
}
frappe.run_serially([
() => this.frm.doc.ignore_pricing_rule=1,
() => me.ignore_pricing_rule(),
() => this.frm.doc.ignore_pricing_rule=0,
() => me.apply_pricing_rule()
]);
}
};

View File

@ -102,7 +102,7 @@ def make_custom_fields():
]
}
create_custom_fields(custom_fields, update=True)
create_custom_fields(custom_fields, ignore_validate=True, update=True)
def update_regional_tax_settings(country, company):
create_ksa_vat_setting(company)

View File

@ -6,7 +6,7 @@ import json
import frappe
import frappe.permissions
from frappe.core.doctype.user_permission.test_user_permission import create_user
from frappe.utils import add_days, flt, getdate, nowdate
from frappe.utils import add_days, flt, getdate, nowdate, today
from erpnext.controllers.accounts_controller import update_child_qty_rate
from erpnext.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import (
@ -1399,6 +1399,48 @@ class TestSalesOrder(ERPNextTestCase):
so.load_from_db()
self.assertEqual(so.billing_status, 'Fully Billed')
def test_so_back_updated_from_wo_via_mr(self):
"SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO."
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_se_from_wo,
)
from erpnext.stock.doctype.material_request.material_request import raise_work_orders
so = make_sales_order(item_list=[{"item_code": "_Test FG Item","qty": 2, "rate":100}])
mr = make_material_request(so.name)
mr.material_request_type = "Manufacture"
mr.schedule_date = today()
mr.submit()
# WO from MR
wo_name = raise_work_orders(mr.name)[0]
wo = frappe.get_doc("Work Order", wo_name)
wo.wip_warehouse = "Work In Progress - _TC"
wo.skip_transfer = True
self.assertEqual(wo.sales_order, so.name)
self.assertEqual(wo.sales_order_item, so.items[0].name)
wo.submit()
make_stock_entry(item_code="_Test Item", # Stock RM
target="Work In Progress - _TC",
qty=4, basic_rate=100
)
make_stock_entry(item_code="_Test Item Home Desktop 100", # Stock RM
target="Work In Progress - _TC",
qty=4, basic_rate=100
)
se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 2))
se.submit() # Finish WO
mr.reload()
wo.reload()
so.reload()
self.assertEqual(so.items[0].work_order_qty, wo.produced_qty)
self.assertEqual(mr.status, "Manufactured")
def automatically_fetch_payment_terms(enable=1):
accounts_settings = frappe.get_doc("Accounts Settings")
accounts_settings.automatically_fetch_payment_terms = enable

View File

@ -169,6 +169,29 @@ erpnext.PointOfSale.Payment = class {
}
});
frappe.ui.form.on('POS Invoice', 'coupon_code', (frm) => {
if (!frm.doc.ignore_pricing_rule) {
if (frm.doc.coupon_code) {
frappe.run_serially([
() => frm.doc.ignore_pricing_rule=1,
() => frm.trigger('ignore_pricing_rule'),
() => frm.doc.ignore_pricing_rule=0,
() => frm.trigger('apply_pricing_rule'),
() => frm.save(),
() => this.update_totals_section(frm.doc)
]);
} else {
frappe.run_serially([
() => frm.doc.ignore_pricing_rule=1,
() => frm.trigger('ignore_pricing_rule'),
() => frm.doc.ignore_pricing_rule=0,
() => frm.save(),
() => this.update_totals_section(frm.doc)
]);
}
}
});
this.setup_listener_for_payments();
this.$payment_modes.on('click', '.shortcut', function() {

View File

@ -0,0 +1,84 @@
// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
/* eslint-disable */
function get_filters() {
let filters = [
{
"fieldname":"company",
"label": __("Company"),
"fieldtype": "Link",
"options": "Company",
"default": frappe.defaults.get_user_default("Company"),
"reqd": 1
},
{
"fieldname":"period_start_date",
"label": __("Start Date"),
"fieldtype": "Date",
"reqd": 1,
"default": frappe.datetime.add_months(frappe.datetime.get_today(), -1)
},
{
"fieldname":"period_end_date",
"label": __("End Date"),
"fieldtype": "Date",
"reqd": 1,
"default": frappe.datetime.get_today()
},
{
"fieldname":"sales_order",
"label": __("Sales Order"),
"fieldtype": "MultiSelectList",
"width": 100,
"options": "Sales Order",
"get_data": function(txt) {
return frappe.db.get_link_options("Sales Order", txt, this.filters());
},
"filters": () => {
return {
docstatus: 1,
payment_terms_template: ['not in', ['']],
company: frappe.query_report.get_filter_value("company"),
transaction_date: ['between', [frappe.query_report.get_filter_value("period_start_date"), frappe.query_report.get_filter_value("period_end_date")]]
}
},
on_change: function(){
frappe.query_report.refresh();
}
}
]
return filters;
}
frappe.query_reports["Payment Terms Status for Sales Order"] = {
"filters": get_filters(),
"formatter": function(value, row, column, data, default_formatter){
if(column.fieldname == 'invoices' && value) {
invoices = value.split(',');
const invoice_formatter = (prev_value, curr_value) => {
if(prev_value != "") {
return prev_value + ", " + default_formatter(curr_value, row, column, data);
}
else {
return default_formatter(curr_value, row, column, data);
}
}
return invoices.reduce(invoice_formatter, "")
}
else if (column.fieldname == 'paid_amount' && value){
formatted_value = default_formatter(value, row, column, data);
if(value > 0) {
formatted_value = "<span style='color:green;'>" + formatted_value + "</span>"
}
return formatted_value;
}
else if (column.fieldname == 'status' && value == 'Completed'){
return "<span style='color:green;'>" + default_formatter(value, row, column, data) + "</span>";
}
return default_formatter(value, row, column, data);
},
};

View File

@ -0,0 +1,38 @@
{
"add_total_row": 1,
"columns": [],
"creation": "2021-12-28 10:39:34.533964",
"disable_prepared_report": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2021-12-30 10:42:06.058457",
"modified_by": "Administrator",
"module": "Selling",
"name": "Payment Terms Status for Sales Order",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Sales Order",
"report_name": "Payment Terms Status for Sales Order",
"report_type": "Script Report",
"roles": [
{
"role": "Sales User"
},
{
"role": "Sales Manager"
},
{
"role": "Maintenance User"
},
{
"role": "Accounts User"
},
{
"role": "Stock User"
}
]
}

View File

@ -0,0 +1,205 @@
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
# License: MIT. See LICENSE
import frappe
from frappe import _, qb, query_builder
from frappe.query_builder import functions
def get_columns():
columns = [
{
"label": _("Sales Order"),
"fieldname": "name",
"fieldtype": "Link",
"options": "Sales Order",
},
{
"label": _("Posting Date"),
"fieldname": "submitted",
"fieldtype": "Date",
},
{
"label": _("Payment Term"),
"fieldname": "payment_term",
"fieldtype": "Data",
},
{
"label": _("Description"),
"fieldname": "description",
"fieldtype": "Data",
},
{
"label": _("Due Date"),
"fieldname": "due_date",
"fieldtype": "Date",
},
{
"label": _("Invoice Portion"),
"fieldname": "invoice_portion",
"fieldtype": "Percent",
},
{
"label": _("Payment Amount"),
"fieldname": "base_payment_amount",
"fieldtype": "Currency",
"options": "currency",
},
{
"label": _("Paid Amount"),
"fieldname": "paid_amount",
"fieldtype": "Currency",
"options": "currency",
},
{
"label": _("Invoices"),
"fieldname": "invoices",
"fieldtype": "Link",
"options": "Sales Invoice",
},
{
"label": _("Status"),
"fieldname": "status",
"fieldtype": "Data",
},
{
"label": _("Currency"),
"fieldname": "currency",
"fieldtype": "Currency",
"hidden": 1
}
]
return columns
def get_conditions(filters):
"""
Convert filter options to conditions used in query
"""
filters = frappe._dict(filters) if filters else frappe._dict({})
conditions = frappe._dict({})
conditions.company = filters.company or frappe.defaults.get_user_default("company")
conditions.end_date = filters.period_end_date or frappe.utils.today()
conditions.start_date = filters.period_start_date or frappe.utils.add_months(
conditions.end_date, -1
)
conditions.sales_order = filters.sales_order or []
return conditions
def get_so_with_invoices(filters):
"""
Get Sales Order with payment terms template with their associated Invoices
"""
sorders = []
so = qb.DocType("Sales Order")
ps = qb.DocType("Payment Schedule")
datediff = query_builder.CustomFunction("DATEDIFF", ["cur_date", "due_date"])
ifelse = query_builder.CustomFunction("IF", ["condition", "then", "else"])
conditions = get_conditions(filters)
query_so = (
qb.from_(so)
.join(ps)
.on(ps.parent == so.name)
.select(
so.name,
so.transaction_date.as_("submitted"),
ifelse(datediff(ps.due_date, functions.CurDate()) < 0, "Overdue", "Unpaid").as_("status"),
ps.payment_term,
ps.description,
ps.due_date,
ps.invoice_portion,
ps.base_payment_amount,
ps.paid_amount,
)
.where(
(so.docstatus == 1)
& (so.payment_terms_template != "NULL")
& (so.company == conditions.company)
& (so.transaction_date[conditions.start_date : conditions.end_date])
)
.orderby(so.name, so.transaction_date, ps.due_date)
)
if conditions.sales_order != []:
query_so = query_so.where(so.name.isin(conditions.sales_order))
sorders = query_so.run(as_dict=True)
invoices = []
if sorders != []:
soi = qb.DocType("Sales Order Item")
si = qb.DocType("Sales Invoice")
sii = qb.DocType("Sales Invoice Item")
query_inv = (
qb.from_(sii)
.right_join(si)
.on(si.name == sii.parent)
.inner_join(soi)
.on(soi.name == sii.so_detail)
.select(sii.sales_order, sii.parent.as_("invoice"), si.base_grand_total.as_("invoice_amount"))
.where((sii.sales_order.isin([x.name for x in sorders])) & (si.docstatus == 1))
.groupby(sii.parent)
)
invoices = query_inv.run(as_dict=True)
return sorders, invoices
def set_payment_terms_statuses(sales_orders, invoices, filters):
"""
compute status for payment terms with associated sales invoice using FIFO
"""
for so in sales_orders:
so.currency = frappe.get_cached_value('Company', filters.get('company'), 'default_currency')
so.invoices = ""
for inv in [x for x in invoices if x.sales_order == so.name and x.invoice_amount > 0]:
if so.base_payment_amount - so.paid_amount > 0:
amount = so.base_payment_amount - so.paid_amount
if inv.invoice_amount >= amount:
inv.invoice_amount -= amount
so.paid_amount += amount
so.invoices += "," + inv.invoice
so.status = "Completed"
break
else:
so.paid_amount += inv.invoice_amount
inv.invoice_amount = 0
so.invoices += "," + inv.invoice
so.status = "Partly Paid"
return sales_orders, invoices
def prepare_chart(s_orders):
if len(set([x.name for x in s_orders])) == 1:
chart = {
"data": {
"labels": [term.payment_term for term in s_orders],
"datasets": [
{"name": "Payment Amount", "values": [x.base_payment_amount for x in s_orders],},
{"name": "Paid Amount", "values": [x.paid_amount for x in s_orders],},
],
},
"type": "bar",
}
return chart
def execute(filters=None):
columns = get_columns()
sales_orders, so_invoices = get_so_with_invoices(filters)
sales_orders, so_invoices = set_payment_terms_statuses(sales_orders, so_invoices, filters)
prepare_chart(sales_orders)
data = sales_orders
message = []
chart = prepare_chart(sales_orders)
return columns, data, message, chart

View File

@ -0,0 +1,198 @@
import datetime
import frappe
from frappe.utils import add_days
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.selling.report.payment_terms_status_for_sales_order.payment_terms_status_for_sales_order import (
execute,
)
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.tests.utils import ERPNextTestCase
test_dependencies = ["Sales Order", "Item", "Sales Invoice", "Payment Terms Template"]
class TestPaymentTermsStatusForSalesOrder(ERPNextTestCase):
def create_payment_terms_template(self):
# create template for 50-50 payments
template = None
if frappe.db.exists("Payment Terms Template", "_Test 50-50"):
template = frappe.get_doc("Payment Terms Template", "_Test 50-50")
else:
template = frappe.get_doc(
{
"doctype": "Payment Terms Template",
"template_name": "_Test 50-50",
"terms": [
{
"doctype": "Payment Terms Template Detail",
"due_date_based_on": "Day(s) after invoice date",
"payment_term_name": "_Test 50% on 15 Days",
"description": "_Test 50-50",
"invoice_portion": 50,
"credit_days": 15,
},
{
"doctype": "Payment Terms Template Detail",
"due_date_based_on": "Day(s) after invoice date",
"payment_term_name": "_Test 50% on 30 Days",
"description": "_Test 50-50",
"invoice_portion": 50,
"credit_days": 30,
},
],
}
)
template.insert()
self.template = template
def test_payment_terms_status(self):
self.create_payment_terms_template()
item = create_item(item_code="_Test Excavator", is_stock_item=0)
so = make_sales_order(
transaction_date="2021-06-15",
delivery_date=add_days("2021-06-15", -30),
item=item.item_code,
qty=10,
rate=100000,
do_not_save=True,
)
so.po_no = ""
so.taxes_and_charges = ""
so.taxes = ""
so.payment_terms_template = self.template.name
so.save()
so.submit()
# make invoice with 60% of the total sales order value
sinv = make_sales_invoice(so.name)
sinv.taxes_and_charges = ""
sinv.taxes = ""
sinv.items[0].qty = 6
sinv.insert()
sinv.submit()
columns, data, message, chart = execute(
{
"company": "_Test Company",
"period_start_date": "2021-06-01",
"period_end_date": "2021-06-30",
"sales_order": [so.name],
}
)
expected_value = [
{
"name": so.name,
"submitted": datetime.date(2021, 6, 15),
"status": "Completed",
"payment_term": None,
"description": "_Test 50-50",
"due_date": datetime.date(2021, 6, 30),
"invoice_portion": 50.0,
"currency": "INR",
"base_payment_amount": 500000.0,
"paid_amount": 500000.0,
"invoices": ","+sinv.name,
},
{
"name": so.name,
"submitted": datetime.date(2021, 6, 15),
"status": "Partly Paid",
"payment_term": None,
"description": "_Test 50-50",
"due_date": datetime.date(2021, 7, 15),
"invoice_portion": 50.0,
"currency": "INR",
"base_payment_amount": 500000.0,
"paid_amount": 100000.0,
"invoices": ","+sinv.name,
},
]
self.assertEqual(data, expected_value)
def create_exchange_rate(self, date):
# make an entry in Currency Exchange list. serves as a static exchange rate
if frappe.db.exists({'doctype': "Currency Exchange",'date': date,'from_currency': 'USD', 'to_currency':'INR'}):
return
else:
doc = frappe.get_doc({
'doctype': "Currency Exchange",
'date': date,
'from_currency': 'USD',
'to_currency': frappe.get_cached_value("Company", '_Test Company','default_currency'),
'exchange_rate': 70,
'for_buying': True,
'for_selling': True
})
doc.insert()
def test_alternate_currency(self):
transaction_date = "2021-06-15"
self.create_payment_terms_template()
self.create_exchange_rate(transaction_date)
item = create_item(item_code="_Test Excavator", is_stock_item=0)
so = make_sales_order(
transaction_date=transaction_date,
currency="USD",
delivery_date=add_days(transaction_date, -30),
item=item.item_code,
qty=10,
rate=10000,
do_not_save=True,
)
so.po_no = ""
so.taxes_and_charges = ""
so.taxes = ""
so.payment_terms_template = self.template.name
so.save()
so.submit()
# make invoice with 60% of the total sales order value
sinv = make_sales_invoice(so.name)
sinv.currency = "USD"
sinv.taxes_and_charges = ""
sinv.taxes = ""
sinv.items[0].qty = 6
sinv.insert()
sinv.submit()
columns, data, message, chart = execute(
{
"company": "_Test Company",
"period_start_date": "2021-06-01",
"period_end_date": "2021-06-30",
"sales_order": [so.name],
}
)
# report defaults to company currency.
expected_value = [
{
"name": so.name,
"submitted": datetime.date(2021, 6, 15),
"status": "Completed",
"payment_term": None,
"description": "_Test 50-50",
"due_date": datetime.date(2021, 6, 30),
"invoice_portion": 50.0,
"currency": frappe.get_cached_value("Company", '_Test Company','default_currency'),
"base_payment_amount": 3500000.0,
"paid_amount": 3500000.0,
"invoices": ","+sinv.name,
},
{
"name": so.name,
"submitted": datetime.date(2021, 6, 15),
"status": "Partly Paid",
"payment_term": None,
"description": "_Test 50-50",
"due_date": datetime.date(2021, 7, 15),
"invoice_portion": 50.0,
"currency": frappe.get_cached_value("Company", '_Test Company','default_currency'),
"base_payment_amount": 3500000.0,
"paid_amount": 700000.0,
"invoices": ","+sinv.name,
},
]
self.assertEqual(data, expected_value)

View File

@ -545,7 +545,7 @@ $.extend(erpnext.item, {
let selected_attributes = {};
me.multiple_variant_dialog.$wrapper.find('.form-column').each((i, col) => {
if(i===0) return;
let attribute_name = $(col).find('label').html();
let attribute_name = $(col).find('label').html().trim();
selected_attributes[attribute_name] = [];
let checked_opts = $(col).find('.checkbox input');
checked_opts.each((i, opt) => {

View File

@ -533,6 +533,7 @@ def raise_work_orders(material_request):
"stock_uom": d.stock_uom,
"expected_delivery_date": d.schedule_date,
"sales_order": d.sales_order,
"sales_order_item": d.get("sales_order_item"),
"bom_no": get_item_details(d.item_code).bom_no,
"material_request": mr.name,
"material_request_item": d.name,

View File

@ -9,7 +9,7 @@ from collections import defaultdict
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import cint, floor, flt, nowdate
from frappe.utils import cint, cstr, floor, flt, nowdate
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.utils import get_stock_balance
@ -142,11 +142,44 @@ def apply_putaway_rule(doctype, items, company, sync=None, purpose=None):
if items_not_accomodated:
show_unassigned_items_message(items_not_accomodated)
items[:] = updated_table if updated_table else items # modify items table
if updated_table and _items_changed(items, updated_table, doctype):
items[:] = updated_table
frappe.msgprint(_("Applied putaway rules."), alert=True)
if sync and json.loads(sync): # sync with client side
return items
def _items_changed(old, new, doctype: str) -> bool:
""" Check if any items changed by application of putaway rules.
If not, changing item table can have side effects since `name` items also changes.
"""
if len(old) != len(new):
return True
old = [frappe._dict(item) if isinstance(item, dict) else item for item in old]
if doctype == "Stock Entry":
compare_keys = ("item_code", "t_warehouse", "transfer_qty", "serial_no")
sort_key = lambda item: (item.item_code, cstr(item.t_warehouse), # noqa
flt(item.transfer_qty), cstr(item.serial_no))
else:
# purchase receipt / invoice
compare_keys = ("item_code", "warehouse", "stock_qty", "received_qty", "serial_no")
sort_key = lambda item: (item.item_code, cstr(item.warehouse), # noqa
flt(item.stock_qty), flt(item.received_qty), cstr(item.serial_no))
old_sorted = sorted(old, key=sort_key)
new_sorted = sorted(new, key=sort_key)
# Once sorted by all relevant keys both tables should align if they are same.
for old_item, new_item in zip(old_sorted, new_sorted):
for key in compare_keys:
if old_item.get(key) != new_item.get(key):
return True
return False
def get_ordered_putaway_rules(item_code, company, source_warehouse=None):
"""Returns an ordered list of putaway rules to apply on an item."""
filters = {

View File

@ -35,6 +35,18 @@ class TestPutawayRule(ERPNextTestCase):
new_uom.uom_name = "Bag"
new_uom.save()
def assertUnchangedItemsOnResave(self, doc):
""" Check if same items remain even after reapplication of rules.
This is required since some business logic like subcontracting
depends on `name` of items to be same if item isn't changed.
"""
doc.reload()
old_items = {d.name for d in doc.items}
doc.save()
new_items = {d.name for d in doc.items}
self.assertSetEqual(old_items, new_items)
def test_putaway_rules_priority(self):
"""Test if rule is applied by priority, irrespective of free space."""
rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=200,
@ -50,6 +62,8 @@ class TestPutawayRule(ERPNextTestCase):
self.assertEqual(pr.items[1].qty, 100)
self.assertEqual(pr.items[1].warehouse, self.warehouse_2)
self.assertUnchangedItemsOnResave(pr)
pr.delete()
rule_1.delete()
rule_2.delete()
@ -162,6 +176,8 @@ class TestPutawayRule(ERPNextTestCase):
# leftover space was for 500 kg (0.5 Bag)
# Since Bag is a whole UOM, 1(out of 2) Bag will be unassigned
self.assertUnchangedItemsOnResave(pr)
pr.delete()
rule_1.delete()
rule_2.delete()
@ -196,6 +212,8 @@ class TestPutawayRule(ERPNextTestCase):
self.assertEqual(pr.items[1].warehouse, self.warehouse_1)
self.assertEqual(pr.items[1].putaway_rule, rule_1.name)
self.assertUnchangedItemsOnResave(pr)
pr.delete()
rule_1.delete()
@ -239,6 +257,8 @@ class TestPutawayRule(ERPNextTestCase):
self.assertEqual(stock_entry_item.qty, 100) # unassigned 100 out of 200 Kg
self.assertEqual(stock_entry_item.putaway_rule, rule_2.name)
self.assertUnchangedItemsOnResave(stock_entry)
stock_entry.delete()
rule_1.delete()
rule_2.delete()
@ -294,6 +314,8 @@ class TestPutawayRule(ERPNextTestCase):
self.assertEqual(stock_entry.items[2].qty, 200)
self.assertEqual(stock_entry.items[2].putaway_rule, rule_2.name)
self.assertUnchangedItemsOnResave(stock_entry)
stock_entry.delete()
rule_1.delete()
rule_2.delete()
@ -344,6 +366,8 @@ class TestPutawayRule(ERPNextTestCase):
self.assertEqual(stock_entry.items[1].serial_no, "\n".join(serial_nos[3:]))
self.assertEqual(stock_entry.items[1].batch_no, "BOTTL-BATCH-1")
self.assertUnchangedItemsOnResave(stock_entry)
stock_entry.delete()
pr.cancel()
rule_1.delete()
@ -366,6 +390,8 @@ class TestPutawayRule(ERPNextTestCase):
self.assertEqual(stock_entry_item.qty, 100)
self.assertEqual(stock_entry_item.putaway_rule, rule_1.name)
self.assertUnchangedItemsOnResave(stock_entry)
stock_entry.delete()
rule_1.delete()
rule_2.delete()