c73f6120d3
Правильная обработка входящих переводов на ИИС вместо прежнего сплошного отказа.
Настройка AcceptIIS (карточка «Кто отвечает на переводы M2M»):
- выкл (по умолчанию) — поведение не меняется: перевод на ИИС отклоняется целиком (M2M06);
- вкл — ИИС принимается ТОЛЬКО целиком: все бумаги зачисляются на ИИС-счёт клиента;
если хоть одной бумаги нет в учёте / не проходит сверку — отказ по ВСЕМУ переводу.
Изменения:
- iisDecisions: «всё или ничего» (при выкл — отказ всем; при вкл — либо все принять,
либо все отклонить, сохраняя реальные коды отказа по проблемным бумагам);
- depo_check: при вкл приёме ИИС убран искусственный отказ «Перевод не на ИИС»,
добавлена проверка «Счёт зачисления — ИИС» (partyChecks получил флаг wantIIS);
- настройка AcceptIIS в Settings + геттер/сеттер + обработчик /admin/setup/accept-iis
+ тумблер в part_nsd.html; тесты обновлены.
Жёсткий запрет частичного ИИС в answerIncoming (коммит dc8b785) остаётся как страховка.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
224 lines
10 KiB
Go
224 lines
10 KiB
Go
package lkgateway
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"time"
|
|
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
|
|
)
|
|
|
|
// SecurityDecision — наше решение по одной бумаге входящего перевода.
|
|
type SecurityDecision struct {
|
|
ReferenceID string
|
|
Confirm bool // true — принять, false — отказать
|
|
RejectCode string // код отказа (по умолчанию M2M05), если Confirm=false
|
|
}
|
|
|
|
// incomingDeps — зависимости для формирования и отправки решения.
|
|
type incomingDeps struct {
|
|
store *IncomingStore
|
|
igwClient *igw.Client
|
|
igwChan string
|
|
rc *RuntimeConfig
|
|
ourCode string // наш депкод (DefaultSender)
|
|
}
|
|
|
|
// answerIncoming формирует M2MTransferDecision на входящий перевод и отправляет
|
|
// его обратно через ИШ. GUID решения = GUID исходного запроса робота (так
|
|
// требует протокол: связь ответа с запросом). Реквизиты зачисления для
|
|
// подтверждённых бумаг берутся из настроек НРД (наш депо-счёт).
|
|
func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions []SecurityDecision) error {
|
|
it, ok := deps.store.Get(id)
|
|
if !ok {
|
|
return fmt.Errorf("входящий перевод %s не найден", id)
|
|
}
|
|
if it.Status == "answered" {
|
|
return fmt.Errorf("по переводу уже отправлено решение (%s)", it.AnswerNote)
|
|
}
|
|
if deps.igwClient == nil || deps.igwChan == "" {
|
|
return fmt.Errorf("ИШ не настроен — нельзя отправить решение")
|
|
}
|
|
|
|
cfg := deps.rc.Snapshot()
|
|
ourCode := deps.ourCode // наш депкод (отправитель решения)
|
|
if ourCode == "" {
|
|
ourCode = it.ReceiverCode // на кого пришёл запрос — тем и отвечаем
|
|
}
|
|
nsd := cfg.NSD
|
|
|
|
decisions = iisDecisions(it, decisions)
|
|
|
|
// Карта решений по ReferenceID.
|
|
byRef := make(map[string]SecurityDecision, len(decisions))
|
|
for _, d := range decisions {
|
|
byRef[d.ReferenceID] = d
|
|
}
|
|
|
|
// Собираем Decision по каждой бумаге входящего перевода.
|
|
var decSecs []m2m.DecisionSecurity
|
|
markMap := make(map[string]IncomingSecurity, len(it.Securities))
|
|
confirmed, rejected := 0, 0
|
|
for _, sec := range it.Securities {
|
|
d, has := byRef[sec.ReferenceID]
|
|
if !has {
|
|
// По умолчанию — отказ (безопаснее не принимать без явного решения).
|
|
d = SecurityDecision{ReferenceID: sec.ReferenceID, Confirm: false}
|
|
}
|
|
ds := m2m.DecisionSecurity{ReferenceID: m2m.ReferenceID(sec.ReferenceID)}
|
|
mark := IncomingSecurity{ReferenceID: sec.ReferenceID}
|
|
if d.Confirm {
|
|
ds.TransferDecision = m2m.DecisionTransfer{
|
|
Confirmation: &m2m.Confirmation{
|
|
SettlementAccount: m2m.DecisionSettlementAccount{
|
|
SettlementRequisites: m2m.SettlementRequisites{
|
|
// ИНН МЕСТА РАСЧЁТОВ (НРД), а не наш собственный — иначе
|
|
// M2M35 «место расчётов отсутствует в реквизитах».
|
|
INN: m2m.OrganizationINN(settlementPlaceINN("nrd")),
|
|
},
|
|
SettlementLocation: m2m.SettlementDepositoryLocation{
|
|
DeponentCode: nsd.DeponentCode,
|
|
AccountID: m2m.AccountID(nsd.AccountID),
|
|
SectionID: nsd.SectionID,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
mark.Decision = "confirmed"
|
|
confirmed++
|
|
} else {
|
|
code := d.RejectCode
|
|
if code == "" {
|
|
code = "M2M05" // дефолтный код отказа
|
|
}
|
|
ds.TransferDecision = m2m.DecisionTransfer{
|
|
Rejection: &m2m.Rejection{Codes: []string{code}},
|
|
}
|
|
mark.Decision = "rejected"
|
|
mark.RejectCode = code
|
|
rejected++
|
|
}
|
|
decSecs = append(decSecs, ds)
|
|
markMap[sec.ReferenceID] = mark
|
|
}
|
|
|
|
// Защита ИИС: перевод по договору ИИС принимается только ЦЕЛИКОМ — все бумаги
|
|
// или ни одной. Частичное решение (часть принять, часть отклонить) дало бы
|
|
// неполный перевод ИИС и два ИИС у клиента с потерей льгот. Этот запрет не
|
|
// обойти ни ручным решением оператора, ни будущим смягчением политики приёма.
|
|
if incomingIsIIS(it) && confirmed > 0 && rejected > 0 {
|
|
return fmt.Errorf("перевод на ИИС принимается только целиком: нельзя принять часть бумаг (%d) и отклонить часть (%d) — примите все бумаги или отклоните весь перевод", confirmed, rejected)
|
|
}
|
|
|
|
dec := &m2m.M2MTransferDecision{
|
|
Header: m2m.DecisionHeader{
|
|
GUID: m2m.UUID(it.GUID), // GUID из запроса робота!
|
|
CreationTimestamp: nsdxml.Now(),
|
|
SenderCode: m2m.DeponentCode(ourCode),
|
|
ReceiverCode: m2m.DeponentCode(it.SenderCode), // обратно роботу
|
|
CostInfo: m2m.CostInfo{No: &m2m.CostInfoNo{}},
|
|
},
|
|
Data: m2m.DecisionData{
|
|
ReceivingDepository: m2m.SettlementRequisites{INN: m2m.OrganizationINN(transferringINN(cfg))},
|
|
Securities: decSecs,
|
|
},
|
|
}
|
|
if err := dec.Validate(); err != nil {
|
|
return fmt.Errorf("собранное решение невалидно: %w", err)
|
|
}
|
|
|
|
xmlBytes, err := nsdxml.Marshal(dec)
|
|
if err != nil {
|
|
return fmt.Errorf("сериализация решения: %w", err)
|
|
}
|
|
deps.store.SetDecisionXML(id, string(nsdxml.DecodeWindows1251(xmlBytes)))
|
|
zipBody, err := igw.PackXML(xmlBytes, "M2MTransferDecision.xml", "#M2MTD")
|
|
if err != nil {
|
|
return fmt.Errorf("упаковка решения: %w", err)
|
|
}
|
|
sctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
pkgID, err := deps.igwClient.SendPackage(sctx, deps.igwChan, "#M2MTD", zipBody)
|
|
if err != nil {
|
|
return fmt.Errorf("отправка решения в ИШ: %w", err)
|
|
}
|
|
|
|
note := fmt.Sprintf("Решение отправлено (пакет ИШ %s): принято %d, отказано %d", pkgID, confirmed, rejected)
|
|
deps.store.MarkAnswered(id, markMap, note)
|
|
go depoOnIncomingDecision(deps.rc, it, decisions)
|
|
log.Printf("lk-gateway: ответ на входящий перевод %s (GUID %s) отправлен: %s", id, it.GUID, note)
|
|
// Письмо депозитарию: перевод принят/зачислён (если что-то приняли).
|
|
if confirmed > 0 {
|
|
if upd, ok := deps.store.Get(id); ok {
|
|
go notifyDepositoryAccepted(deps.rc, upd, confirmed, rejected)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// transferringINN — ИНН нашей организации (передающий депозитарий в запросе, принимающий — в решении).
|
|
func transferringINN(s Settings) string { return orgINN(s) }
|
|
|
|
// iisRejectCode — код отказа по переводу на ИИС: M2M06 «Приостановление операций по счёту депо
|
|
// зачисления» (решение депозитария 07.10.2026; до этого — M2M07).
|
|
const iisRejectCode = "M2M06"
|
|
|
|
// iisDecisions применяет к ИИС-переводу правило «ВСЁ ИЛИ НИЧЕГО»: ИИС переводится только целиком,
|
|
// иначе у клиента окажется два ИИС с потерей льгот.
|
|
// - приём ИИС выключен (по умолчанию, AcceptIIS=false) → отказ по всем бумагам (iisRejectCode);
|
|
// - приём ИИС включён и подтверждена КАЖДАЯ бумага перевода → принимаем ИИС полностью;
|
|
// - приём ИИС включён, но хоть одна бумага не подтверждена → отказ по ВСЕМ бумагам (у бумаг с
|
|
// явной причиной отказа её код сохраняется, у остальных — iisRejectCode).
|
|
//
|
|
// Не-ИИС переводы не трогаем — решение по каждой бумаге отдельно.
|
|
func iisDecisions(it *IncomingTransfer, decisions []SecurityDecision) []SecurityDecision {
|
|
if it == nil || !incomingIsIIS(it) {
|
|
return decisions
|
|
}
|
|
rejectAll := func() []SecurityDecision {
|
|
explicit := make(map[string]SecurityDecision, len(decisions))
|
|
for _, d := range decisions {
|
|
explicit[d.ReferenceID] = d
|
|
}
|
|
out := make([]SecurityDecision, 0, len(it.Securities))
|
|
for _, sec := range it.Securities {
|
|
d := SecurityDecision{ReferenceID: sec.ReferenceID, RejectCode: iisRejectCode}
|
|
if e, ok := explicit[sec.ReferenceID]; ok && !e.Confirm && e.RejectCode != "" {
|
|
d.RejectCode = e.RejectCode // сохраняем реальную причину отказа оператора/сверки
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out
|
|
}
|
|
if !m2mAcceptIIS() {
|
|
return rejectAll() // приём ИИС выключен — отклоняем перевод целиком
|
|
}
|
|
if len(it.Securities) == 0 {
|
|
return rejectAll()
|
|
}
|
|
// Принимаем ИИС, только если подтверждена КАЖДАЯ бумага перевода.
|
|
confirm := make(map[string]bool, len(decisions))
|
|
for _, d := range decisions {
|
|
if d.Confirm {
|
|
confirm[d.ReferenceID] = true
|
|
}
|
|
}
|
|
for _, sec := range it.Securities {
|
|
if !confirm[sec.ReferenceID] {
|
|
return rejectAll() // хоть одна бумага не принимается — отказ по всему ИИС
|
|
}
|
|
}
|
|
return decisions // все бумаги подтверждены — принимаем ИИС полностью
|
|
}
|
|
|
|
// m2mAcceptIIS — включён ли приём входящих переводов на ИИС в текущих настройках.
|
|
func m2mAcceptIIS() bool {
|
|
if globalRC == nil {
|
|
return false
|
|
}
|
|
return globalRC.AcceptIIS()
|
|
}
|