fix integration
Build and Deploy / build-and-deploy (push) Successful in 19m19s

This commit is contained in:
2026-06-04 19:52:44 +03:00
parent 5f5c5de407
commit a9c931bd71
6 changed files with 132 additions and 39 deletions
+2
View File
@@ -348,6 +348,8 @@ func handleTransactionError(c *gin.Context, err error) {
switch {
case errors.Is(err, services.ErrTransactionNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrReceiptAlreadyExists):
c.JSON(http.StatusConflict, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrTransactionPatch),
errors.Is(err, services.ErrReceiptLinkConflict),
errors.Is(err, services.ErrInvalidTransaction),
@@ -366,6 +366,32 @@ func TestTransactionsRouter_Create(t *testing.T) {
assert.Contains(t, w.Body.String(), "receipt not found")
})
t.Run("returns 409 when receipt already exists in photo flow", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
return "21.01.2026 " + validNumber, nil
}}
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
return nil, services.ErrReceiptAlreadyExists
}}
creationService := services.NewTransactionCreationService(&transactionServiceMock{}, receiptSvc, ocrSvc)
router := NewTransactionsRouter(&transactionServiceMock{}, creationService)
router.RegisterRoutes(apiV1)
req := newMultipartRequest(t, map[string]string{
"family_id": "1",
"created_by": "2",
})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "receipt already exists")
})
t.Run("returns 503 when receipt provider is unavailable in photo flow", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
+5
View File
@@ -6,6 +6,7 @@ import (
"FamilyHub/src/repositories"
"FamilyHub/src/utils"
"context"
"errors"
"fmt"
"log"
"strings"
@@ -47,6 +48,10 @@ func (s *receiptService) AddReceipt(
receiptID, err := s.repo.Create(ctx, receipt)
if err != nil {
if errors.Is(err, repositories.ErrReceiptAlreadyExists) {
log.Printf("receipt persist failed: err=%v receipt=%s", ErrReceiptAlreadyExists, utils.ToLogJSON(receipt))
return nil, ErrReceiptAlreadyExists
}
log.Printf("receipt persist failed: err=%v receipt=%s", err, utils.ToLogJSON(receipt))
return nil, err
}
+1
View File
@@ -34,6 +34,7 @@ var (
ErrTransactionNotFound = errors.New("transaction not found")
ErrTransactionPatch = errors.New("empty update payload")
ErrReceiptLinkConflict = errors.New("receipt_id and detach_receipt cannot be used together")
ErrReceiptAlreadyExists = errors.New("receipt already exists")
ErrInvalidTransaction = errors.New("type and category are required")
ErrReceiptNotFound = errors.New("receipt not found")
ErrInvalidAnalytics = errors.New("type must be income or expense")
@@ -13,6 +13,8 @@ import (
"log"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"regexp"
"strings"
"time"
)
@@ -44,8 +46,10 @@ type receiptProvider struct {
}
func NewReceiptProvider() *receiptProvider {
jar, _ := cookiejar.New(nil)
return &receiptProvider{
client: &http.Client{
Jar: jar,
Timeout: 60 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
@@ -56,56 +60,93 @@ func NewReceiptProvider() *receiptProvider {
}
}
// ===== SESSID EXTRACT =====
var sessidRe = regexp.MustCompile(`"bitrix_sessid"\s*:\s*"([a-f0-9]+)"`)
func extractSessID(html string) (string, error) {
matches := sessidRe.FindStringSubmatch(html)
if len(matches) < 2 {
return "", errors.New("bitrix_sessid not found")
}
return matches[1], nil
}
// ===== MAIN =====
func (s *receiptProvider) GetReceipt(
ctx context.Context,
date, number string,
) (*domain.Receipt, error) {
url := "https://ch.info-center.by/ajax/check1.php"
var receipt domain.Receipt
body, contentType := buildMultipartBody(date, number)
requestBody := body.String()
log.Printf(
"external request: service=receipt_provider method=%s url=%s content_type=%s body=%q",
http.MethodPost,
url,
contentType,
utils.TruncateForLog(requestBody, utils.DefaultLogValueLimit),
)
portalURL := "https://ch.info-center.by/"
// ===== 1. GET PAGE =====
getReq, err := http.NewRequestWithContext(ctx, http.MethodGet, portalURL, nil)
if err != nil {
return nil, err
}
getReq.Header.Set("User-Agent",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36")
resp, err := s.client.Do(getReq)
if err != nil {
return nil, err
}
htmlBytes, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, err
}
html := string(htmlBytes)
// ===== 2. EXTRACT SESSID =====
sessid, err := extractSessID(html)
if err != nil {
log.Printf("sessid extract error: %v", err)
return nil, err
}
log.Printf("sessid: %s", sessid)
// ===== 3. POST REQUEST =====
time.Sleep(300 * time.Millisecond)
postUrl := "https://ch.info-center.by/ajax/check1.php"
body, contentType := buildMultipartBody(date, number, sessid)
httpReq, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
url,
postUrl,
body,
)
if err != nil {
log.Println(err.Error())
return nil, err
}
httpReq.Header.Set("Content-Type", contentType)
httpReq.Header.Set("Accept", "*/*")
httpReq.Header.Set("X-Requested-With", "XMLHttpRequest")
httpReq.Header.Set("Origin", "https://ch.info-center.by")
httpReq.Header.Set("Referer", "https://ch.info-center.by/")
resp, err := s.client.Do(httpReq)
resp, err = s.client.Do(httpReq)
if err != nil {
log.Printf("external response: service=receipt_provider method=%s url=%s err=%v", http.MethodPost, url, err)
return nil, err
}
defer resp.Body.Close()
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
log.Printf("failed to read external service response body: %v", readErr)
return nil, readErr
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
bodyText := strings.TrimSpace(string(responseBody))
log.Printf(
"external response: service=receipt_provider method=%s url=%s status=%d body=%q",
http.MethodPost,
url,
resp.StatusCode,
utils.TruncateForLog(bodyText, utils.DefaultLogValueLimit),
)
if resp.StatusCode != http.StatusOK {
return nil, &ExternalServiceError{
@@ -114,33 +155,33 @@ func (s *receiptProvider) GetReceipt(
}
}
// ===== PARSE =====
var raw struct {
Message map[string]interface{} `json:"message"`
}
if err := json.Unmarshal(responseBody, &raw); err != nil {
log.Printf("external service returned invalid json: %v", err)
return nil, err
}
bytes_, _ := json.Marshal(raw.Message)
var receipt domain.Receipt
if err := json.Unmarshal(bytes_, &receipt); err != nil {
return nil, err
}
if receipt.IssuedAtRaw == "" {
log.Printf("external response parse failed: service=receipt_provider err=%v date=%s number=%s", ErrReceiptNotFound, date, number)
return nil, ErrReceiptNotFound
}
positions, err := parsePositions(receipt.PositionsRaw)
if err != nil {
log.Printf("failed to parse positions: %s", err.Error())
return nil, err
}
receipt.IssuedAt, err = utils.ParseIssuedAt(receipt.IssuedAtRaw)
if err != nil {
log.Printf("failed to parse issued at: %s", err.Error())
return nil, err
}
@@ -151,13 +192,11 @@ func (s *receiptProvider) GetReceipt(
p.ProductCount, err = utils.ParseFloat(p.ProductCountRaw)
if err != nil {
log.Printf("failed to parse product count: %s", err.Error())
return nil, err
}
p.Amount, err = utils.ParseFloat(p.AmountRaw)
if err != nil {
log.Printf("failed to parse amount: %s", err.Error())
return nil, err
}
@@ -168,7 +207,9 @@ func (s *receiptProvider) GetReceipt(
return &receipt, nil
}
func buildMultipartBody(date, number string) (*bytes.Buffer, string) {
// ===== MULTIPART =====
func buildMultipartBody(date, number, sessid string) (*bytes.Buffer, string) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
@@ -180,6 +221,9 @@ func buildMultipartBody(date, number string) (*bytes.Buffer, string) {
_ = writer.WriteField("orig_date", normalizedDate)
_ = writer.WriteField("orig_ui", number)
// 🔥 ВОТ ГЛАВНОЕ
_ = writer.WriteField("sessid", sessid)
_ = writer.Close()
return body, writer.FormDataContentType()
+15
View File
@@ -7,8 +7,11 @@ import (
"log"
"FamilyHub/src/domain"
"github.com/lib/pq"
)
var ErrReceiptAlreadyExists = errors.New("receipt already exists")
type ReceiptsRepository interface {
Create(ctx context.Context, receipt *domain.Receipt) (int64, error)
GetByID(ctx context.Context, id int64) (*domain.Receipt, error)
@@ -129,6 +132,9 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
err = tx.QueryRowContext(ctx, query, args...).Scan(&receiptID)
if err != nil {
if isReceiptAlreadyExistsError(err) {
return 0, ErrReceiptAlreadyExists
}
return 0, err
}
@@ -185,6 +191,15 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
return receiptID, nil
}
func isReceiptAlreadyExistsError(err error) bool {
var pqErr *pq.Error
if !errors.As(err, &pqErr) {
return false
}
return pqErr.Code == "23505" && pqErr.Constraint == "receipts_receipt_number_key"
}
func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Receipt, error) {
var receipt domain.Receipt