selections/selections/api.py

630 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
API для формы подбора товаров
"""
import frappe
from frappe import _
from frappe.utils import flt, cstr
from frappe.query_builder.functions import Count
from pypika import Order
from erpnext.stock.get_item_details import get_item_details
@frappe.whitelist()
def get_items_for_selection(filters=None, start=0, page_length=50, custom_filters=None, skip_price=0):
"""
Получить список товаров с остатками и ценами для формы подбора
Args:
filters (dict): Фильтры для поиска товаров
start (int): Начальная позиция для пагинации
page_length (int): Количество товаров на странице
custom_filters (list): Произвольные фильтры по полям DocType Item
Returns:
dict: {
'items': list of dicts with item details,
'total_count': int
}
"""
if isinstance(filters, str):
filters = frappe.parse_json(filters)
if isinstance(custom_filters, str):
custom_filters = frappe.parse_json(custom_filters)
skip_price = flt(skip_price)
if not filters:
filters = {}
start = int(start)
page_length = int(page_length)
# Извлекаем параметры из фильтров
search_term = cstr(filters.get('search_term', '')).strip()
item_group = filters.get('item_group')
warehouse = filters.get('warehouse')
customer = filters.get('customer')
price_list = filters.get('price_list')
company = filters.get('company')
transaction_date = filters.get('transaction_date')
only_in_stock = filters.get('only_in_stock', False)
maintain_stock = filters.get('maintain_stock', False)
sort_by = filters.get('sort_by', 'item_name') # item_name, available_qty, rate
sort_order = filters.get('sort_order', 'asc') # asc, desc
# Базовый запрос к Item
Item = frappe.qb.DocType('Item')
UOM = frappe.qb.DocType('UOM')
query = (
frappe.qb.from_(Item)
.left_join(UOM).on(UOM.name == Item.stock_uom)
.select(
Item.name.as_('item_code'),
Item.item_name,
Item.item_group,
Item.stock_uom,
Item.image,
Item.description,
UOM.must_be_whole_number
)
.where(Item.disabled == 0)
.where(Item.is_sales_item == 1)
)
if maintain_stock:
query = query.where(Item.is_stock_item == 1)
# Фильтр по поиску
if search_term:
search_condition = (
Item.name.like(f'%{search_term}%') |
Item.item_name.like(f'%{search_term}%') |
Item.description.like(f'%{search_term}%')
)
query = query.where(search_condition)
# Фильтр по Item Group (включая дочерние)
if item_group:
item_groups = get_child_item_groups(item_group)
if item_groups:
query = query.where(Item.item_group.isin(item_groups))
else:
query = query.where(Item.item_group == item_group)
# LEFT JOIN с Bin когда указан склад — для SQL-сортировки и фильтрации
Bin = None
if warehouse:
Bin = frappe.qb.DocType('Bin')
avail_expr = Bin.actual_qty - Bin.reserved_qty
query = (
query
.left_join(Bin)
.on((Bin.item_code == Item.name) & (Bin.warehouse == warehouse))
.select(
Bin.actual_qty,
Bin.reserved_qty,
avail_expr.as_('available_qty')
)
)
if only_in_stock:
query = query.where(avail_expr > 0)
# Применяем произвольные фильтры
if custom_filters:
query = _apply_custom_filters(query, Item, custom_filters)
# Подсчет общего количества
count_query = query.select(Count('*').as_('count'))
count_result = count_query.run(as_dict=True)
total_count = count_result[0].count if count_result else 0
# Сортировка
if sort_by == 'available_qty' and warehouse and Bin:
avail_expr = Bin.actual_qty - Bin.reserved_qty
if sort_order == 'desc':
query = query.orderby(avail_expr, order=Order.desc)
else:
query = query.orderby(avail_expr, order=Order.asc)
else:
if sort_order == 'desc':
query = query.orderby(Item.item_name, order=Order.desc)
else:
query = query.orderby(Item.item_name, order=Order.asc)
# Пагинация
query = query.limit(page_length).offset(start)
items_data = query.run(as_dict=True)
# Обогащаем данные ценами
result_items = []
for item in items_data:
item_dict = {
'item_code': item.item_code,
'item_name': item.item_name,
'item_group': item.item_group,
'stock_uom': item.stock_uom,
'image': item.image,
'description': item.description or '',
'must_be_whole_number': bool(item.get('must_be_whole_number', 0)),
}
# Остатки из JOIN (не нужен отдельный запрос)
if warehouse:
actual_qty = flt(item.get('actual_qty', 0))
reserved_qty = flt(item.get('reserved_qty', 0))
item_dict.update({
'actual_qty': actual_qty,
'reserved_qty': reserved_qty,
'available_qty': actual_qty - reserved_qty
})
else:
item_dict.update({
'actual_qty': 0.0,
'reserved_qty': 0.0,
'available_qty': 0.0
})
# Цены
if not skip_price and price_list and company:
price_data = get_price_data(
item.item_code,
warehouse=warehouse,
customer=customer,
price_list=price_list,
company=company,
transaction_date=transaction_date
)
item_dict.update(price_data)
else:
item_dict.update({
'price_list_rate': 0.0,
'rate': 0.0,
'currency': '' if skip_price else (frappe.defaults.get_global_default('currency') or 'USD')
})
result_items.append(item_dict)
# Сортировка по цене в Python (текущая страница)
if sort_by == 'rate':
result_items.sort(key=lambda x: x.get('rate', 0), reverse=(sort_order == 'desc'))
return {
'items': result_items,
'total_count': total_count
}
def _apply_custom_filters(query, Item, custom_filters):
"""
Применить произвольные фильтры по полям DocType Item
Args:
query: Query Builder запрос
Item: DocType Item
custom_filters (list): Список фильтров [{field, operator, value}]
Returns:
query с применёнными фильтрами
"""
item_meta = frappe.get_meta('Item')
valid_fields = {f.fieldname: f for f in item_meta.fields}
# Для этих типов значение в БД может содержать HTML-обёртку — точное совпадение бессмысленно
text_like_types = {'Small Text', 'Text', 'Long Text', 'Text Editor'}
for cf in custom_filters:
field = cstr(cf.get('field', '')).strip()
operator = cstr(cf.get('operator', '=')).strip()
value = cf.get('value', '')
if not field or field not in valid_fields:
continue
field_meta = valid_fields[field]
item_field = Item[field]
# Check: значение приходит как 'True'/'False', преобразуем в 1/0
if field_meta.fieldtype == 'Check':
if value == 'True':
value = 1
elif value == 'False':
value = 0
else:
continue # пустой выбор — пропускаем фильтр
# Для текстовых HTML-полей = и != заменяем на LIKE / NOT LIKE
if field_meta.fieldtype in text_like_types and operator in ('=', '!='):
if operator == '=':
query = query.where(item_field.like(f'%{value}%'))
else:
query = query.where(~item_field.like(f'%{value}%'))
continue
if operator == '=':
query = query.where(item_field == value)
elif operator == '!=':
query = query.where(item_field != value)
elif operator == 'like':
query = query.where(item_field.like(f'%{value}%'))
elif operator == 'not like':
query = query.where(~item_field.like(f'%{value}%'))
elif operator == '>':
query = query.where(item_field > flt(value))
elif operator == '<':
query = query.where(item_field < flt(value))
elif operator == '>=':
query = query.where(item_field >= flt(value))
elif operator == '<=':
query = query.where(item_field <= flt(value))
elif operator == 'is set':
query = query.where(item_field.isnotnull() & (item_field != ''))
elif operator == 'is not set':
query = query.where(item_field.isnull() | (item_field == ''))
return query
@frappe.whitelist()
def get_stock_across_warehouses(item_code):
"""
Получить остатки товара по всем складам
Args:
item_code (str): Код товара
Returns:
list: Список складов с остатками
"""
Bin = frappe.qb.DocType('Bin')
Warehouse = frappe.qb.DocType('Warehouse')
query = (
frappe.qb.from_(Bin)
.join(Warehouse).on(Warehouse.name == Bin.warehouse)
.select(
Bin.warehouse,
Bin.actual_qty,
Bin.reserved_qty,
(Bin.actual_qty - Bin.reserved_qty).as_('available_qty')
)
.where(Bin.item_code == item_code)
.where(Bin.actual_qty != 0)
.where(Warehouse.disabled == 0)
.where(Warehouse.is_group == 0)
.orderby(Bin.warehouse)
)
return query.run(as_dict=True)
def get_stock_data(item_code, warehouse):
"""
Получить данные об остатках товара на складе
Args:
item_code (str): Код товара
warehouse (str): Склад
Returns:
dict: Данные об остатках
"""
Bin = frappe.qb.DocType('Bin')
stock_query = (
frappe.qb.from_(Bin)
.select(
Bin.actual_qty,
Bin.reserved_qty,
Bin.projected_qty
)
.where(Bin.item_code == item_code)
.where(Bin.warehouse == warehouse)
)
stock_result = stock_query.run(as_dict=True)
if stock_result:
stock = stock_result[0]
actual_qty = flt(stock.actual_qty)
reserved_qty = flt(stock.reserved_qty)
available_qty = actual_qty - reserved_qty
return {
'actual_qty': actual_qty,
'reserved_qty': reserved_qty,
'available_qty': available_qty
}
else:
return {
'actual_qty': 0.0,
'reserved_qty': 0.0,
'available_qty': 0.0
}
def get_price_data(item_code, warehouse=None, customer=None, price_list=None, company=None, transaction_date=None):
"""
Получить данные о ценах товара
Args:
item_code (str): Код товара
warehouse (str): Склад
customer (str): Клиент
price_list (str): Прайс-лист
company (str): Компания
transaction_date (str): Дата транзакции
Returns:
dict: Данные о ценах
"""
# First try to get price from Item Price directly (more reliable)
price_list_rate = 0.0
currency = frappe.defaults.get_global_default('currency') or 'USD'
if price_list:
# Try to get price for specific customer first
price_data = None
if customer:
price_data = frappe.db.get_value(
'Item Price',
{
'item_code': item_code,
'price_list': price_list,
'customer': customer
},
['price_list_rate', 'currency'],
as_dict=True
)
# If no customer-specific price, get general price
if not price_data:
price_data = frappe.db.get_value(
'Item Price',
{
'item_code': item_code,
'price_list': price_list,
'customer': ['in', ['', None]]
},
['price_list_rate', 'currency'],
as_dict=True
)
if price_data:
price_list_rate = flt(price_data.get('price_list_rate', 0.0))
currency = price_data.get('currency') or currency
# Now try get_item_details for additional processing (pricing rules, discounts)
try:
args = {
'item_code': item_code,
'doctype': 'Sales Invoice',
'company': company,
'qty': 1.0,
'conversion_rate': 1.0,
'price_list_rate': price_list_rate # Pass the price we found
}
if warehouse:
args['warehouse'] = warehouse
if customer:
args['customer'] = customer
if price_list:
args['selling_price_list'] = price_list
if transaction_date:
args['transaction_date'] = transaction_date
item_details = get_item_details(args)
# Use get_item_details results if available, otherwise use our direct price
final_price_list_rate = flt(item_details.get('price_list_rate', 0.0)) or price_list_rate
final_rate = flt(item_details.get('rate', 0.0)) or final_price_list_rate
final_currency = item_details.get('currency') or currency
return {
'price_list_rate': final_price_list_rate,
'rate': final_rate,
'currency': final_currency
}
except Exception as e:
frappe.log_error(f"Error getting price details for {item_code}: {str(e)}")
# Return direct price even if get_item_details fails
return {
'price_list_rate': price_list_rate,
'rate': price_list_rate,
'currency': currency
}
def get_child_item_groups(item_group):
"""
Получить список дочерних Item Groups
Args:
item_group (str): Название родительской группы
Returns:
list: Список названий групп (включая саму группу)
"""
from erpnext.setup.doctype.item_group import item_group as ig_module
try:
# Используем встроенную функцию ERPNext
child_groups = ig_module.get_child_item_groups(item_group)
return child_groups
except:
# Fallback - возвращаем только саму группу
return [item_group]
@frappe.whitelist()
def get_item_groups_tree():
"""
Get Item Groups tree for filter
Returns:
list: Tree of item groups
"""
# Check cache
cache_key = 'item_groups_tree'
cached_tree = frappe.cache().get_value(cache_key)
if cached_tree:
return cached_tree
# Query all groups
ItemGroup = frappe.qb.DocType('Item Group')
query = (
frappe.qb.from_(ItemGroup)
.select(
ItemGroup.name,
ItemGroup.item_group_name,
ItemGroup.parent_item_group,
ItemGroup.is_group,
ItemGroup.lft,
ItemGroup.rgt
)
.orderby(ItemGroup.lft)
)
groups = query.run(as_dict=True)
# Строим дерево
tree = build_tree(groups)
# Кэшируем на 5 минут
frappe.cache().set_value(cache_key, tree, expires_in_sec=300)
return tree
def build_tree(groups):
"""
Построить дерево из плоского списка групп (NestedSet)
Args:
groups (list): Список групп с lft/rgt
Returns:
list: Дерево групп
"""
if not groups:
return []
# Создаем словарь для быстрого доступа
groups_dict = {g['name']: g for g in groups}
# Создаем структуру дерева
tree = []
for group in groups:
node = {
'value': group['name'],
'label': group['item_group_name'] or group['name'],
'is_group': group['is_group'],
'children': []
}
# Находим детей (следующий уровень)
if group['is_group']:
for potential_child in groups:
if potential_child['parent_item_group'] == group['name']:
child_node = {
'value': potential_child['name'],
'label': potential_child['item_group_name'] or potential_child['name'],
'is_group': potential_child['is_group']
}
node['children'].append(child_node)
# Добавляем в дерево только корневые элементы
if not group['parent_item_group']:
tree.append(node)
# Рекурсивно строим поддерево
def add_children(node, all_groups):
if not node.get('is_group'):
return
children = []
for g in all_groups:
if g['parent_item_group'] == node['value']:
child = {
'value': g['name'],
'label': g['item_group_name'] or g['name'],
'is_group': g['is_group'],
'children': []
}
add_children(child, all_groups)
children.append(child)
node['children'] = children
for node in tree:
add_children(node, groups)
return tree
@frappe.whitelist()
def add_items_to_document(doctype, docname, items):
"""
Add selected items to document
Args:
doctype (str): Document type (e.g., 'Sales Invoice')
docname (str): Document name
items (list): List of items to add
Returns:
dict: Operation result
"""
if isinstance(items, str):
items = frappe.parse_json(items)
if not items:
frappe.throw(_('No items to add'))
try:
# Load document
doc = frappe.get_doc(doctype, docname)
# Check permissions
doc.check_permission('write')
added_count = 0
for item in items:
item_code = item.get('item_code')
qty = flt(item.get('qty', 1.0))
warehouse = item.get('warehouse')
if not item_code:
continue
# Add row to items table
row = doc.append('items', {})
row.item_code = item_code
row.qty = qty
if warehouse:
row.warehouse = warehouse
added_count += 1
# Update item details (prices, taxes, etc.)
doc.run_method('set_missing_values')
# Save document
doc.save()
return {
'success': True,
'message': _('Items added: {0}').format(added_count),
'added_count': added_count
}
except Exception as e:
frappe.log_error(f"Error adding items to {doctype} {docname}: {str(e)}")
frappe.throw(_('Error adding items: {0}').format(str(e)))