Compare commits

..

3 commits

Author SHA1 Message Date
nurdotnet e9a82dd528 fix(moysklad): убираем выдумку Kind полностью — у MoySklad этого поля нет
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Images / API image (push) Waiting to run
Docker Images / Web image (push) Waiting to run
Проверил через API под реальным токеном (entity/counterparty?expand=group,tags):
у MoySklad **нет** поля «Поставщик/Покупатель» у контрагентов вообще. Есть только:
- group (группа доступа сотрудников, у всех "Основной")
- tags (произвольные ярлыки, у большинства пусто)
- state (пользовательская цепочка статусов)
- companyType (legal/individual/entrepreneur — это наш Type)

Один и тот же контрагент может быть поставщиком в одной приёмке и покупателем
в другом чеке — классификация контекстная, не атрибут сущности.

Изменения:
- ImportCounterpartiesAsync.ResolveKind теперь ВСЕГДА возвращает Unspecified.
  Никаких эвристик по тегам — просто null для Kind.
- useSuppliers хук теперь useCounterparties — возвращает ВСЕХ контрагентов,
  не фильтрует по Kind. Селекторы поставщика в Supply/RetailSale показывают
  всех. Пользователь сам выбирает кто поставщик в этом конкретном документе.
- Создание контрагента в UI: дефолт Kind = Unspecified, не Supplier.

Поле Kind в нашей модели остаётся для пользователей которые сами хотят
классифицировать. Но импорт его не трогает.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:01:10 +05:00
nurdotnet 50f12ef7f0 fix(moysklad): не выдумывать Kind=Both для импортированных контрагентов
У MoySklad НЕТ встроенного поля «Поставщик/Покупатель» у контрагентов —
эта классификация целиком пользовательская через теги или группы. Импорт
ставил Kind=Both дефолтом когда тегов не было, что искажало данные:
все 586 контрагентов на stage стали «Оба», хотя в MoySklad ничего такого
не было.

- CounterpartyKind: добавлен Unspecified=0 как дефолт
- ImportCounterpartiesAsync.ResolveKind: возвращает Unspecified когда
  тегов нет; Both только если в тегах ОБА маркера ("постав" + "покуп");
  иначе один из конкретных
- UI: dropdown получил опцию «Не указано», лейбл «Оба» переименован в
  «Поставщик + Покупатель» (точнее)
- Существующие данные: SQL UPDATE Kind=3 → Kind=0 на stage (586 строк)
  и dev (0 строк, локально пусто)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:01:10 +05:00
