feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР

- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk)
- трансферные таблицы FTV_M2M: создание при установке, события, справочник участников
- сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO
- боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК
- онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML)
- модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-15 19:41:12 +03:00
parent 59fc774f08
commit da12a9ec1c
132 changed files with 29969 additions and 1089 deletions
+612
View File
@@ -0,0 +1,612 @@
package lkgateway
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
// settlementPlaceINN возвращает ИНН места расчётов по коду площадки из ЛК:
// spb → СПБ Банк (7831000034), иначе (nrd/пусто) → НРД (7702165310). Это два
// единственных места расчётов из справочника M2M.
func settlementPlaceINN(place string) string {
if strings.EqualFold(strings.TrimSpace(place), "spb") {
return "7831000034"
}
return "7702165310"
}
// lkClaimInvestorKey — стабильный ключ инвестора для lkInvestorUUID: ЛК user_id
// (или id заявки, если пользователь не задан). Один инвестор → один UUID сделки.
func lkClaimInvestorKey(cl map[string]any) string {
if u := str(cl["user_id"]); u != "" && u != "0" {
return "esia-user:" + u
}
return "esia-claim:" + str(cl["id"])
}
// lkClaimLinks — guid отправленного в НРД запроса → id заявки в ЛК, чтобы по
// ответу НРД PATCH-нуть статус на их стороне. Переживает рестарт.
const lkLinksFile = "/var/lib/bj/.bj/lk-claim-links.json"
var (
lkLinksMu sync.Mutex
lkLinks = map[string]int{}
lkLoaded bool
)
func lkLinkGUID(guid string, _ LKEndpoint, claimID int) {
lkLinksMu.Lock()
lkEnsureLinksLoaded()
lkLinks[guid] = claimID
if raw, err := json.Marshal(lkLinks); err == nil {
_ = os.WriteFile(lkLinksFile, raw, 0o600)
}
lkLinksMu.Unlock()
// Персистентная запись происхождения — для отображения «путь из ЛК» в
// карточке. В отличие от lkLinks, НЕ удаляется после финального статуса.
lkRecordOrigin(guid, claimID)
}
func lkEnsureLinksLoaded() {
if lkLoaded {
return
}
lkLoaded = true
if raw, err := os.ReadFile(lkLinksFile); err == nil {
_ = json.Unmarshal(raw, &lkLinks)
}
}
// lkOrigin — guid → id заявки ЛК, ПОСТОЯННАЯ запись происхождения (для UI
// «путь заявки»). lkLinks чистится после финального PATCH, а origin остаётся.
const lkOriginFile = "/var/lib/bj/.bj/lk-origin.json"
var (
lkOriginMu sync.Mutex
lkOrigin = map[string]int{}
lkOriginLoaded bool
)
func lkEnsureOriginLoaded() {
if lkOriginLoaded {
return
}
lkOriginLoaded = true
if raw, err := os.ReadFile(lkOriginFile); err == nil {
_ = json.Unmarshal(raw, &lkOrigin)
return
}
// Бэкофилл: если постоянного файла ещё нет — засеваем из активных связок
// (уже отправленные, но не финализированные ЛК-заявки тоже покажут путь).
if raw, err := os.ReadFile(lkLinksFile); err == nil {
_ = json.Unmarshal(raw, &lkOrigin)
}
}
func lkRecordOrigin(guid string, claimID int) {
lkOriginMu.Lock()
defer lkOriginMu.Unlock()
lkEnsureOriginLoaded()
lkOrigin[guid] = claimID
if raw, err := json.Marshal(lkOrigin); err == nil {
_ = os.WriteFile(lkOriginFile, raw, 0o600)
}
}
// LKClaimIDByGUID возвращает id заявки ЛК по guid отправленного запроса и true,
// если заявка происходит из ЛК ЕСИА Финанс (для отображения пути в карточке).
func LKClaimIDByGUID(guid string) (int, bool) {
lkOriginMu.Lock()
defer lkOriginMu.Unlock()
lkEnsureOriginLoaded()
id, ok := lkOrigin[guid]
return id, ok
}
// lkSent — id заявок ЛК, уже отправленных в НРД (at-most-once защита от дублей
// переводов при повторных проходах поллера). Переживает рестарт.
const lkSentFile = "/var/lib/bj/.bj/lk-sent.json"
var (
lkSentMu sync.Mutex
lkSent = map[int]bool{}
lkSentLoaded bool
)
func lkEnsureSentLoaded() {
if lkSentLoaded {
return
}
lkSentLoaded = true
if raw, err := os.ReadFile(lkSentFile); err == nil {
var ids []int
if json.Unmarshal(raw, &ids) == nil {
for _, id := range ids {
lkSent[id] = true
}
}
}
}
func lkAlreadySent(id int) bool {
lkSentMu.Lock()
defer lkSentMu.Unlock()
lkEnsureSentLoaded()
return lkSent[id]
}
func lkMarkSent(id int) {
lkSentMu.Lock()
defer lkSentMu.Unlock()
lkEnsureSentLoaded()
lkSent[id] = true
ids := make([]int, 0, len(lkSent))
for k := range lkSent {
ids = append(ids, k)
}
if raw, err := json.Marshal(ids); err == nil {
_ = os.WriteFile(lkSentFile, raw, 0o600)
}
}
// OnLKOutcome вызывается nsdpoller по ответу НРД: PATCH статуса заявки в ЛК.
// status: "done" (исполнена) или "rejected" (отклонена). reason — в client_notation.
func OnLKOutcome(ctx context.Context, rc *RuntimeConfig, guid, status, reason string) {
lkLinksMu.Lock()
lkEnsureLinksLoaded()
id, ok := lkLinks[guid]
if ok {
delete(lkLinks, guid)
if raw, err := json.Marshal(lkLinks); err == nil {
_ = os.WriteFile(lkLinksFile, raw, 0o600)
}
}
lkLinksMu.Unlock()
if !ok {
return // не заявка из ЛК-коннектора
}
ep, okEp := lkActiveEndpoint(rc)
if !okEp {
return
}
fs := "done"
if status != "done" {
fs = "rejected"
}
body, _ := json.Marshal(map[string]any{"claim": map[string]any{"status": fs, "client_notation": reason}})
pc, pb := lkPatch(ctx, ep, "/claims/"+strconv.Itoa(id), body)
title, detail := "Статус в ЛК: «Исполнена»", reason
if fs == "rejected" {
title = "Статус в ЛК: «Отклонена»"
}
if pc >= 400 || pc == 0 {
title, detail = title+" — не записан", fmt.Sprintf("HTTP %d %s", pc, pb)
}
globalLKFlows.stepIfExists(guid, "response", title, detail)
}
// Коннектор к API ЕСИА Финанс (back_office). bj — КЛИЕНТ: забирает новые заявки
// depo_m2m, строит M2MTransferRequest, отправляет в НРД (робот на тесте) и
// возвращает статус через PATCH. Структуры — по swagger_backoffice_4-*.yaml.
//
// Поток (ручной триггер «Забрать заявки из ЛК» / позже — фоновый поллер):
// GET /claims?kind=depo_m2m&status=in_queue — новые заявки
// GET /users/{user_id} — ФИО/ДУЛ инвестора
// → маппинг → BuildRequest → SendBuiltRequest (в НРД)
// PATCH /claims/{id} {"claim":{"status":"in_process"}} — «В работе»
// Ответ НРД (Decision/ERROR) ловит nsdpoller и через OnLKOutcome PATCH-ит
// «Исполнена»/«Отклонена».
// lkHTTP — общий клиент к API ЛК с Basic активного окружения.
func lkActiveEndpoint(rc *RuntimeConfig) (LKEndpoint, bool) {
s := rc.Snapshot().LK
ep := s.Test
if s.ActiveEnv == "prod" {
ep = s.Prod
}
return ep, ep.BaseURL != "" && ep.Login != ""
}
func lkGet(ctx context.Context, ep LKEndpoint, path string) (int, []byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, lkAPIBase(ep.BaseURL)+path, nil)
if err != nil {
return 0, nil, err
}
req.Header.Set("Accept", "application/json")
req.SetBasicAuth(ep.Login, ep.Password)
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return resp.StatusCode, b, nil
}
func lkPatch(ctx context.Context, ep LKEndpoint, path string, body []byte) (int, string) {
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, lkAPIBase(ep.BaseURL)+path, strings.NewReader(string(body)))
if err != nil {
return 0, err.Error()
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.SetBasicAuth(ep.Login, ep.Password)
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
return 0, err.Error()
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return resp.StatusCode, strings.TrimSpace(string(b))
}
// LKPullResult — итог одного прохода «забрать заявки».
type LKPullResult struct {
Fetched int
Sent int // отправлено в НРД (или имитатору)
Rejected int // отклонено сверкой до отправки
Emulated bool
Errors []string
}
// pollLKClaims — фоновый опрос ЛК ЕСИА Финанс раз в минуту. Время ответов в
// сервисе регламентировано, поэтому чаще опрашивать смысла нет. Ничего не
// делает, если активное окружение ЛК не настроено (адрес/логин пустые).
func (s *Server) pollLKClaims(ctx context.Context) {
const interval = time.Minute
ticker := time.NewTicker(interval)
defer ticker.Stop()
log.Printf("lk-gateway: поллер заявок ЛК ЕСИА Финанс запущен (интервал %s)", interval)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
healthBeat("poll-lk")
if _, ok := lkActiveEndpoint(s.rc); !ok {
continue // ЛК не настроен — тихо ждём
}
cctx, cancel := context.WithTimeout(ctx, 50*time.Second)
res, err := PullLKClaims(cctx, s.rc, s.svc)
cancel()
if err != nil {
log.Printf("lk-gateway: поллер ЛК: %v", err)
continue
}
if res.Fetched > 0 || len(res.Errors) > 0 {
log.Printf("lk-gateway: поллер ЛК: получено %d, отправлено %d, отклонено %d, ошибок %d (имитация НРД: %v)",
res.Fetched, res.Sent, res.Rejected, len(res.Errors), res.Emulated)
for i, e := range res.Errors {
if i >= 5 {
log.Printf("lk-gateway: поллер ЛК: …ещё %d ошибок", len(res.Errors)-5)
break
}
log.Printf("lk-gateway: поллер ЛК: ошибка %s", e)
}
}
}
}
}
// PullLKClaims забирает новые depo_m2m заявки из ЛК и отправляет в НРД.
func PullLKClaims(ctx context.Context, rc *RuntimeConfig, svc *Service) (LKPullResult, error) {
var res LKPullResult
ep, ok := lkActiveEndpoint(rc)
if !ok {
return res, fmt.Errorf("ЛК: не настроено активное окружение (адрес/логин)")
}
res.Emulated, _ = lkEmulateNSD(rc)
// ВАЖНО: back_office фильтрует ТОЛЬКО по filter[...] (Rails strong params);
// плоские kind=/status= молча игнорируются, а список отдаётся id по
// возрастанию (старейшие) — свежая заявка на первую страницу не попадёт.
// order[]=-id — новые первыми. Статус фильтруем у себя: на тесте подписанная
// заявка встаёт «В очереди» (in_queue), в бою сразу «В работе» (in_process).
code, body, err := lkGet(ctx, ep, "/claims?filter%5Bkind%5D=depo_m2m&order%5B%5D=-id&per_page=50")
if err != nil {
return res, fmt.Errorf("GET /claims: %w", err)
}
if code != 200 {
return res, fmt.Errorf("GET /claims → HTTP %d: %s", code, snippet(body))
}
var list struct {
Claims []map[string]any `json:"claims"`
}
if json.Unmarshal(body, &list) != nil {
return res, fmt.Errorf("разбор списка заявок не удался")
}
// ВАЖНО: API back_office может игнорировать query-фильтры — фильтруем строго на
// нашей стороне: depo_m2m в статусе in_queue/in_process, которые bj ещё не
// обрабатывал (после обработки bj сам ставит «В работе» — такие пропускаем молча).
for _, c := range list.Claims {
if st := str(c["status"]); str(c["kind"]) != "depo_m2m" || (st != "in_queue" && st != "in_process") {
continue // чужой вид/статус — не наша заявка
}
id := toInt(c["id"])
if id == 0 || lkAlreadySent(id) {
continue
}
res.Fetched++
outcome, err := pullOneLKClaim(ctx, rc, svc, ep, id)
if err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("#%d: %v", id, err))
continue
}
if outcome == "rejected" {
res.Rejected++
} else {
res.Sent++
}
}
return res, nil
}
// pullOneLKClaim: полная заявка → инвестор → M2M → НРД (или имитатор со сверкой DEPO,
// lkemulate.go) → PATCH «В работе». Возвращает "sent" или "rejected".
func pullOneLKClaim(ctx context.Context, rc *RuntimeConfig, svc *Service, ep LKEndpoint, id int) (string, error) {
code, body, err := lkGet(ctx, ep, "/claims/"+strconv.Itoa(id))
if err != nil || code != 200 {
return "", fmt.Errorf("GET /claims/%d → %d %v", id, code, err)
}
var wrap struct {
Claim map[string]any `json:"claim"`
}
if json.Unmarshal(body, &wrap) != nil || wrap.Claim == nil {
return "", fmt.Errorf("разбор заявки")
}
cl := wrap.Claim
data, _ := cl["data"].(map[string]any)
if data == nil {
return "", fmt.Errorf("нет data в заявке")
}
claimReq, err := lkClaimToRequest(ctx, rc, ep, cl, data)
if err != nil {
return "", err
}
// Регистрируем клиента в справочнике (как для ЛК-подачи).
if ss, ok := svc.store.(*SeedStore); ok {
nsd := rc.Snapshot().NSD
settle := m2mcore.DepoAccount{
DeponentCode: nsd.DeponentCode,
AccountID: m2m.AccountID(nsd.AccountID),
SectionID: nsd.SectionID,
// SettlementRequisites.INN — это ИНН МЕСТА РАСЧЁТОВ (центральный
// депозитарий), а НЕ наш собственный ИНН: НРД 7702165310 либо СПБ
// Банк 7831000034 по place_of_payment. Иначе M2M19 «недопустимое
// место расчётов» (наш ИНН отсутствует в списке мест расчётов).
DepositoryINN: m2m.OrganizationINN(settlementPlaceINN(str(data["contragent_place_of_payment"]))),
}
ss.EnsureLKClient(lkUserFromClaim(cl, claimReq), nil, settle)
}
if emu, _ := lkEmulateNSD(rc); emu {
return lkProcessEmulated(ctx, rc, svc, ep, id, claimReq)
}
for _, sec := range claimReq.Securities {
if isin := sec.SecurityDetails.ISIN; isin != "" && !isRussianISIN(isin) {
lkRejectInvalid(ctx, rc, ep, id, &lkDataError{Code: "M2M01", Text: fmt.Sprintf("иностранная бумага %s — перевод M2M недоступен", isin)})
return "rejected", nil
}
}
req, err := svc.BuildRequest(ctx, claimReq)
if err != nil {
if strings.Contains(err.Error(), "невалиден") { // данные не проходят формат НРД — повтор не поможет
lkRejectInvalid(ctx, rc, ep, id, &lkDataError{Text: "данные заявки не прошли проверку формата НРД: " + err.Error()})
return "rejected", nil
}
return "", fmt.Errorf("BuildRequest: %w", err)
}
// At-most-once: если заявка уже отправлялась в НРД — не шлём повторно
// (иначе поллер каждую минуту плодил бы дубли переводов при ошибке PATCH).
if lkAlreadySent(id) {
return "", fmt.Errorf("заявка #%d уже отправлялась в НРД — повторная отправка подавлена", id)
}
if _, err := svc.SendBuiltRequest(ctx, req, claimReq.Investor.ID, nil); err != nil {
// Отправка не удалась (напр. до реального Send в НРД) — НЕ помечаем
// отправленной, чтобы можно было повторить после устранения причины.
return "", fmt.Errorf("отправка в НРД: %w", err)
}
// Успешно ушло в НРД — фиксируем, чтобы повторный проход не создал дубль.
lkMarkSent(id)
// Связка guid → id заявки ЛК для обратного PATCH статуса.
lkLinkGUID(string(req.Header.GUID), ep, id)
// «В работе».
pc, pb := lkPatch(ctx, ep, "/claims/"+strconv.Itoa(id), []byte(`{"claim":{"status":"in_process"}}`))
if pc >= 400 {
return "", fmt.Errorf("отправлено в НРД, но PATCH статуса не прошёл: HTTP %d %s", pc, pb)
}
return "sent", nil
}
// lkClaimToRequest маппит заявку ЕСИА Финанс → наш CreateClaimRequest.
func lkClaimToRequest(ctx context.Context, rc *RuntimeConfig, ep LKEndpoint, cl, data map[string]any) (CreateClaimRequest, error) {
var req CreateClaimRequest
nsd := rc.Snapshot().NSD
// Бумага (paper вложен; paper_count плоско).
paper, _ := data["paper"].(map[string]any)
// Данные заявки здесь не браковать: заявка должна попасть в журнал и пройти проверку
// шлюза, которая и ответит отказом (lkemulate.go / lkflow.go).
isin := strings.ToUpper(strings.TrimSpace(str(paper["isin"])))
var qty Quantity
pc := toFloat(data["paper_count"])
switch {
case pc <= 0:
qty.Fractional = "0" // пакет соберётся без проверки формата, отказ даст сверка
case pc == float64(uint64(pc)):
qty.Whole = uint64(pc)
default:
qty.Fractional = strconv.FormatFloat(pc, 'f', -1, 64)
}
sd := SecurityDetails{}
code := isin
if isin != "" {
sd.ISIN = isin
} else {
code = str(paper["registration_number"])
sd.SecurityInfo = &SecurityInfo{IdentificationDetails: IdentificationDetails{RegNumber: str(paper["registration_number"])}}
}
sec := ClaimSec{
SecurityCode: code,
SecurityDetails: sd,
Quantity: qty,
SettlementAccounts: []SettlementAccount{{
SettlementRequisitesINN: transferringINN(rc.Snapshot()),
SettlementLocation: SettlementLocation{DeponentCode: nsd.DeponentCode, AccountID: nsd.AccountID, SectionID: nsd.SectionID},
}},
}
// Инвестор — из GET /users/{user_id}. ID инвестора хранится в uuid-колонке
// журнала сделок, поэтому строим детерминированный UUIDv5 из ЛК-идентификатора
// (один инвестор ЛК → один UUID во всех его заявках).
inv := Investor{ID: lkInvestorUUID(lkClaimInvestorKey(cl))}
if uid := toInt(cl["user_id"]); uid != 0 {
if _, ub, err := lkGet(ctx, ep, "/users/"+strconv.Itoa(uid)); err == nil {
var uw map[string]any
if json.Unmarshal(ub, &uw) == nil {
u := pickMap(uw, "user", "data")
if u == nil {
u = uw
}
inv.LastName = str(u["surname"])
inv.FirstName = str(u["name"])
inv.MiddleName = str(u["patronymic"])
inv.BirthDate = firstNonEmpty(u, "birthday", "birth_date", "date_of_birth")
inv.Document = Document{
DocumentType: mapDocType(str(u["document_type"])),
// На стенде поля паспорта: pass_series / pass_number.
Series: firstNonEmpty(u, "pass_series", "document_series", "series", "passport_series"),
Number: firstNonEmpty(u, "pass_number", "document_number", "number", "passport_number"),
}
}
}
}
cost := CostInfo{No: &struct{}{}}
if b, _ := data["expense_information"].(bool); b {
cost = CostInfo{Yes: &CostInfoYes{Code: nsd.DeponentCode}}
}
req = CreateClaimRequest{
Investor: inv,
TransferringDepositoryINN: transferringINN(rc.Snapshot()),
ReceivingDepositoryINN: str(data["contragent_inn"]),
CostInfo: cost,
Securities: []ClaimSec{sec},
SignatureFormat: "ESIA",
ClientAccount: strings.TrimSpace(str(data["account"])),
}
// ТЕСТ-МАРШРУТ К РОБОТУ НРД. Если наименование контрагента содержит маркер
// «РОБОТ <сценарий>» (1111/2001/2002/3333) — заявка адресуется роботу
// MC0012500000, а серия/номер ДУЛ переопределяются на сценарий (робот
// читает сценарий из DocumentSeries). Прод-заявки этим маркером не
// затрагиваются. Позволяет прогонять все сценарии через путь ЛК.
if sc := lkDetectRobotScenario(str(data["contragent_name"])); sc != "" {
doc := Document{DocumentType: "21", Series: sc, Number: robotDocumentNumbers[sc]}
req.Investor.Document = doc
req.InvestorDocumentOverride = &doc
req.ReceiverCodeOverride = NSDRobotCode
// Робот проверяет реквизиты по своему эталонному набору: конкретные ИНН
// передающего/зачисляющего депозитариев и бумаги с кодом НРД (не ISIN),
// иначе МОСТ отклоняет (M2M18/M2M30). Подставляем ровно как в эталонном
// buildRobotClaimRequest — ЛК-заявка служит только триггером сценария.
req.TransferringDepositoryINN = "7703807489"
req.ReceivingDepositoryINN = "7722061076"
req.CostInfo = CostInfo{Yes: &CostInfoYes{Code: nsd.DeponentCode}}
req.Securities = buildRobotSecurities(nsd.DeponentCode, nsd.AccountID, nsd.SectionID, 0)
}
return req, nil
}
// lkDetectRobotScenario извлекает код сценария робота из наименования
// контрагента ЛК-заявки (маркер вида «РОБОТ 2001» / «robot-1111»). Возвращает
// "" если маркера нет — тогда заявка идёт обычным путём.
func lkDetectRobotScenario(contragentName string) string {
n := strings.ToUpper(contragentName)
if !strings.Contains(n, "РОБОТ") && !strings.Contains(n, "ROBOT") {
return ""
}
for _, sc := range []string{"1111", "2001", "2002", "3333"} {
if strings.Contains(n, sc) {
return sc
}
}
return ""
}
// --- вспомогательное ---
func lkUserFromClaim(cl map[string]any, req CreateClaimRequest) LKUser {
return LKUser{
Login: "lk:" + str(cl["id"]), Contract: req.Investor.ID,
LastName: req.Investor.LastName, FirstName: req.Investor.FirstName, MiddleName: req.Investor.MiddleName,
DocType: req.Investor.Document.DocumentType, DocSeries: req.Investor.Document.Series, DocNumber: req.Investor.Document.Number,
Active: true,
}
}
func mapDocType(t string) string {
if t == "passport" || t == "" {
return "21" // паспорт РФ по справочнику НРД
}
return t
}
func firstNonEmpty(m map[string]any, keys ...string) string {
for _, k := range keys {
if v := str(m[k]); v != "" {
return v
}
}
return ""
}
func pickMap(m map[string]any, keys ...string) map[string]any {
for _, k := range keys {
if v, ok := m[k].(map[string]any); ok {
return v
}
}
return nil
}
func str(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
func toInt(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case string:
i, _ := strconv.Atoi(n)
return i
}
return 0
}
func toFloat(v any) float64 {
switch n := v.(type) {
case float64:
return n
case string:
f, _ := strconv.ParseFloat(strings.ReplaceAll(n, ",", "."), 64)
return f
}
return 0
}
func snippet(b []byte) string {
if len(b) > 200 {
return string(b[:200]) + "…"
}
return string(b)
}