docs: add MoySklad orders sync implementation plan

This commit is contained in:
abounoone 2026-03-20 03:35:52 +00:00
parent cc3e5f6e6a
commit 21a39e88e8

View file

@ -0,0 +1,754 @@
# МойСклад: синхронизация заказов покупателей — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Добавить синхронизацию заказов покупателей (customerorder) из МойСклада в очередь комплектации наравне с перемещениями.
**Architecture:** Расширяем существующий `Picking List` полем `source_type`, добавляем префикс `move:`/`order:` к `ms_id` (снимаем unique-констрейнт), добавляем `sync_picking_orders()` и `sync_all()` в `api.py`, обновляем UI попапа.
**Tech Stack:** Python (Frappe), JSON DocType definitions, vanilla JS (jQuery/Frappe dialogs)
---
## Файловая структура
| Файл | Действие | Что меняем |
|------|----------|------------|
| `/home/mkr/picking_app/picking/doctype/picking_settings/picking_settings.json` | Modify | Добавить поле `ms_order_state` |
| `/home/mkr/picking_app/picking/doctype/picking_list/picking_list.json` | Modify | Добавить `source_type`, убрать `unique` с `ms_id` |
| `/home/mkr/picking_app/picking/api.py` | Modify | `sync_picking_list()` backfill, новые `sync_picking_orders()` + `sync_all()`, фикс бага `added_rows`, обновить `get_picking_list_items()` |
| `/home/mkr/picking_app/picking/page/picking_doc/picking_doc.js` | Modify | UI: кнопка → `sync_all`, колонка Тип, фильтр-табы |
> **Важно:** `/home/mkr/picking_app/` смонтирован как read-only том в контейнер `frappe-project-backend-1` в `/home/frappe/frappe-bench/apps/picking_app/picking_app/`. Все изменения делаются в файлах на хосте. После изменений JS нужно пересобрать: `docker exec frappe-project-backend-1 bash -c "cd /home/frappe/frappe-bench && bench --site erp.local migrate"`. После изменений JSON-схем нужен `bench migrate`. После изменений JS — `bench build --app picking_app` или перезагрузка страницы в dev-режиме.
---
## Task 1: Добавить поле `ms_order_state` в Picking Settings
**Files:**
- Modify: `/home/mkr/picking_app/picking/doctype/picking_settings/picking_settings.json`
- [ ] **Step 1: Открыть файл и найти конец массива `fields`**
```bash
cat /home/mkr/picking_app/picking/doctype/picking_settings/picking_settings.json
```
- [ ] **Step 2: Добавить поле после `ms_account_id`**
В массив `fields` добавить перед закрывающей `]`:
```json
,{
"fieldname": "ms_order_state",
"fieldtype": "Data",
"label": "Статус заказа для синхронизации",
"description": "Название статуса в МоёмСкладе для фильтрации заказов покупателей",
"default": "Подтверждён",
"reqd": 1
}
```
- [ ] **Step 3: Применить миграцию**
```bash
docker exec frappe-project-backend-1 bash -c "cd /home/frappe/frappe-bench && bench --site erp.local migrate"
```
Ожидаемый вывод: `Migrating erp.local``Done`
- [ ] **Step 4: Проверить что поле появилось**
```bash
echo "
result = frappe.db.get_value('Picking Settings', 'Default', 'ms_order_state')
print('ms_order_state:', repr(result))
" | docker exec -i frappe-project-backend-1 bash -c "cd /home/frappe/frappe-bench && bench --site erp.local console" 2>&1 | grep "ms_order_state:"
```
Ожидаемый вывод: `ms_order_state: 'Подтверждён'`
- [ ] **Step 5: Commit**
```bash
cd /home/mkr/picking_app
git add picking/doctype/picking_settings/picking_settings.json
git commit -m "feat: add ms_order_state field to Picking Settings"
```
---
## Task 2: Обновить Picking List — добавить `source_type`, убрать `unique` с `ms_id`
**Files:**
- Modify: `/home/mkr/picking_app/picking/doctype/picking_list/picking_list.json`
- [ ] **Step 1: Убрать `"unique": 1` из поля `ms_id`**
В `picking_list.json` найти блок поля `ms_id`:
```json
{
"fieldname": "ms_id",
"fieldtype": "Data",
"label": "МойСклад ID",
"unique": 1,
"reqd": 1
}
```
Изменить на:
```json
{
"fieldname": "ms_id",
"fieldtype": "Data",
"label": "МойСклад ID",
"reqd": 1
}
```
- [ ] **Step 2: Добавить поле `source_type` после поля `synced_at`**
После блока `synced_at`, перед `items`:
```json
,{
"fieldname": "source_type",
"fieldtype": "Select",
"label": "Тип источника",
"options": "\nMove\nOrder",
"default": "Move"
}
```
- [ ] **Step 3: Применить миграцию**
```bash
docker exec frappe-project-backend-1 bash -c "cd /home/frappe/frappe-bench && bench --site erp.local migrate"
```
- [ ] **Step 4: Проверить что поле создалось и unique снят**
```bash
echo "
exec('''
import frappe
# Проверить что source_type существует
cols = frappe.db.get_table_columns(\"tabPicking List\")
print(\"source_type in columns:\", \"source_type\" in cols)
# Проверить что unique снят — должна пройти вставка двух записей с одинаковым ms_id (НЕ делаем, просто проверим мета)
meta = frappe.get_meta(\"Picking List\")
ms_id_field = next((f for f in meta.fields if f.fieldname == \"ms_id\"), None)
print(\"ms_id unique:\", getattr(ms_id_field, \"unique\", None))
''')
" | docker exec -i frappe-project-backend-1 bash -c "cd /home/frappe/frappe-bench && bench --site erp.local console" 2>&1 | grep -E "source_type|ms_id unique"
```
Ожидаемый вывод:
```
source_type in columns: True
ms_id unique: 0
```
- [ ] **Step 5: Commit**
```bash
cd /home/mkr/picking_app
git add picking/doctype/picking_list/picking_list.json
git commit -m "feat: add source_type to Picking List, remove unique from ms_id"
```
---
## Task 3: Миграция существующих записей Picking List — добавить префикс `move:` к ms_id
**Files:**
- Нет изменений в файлах — только разовая миграция данных в БД
> **Выполнить до Task 4.** Без этого шага после обновления `sync_picking_list()` существующие записи с чистыми UUID не будут найдены при поиске по `move:{uuid}` — возникнут дубликаты.
- [ ] **Step 1: Проверить количество записей для миграции**
```bash
echo "
exec('''
import frappe
total = frappe.db.count(\"Picking List\")
already_prefixed = frappe.db.sql(\"SELECT COUNT(*) FROM \`tabPicking List\` WHERE ms_id LIKE \\\"move:%\\\" OR ms_id LIKE \\\"order:%\\\"\")[0][0]
needs_migration = total - already_prefixed
print(f\"Всего: {total}, уже с префиксом: {already_prefixed}, требуют миграции: {needs_migration}\")
''', globals())
" | docker exec -i frappe-project-backend-1 bash -c "cd /home/frappe/frappe-bench && bench --site erp.local console" 2>&1 | grep "Всего:"
```
- [ ] **Step 2: Выполнить миграцию**
```bash
echo "
exec('''
import frappe
rows = frappe.db.sql(
\"SELECT name, ms_id FROM \`tabPicking List\` WHERE ms_id NOT LIKE \\\"move:%\\\" AND ms_id NOT LIKE \\\"order:%\\\"\",
as_dict=True
)
updated = 0
for row in rows:
new_ms_id = f\"move:{row.ms_id}\"
frappe.db.set_value(\"Picking List\", row.name, \"ms_id\", new_ms_id)
updated += 1
frappe.db.commit()
print(f\"Обновлено записей: {updated}\")
''', globals())
" | docker exec -i frappe-project-backend-1 bash -c "cd /home/frappe/frappe-bench && bench --site erp.local console" 2>&1 | grep "Обновлено"
```
Ожидаемый вывод: `Обновлено записей: N` (N ≥ 0, не ошибка)
- [ ] **Step 3: Верифицировать — не должно остаться записей без префикса**
```bash
echo "
exec('''
import frappe
orphans = frappe.db.sql(
\"SELECT COUNT(*) FROM \`tabPicking List\` WHERE ms_id NOT LIKE \\\"move:%\\\" AND ms_id NOT LIKE \\\"order:%\\\"\",
)[0][0]
print(\"Без префикса:\", orphans)
''', globals())
" | docker exec -i frappe-project-backend-1 bash -c "cd /home/frappe/frappe-bench && bench --site erp.local console" 2>&1 | grep "Без префикса:"
```
Ожидаемый вывод: `Без префикса: 0`
---
## Task 4: Обновить `sync_picking_list()` — prefixed ms_id + backfill source_type
**Files:**
- Modify: `/home/mkr/picking_app/picking/api.py`
Текущая логика `sync_picking_list()` ищет записи через `frappe.db.get_value("Picking List", {"ms_id": ms_id}, "name")` где `ms_id` — чистый UUID. После этого изменения поиск и хранение будут через `move:{uuid}`.
- [ ] **Step 1: Найти строку с `ms_id = move.get("id")`**
```bash
grep -n "ms_id\|existing\|source_type" /home/mkr/picking_app/picking/api.py | head -30
```
- [ ] **Step 2: Обновить функцию `sync_picking_list()`**
Найти блок (примерно строки 298365 в api.py):
```python
ms_id = move.get("id")
if not ms_id:
continue
...
existing = frappe.db.get_value("Picking List", {"ms_id": ms_id}, "name")
if existing:
pl = frappe.get_doc("Picking List", existing)
pl.ms_number = ms_number
...
pl.save(ignore_permissions=True)
updated += 1
else:
frappe.get_doc({
"doctype": "Picking List",
"ms_id": ms_id,
...
}).insert(ignore_permissions=True)
created += 1
```
Изменить на:
```python
raw_id = move.get("id")
if not raw_id:
continue
ms_id = f"move:{raw_id}"
...
existing = frappe.db.get_value("Picking List", {"ms_id": ms_id}, "name")
if existing:
pl = frappe.get_doc("Picking List", existing)
pl.ms_number = ms_number
pl.ms_date = ms_date
pl.from_warehouse = from_wh
pl.to_warehouse = to_wh
pl.synced_at = synced_at
pl.source_type = "Move" # backfill
if pl.status != "Added":
_update_pl_items(pl, positions)
pl.save(ignore_permissions=True)
updated += 1
else:
frappe.get_doc({
"doctype": "Picking List",
"ms_id": ms_id,
"ms_number": ms_number,
"ms_date": ms_date,
"from_warehouse": from_wh,
"to_warehouse": to_wh,
"status": "Draft",
"source_type": "Move",
"synced_at": synced_at,
"items": [_item_from_position(p) for p in positions],
}).insert(ignore_permissions=True)
created += 1
```
- [ ] **Step 3: Проверить синтаксис**
```bash
python3 -c "import ast; ast.parse(open('/home/mkr/picking_app/picking/api.py').read()); print('OK')"
```
- [ ] **Step 4: Commit**
```bash
cd /home/mkr/picking_app
git add picking/api.py
git commit -m "feat: prefix ms_id with move:, backfill source_type in sync_picking_list"
```
---
## Task 5: Добавить `sync_picking_orders()` и `sync_all()`
**Files:**
- Modify: `/home/mkr/picking_app/picking/api.py`
> **Порядок шагов важен:** сначала обновляем `_ms_fetch_all()` (Step 1), затем добавляем `sync_picking_orders()` которая его использует (Step 2).
- [ ] **Step 1: Обновить `_ms_fetch_all()` — добавить параметр `max_pages`**
Найти функцию `_ms_fetch_all`:
```python
def _ms_fetch_all(endpoint, params):
"""Получить все строки с автопагинацией через nextHref."""
headers = _ms_headers()
rows = []
url = f"{MS_BASE}/{endpoint}"
while url:
data = _ms_get(url, params=params, headers=headers)
rows.extend(data.get("rows", []))
url = data.get("meta", {}).get("nextHref")
params = None
return rows
```
Изменить на:
```python
def _ms_fetch_all(endpoint, params, max_pages=None):
"""Получить все строки с автопагинацией через nextHref.
max_pages: ограничить количество страниц (None = без ограничения).
"""
headers = _ms_headers()
rows = []
url = f"{MS_BASE}/{endpoint}"
page = 0
while url:
if max_pages and page >= max_pages:
break
data = _ms_get(url, params=params, headers=headers)
rows.extend(data.get("rows", []))
url = data.get("meta", {}).get("nextHref")
params = None
page += 1
return rows
```
- [ ] **Step 2: Добавить функцию `sync_picking_orders()` после `sync_picking_list()`**
Вставить после функции `sync_picking_list()` (перед `def _update_pl_items`):
```python
@frappe.whitelist()
def sync_picking_orders():
"""Синхронизировать заказы покупателей из МоёгоСклада в Picking List.
Фильтрация по state.name == ms_order_state (из Picking Settings).
Лимит: 10 страниц × 100 заказов = 1000 за синхронизацию.
"""
settings_name = frappe.db.get_value("Picking Settings", {}, "name")
order_state = (
frappe.db.get_value("Picking Settings", settings_name, "ms_order_state")
if settings_name else None
) or "Подтверждён"
orders = _ms_fetch_all("entity/customerorder", {
"limit": 100,
"order": "moment,desc",
"expand": "state,store,positions.assortment,positions.assortment.uom",
}, max_pages=10)
wh_cache = _build_wh_cache()
synced_at = now_datetime()
created = updated = 0
for order in orders:
state_obj = order.get("state") or {}
if state_obj.get("name") != order_state:
continue
raw_id = order.get("id")
if not raw_id:
continue
ms_id = f"order:{raw_id}"
ms_number = order.get("name", "")
ms_date = _parse_ms_datetime(order.get("moment", ""))
store_id, store_name = _extract_store_id(order.get("store"))
to_wh = wh_cache.get(store_id) or wh_cache.get(store_name)
positions = _positions_from_move(order)
existing = frappe.db.get_value("Picking List", {"ms_id": ms_id}, "name")
if existing:
pl = frappe.get_doc("Picking List", existing)
pl.ms_number = ms_number
pl.ms_date = ms_date
pl.to_warehouse = to_wh
pl.synced_at = synced_at
pl.source_type = "Order"
if pl.status != "Added":
_update_pl_items(pl, positions)
pl.save(ignore_permissions=True)
updated += 1
else:
frappe.get_doc({
"doctype": "Picking List",
"ms_id": ms_id,
"ms_number": ms_number,
"ms_date": ms_date,
"to_warehouse": to_wh,
"status": "Draft",
"source_type": "Order",
"synced_at": synced_at,
"items": [_item_from_position(p) for p in positions],
}).insert(ignore_permissions=True)
created += 1
frappe.db.commit()
return {"created": created, "updated": updated, "total": len(orders)}
@frappe.whitelist()
def sync_all():
"""Синхронизировать перемещения и заказы покупателей из МоёгоСклада."""
moves = sync_picking_list()
orders = sync_picking_orders()
return {
"status": "ok",
"moves": {"created": moves["created"], "updated": moves["updated"]},
"orders": {"created": orders["created"], "updated": orders["updated"]},
}
```
- [ ] **Step 3: Проверить синтаксис**
```bash
python3 -c "import ast; ast.parse(open('/home/mkr/picking_app/picking/api.py').read()); print('OK')"
```
- [ ] **Step 4: Проверить что функции доступны через bench**
```bash
echo "
exec('''
import picking_app.picking.api as api
print(\"sync_picking_orders:\", callable(api.sync_picking_orders))
print(\"sync_all:\", callable(api.sync_all))
''')
" | docker exec -i frappe-project-backend-1 bash -c "cd /home/frappe/frappe-bench && bench --site erp.local console" 2>&1 | grep -E "sync_picking|sync_all"
```
Ожидаемый вывод:
```
sync_picking_orders: True
sync_all: True
```
- [ ] **Step 5: Commit**
```bash
cd /home/mkr/picking_app
git add picking/api.py
git commit -m "feat: add sync_picking_orders() and sync_all()"
```
---
## Task 6: Исправить баг `added_rows` и обновить `get_picking_list_items()`
**Files:**
- Modify: `/home/mkr/picking_app/picking/api.py`
- [ ] **Step 1: Исправить `added_rows``added_row_names` в `add_items_from_picking_list()`**
Найти строку (примерно строка 462):
```python
return {"status": "ok", "added": len(added_rows), "items": updated_items}
```
Изменить на:
```python
return {"status": "ok", "added": len(added_row_names), "items": updated_items}
```
- [ ] **Step 2: Обновить `get_picking_list_items()` — добавить параметр `source_type` и поле в ответ**
Найти функцию:
```python
@frappe.whitelist()
def get_picking_list_items(from_warehouse=None, to_warehouse=None):
"""Вернуть Picking List для попап-диалога (только не полностью добавленные)."""
filters = {"status": ["!=", "Added"]}
if from_warehouse:
filters["from_warehouse"] = from_warehouse
if to_warehouse:
filters["to_warehouse"] = to_warehouse
pls = frappe.get_all(
"Picking List",
filters=filters,
fields=["name", "ms_id", "ms_number", "ms_date", "from_warehouse", "to_warehouse", "status", "synced_at"],
order_by="ms_date desc",
limit=200
)
```
Изменить на:
```python
@frappe.whitelist()
def get_picking_list_items(from_warehouse=None, to_warehouse=None, source_type=None):
"""Вернуть Picking List для попап-диалога (только не полностью добавленные).
source_type=None — все записи (включая legacy без source_type).
source_type="Move" — Move + NULL (обратная совместимость).
source_type="Order" — только Order.
"""
filters = {"status": ["!=", "Added"]}
if from_warehouse:
filters["from_warehouse"] = from_warehouse
if to_warehouse:
filters["to_warehouse"] = to_warehouse
if source_type == "Order":
filters["source_type"] = "Order"
elif source_type == "Move":
# Frappe хранит пустой Select как пустую строку, не NULL
# Пустая строка = legacy-запись до добавления source_type (считается Move)
filters["source_type"] = ["in", ["Move", ""]]
pls = frappe.get_all(
"Picking List",
filters=filters,
fields=["name", "ms_id", "ms_number", "ms_date", "from_warehouse",
"to_warehouse", "status", "synced_at", "source_type"],
order_by="ms_date desc",
limit=200
)
```
- [ ] **Step 3: Проверить синтаксис**
```bash
python3 -c "import ast; ast.parse(open('/home/mkr/picking_app/picking/api.py').read()); print('OK')"
```
- [ ] **Step 4: Commit**
```bash
cd /home/mkr/picking_app
git add picking/api.py
git commit -m "fix: added_rows NameError in add_items_from_picking_list; feat: source_type filter in get_picking_list_items"
```
---
## Task 7: Обновить UI попапа — фильтр-табы и колонка Тип
**Files:**
- Modify: `/home/mkr/picking_app/picking/page/picking_doc/picking_doc.js`
Все изменения в методах `_showMsDialog()` и `_loadMsList()`.
- [ ] **Step 1: Добавить таб-фильтр в HTML диалога**
В `_showMsDialog()` найти HTML фильтров (строки с `ms-filter-from`, `ms-filter-to`) и добавить после них строку с табами:
```html
<div id="ms-type-filter" style="display:flex;gap:6px;margin-bottom:8px;">
<button class="btn btn-xs btn-default ms-type-btn active" data-type="">Все</button>
<button class="btn btn-xs btn-default ms-type-btn" data-type="Move">Перемещения</button>
<button class="btn btn-xs btn-default ms-type-btn" data-type="Order">Заказы</button>
</div>
```
- [ ] **Step 2: Добавить обработчик клика по табам**
В `_showMsDialog()` добавить обработчик после других `dialog.$body.on(...)`:
```javascript
dialog.$body.on('click', '.ms-type-btn', function() {
dialog.$body.find('.ms-type-btn').removeClass('active btn-primary').addClass('btn-default');
$(this).removeClass('btn-default').addClass('btn-primary active');
self._loadMsList(dialog);
});
```
- [ ] **Step 3: Обновить кнопку синхронизации — вызывать `sync_all` вместо `sync_picking_list`**
Найти:
```javascript
method: 'picking_app.picking.api.sync_picking_list'
```
Заменить на:
```javascript
method: 'picking_app.picking.api.sync_all'
```
Найти строку с отображением результата:
```javascript
$status.text(`Готово: создано ${d.created || 0}, обновлено ${d.updated || 0} (всего ${d.total || 0})`);
```
Заменить на:
```javascript
const m = d.moves || {};
const o = d.orders || {};
$status.text(
`Перемещения: +${m.created || 0} / ~${m.updated || 0} | Заказы: +${o.created || 0} / ~${o.updated || 0}`
);
```
- [ ] **Step 4: Передавать `source_type` при вызове `get_picking_list_items`**
В методе `_loadMsList(dialog)` найти весь блок:
```javascript
async _loadMsList(dialog) {
const from_wh = dialog.$body.find('#ms-filter-from').val().trim();
const to_wh = dialog.$body.find('#ms-filter-to').val().trim();
const $cont = dialog.$body.find('#ms-list-container');
$cont.html('<div style="text-align:center;color:#888;padding:20px">Загрузка...</div>');
try {
const r = await frappe.call({
method: 'picking_app.picking.api.get_picking_list_items',
args: { from_warehouse: from_wh || null, to_warehouse: to_wh || null }
});
```
Заменить на:
```javascript
async _loadMsList(dialog) {
const from_wh = dialog.$body.find('#ms-filter-from').val().trim();
const to_wh = dialog.$body.find('#ms-filter-to').val().trim();
const activeType = dialog.$body.find('.ms-type-btn.active').data('type') || null;
const $cont = dialog.$body.find('#ms-list-container');
$cont.html('<div style="text-align:center;color:#888;padding:20px">Загрузка...</div>');
try {
const r = await frappe.call({
method: 'picking_app.picking.api.get_picking_list_items',
args: { from_warehouse: from_wh || null, to_warehouse: to_wh || null, source_type: activeType }
});
```
- [ ] **Step 5: Добавить колонку "Тип" в заголовок каждой строки Picking List**
В методе `_loadMsList()` в функции рендеринга строки (`pls.map(pl => {...})`) найти строку с `from` и `to`:
```javascript
const from = pl.from_warehouse || '?';
const to = pl.to_warehouse || '?';
```
Добавить после:
```javascript
const typeLabel = pl.source_type === 'Order'
? '<span class="ms-badge" style="background:#cfe2ff;color:#084298">Заказ</span>'
: '<span class="ms-badge" style="background:#e9ecef;color:#666">Перемещение</span>';
```
Добавить `${typeLabel}` в шаблон строки `.ms-pl-head` после `${statusBadge}`:
```javascript
${statusBadge}
${typeLabel}
```
- [ ] **Step 6: Пересобрать фронтенд и проверить**
```bash
docker exec frappe-project-backend-1 bash -c "cd /home/frappe/frappe-bench && bench build --app picking_app" 2>&1 | tail -5
```
Открыть страницу Picking Document в браузере, нажать кнопку добавления из МС — проверить:
- Появились табы Все / Перемещения / Заказы
- В строках видна метка типа
- Синхронизация показывает статистику по двум типам
- [ ] **Step 7: Commit**
```bash
cd /home/mkr/picking_app
git add picking/page/picking_doc/picking_doc.js
git commit -m "feat: add source_type filter tabs and type badge to MoySklad popup"
```
---
## Task 8: Финальная проверка end-to-end
- [ ] **Step 1: Убедиться что токен заполнен**
Открыть в ERPNext: **Picking Settings → Default**, секция МойСклад, поле API-токен должно быть заполнено.
- [ ] **Step 2: Запустить `sync_all` вручную**
```bash
echo "
exec('''
import picking_app.picking.api as api
result = api.sync_all()
print(result)
''', globals())
" | docker exec -i frappe-project-backend-1 bash -c "cd /home/frappe/frappe-bench && bench --site erp.local console" 2>&1 | grep -E "moves|orders|status"
```
Ожидаемый вывод вида:
```
{'status': 'ok', 'moves': {'created': N, 'updated': N}, 'orders': {'created': N, 'updated': N}}
```
- [ ] **Step 3: Проверить записи в БД**
```bash
echo "
exec('''
import frappe
moves = frappe.db.count(\"Picking List\", {\"source_type\": \"Move\"})
orders = frappe.db.count(\"Picking List\", {\"source_type\": \"Order\"})
nulls = frappe.db.count(\"Picking List\", {\"source_type\": [\"\", None]})
print(f\"Move: {moves}, Order: {orders}, NULL/legacy: {nulls}\")
''', globals())
" | docker exec -i frappe-project-backend-1 bash -c "cd /home/frappe/frappe-bench && bench --site erp.local console" 2>&1 | grep -E "Move:|Order:"
```
- [ ] **Step 4: Проверить `get_picking_list_items` с фильтром**
```bash
echo "
exec('''
import picking_app.picking.api as api
all_items = api.get_picking_list_items()
order_items = api.get_picking_list_items(source_type=\"Order\")
move_items = api.get_picking_list_items(source_type=\"Move\")
print(f\"All: {len(all_items)}, Orders: {len(order_items)}, Moves: {len(move_items)}\")
# Проверяем что source_type возвращается
if all_items:
print(\"source_type in response:\", \"source_type\" in all_items[0])
''', globals())
" | docker exec -i frappe-project-backend-1 bash -c "cd /home/frappe/frappe-bench && bench --site erp.local console" 2>&1 | grep -E "All:|source_type"
```
- [ ] **Step 5: Final commit tag**
```bash
cd /home/mkr/picking_app
git log --oneline -7
```