nurdotnet d455087bc8 feat(ops): Telegram <-> tmux bridge + local docker-registry unit
Telegram bridge lets me drive the local Claude Code tmux session from my
phone — inbound messages are typed into the 'claude' session, pane diffs
are streamed back as plain Telegram messages (TUI noise, tool-call
blocks, echoed user input and already-sent lines are filtered so only
the assistant's actual reply reaches the chat). Deployed as
food-market-telegram-bridge.service, reads creds from
/etc/food-market/telegram.env (not committed).

Also committing the local docker-registry unit for reproducibility —
registry:2 on 127.0.0.1:5001, data persisted in
/opt/food-market-data/docker-registry.

Setup docs in docs/telegram-bridge.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:53:45 +05:00
10 changed files with 581 additions and 19 deletions

View file

@ -0,0 +1,19 @@
[Unit]
Description=Local Docker Registry for food-market
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=simple
ExecStartPre=-/usr/bin/docker rm -f food-market-registry
ExecStart=/usr/bin/docker run --rm --name food-market-registry \
-p 127.0.0.1:5001:5000 \
-v /opt/food-market-data/docker-registry:/var/lib/registry \
-e REGISTRY_STORAGE_DELETE_ENABLED=true \
registry:2
ExecStop=/usr/bin/docker stop food-market-registry
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,432 @@
"""Telegram <-> tmux bridge for controlling a local Claude Code session from a phone.
Reads creds from /etc/food-market/telegram.env (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID).
Only the single whitelisted chat_id is allowed; everything else is silently ignored.
Inbound: each Telegram message is typed into tmux session 'claude' via `tmux send-keys
-t claude -l <text>` followed by an Enter keypress.
Outbound: every poll_interval seconds, capture the current pane, diff against the last
snapshot, filter TUI noise (box-drawing, spinners, the user's own echoed prompt), then
send any remaining text as plain Telegram messages.
"""
from __future__ import annotations
import asyncio
import collections
import logging
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from telegram import Update
from telegram.ext import (
Application,
ApplicationBuilder,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)
ENV_FILE = Path("/etc/food-market/telegram.env")
TMUX_SESSION = os.environ.get("TMUX_SESSION", "claude")
POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL_SEC", "8"))
CAPTURE_HISTORY = int(os.environ.get("CAPTURE_HISTORY_LINES", "200"))
TG_MAX_CHARS = 3500
MAX_SEND_PER_TICK = int(os.environ.get("MAX_SEND_CHARS_PER_TICK", "900"))
ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]")
BOX_CHARS = set("╭╮╰╯│─┌┐└┘├┤┬┴┼║═╔╗╚╝╠╣╦╩╬▌▐█▀▄")
SPINNER_CHARS = set("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣾⣽⣻⢿⡿⣟⣯⣷")
# Claude Code TUI markers
TOOL_CALL_RE = re.compile(r"^\s*[⏺●⏻◯◎⬤]\s+\S")
TOOL_RESULT_RE = re.compile(r"^\s*⎿")
USER_PROMPT_RE = re.compile(r"^>\s?(.*)$")
def load_env(path: Path) -> dict[str, str]:
out: dict[str, str] = {}
if not path.exists():
return out
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
out[key.strip()] = value.strip().strip('"').strip("'")
return out
@dataclass
class BridgeState:
chat_id: int
last_snapshot: str = ""
last_sent_text: str = ""
recent_user_inputs: collections.deque = field(
default_factory=lambda: collections.deque(maxlen=50)
)
recently_sent_lines: collections.deque = field(
default_factory=lambda: collections.deque(maxlen=400)
)
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
async def tmux_send_text(session: str, text: str) -> None:
proc = await asyncio.create_subprocess_exec(
"tmux", "send-keys", "-t", session, "-l", text,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"tmux send-keys -l failed: {stderr.decode().strip()}")
proc = await asyncio.create_subprocess_exec(
"tmux", "send-keys", "-t", session, "Enter",
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"tmux send-keys Enter failed: {stderr.decode().strip()}")
async def tmux_capture(session: str) -> str:
proc = await asyncio.create_subprocess_exec(
"tmux", "capture-pane", "-t", session, "-p", "-S", f"-{CAPTURE_HISTORY}",
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"tmux capture-pane failed: {stderr.decode().strip()}")
return stdout.decode("utf-8", errors="replace").rstrip("\n")
def _strip_box(line: str) -> str:
# Drop leading/trailing box-drawing chars and their padding.
while line and (line[0] in BOX_CHARS or (line[0] == " " and len(line) > 1 and line[1] in BOX_CHARS)):
line = line[1:].lstrip()
if not line:
break
while line and (line[-1] in BOX_CHARS or (line[-1] == " " and len(line) > 1 and line[-2] in BOX_CHARS)):
line = line[:-1].rstrip()
if not line:
break
return line
def _is_noise(line: str) -> bool:
s = line.strip()
if not s:
return True
# Lines made of only box/spinner/decoration chars + spaces.
if all(c in BOX_CHARS or c in SPINNER_CHARS or c.isspace() for c in s):
return True
# Claude Code TUI hints.
lowered = s.lower()
if "shift+tab" in lowered or "bypass permissions" in lowered:
return True
if lowered.startswith("? for shortcuts"):
return True
# Spinner + status line ("✻ Thinking…", "· Pondering…").
if s.startswith(("", "", "", "·")) and len(s) < 80:
return True
# Typing indicator prompt ("> " empty or near-empty input box).
if s.startswith(">") and len(s) <= 2:
return True
return False
def clean_text(text: str, recent_user: collections.deque | None = None) -> str:
"""Strip TUI noise, tool-call blocks and echoed user input.
Only the assistant's prose reply should survive.
"""
recent_user = recent_user if recent_user is not None else collections.deque()
out: list[str] = []
in_tool_block = False
for raw in text.splitlines():
line = ANSI_RE.sub("", raw).rstrip()
line = _strip_box(line)
stripped = line.strip()
if not stripped:
in_tool_block = False
out.append("")
continue
# Tool call / tool result blocks — skip the header and any indented follow-ups.
if TOOL_CALL_RE.match(line) or TOOL_RESULT_RE.match(line):
in_tool_block = True
continue
if in_tool_block:
# continuation of a tool block is usually indented; a flush-left line ends it
if line.startswith(" ") or line.startswith("\t"):
continue
in_tool_block = False
# Echo of the user's own prompt ("> hello") — drop it.
m = USER_PROMPT_RE.match(stripped)
if m:
continue
if stripped in recent_user:
continue
if _is_noise(line):
continue
out.append(line)
# collapse runs of blank lines
collapsed: list[str] = []
prev_blank = False
for line in out:
blank = not line.strip()
if blank and prev_blank:
continue
collapsed.append(line)
prev_blank = blank
return "\n".join(collapsed).strip("\n")
def diff_snapshot(prev: str, curr: str) -> str:
"""Return only lines that weren't already present anywhere in the previous snapshot.
Set-based: handles TUI scrolling and partial redraws without re-sending history.
"""
if not prev:
return curr
if prev == curr:
return ""
prev_set = set(prev.splitlines())
new_lines = [ln for ln in curr.splitlines() if ln.rstrip() and ln not in prev_set]
return "\n".join(new_lines)
def chunk_for_telegram(text: str, limit: int = TG_MAX_CHARS) -> list[str]:
if not text:
return []
out: list[str] = []
buf: list[str] = []
buf_len = 0
for line in text.splitlines():
if buf_len + len(line) + 1 > limit and buf:
out.append("\n".join(buf))
buf, buf_len = [], 0
while len(line) > limit:
out.append(line[:limit])
line = line[limit:]
buf.append(line)
buf_len += len(line) + 1
if buf:
out.append("\n".join(buf))
return out
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
state: BridgeState = context.application.bot_data["state"]
if update.effective_chat is None or update.effective_chat.id != state.chat_id:
return
text = (update.message.text or "").strip() if update.message else ""
if not text:
return
# Remember what we sent so we can suppress its echo from the pane capture.
async with state._lock:
state.recent_user_inputs.append(text)
# Also store reasonable substrings in case the TUI wraps or truncates
if len(text) > 40:
state.recent_user_inputs.append(text[:40])
try:
await tmux_send_text(TMUX_SESSION, text)
except Exception as exc: # noqa: BLE001
await update.message.reply_text(f"⚠️ tmux error: {exc}")
async def cmd_ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
state: BridgeState = context.application.bot_data["state"]
if update.effective_chat is None or update.effective_chat.id != state.chat_id:
return
await update.message.reply_text(
f"pong — session '{TMUX_SESSION}', poll {POLL_INTERVAL}s"
)
async def cmd_snapshot(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
state: BridgeState = context.application.bot_data["state"]
if update.effective_chat is None or update.effective_chat.id != state.chat_id:
return
try:
snap = await tmux_capture(TMUX_SESSION)
except Exception as exc: # noqa: BLE001
await update.message.reply_text(f"⚠️ tmux error: {exc}")
return
async with state._lock:
cleaned = clean_text(snap, state.recent_user_inputs)
state.last_snapshot = snap # reset baseline so poller doesn't resend
for part in chunk_for_telegram(cleaned) or ["(nothing to show)"]:
await update.message.reply_text(part)
async def poll_and_forward(application: Application) -> None:
state: BridgeState = application.bot_data["state"]
bot = application.bot
logger = logging.getLogger("bridge.poll")
while True:
await asyncio.sleep(POLL_INTERVAL)
# Stability check: capture twice, ~1.5s apart. If pane still changes, assistant
# is still streaming — skip this tick and try next time.
try:
snap1 = await tmux_capture(TMUX_SESSION)
except Exception as exc: # noqa: BLE001
logger.warning("capture failed: %s", exc)
continue
await asyncio.sleep(1.5)
try:
snap2 = await tmux_capture(TMUX_SESSION)
except Exception as exc: # noqa: BLE001
logger.warning("capture failed: %s", exc)
continue
if snap1 != snap2:
# still being written — don't send partials
continue
snapshot = snap2
async with state._lock:
prev = state.last_snapshot
state.last_snapshot = snapshot
recent_user_copy = list(state.recent_user_inputs)
recently_sent_copy = list(state.recently_sent_lines)
raw_new = diff_snapshot(prev, snapshot)
new_text = clean_text(raw_new, collections.deque(recent_user_copy))
if not new_text:
continue
# Line-level dedup vs. what we already shipped: drop lines that are
# substring-equivalent to a recently sent one (handles streaming dupes).
deduped: list[str] = []
for line in new_text.splitlines():
s = line.rstrip()
if not s.strip():
deduped.append(line)
continue
ss = s.strip()
is_dup = False
for past in recently_sent_copy:
if ss == past:
is_dup = True
break
if len(ss) >= 15 and len(past) >= 15 and (ss in past or past in ss):
is_dup = True
break
if is_dup:
continue
deduped.append(line)
recently_sent_copy.append(ss)
async with state._lock:
state.recently_sent_lines.clear()
state.recently_sent_lines.extend(recently_sent_copy[-400:])
new_text = "\n".join(deduped).strip("\n")
if not new_text:
continue
async with state._lock:
if new_text == state.last_sent_text:
continue
state.last_sent_text = new_text
# Cap total outbound per tick so a big burst doesn't flood Telegram.
if len(new_text) > MAX_SEND_PER_TICK:
keep = new_text[-MAX_SEND_PER_TICK:]
# snap to next newline to avoid cutting mid-line
nl = keep.find("\n")
if 0 <= nl < 200:
keep = keep[nl + 1 :]
dropped = len(new_text) - len(keep)
new_text = f"… (+{dropped} chars earlier)\n{keep}"
for part in chunk_for_telegram(new_text):
try:
await bot.send_message(
chat_id=state.chat_id,
text=part,
disable_notification=True,
)
except Exception as exc: # noqa: BLE001
logger.warning("telegram send failed: %s", exc)
break
async def on_startup(application: Application) -> None:
state: BridgeState = application.bot_data["state"]
try:
state.last_snapshot = await tmux_capture(TMUX_SESSION)
except Exception: # noqa: BLE001
state.last_snapshot = ""
application.bot_data["poll_task"] = asyncio.create_task(
poll_and_forward(application), name="bridge-poll"
)
async def on_shutdown(application: Application) -> None:
task = application.bot_data.get("poll_task")
if task:
task.cancel()
def main() -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
env = {**os.environ, **load_env(ENV_FILE)}
token = env.get("TELEGRAM_BOT_TOKEN", "").strip()
chat_id_raw = env.get("TELEGRAM_CHAT_ID", "").strip()
if not token or not chat_id_raw:
print(
"ERROR: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID must be set in "
f"{ENV_FILE} or environment",
file=sys.stderr,
)
return 78
try:
chat_id = int(chat_id_raw)
except ValueError:
print(f"ERROR: TELEGRAM_CHAT_ID must be an integer, got: {chat_id_raw!r}",
file=sys.stderr)
return 78
check = subprocess.run(
["tmux", "has-session", "-t", TMUX_SESSION],
capture_output=True, text=True,
)
if check.returncode != 0:
print(
f"WARNING: tmux session '{TMUX_SESSION}' not found — bridge will run "
"but send/capture will fail until the session is created.",
file=sys.stderr,
)
application = (
ApplicationBuilder()
.token(token)
.post_init(on_startup)
.post_shutdown(on_shutdown)
.build()
)
application.bot_data["state"] = BridgeState(chat_id=chat_id)
application.add_handler(CommandHandler("ping", cmd_ping))
application.add_handler(CommandHandler("snapshot", cmd_snapshot))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
application.run_polling(allowed_updates=Update.ALL_TYPES, stop_signals=None)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1 @@
python-telegram-bot[rate-limiter]==21.6

View file

@ -0,0 +1,19 @@
[Unit]
Description=food-market Telegram <-> tmux bridge
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=nns
Group=nns
WorkingDirectory=/opt/food-market-data/telegram-bridge
EnvironmentFile=-/etc/food-market/telegram.env
ExecStart=/opt/food-market-data/telegram-bridge/venv/bin/python /opt/food-market-data/telegram-bridge/bridge.py
Restart=on-failure
RestartSec=10
# Access tmux sockets under /tmp/tmux-1000/
Environment=TMUX_TMPDIR=/tmp
[Install]
WantedBy=multi-user.target

86
docs/telegram-bridge.md Normal file
View file

@ -0,0 +1,86 @@
# Telegram ↔ tmux bridge
Управление локальной сессией Claude Code с телефона через Telegram-бота. Входящее сообщение от whitelisted `chat_id` набирается в tmux-сессию `claude` как будто вы сами печатаете; ответный вывод пайнa каждые ~2.5 с отправляется обратно в чат.
## Как это выглядит в работе
- Вы пишете боту: `запусти тесты`
- Бот делает `tmux send-keys -t claude -l "запусти тесты" && tmux send-keys -t claude Enter` — текст попадает в поле ввода Claude
- Фоновый поллер раз в 2.5 с снимает `tmux capture-pane`, сравнивает с предыдущим снапшотом, присылает новые строки как `<pre>…</pre>`-блок
Команды бота:
- `/ping` — живой ли, какая сессия и интервал
- `/snapshot` — выслать полный текущий пайн (полезно после длинного молчания или после рестарта)
## Один раз — настройка
### 1. Креды
Положите в `/etc/food-market/telegram.env`:
```
TELEGRAM_BOT_TOKEN=<токен от @BotFather>
TELEGRAM_CHAT_ID=<ваш личный chat_id, целое число>
```
Узнать `chat_id` — напишите `@userinfobot` в Telegram, он ответит с вашим id. Файл доступен только владельцу (`chmod 600`).
Только сообщения от этого **одного** chat_id будут обработаны — всё остальное молча игнорируется.
### 2. tmux-сессия `claude`
Бот ожидает существующую сессию с именем `claude`. Создайте её как обычно:
```bash
tmux new-session -d -s claude
tmux attach -t claude # и запустите внутри `claude` (или что там у вас)
```
Сервис стартует даже без сессии — в лог упадёт warning, но `send-keys` / `capture-pane` начнут работать как только сессия появится. Имя сессии можно переопределить через env `TMUX_SESSION=other` в юните.
### 3. Старт сервиса
```bash
sudo systemctl enable --now food-market-telegram-bridge.service
```
В ответ бот пришлёт `✅ bridge up …` — это индикатор успеха.
## Эксплуатация
### Логи
```bash
sudo journalctl -u food-market-telegram-bridge.service -f
sudo journalctl -u food-market-telegram-bridge.service --since '10 min ago'
```
### Перезапуск
```bash
sudo systemctl restart food-market-telegram-bridge.service
```
### Остановить
```bash
sudo systemctl stop food-market-telegram-bridge.service # до ребута
sudo systemctl disable food-market-telegram-bridge.service # и после ребута
```
### Поменять интервал/сессию
Отредактируйте `/etc/systemd/system/food-market-telegram-bridge.service`, добавьте в секцию `[Service]`:
```
Environment=POLL_INTERVAL_SEC=1.5
Environment=TMUX_SESSION=other-session
Environment=CAPTURE_HISTORY_LINES=400
```
Затем `sudo systemctl daemon-reload && sudo systemctl restart food-market-telegram-bridge`.
## Раскладка
- Скрипт: `/opt/food-market-data/telegram-bridge/bridge.py`
- venv (Python 3.12, `python-telegram-bot 21.x`): `/opt/food-market-data/telegram-bridge/venv/`
- Креды: `/etc/food-market/telegram.env` (owner `nns`, mode `0600`)
- systemd unit: `/etc/systemd/system/food-market-telegram-bridge.service`
## Что хорошо знать
- `disable_notification=True` стоит на фоновых сообщениях пайна — не будет жужжать при каждом diff'e.
- Telegram-лимит 4096 символов; длинные пайн-блоки режутся на куски по ~3800 символов.
- Если после долгого молчания в чате слишком много истории, шлите `/snapshot` — бот обнуляет baseline и присылает текущий экран целиком.
- Бот заходит в Telegram long-polling (исходящее к api.telegram.org, без входящих портов) — никакого проброса портов не нужно.

View file

@ -2,6 +2,10 @@ namespace foodmarket.Domain.Catalog;
public enum CounterpartyKind public enum CounterpartyKind
{ {
/// <summary>Не указано — дефолт для импортированных без явной классификации.
/// MoySklad сам не имеет встроенного поля Supplier/Customer, оно ставится
/// через теги или группы, и часто отсутствует. Не выдумываем за пользователя.</summary>
Unspecified = 0,
Supplier = 1, Supplier = 1,
Customer = 2, Customer = 2,
Both = 3, Both = 3,

View file

@ -39,17 +39,14 @@ public async Task<MoySkladImportResult> ImportCounterpartiesAsync(string token,
{ {
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context."); var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
// Map MoySklad tag set → local CounterpartyKind. If no tags say otherwise, assume Both. // MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще — это
// не наша выдумка, проверено через API: counterparty entity содержит только
// group (группа доступа), tags (произвольные), state (пользовательская цепочка
// статусов), companyType (legal/individual/entrepreneur). Никакой role/kind.
// Поэтому при импорте ВСЕГДА ставим Unspecified — пользователь сам решит.
// Параметр tags оставлен ради совместимости сигнатуры, не используется.
static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList<string>? tags) static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList<string>? tags)
{ => foodmarket.Domain.Catalog.CounterpartyKind.Unspecified;
if (tags is null || tags.Count == 0) return foodmarket.Domain.Catalog.CounterpartyKind.Both;
var lower = tags.Select(t => t.ToLowerInvariant()).ToList();
var hasSupplier = lower.Any(t => t.Contains("постав"));
var hasCustomer = lower.Any(t => t.Contains("покуп") || t.Contains("клиент"));
if (hasSupplier && !hasCustomer) return foodmarket.Domain.Catalog.CounterpartyKind.Supplier;
if (hasCustomer && !hasSupplier) return foodmarket.Domain.Catalog.CounterpartyKind.Customer;
return foodmarket.Domain.Catalog.CounterpartyKind.Both;
}
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType) static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
=> companyType switch => companyType switch

View file

@ -6,7 +6,7 @@ export interface PagedResult<T> {
totalPages: number totalPages: number
} }
export const CounterpartyKind = { Supplier: 1, Customer: 2, Both: 3 } as const export const CounterpartyKind = { Unspecified: 0, Supplier: 1, Customer: 2, Both: 3 } as const
export type CounterpartyKind = (typeof CounterpartyKind)[keyof typeof CounterpartyKind] export type CounterpartyKind = (typeof CounterpartyKind)[keyof typeof CounterpartyKind]
export const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const export const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const

View file

@ -20,8 +20,8 @@ export const useCountries = () => useLookup<Country>('countries', '/api/catalog/
export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies') export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies')
export const useStores = () => useLookup<Store>('stores', '/api/catalog/stores') export const useStores = () => useLookup<Store>('stores', '/api/catalog/stores')
export const usePriceTypes = () => useLookup<PriceType>('price-types', '/api/catalog/price-types') export const usePriceTypes = () => useLookup<PriceType>('price-types', '/api/catalog/price-types')
export const useSuppliers = () => useQuery({ // MoySklad-style: контрагент один, может быть и поставщиком, и покупателем
queryKey: ['lookup:suppliers'], // в разных документах. Не фильтруем по Kind — пользователь сам выбирает.
queryFn: async () => (await api.get<PagedResult<Counterparty>>('/api/catalog/counterparties?pageSize=500&kind=1')).data.items, export const useCounterparties = () => useLookup<Counterparty>('counterparties', '/api/catalog/counterparties')
staleTime: 5 * 60 * 1000, // Алиас для обратной совместимости со старым кодом форм Supply/RetailSale.
}) export const useSuppliers = useCounterparties

View file

@ -36,7 +36,9 @@ interface Form {
} }
const blankForm: Form = { const blankForm: Form = {
name: '', legalName: '', kind: CounterpartyKind.Supplier, type: CounterpartyType.LegalEntity, // Kind по умолчанию Unspecified — MoySklad не имеет такого поля у контрагентов,
// не выдумываем за пользователя. Пусть выберет вручную если нужно.
name: '', legalName: '', kind: CounterpartyKind.Unspecified, type: CounterpartyType.LegalEntity,
bin: '', iin: '', taxNumber: '', countryId: '', bin: '', iin: '', taxNumber: '', countryId: '',
address: '', phone: '', email: '', address: '', phone: '', email: '',
bankName: '', bankAccount: '', bik: '', bankName: '', bankAccount: '', bik: '',
@ -44,9 +46,10 @@ const blankForm: Form = {
} }
const kindLabel: Record<CounterpartyKind, string> = { const kindLabel: Record<CounterpartyKind, string> = {
[CounterpartyKind.Unspecified]: '—',
[CounterpartyKind.Supplier]: 'Поставщик', [CounterpartyKind.Supplier]: 'Поставщик',
[CounterpartyKind.Customer]: 'Покупатель', [CounterpartyKind.Customer]: 'Покупатель',
[CounterpartyKind.Both]: 'Оба', [CounterpartyKind.Both]: 'Поставщик + Покупатель',
} }
export function CounterpartiesPage() { export function CounterpartiesPage() {
@ -138,9 +141,10 @@ export function CounterpartiesPage() {
</Field> </Field>
<Field label="Роль"> <Field label="Роль">
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as CounterpartyKind })}> <Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as CounterpartyKind })}>
<option value={CounterpartyKind.Unspecified}>Не указано</option>
<option value={CounterpartyKind.Supplier}>Поставщик</option> <option value={CounterpartyKind.Supplier}>Поставщик</option>
<option value={CounterpartyKind.Customer}>Покупатель</option> <option value={CounterpartyKind.Customer}>Покупатель</option>
<option value={CounterpartyKind.Both}>Оба</option> <option value={CounterpartyKind.Both}>Поставщик + Покупатель</option>
</Select> </Select>
</Field> </Field>
<Field label="Тип лица"> <Field label="Тип лица">