630 lines
16 KiB
Python
630 lines
16 KiB
Python
"""
|
||
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)))
|