From 369db87091f7dcafd65444afea60da653698a806 Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Wed, 18 Feb 2026 13:51:18 +0300 Subject: [PATCH] Merge remote-tracking branch 'origin/master' --- svc/queries/statement_header.go | 118 ++++++++++++++-------- svc/queries/statements_detail.go | 14 ++- svc/routes/login.go | 17 ++++ ui/dist/spa/index.html | 2 +- ui/src/services/api.js | 163 +++++++++++++++++++++++++++---- 5 files changed, 251 insertions(+), 63 deletions(-) diff --git a/svc/queries/statement_header.go b/svc/queries/statement_header.go index 2dbea7e..41d1d79 100644 --- a/svc/queries/statement_header.go +++ b/svc/queries/statement_header.go @@ -26,58 +26,82 @@ func GetStatements(params models.StatementParams) ([]models.StatementHeader, err } query := fmt.Sprintf(` + ;WITH Opening AS ( SELECT - b.CurrAccCode AS Cari_Kod, + b.CurrAccCode AS Cari_Kod, b.DocCurrencyCode AS Para_Birimi, SUM(c.Debit - c.Credit) AS Devir_Bakiyesi + FROM trCurrAccBook b + LEFT JOIN trCurrAccBookCurrency c ON c.CurrAccBookID = b.CurrAccBookID AND c.CurrencyCode = b.DocCurrencyCode + WHERE b.CurrAccCode LIKE '%%' + @Carikod + '%%' AND b.DocumentDate < @startdate + AND EXISTS ( SELECT 1 FROM CurrAccBookATAttributesFilter f2 WHERE f2.CurrAccBookID = b.CurrAccBookID AND f2.ATAtt01 IN (%s) ) - GROUP BY b.CurrAccCode, b.DocCurrencyCode + + GROUP BY + b.CurrAccCode, + b.DocCurrencyCode ), + Movements AS ( SELECT b.CurrAccCode AS Cari_Kod, d.CurrAccDescription AS Cari_Isim, + CONVERT(varchar(10), b.DocumentDate, 23) AS Belge_Tarihi, CONVERT(varchar(10), b.DueDate, 23) AS Vade_Tarihi, + b.RefNumber AS Belge_No, b.BaseApplicationCode AS Islem_Tipi, b.LineDescription AS Aciklama, + b.DocCurrencyCode AS Para_Birimi, + c.Debit AS Borc, c.Credit AS Alacak, + SUM(c.Debit - c.Credit) - OVER (PARTITION BY b.CurrAccCode, c.CurrencyCode - ORDER BY b.DocumentDate, b.CurrAccBookID) AS Hareket_Bakiyesi, + OVER ( + PARTITION BY b.CurrAccCode, c.CurrencyCode + ORDER BY b.DocumentDate, b.CurrAccBookID + ) AS Hareket_Bakiyesi, + f.ATAtt01 AS Parislemtipi + FROM trCurrAccBook b + LEFT JOIN cdCurrAccDesc d ON b.CurrAccCode = d.CurrAccCode + LEFT JOIN trCurrAccBookCurrency c ON b.CurrAccBookID = c.CurrAccBookID AND b.DocCurrencyCode = c.CurrencyCode + LEFT JOIN CurrAccBookATAttributesFilter f ON b.CurrAccBookID = f.CurrAccBookID + WHERE b.CurrAccCode LIKE '%%' + @Carikod + '%%' AND b.DocumentDate BETWEEN @startdate AND @enddate + AND EXISTS ( SELECT 1 FROM CurrAccBookATAttributesFilter f2 WHERE f2.CurrAccBookID = b.CurrAccBookID AND f2.ATAtt01 IN (%s) ) -) +) + SELECT m.Cari_Kod, m.Cari_Isim, @@ -87,54 +111,66 @@ SELECT m.Islem_Tipi, m.Aciklama, m.Para_Birimi, + m.Borc, m.Alacak, + ISNULL(o.Devir_Bakiyesi,0) + m.Hareket_Bakiyesi AS Bakiye, + m.Parislemtipi AS Parislemler + FROM Movements m + LEFT JOIN Opening o ON o.Cari_Kod = m.Cari_Kod AND o.Para_Birimi = m.Para_Birimi + UNION ALL --- Devir satırı -SELECT - @Carikod AS Cari_Kod, - MAX(d.CurrAccDescription) AS Cari_Isim, - CONVERT(varchar(10), @startdate, 23) AS Belge_Tarihi, - CONVERT(varchar(10), @startdate, 23) AS Vade_Tarihi, - 'Baslangic_devir' AS Belge_No, - 'Devir' AS Islem_Tipi, - 'Devir Bakiyesi' AS Aciklama, - b.DocCurrencyCode AS Para_Birimi, - SUM(c.Debit) AS Borc, - SUM(c.Credit) AS Alacak, - SUM(c.Debit) - SUM(c.Credit) AS Bakiye, - ( - SELECT STRING_AGG(x.ATAtt01, ',') - FROM ( - SELECT DISTINCT f2.ATAtt01 - FROM CurrAccBookATAttributesFilter f2 - INNER JOIN trCurrAccBook bb - ON f2.CurrAccBookID = bb.CurrAccBookID - WHERE bb.CurrAccCode LIKE '%%' + @Carikod + '%%' - AND bb.DocumentDate < @startdate - AND f2.ATAtt01 IN (%s) - ) x - ) AS Parislemler -FROM trCurrAccBook b -LEFT JOIN cdCurrAccDesc d - ON b.CurrAccCode = d.CurrAccCode -LEFT JOIN trCurrAccBookCurrency c - ON b.CurrAccBookID = c.CurrAccBookID - AND b.DocCurrencyCode = c.CurrencyCode -WHERE b.CurrAccCode LIKE '%%' + @Carikod + '%%' - AND b.DocumentDate < @startdate -GROUP BY b.DocCurrencyCode -ORDER BY Para_Birimi, Belge_Tarihi; -`, parislemFilter, parislemFilter, parislemFilter) +/* ✅ Devir satırı sadece Opening’den */ +SELECT + o.Cari_Kod, + d.CurrAccDescription, + + CONVERT(varchar(10), @startdate, 23), + CONVERT(varchar(10), @startdate, 23), + + 'Baslangic_devir', + 'Devir', + 'Devir Bakiyesi', + + o.Para_Birimi, + + CASE + WHEN o.Devir_Bakiyesi >= 0 THEN o.Devir_Bakiyesi + ELSE 0 + END, + + CASE + WHEN o.Devir_Bakiyesi < 0 THEN ABS(o.Devir_Bakiyesi) + ELSE 0 + END, + + o.Devir_Bakiyesi, + + '%s' + +FROM Opening o + +LEFT JOIN cdCurrAccDesc d + ON d.CurrAccCode = o.Cari_Kod + + +ORDER BY + Para_Birimi, + Belge_Tarihi; +`, + parislemFilter, + parislemFilter, + parislemFilter, + ) rows, err := db.MssqlDB.Query(query, sql.Named("startdate", params.StartDate), diff --git a/svc/queries/statements_detail.go b/svc/queries/statements_detail.go index 67b27f9..880a480 100644 --- a/svc/queries/statements_detail.go +++ b/svc/queries/statements_detail.go @@ -31,9 +31,17 @@ SELECT COALESCE(MAX(KisaKarDesc.AttributeDescription), '') AS Icerik, a.ItemCode AS Urun_Kodu, a.ColorCode AS Urun_Rengi, - SUM(a.Qty1) AS Toplam_Adet, - SUM(ABS(a.Doc_Price)) AS Toplam_Fiyat, - CAST(SUM(a.Qty1 * ABS(a.Doc_Price)) AS numeric(18,2)) AS Toplam_Tutar + SUM(a.Qty1) AS Toplam_Adet, + +CAST( + SUM(a.Qty1 * ABS(a.Doc_Price)) + / NULLIF(SUM(a.Qty1),0) +AS numeric(18,4)) AS Doviz_Fiyat, + +CAST( + SUM(a.Qty1 * ABS(a.Doc_Price)) +AS numeric(18,2)) AS Toplam_Tutar + FROM AllInvoicesWithAttributes a LEFT JOIN prItemAttribute AnaGrup ON a.ItemCode = AnaGrup.ItemCode AND AnaGrup.AttributeTypeCode = 1 diff --git a/svc/routes/login.go b/svc/routes/login.go index e05530c..dacf33e 100644 --- a/svc/routes/login.go +++ b/svc/routes/login.go @@ -3,6 +3,7 @@ package routes import ( "bssapp-backend/auth" "bssapp-backend/internal/auditlog" + "bssapp-backend/internal/security" "bssapp-backend/models" "bssapp-backend/queries" "bssapp-backend/repository" @@ -208,6 +209,22 @@ func writeLoginResponse(w http.ResponseWriter, db *sql.DB, user *models.MkUser) return } + refreshPlain, refreshHash, err := security.GenerateRefreshToken() + if err != nil { + http.Error(w, "Refresh token üretilemedi", http.StatusInternalServerError) + return + } + + refreshExp := time.Now().Add(14 * 24 * time.Hour) + rtRepo := repository.NewRefreshTokenRepository(db) + if err := rtRepo.IssueRefreshToken(user.ID, refreshHash, refreshExp); err != nil { + log.Printf("refresh token store failed user=%d err=%v", user.ID, err) + http.Error(w, "Session başlatılamadı", http.StatusInternalServerError) + return + } + + setRefreshCookie(w, refreshPlain, refreshExp) + _ = json.NewEncoder(w).Encode(map[string]any{ "token": token, "user": map[string]any{ diff --git a/ui/dist/spa/index.html b/ui/dist/spa/index.html index ccd7b17..1174c68 100644 --- a/ui/dist/spa/index.html +++ b/ui/dist/spa/index.html @@ -1 +1 @@ -Baggi SS
\ No newline at end of file +Baggi SS
\ No newline at end of file diff --git a/ui/src/services/api.js b/ui/src/services/api.js index 85e1216..9399f6a 100644 --- a/ui/src/services/api.js +++ b/ui/src/services/api.js @@ -3,6 +3,7 @@ import qs from 'qs' import { useAuthStore } from 'stores/authStore' export const API_BASE_URL = '/api' +const AUTH_REFRESH_PATH = '/auth/refresh' const api = axios.create({ baseURL: API_BASE_URL, @@ -12,17 +13,69 @@ const api = axios.create({ withCredentials: true }) +function isPublicPath(url) { + return ( + url.startsWith('/auth/login') || + url.startsWith(AUTH_REFRESH_PATH) || + url.startsWith('/password/forgot') || + url.startsWith('/password/reset') + ) +} + +function extractToken(payload) { + return ( + payload?.token || + payload?.access_token || + payload?.data?.token || + payload?.data?.access_token || + '' + ) +} + +function isHtmlLike(value) { + const text = String(value || '').trim().toLowerCase() + return text.startsWith('= 500) { + return `Upstream server error (${status}).` + } + return `Unexpected HTML error response (${status || '-'})` + } + + const compact = normalized.replace(/\s+/g, ' ').trim() + if (compact.length > 320) { + return `${compact.slice(0, 320)}...` + } + + return compact +} + +function redirectToLogin() { + if (typeof window === 'undefined') return + if (window.location.hash === '#/login') return + window.location.hash = '/login' +} + api.interceptors.request.use((config) => { const auth = useAuthStore() const url = config.url || '' - const isPublic = - url.startsWith('/auth/login') || - url.startsWith('/auth/refresh') || - url.startsWith('/password/forgot') || - url.startsWith('/password/reset') - - if (!isPublic && auth?.token) { + if (!isPublicPath(url) && auth?.token) { config.headers ||= {} config.headers.Authorization = `Bearer ${auth.token}` } @@ -31,33 +84,106 @@ api.interceptors.request.use((config) => { }) let isLoggingOut = false +let refreshPromise = null + +async function refreshAccessToken() { + if (refreshPromise) { + return refreshPromise + } + + refreshPromise = (async () => { + const auth = useAuthStore() + const response = await api.post( + AUTH_REFRESH_PATH, + {}, + { + skipAuthRefresh: true, + skipAutoLogout: true + } + ) + + const rawToken = extractToken(response?.data) + const token = typeof rawToken === 'string' ? rawToken.trim() : '' + const isJwt = token.split('.').length === 3 + + if (!token || !isJwt) { + throw new Error('Invalid refresh token response') + } + + auth.setSession({ + token, + user: auth.user || null + }) + + return token + })().finally(() => { + refreshPromise = null + }) + + return refreshPromise +} + +function clearSessionAndRedirect() { + if (isLoggingOut) return + + isLoggingOut = true + try { + useAuthStore().clearSession() + } finally { + isLoggingOut = false + redirectToLogin() + } +} api.interceptors.response.use( r => r, async (error) => { + const requestConfig = error?.config || {} const status = error?.response?.status - const requestUrl = String(error?.config?.url || '') + const requestUrl = String(requestConfig.url || '') const hasBlob = typeof Blob !== 'undefined' && error?.response?.data instanceof Blob const isPasswordChangeRequest = requestUrl.startsWith('/password/change') || requestUrl.startsWith('/me/password') + const isPublicRequest = isPublicPath(requestUrl) if ((status >= 500 || hasBlob) && error) { - const method = String(error?.config?.method || 'GET').toUpperCase() - const detail = await extractApiErrorDetail(error) + const method = String(requestConfig.method || 'GET').toUpperCase() + const detail = sanitizeApiErrorDetail( + await extractApiErrorDetail(error), + status + ) error.parsedMessage = detail console.error(`API ${status || '-'} ${method} ${requestUrl}: ${detail}`) } + const shouldTryRefresh = + status === 401 && + !requestConfig._retry && + !requestConfig.skipAuthRefresh && + !isPasswordChangeRequest && + !isPublicRequest + + if (shouldTryRefresh) { + requestConfig._retry = true + try { + const newToken = await refreshAccessToken() + requestConfig.headers ||= {} + requestConfig.headers.Authorization = `Bearer ${newToken}` + return api(requestConfig) + } catch { + // fall through to logout + } + } + // Password change endpoints may return 401 for business reasons // (for example current password mismatch). Keep session in that case. - if (status === 401 && !isPasswordChangeRequest && !isLoggingOut) { - isLoggingOut = true - try { - useAuthStore().clearSession() - } finally { - isLoggingOut = false - } + if ( + status === 401 && + !isPasswordChangeRequest && + !requestConfig.skipAutoLogout + ) { + clearSessionAndRedirect() } return Promise.reject(error) @@ -114,6 +240,7 @@ async function parseBlobErrorMessage(data) { } export async function extractApiErrorDetail(err) { + const status = err?.response?.status let detail = err?.parsedMessage || err?.response?.data?.detail || @@ -129,7 +256,7 @@ export async function extractApiErrorDetail(err) { detail = err?.message || 'Request failed' } - return detail + return sanitizeApiErrorDetail(detail, status) } export const download = async (u, p = {}, c = {}) => {