first commit
This commit is contained in:
parent
855c68b7ad
commit
410216992e
|
|
@ -0,0 +1,131 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
A Frappe/ERPNext app that replaces the standard "Add Multiple" button in sales documents (currently Sales Invoice) with a custom item selection dialog inspired by 1C-style item selection. The dialog provides enhanced filtering, real-time stock visibility, pricing, and a shopping cart interface.
|
||||
|
||||
## Development Commands
|
||||
|
||||
All `bench` commands run from `/home/frappe/frappe-bench`:
|
||||
|
||||
```bash
|
||||
bench install-app selections # Install/reinstall
|
||||
bench start # Development server
|
||||
bench console # Python REPL with Frappe context
|
||||
bench clear-cache # Required after JS/CSS/hook changes
|
||||
bench migrate # Apply DB schema changes
|
||||
bench --site [sitename] tail-logs # View error logs
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
Pre-commit hooks (ruff, eslint, prettier, pyupgrade) — install once:
|
||||
|
||||
```bash
|
||||
cd apps/selections && pre-commit install
|
||||
```
|
||||
|
||||
Manual linting (from app directory):
|
||||
|
||||
```bash
|
||||
ruff check . && ruff format .
|
||||
npx eslint selections/public/js/**/*.js
|
||||
npx prettier --write selections/public/js/**/*.js
|
||||
```
|
||||
|
||||
Ruff: line length 110, tab indentation, import sorting enabled. See `pyproject.toml` for ignored rules.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Key Components
|
||||
|
||||
**Backend API (`selections/api.py`)**:
|
||||
- `get_items_for_selection(filters, start, page_length)`: Main endpoint. Fetches items with stock via LEFT JOIN on `Bin`, then enriches with prices per-item in Python.
|
||||
- `get_item_groups_tree()`: Returns hierarchical item group tree for filtering. Cached in `frappe.cache()` for 5 minutes.
|
||||
- `get_stock_across_warehouses(item_code)`: Returns per-warehouse stock for a single item (used for stock tooltip in dialog).
|
||||
- `add_items_to_document(doctype, docname, items)`: Saves items server-side. Currently unused — the preferred flow adds items directly via JS.
|
||||
|
||||
**Frontend Dialog (`selections/public/js/selection_dialog.js`)**:
|
||||
- `SelectionDialog` class: three-column layout (filters, items grid, cart). Exposed as `window.SelectionDialog`.
|
||||
- Loaded globally via `app_include_js` in `hooks.py` — available on all desk pages.
|
||||
|
||||
**DocType Integration (`selections/public/js/sales_invoice.js`)**:
|
||||
- Loaded per-doctype via `doctype_js` in `hooks.py`.
|
||||
- On `refresh`, removes `.grid-add-multiple-rows` (Frappe's standard button) and injects a replacement button using `insertAfter` + `e.stopImmediatePropagation()` to prevent Frappe's own click handler from firing on the shared CSS class.
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. User clicks "Add Multiple" in Sales Invoice → `SelectionDialog` instantiated
|
||||
2. Dialog fetches warehouses, price lists, item groups via API
|
||||
3. User filters → `get_items_for_selection()` queries DB and returns paginated results
|
||||
4. Cart state managed client-side
|
||||
5. Finalize → items added to form via `frappe.model.add_child()`, then `frm.doc.set_missing_values()` fills taxes/prices
|
||||
|
||||
### Price Lookup Chain
|
||||
|
||||
`get_price_data()` in `api.py` attempts pricing in this order:
|
||||
1. Customer-specific `Item Price` record
|
||||
2. General `Item Price` record (no customer)
|
||||
3. ERPNext's `get_item_details()` for pricing rules/discounts (falls back gracefully on exception)
|
||||
|
||||
### Sorting Behavior
|
||||
|
||||
- Sort by `item_name` or `available_qty`: done at SQL level (works correctly across pages)
|
||||
- Sort by `rate`: done in Python on the current page only — price data is fetched per-item after the SQL query
|
||||
|
||||
### Frappe Framework Patterns
|
||||
|
||||
**Whitelisted Methods**: `@frappe.whitelist()` required for any method callable from the client.
|
||||
|
||||
**Query Builder** (not raw SQL):
|
||||
```python
|
||||
Item = frappe.qb.DocType('Item')
|
||||
query = frappe.qb.from_(Item).select(Item.name).where(Item.disabled == 0)
|
||||
```
|
||||
|
||||
**Child Table Operations**:
|
||||
```javascript
|
||||
const row = frappe.model.add_child(frm.doc, 'Sales Invoice Item', 'items');
|
||||
row.item_code = 'ITEM-001';
|
||||
frm.refresh_field('items');
|
||||
```
|
||||
|
||||
**Form Scripting**:
|
||||
```javascript
|
||||
frappe.ui.form.on('DocType Name', {
|
||||
refresh: function(frm) { /* ... */ }
|
||||
});
|
||||
```
|
||||
|
||||
## Important Conventions
|
||||
|
||||
### Python
|
||||
- Tabs for indentation (ruff-enforced)
|
||||
- `from frappe.utils import flt, cstr` for numeric/string helpers
|
||||
- `frappe.throw(_('Message'))` for user-facing errors; wrap all user strings with `_()`
|
||||
- API docstrings are in Russian (existing codebase convention)
|
||||
|
||||
### JavaScript
|
||||
- Frappe globals available: `frappe`, `__()`, `flt()`, `format_currency()`, `$` (jQuery)
|
||||
- `frappe.show_alert()` for transient notifications; `frappe.msgprint()` for blocking messages
|
||||
|
||||
## Extending to Other DocTypes
|
||||
|
||||
1. Add to `doctype_js` in `hooks.py`:
|
||||
```python
|
||||
doctype_js = {
|
||||
"Sales Invoice": "public/js/sales_invoice.js",
|
||||
"Sales Order": "public/js/sales_order.js"
|
||||
}
|
||||
```
|
||||
2. Create the JS file following the pattern in `sales_invoice.js`
|
||||
3. Pass the correct child table name to `SelectionDialog` (e.g., `'Sales Order Item'`)
|
||||
|
||||
## After Making Changes
|
||||
|
||||
```bash
|
||||
bench clear-cache # Always required for JS/CSS/hook changes
|
||||
# Then hard-refresh browser (Ctrl+Shift+R)
|
||||
```
|
||||
|
|
@ -0,0 +1,545 @@
|
|||
"""
|
||||
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):
|
||||
"""
|
||||
Получить список товаров с остатками и ценами для формы подбора
|
||||
|
||||
Args:
|
||||
filters (dict): Фильтры для поиска товаров
|
||||
start (int): Начальная позиция для пагинации
|
||||
page_length (int): Количество товаров на странице
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'items': list of dicts with item details,
|
||||
'total_count': int
|
||||
}
|
||||
"""
|
||||
if isinstance(filters, str):
|
||||
filters = frappe.parse_json(filters)
|
||||
|
||||
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)
|
||||
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 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)
|
||||
|
||||
# Подсчет общего количества
|
||||
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 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': 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
|
||||
}
|
||||
|
||||
|
||||
@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)))
|
||||
|
|
@ -25,8 +25,8 @@ app_license = "unlicense"
|
|||
# ------------------
|
||||
|
||||
# include js, css files in header of desk.html
|
||||
# app_include_css = "/assets/selections/css/selections.css"
|
||||
# app_include_js = "/assets/selections/js/selections.js"
|
||||
app_include_css = "/assets/selections/css/selection_dialog.css"
|
||||
app_include_js = "/assets/selections/js/selection_dialog.js"
|
||||
|
||||
# include js, css files in header of web template
|
||||
# web_include_css = "/assets/selections/css/selections.css"
|
||||
|
|
@ -43,7 +43,9 @@ app_license = "unlicense"
|
|||
# page_js = {"page" : "public/js/file.js"}
|
||||
|
||||
# include js in doctype views
|
||||
# doctype_js = {"doctype" : "public/js/doctype.js"}
|
||||
doctype_js = {
|
||||
"Sales Invoice": "public/js/sales_invoice.js"
|
||||
}
|
||||
# doctype_list_js = {"doctype" : "public/js/doctype_list.js"}
|
||||
# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
|
||||
# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,623 @@
|
|||
/**
|
||||
* Стили для формы подбора товаров
|
||||
*/
|
||||
|
||||
/* Fullscreen modal for selection dialog */
|
||||
.selection-dialog-wrapper .modal-dialog {
|
||||
max-width: 100vw !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.selection-dialog-wrapper .modal-content {
|
||||
height: 100vh !important;
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.selection-dialog-wrapper .modal-body {
|
||||
padding: 15px !important;
|
||||
overflow: hidden !important;
|
||||
flex: 1 !important;
|
||||
}
|
||||
|
||||
/* CRITICAL: Force frappe-control to full width */
|
||||
.selection-dialog-wrapper .frappe-control {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.selection-dialog-wrapper .form-group {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* 3-колоночный layout */
|
||||
.selection-dialog-content {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
height: calc(100vh - 130px);
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
/* Левая колонка: Фильтры */
|
||||
.selection-filters {
|
||||
flex: 0 0 250px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding-right: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Переключатель "Только в наличии" */
|
||||
.selection-stock-toggle {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--bg-light-gray);
|
||||
}
|
||||
|
||||
.stock-toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stock-toggle-label input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.stock-toggle-label input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.item-group-tree {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.tree-node:hover {
|
||||
background-color: var(--bg-light-gray);
|
||||
}
|
||||
|
||||
.tree-node.active {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tree-node.active .tree-link {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.tree-link {
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tree-link i {
|
||||
margin-right: 5px;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Средняя колонка: Товары */
|
||||
.selection-items-column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Панель сортировки */
|
||||
.selection-sort-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background-color: var(--bg-light-gray);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sort-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sort-btn {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.sort-btn:hover {
|
||||
background-color: var(--bg-light-gray);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.sort-btn.active {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.sort-btn i {
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.selection-items-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.selection-table {
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selection-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--bg-color);
|
||||
z-index: 10;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
white-space: nowrap;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Кликабельные заголовки для сортировки */
|
||||
.sortable-header {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.sortable-header:hover {
|
||||
background-color: var(--bg-light-gray) !important;
|
||||
}
|
||||
|
||||
.sortable-header.sort-active {
|
||||
background-color: #e8f0fe !important;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.sortable-header i {
|
||||
margin-left: 4px;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.selection-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.selection-table tbody tr:hover {
|
||||
background-color: var(--bg-light-gray);
|
||||
}
|
||||
|
||||
/* Выделение строки при клавиатурной навигации */
|
||||
.selection-row-focused {
|
||||
background-color: #dbeafe !important;
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Изображения товаров */
|
||||
.item-image-cell {
|
||||
width: 50px;
|
||||
padding: 4px !important;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.item-thumbnail {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.item-thumbnail-placeholder {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background-color: var(--bg-light-gray);
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Поле qty и кнопка в строке товара */
|
||||
.action-cell {
|
||||
white-space: nowrap;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.row-action-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.row-qty-input {
|
||||
width: 65px !important;
|
||||
text-align: center;
|
||||
padding: 4px 6px !important;
|
||||
}
|
||||
|
||||
.btn-add-to-cart {
|
||||
padding: 4px 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
/* Stock indicators */
|
||||
.stock-indicator {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.stock-indicator:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stock-indicator.in-stock {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.stock-indicator.low-stock {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.stock-indicator.out-of-stock {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
/* Tooltip остатков по складам */
|
||||
.stock-warehouses-tooltip {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 10px 14px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
font-size: 13px;
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.stock-warehouses-tooltip strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.stock-tooltip-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.stock-tooltip-table td {
|
||||
padding: 3px 6px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stock-tooltip-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stock-tooltip-qty {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px !important;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Пагинация */
|
||||
.selection-pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background-color: var(--bg-light-gray);
|
||||
border-radius: var(--border-radius);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Правая колонка: Корзина */
|
||||
.selection-cart {
|
||||
flex: 0 0 290px;
|
||||
border-left: 1px solid var(--border-color);
|
||||
padding-left: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cart-header {
|
||||
padding: 10px;
|
||||
background-color: var(--bg-light-gray);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cart-count {
|
||||
display: inline-block;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cart-items-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Cart item */
|
||||
.cart-item {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 10px;
|
||||
background-color: var(--bg-color);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.cart-item:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.cart-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cart-item-header strong {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.cart-item-details {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cart-item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.cart-field-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
min-width: 40px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cart-item-qty {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cart-item-discount {
|
||||
width: 65px !important;
|
||||
text-align: center;
|
||||
padding: 3px 6px !important;
|
||||
}
|
||||
|
||||
.cart-item-price {
|
||||
margin-top: 6px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cart-item-price strong {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Зачёркнутая цена при скидке */
|
||||
.line-through {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Предупреждение превышения остатка */
|
||||
.stock-warning {
|
||||
margin-top: 5px;
|
||||
padding: 3px 8px;
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stock-warning i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.btn-remove-from-cart {
|
||||
padding: 2px 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cart-footer {
|
||||
padding: 10px;
|
||||
background-color: var(--bg-light-gray);
|
||||
border-radius: var(--border-radius);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cart-total {
|
||||
font-size: 16px;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cart-total strong {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.cart-total span {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Адаптация для разных размеров экрана */
|
||||
@media (max-width: 1400px) {
|
||||
.selection-filters {
|
||||
flex: 0 0 210px;
|
||||
}
|
||||
|
||||
.selection-cart {
|
||||
flex: 0 0 255px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.selection-dialog-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.selection-filters,
|
||||
.selection-cart {
|
||||
flex: 0 0 auto;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.selection-filters {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.selection-cart {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Общие улучшения */
|
||||
.selection-dialog-content .btn-primary {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selection-dialog-content .form-control:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(var(--primary-color-rgb), 0.25);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.selection-items-grid::-webkit-scrollbar,
|
||||
.cart-items-list::-webkit-scrollbar,
|
||||
.item-group-tree::-webkit-scrollbar,
|
||||
.selection-filters::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.selection-items-grid::-webkit-scrollbar-track,
|
||||
.cart-items-list::-webkit-scrollbar-track,
|
||||
.item-group-tree::-webkit-scrollbar-track,
|
||||
.selection-filters::-webkit-scrollbar-track {
|
||||
background: var(--bg-light-gray);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.selection-items-grid::-webkit-scrollbar-thumb,
|
||||
.cart-items-list::-webkit-scrollbar-thumb,
|
||||
.item-group-tree::-webkit-scrollbar-thumb,
|
||||
.selection-filters::-webkit-scrollbar-thumb {
|
||||
background: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.selection-items-grid::-webkit-scrollbar-thumb:hover,
|
||||
.cart-items-list::-webkit-scrollbar-thumb:hover,
|
||||
.item-group-tree::-webkit-scrollbar-thumb:hover,
|
||||
.selection-filters::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-color);
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Интеграция формы подбора с Sales Invoice
|
||||
*/
|
||||
|
||||
frappe.ui.form.on('Sales Invoice', {
|
||||
refresh: function(frm) {
|
||||
if (frm.doc.docstatus === 0) { // Только для черновиков
|
||||
setup_selection_button(frm);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function setup_selection_button(frm) {
|
||||
const grid = frm.fields_dict.items.grid;
|
||||
|
||||
// Удаляем стандартную кнопку "Add Multiple" и нашу предыдущую (если есть)
|
||||
grid.wrapper.find('.grid-add-multiple-rows').remove();
|
||||
|
||||
// Вставляем нашу кнопку сразу после "Add Row" — в то же место, где была оригинальная.
|
||||
// Это гарантирует одинаковое поведение: кнопка сдвигается вправо вместе с "Add Row"
|
||||
// когда появляются Delete/Duplicate кнопки при выборе строк.
|
||||
$('<button type="button" class="grid-add-multiple-rows btn btn-xs btn-secondary">')
|
||||
.html(__('Add Multiple'))
|
||||
.insertAfter(grid.wrapper.find('.grid-add-row'))
|
||||
.on('click', function(e) {
|
||||
// Предотвращаем срабатывание чужих обработчиков на этой же кнопке
|
||||
// (Frappe привязывает свой хендлер через set_multiple_add к классу grid-add-multiple-rows)
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
if (!frm.doc.customer) {
|
||||
frappe.msgprint({
|
||||
title: __('Warning'),
|
||||
message: __('Please select Customer first'),
|
||||
indicator: 'orange'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const selection_dialog = new SelectionDialog({
|
||||
frm: frm,
|
||||
doctype: 'Sales Invoice'
|
||||
});
|
||||
selection_dialog.show();
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue