Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-03-03 00:30:19 +03:00
parent ea27d34336
commit a4f4c2457f
29 changed files with 4522 additions and 752 deletions

View File

@@ -431,6 +431,30 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
wrapV3(http.HandlerFunc(routes.GetCustomerBalanceListHandler)), wrapV3(http.HandlerFunc(routes.GetCustomerBalanceListHandler)),
) )
bindV3(r, pgDB,
"/api/finance/customer-balances/export-pdf", "GET",
"finance", "export",
wrapV3(routes.ExportCustomerBalancePDFHandler(mssql)),
)
bindV3(r, pgDB,
"/api/finance/customer-balances/export-excel", "GET",
"finance", "export",
wrapV3(routes.ExportCustomerBalanceExcelHandler(mssql)),
)
bindV3(r, pgDB,
"/api/finance/account-aging-statement", "GET",
"finance", "view",
wrapV3(http.HandlerFunc(routes.GetStatementAgingHandler)),
)
bindV3(r, pgDB,
"/api/finance/account-aging-statement/export-pdf", "GET",
"finance", "export",
wrapV3(routes.ExportStatementAgingPDFHandler(mssql)),
)
// ============================================================ // ============================================================
// REPORT (STATEMENTS) // REPORT (STATEMENTS)
// ============================================================ // ============================================================

View File

@@ -9,6 +9,8 @@ type CustomerBalanceListParams struct {
RiskDurumu string RiskDurumu string
IslemTipi string IslemTipi string
Ulke string Ulke string
Il string
Ilce string
} }
type CustomerBalanceListRow struct { type CustomerBalanceListRow struct {
@@ -20,10 +22,18 @@ type CustomerBalanceListRow struct {
AnaCariAdi string `json:"ana_cari_adi"` AnaCariAdi string `json:"ana_cari_adi"`
CariKodu string `json:"cari_kodu"` CariKodu string `json:"cari_kodu"`
CariDetay string `json:"cari_detay"` CariDetay string `json:"cari_detay"`
CariTip string `json:"cari_tip"`
Kanal1 string `json:"kanal_1"`
Ozellik03 string `json:"ozellik03"` Ozellik03 string `json:"ozellik03"`
Ozellik05 string `json:"ozellik05"` Ozellik05 string `json:"ozellik05"`
Ozellik06 string `json:"ozellik06"` Ozellik06 string `json:"ozellik06"`
Ozellik07 string `json:"ozellik07"` Ozellik07 string `json:"ozellik07"`
Il string `json:"il"`
Ilce string `json:"ilce"`
MuhasebeKodu string `json:"muhasebe_kodu"`
TC string `json:"tc"`
RiskDurumu string `json:"risk_durumu"`
SirketDetay string `json:"sirket_detay"`
CariDoviz string `json:"cari_doviz"` CariDoviz string `json:"cari_doviz"`
Bakiye12 float64 `json:"bakiye_1_2"` Bakiye12 float64 `json:"bakiye_1_2"`
TLBakiye12 float64 `json:"tl_bakiye_1_2"` TLBakiye12 float64 `json:"tl_bakiye_1_2"`
@@ -31,6 +41,4 @@ type CustomerBalanceListRow struct {
Bakiye13 float64 `json:"bakiye_1_3"` Bakiye13 float64 `json:"bakiye_1_3"`
TLBakiye13 float64 `json:"tl_bakiye_1_3"` TLBakiye13 float64 `json:"tl_bakiye_1_3"`
USDBakiye13 float64 `json:"usd_bakiye_1_3"` USDBakiye13 float64 `json:"usd_bakiye_1_3"`
HesapAlinmayanGun NullInt32 `json:"hesap_alinmayan_gun"`
KalanFaturaOrtalamaVadeTarihi NullString `json:"kalan_fatura_ortalama_vade_tarihi"`
} }

View File

@@ -0,0 +1,7 @@
package models
type StatementAgingParams struct {
AccountCode string `json:"accountcode"`
EndDate string `json:"enddate"`
Parislemler []string `json:"parislemler"`
}

View File

@@ -1,91 +0,0 @@
package queries
import (
"bssapp-backend/models"
"context"
)
func GetCustomerBalanceList(
ctx context.Context,
params models.CustomerBalanceListParams,
) ([]models.CustomerBalanceListRow, error) {
//------------------------------------------------
// 1⃣ DATA ÇEK
//------------------------------------------------
balances, err := getFastBalances(ctx, params.SelectedDate)
if err != nil {
return nil, err
}
masterMap, err := getCariMasterMap(ctx)
if err != nil {
return nil, err
}
//------------------------------------------------
// 2⃣ MERGE
//------------------------------------------------
resultMap := make(map[string]*models.CustomerBalanceListRow)
for _, b := range balances {
key := b.CariKodu + "|" + b.CariDoviz
r, ok := resultMap[key]
if !ok {
r = &models.CustomerBalanceListRow{
CariKodu: b.CariKodu,
CariDoviz: b.CariDoviz,
}
// Master
if m, ok := masterMap[b.CariKodu]; ok {
r.CariDetay = m.CariDetay
r.Piyasa = m.Piyasa
r.Temsilci = m.Temsilci
r.Ozellik03 = m.Ozellik03
r.Ozellik05 = m.Ozellik05
r.Ozellik06 = m.Ozellik06
r.Ozellik07 = m.Ozellik07
r.CariIlkGrup = m.Ozellik08
}
resultMap[key] = r
}
//------------------------------------------------
// 3⃣ TOPLA
//------------------------------------------------
if b.PislemTipi == "1_2" {
r.Bakiye12 += b.Bakiye
r.TLBakiye12 += b.KurBakiye
r.USDBakiye12 += b.KurBakiye / b.UsdKur
} else if b.PislemTipi == "1_3" {
r.Bakiye13 += b.Bakiye
r.TLBakiye13 += b.KurBakiye
r.USDBakiye13 += b.KurBakiye / b.UsdKur
}
}
//------------------------------------------------
// 4⃣ SLICE DÖN
//------------------------------------------------
out := make([]models.CustomerBalanceListRow, 0, len(resultMap))
for _, v := range resultMap {
out = append(out, *v)
}
return out, nil
}

View File

@@ -1,79 +0,0 @@
package queries
import (
"bssapp-backend/db"
"context"
)
type CariMasterRow struct {
CariKodu string
CariDetay string
Piyasa string
Temsilci string
Ozellik03 string
Ozellik05 string
Ozellik06 string
Ozellik07 string
Ozellik08 string
}
func getCariMasterMap(
ctx context.Context,
) (map[string]CariMasterRow, error) {
const q = `
WITH CTE AS (
SELECT
*,
rn = ROW_NUMBER() OVER (
PARTITION BY LEFT(CariKodu,8)
ORDER BY CariKodu
)
FROM dbo.MK_CARI_ILETISIM WITH(NOLOCK)
)
SELECT
CariKodu,
CariDetay,
PIYASA,
CARI_TEMSILCI,
Ozellik03,
Ozellik05,
Ozellik06,
Ozellik07,
Ozellik08
FROM CTE
WHERE rn=1
`
rows, err := db.MssqlDB.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]CariMasterRow, 4096)
for rows.Next() {
var r CariMasterRow
err := rows.Scan(
&r.CariKodu,
&r.CariDetay,
&r.Piyasa,
&r.Temsilci,
&r.Ozellik03,
&r.Ozellik05,
&r.Ozellik06,
&r.Ozellik07,
&r.Ozellik08,
)
if err != nil {
return nil, err
}
out[r.CariKodu] = r
}
return out, nil
}

View File

@@ -3,62 +3,10 @@ package queries
import ( import (
"bssapp-backend/models" "bssapp-backend/models"
"database/sql" "database/sql"
"sync" "strings"
"time"
) )
/* =============================== // GetCachedCurrencyV3 keeps compatibility with existing order routes.
CACHE STRUCT func GetCachedCurrencyV3(db *sql.DB, currencyCode string) (*models.TodayCurrencyV3, error) {
================================ */ return GetTodayCurrencyV3(db, strings.ToUpper(strings.TrimSpace(currencyCode)))
type currencyCacheItem struct {
data *models.TodayCurrencyV3
expiresAt time.Time
}
var (
currencyCache = make(map[string]currencyCacheItem)
cacheMutex sync.RWMutex
cacheTTL = 5 * time.Minute
)
/* ===============================
MAIN CACHE FUNC
================================ */
func GetCachedCurrencyV3(db *sql.DB, code string) (*models.TodayCurrencyV3, error) {
now := time.Now()
/* ---------- READ CACHE ---------- */
cacheMutex.RLock()
item, ok := currencyCache[code]
if ok && now.Before(item.expiresAt) {
cacheMutex.RUnlock()
return item.data, nil
}
cacheMutex.RUnlock()
/* ---------- FETCH DB ---------- */
data, err := GetTodayCurrencyV3(db, code)
if err != nil {
return nil, err
}
/* ---------- WRITE CACHE ---------- */
cacheMutex.Lock()
currencyCache[code] = currencyCacheItem{
data: data,
expiresAt: now.Add(cacheTTL),
}
cacheMutex.Unlock()
return data, nil
} }

View File

@@ -0,0 +1,784 @@
package queries
import (
"bssapp-backend/auth"
"bssapp-backend/db"
"bssapp-backend/internal/authz"
"bssapp-backend/models"
"context"
"database/sql"
"fmt"
"log"
"sort"
"strconv"
"strings"
)
type mkCariBakiyeLine struct {
CurrAccTypeCode int
CariKodu string
CariDoviz string
SirketKodu int
PislemTipi string
YerelBakiye float64
Bakiye float64
}
type cariMeta struct {
CariDetay string
CariTip string
Kanal1 string
Piyasa string
Temsilci string
Ulke string
Il string
Ilce string
TC string
RiskDurumu string
MuhasebeKodu string
SirketDetay string
}
type masterCariMeta struct {
CariDetay string
Kanal1 string
Piyasa string
Temsilci string
Ulke string
Il string
Ilce string
RiskDurumu string
}
type balanceFilters struct {
cariIlkGrup map[string]struct{}
piyasa map[string]struct{}
temsilci map[string]struct{}
riskDurumu map[string]struct{}
islemTipi map[string]struct{}
ulke map[string]struct{}
il map[string]struct{}
ilce map[string]struct{}
}
func GetCustomerBalanceList(ctx context.Context, params models.CustomerBalanceListParams) ([]models.CustomerBalanceListRow, error) {
if strings.TrimSpace(params.SelectedDate) == "" {
return nil, fmt.Errorf("selected_date is required")
}
lines, err := loadBalanceLines(ctx, params.SelectedDate, params.CariSearch)
if err != nil {
return nil, err
}
metaMap, err := loadCariMetaMap(ctx, lines)
if err != nil {
log.Printf("customer_balance_list: cari meta query failed, fallback without meta: %v", err)
metaMap = map[string]cariMeta{}
}
masterMetaMap, err := loadMasterCariMetaMap(ctx, lines)
if err != nil {
log.Printf("customer_balance_list: master cari meta query failed, fallback without master meta: %v", err)
masterMetaMap = map[string]masterCariMeta{}
}
companyMap, err := loadCompanyMap(ctx)
if err != nil {
return nil, err
}
glMap, err := loadGLAccountMap(ctx, lines)
if err != nil {
return nil, err
}
rateMap, err := loadNearestTryRates(ctx)
if err != nil {
return nil, err
}
usdTry := rateMap["USD"]
if usdTry <= 0 {
usdTry = 1
}
filters := buildFilters(params)
agg := make(map[string]*models.CustomerBalanceListRow, len(lines))
for _, ln := range lines {
cari := strings.TrimSpace(ln.CariKodu)
if cari == "" {
continue
}
curr := strings.ToUpper(strings.TrimSpace(ln.CariDoviz))
if curr == "" {
curr = "TRY"
}
meta := metaMap[metaKey(ln.CurrAccTypeCode, cari)]
meta.MuhasebeKodu = glMap[glKey(ln.CurrAccTypeCode, cari, ln.SirketKodu)]
meta.SirketDetay = companyMap[ln.SirketKodu]
master := deriveMasterCari(cari)
mm := masterMetaMap[master]
if strings.TrimSpace(mm.Kanal1) != "" {
meta.Kanal1 = mm.Kanal1
}
if strings.TrimSpace(mm.Piyasa) != "" {
meta.Piyasa = mm.Piyasa
}
if strings.TrimSpace(mm.Temsilci) != "" {
meta.Temsilci = mm.Temsilci
}
if strings.TrimSpace(mm.Ulke) != "" {
meta.Ulke = mm.Ulke
}
if strings.TrimSpace(mm.Il) != "" {
meta.Il = mm.Il
}
if strings.TrimSpace(mm.Ilce) != "" {
meta.Ilce = mm.Ilce
}
if strings.TrimSpace(mm.RiskDurumu) != "" {
meta.RiskDurumu = mm.RiskDurumu
}
if !filters.matchLine(ln.PislemTipi, meta) {
continue
}
key := strconv.Itoa(ln.CurrAccTypeCode) + "|" + cari + "|" + curr + "|" + strconv.Itoa(ln.SirketKodu)
row, ok := agg[key]
if !ok {
row = &models.CustomerBalanceListRow{
CariIlkGrup: meta.Kanal1,
Piyasa: meta.Piyasa,
Temsilci: meta.Temsilci,
Sirket: strconv.Itoa(ln.SirketKodu),
AnaCariKodu: master,
AnaCariAdi: firstNonEmpty(mm.CariDetay, meta.CariDetay),
CariKodu: cari,
CariDetay: meta.CariDetay,
CariTip: meta.CariTip,
Kanal1: meta.Kanal1,
Ozellik03: meta.RiskDurumu,
Ozellik05: meta.Ulke,
Ozellik06: meta.Il,
Ozellik07: meta.Ilce,
Il: meta.Il,
Ilce: meta.Ilce,
MuhasebeKodu: meta.MuhasebeKodu,
TC: meta.TC,
RiskDurumu: meta.RiskDurumu,
SirketDetay: meta.SirketDetay,
CariDoviz: curr,
}
agg[key] = row
}
usd := toUSD(ln.Bakiye, curr, usdTry, rateMap)
switch strings.TrimSpace(ln.PislemTipi) {
case "1_2":
row.Bakiye12 += ln.Bakiye
row.TLBakiye12 += ln.YerelBakiye
row.USDBakiye12 += usd
case "1_3":
row.Bakiye13 += ln.Bakiye
row.TLBakiye13 += ln.YerelBakiye
row.USDBakiye13 += usd
}
}
out := make([]models.CustomerBalanceListRow, 0, len(agg))
for _, v := range agg {
out = append(out, *v)
}
sort.Slice(out, func(i, j int) bool {
if out[i].AnaCariKodu == out[j].AnaCariKodu {
if out[i].CariKodu == out[j].CariKodu {
return out[i].CariDoviz < out[j].CariDoviz
}
return out[i].CariKodu < out[j].CariKodu
}
return out[i].AnaCariKodu < out[j].AnaCariKodu
})
return out, nil
}
func loadMasterCariMetaMap(ctx context.Context, lines []mkCariBakiyeLine) (map[string]masterCariMeta, error) {
masters := make(map[string]struct{})
for _, ln := range lines {
m := strings.TrimSpace(deriveMasterCari(ln.CariKodu))
if m != "" {
masters[m] = struct{}{}
}
}
if len(masters) == 0 {
return map[string]masterCariMeta{}, nil
}
query := fmt.Sprintf(`
WITH BaseCari AS
(
SELECT
CB.CurrAccCode,
CB.CurrAccTypeCode,
MasterCari = LEFT(CB.CurrAccCode, 8),
rn = ROW_NUMBER() OVER
(
PARTITION BY LEFT(CB.CurrAccCode, 8)
ORDER BY CB.CurrAccCode
)
FROM cdCurrAcc CB WITH (NOLOCK)
WHERE CB.CurrAccTypeCode IN (1,3)
AND LEFT(CB.CurrAccCode, 8) IN (%s)
),
FirstCari AS
(
SELECT *
FROM BaseCari
WHERE rn = 1
)
SELECT
CariKodu = F.MasterCari,
CariDetay = ISNULL(cd.CurrAccDescription, ''),
KANAL_1 = ISNULL(CASE WHEN F.CurrAccTypeCode=1 THEN VDesc.VendorAtt08Desc ELSE CDesc.CustomerAtt08Desc END, ''),
PIYASA = ISNULL(CASE WHEN F.CurrAccTypeCode=1 THEN VDesc.VendorAtt01Desc ELSE CDesc.CustomerAtt01Desc END, ''),
CARI_TEMSILCI = ISNULL(
CASE
WHEN ISNULL(CASE WHEN F.CurrAccTypeCode = 1 THEN VDesc.VendorAtt02Desc ELSE CDesc.CustomerAtt02Desc END,'') = ''
THEN ISNULL(CASE WHEN F.CurrAccTypeCode = 1 THEN VAttr.VendorAtt09 ELSE CAttr.CustomerAtt09 END,'')
ELSE CASE WHEN F.CurrAccTypeCode = 1 THEN VDesc.VendorAtt02Desc ELSE CDesc.CustomerAtt02Desc END
END,''
),
ULKE = ISNULL(CASE WHEN F.CurrAccTypeCode=1 THEN VDesc.VendorAtt05Desc ELSE CDesc.CustomerAtt05Desc END, ''),
IL = ISNULL(CASE WHEN F.CurrAccTypeCode=1 THEN VDesc.VendorAtt06Desc ELSE CDesc.CustomerAtt06Desc END, ''),
ILCE = ISNULL(CASE WHEN F.CurrAccTypeCode=1 THEN VDesc.VendorAtt07Desc ELSE CDesc.CustomerAtt07Desc END, ''),
Risk_Durumu = ISNULL(CASE WHEN F.CurrAccTypeCode=1 THEN VDesc.VendorAtt03Desc ELSE CDesc.CustomerAtt03Desc END, '')
FROM FirstCari F
LEFT JOIN cdCurrAccDesc cd WITH (NOLOCK)
ON cd.CurrAccTypeCode = F.CurrAccTypeCode
AND cd.CurrAccCode = F.CurrAccCode
AND cd.LangCode = 'TR'
LEFT JOIN VendorAttributeDescriptions('TR') VDesc
ON VDesc.CurrAccCode = F.CurrAccCode
AND VDesc.CurrAccTypeCode = F.CurrAccTypeCode
LEFT JOIN CustomerAttributeDescriptions('TR') CDesc
ON CDesc.CurrAccCode = F.CurrAccCode
AND CDesc.CurrAccTypeCode = F.CurrAccTypeCode
LEFT JOIN VendorAttributes VAttr
ON VAttr.CurrAccCode = F.CurrAccCode
AND VAttr.CurrAccTypeCode = F.CurrAccTypeCode
LEFT JOIN CustomerAttributes CAttr
ON CAttr.CurrAccCode = F.CurrAccCode
AND CAttr.CurrAccTypeCode = F.CurrAccTypeCode
ORDER BY F.MasterCari;
`, quotedInList(masters))
rows, err := db.MssqlDB.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("master cari meta query error: %w", err)
}
defer rows.Close()
out := make(map[string]masterCariMeta, len(masters))
for rows.Next() {
var master string
var m masterCariMeta
if err := rows.Scan(
&master,
&m.CariDetay,
&m.Kanal1,
&m.Piyasa,
&m.Temsilci,
&m.Ulke,
&m.Il,
&m.Ilce,
&m.RiskDurumu,
); err != nil {
return nil, err
}
out[strings.TrimSpace(master)] = m
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func loadBalanceLines(ctx context.Context, selectedDate, cariSearch string) ([]mkCariBakiyeLine, error) {
query := `
SELECT
CurrAccTypeCode,
CariKodu,
CariDoviz,
SirketKodu,
PislemTipi,
YerelBakiye,
Bakiye
FROM dbo.MK_CARI_BAKIYE_LIST(@SonTarih)
WHERE (@CariSearch = '' OR CariKodu LIKE '%' + @CariSearch + '%')
`
rows, err := db.MssqlDB.QueryContext(ctx, query,
sql.Named("SonTarih", selectedDate),
sql.Named("CariSearch", strings.TrimSpace(cariSearch)),
)
if err != nil {
return nil, fmt.Errorf("MK_CARI_BAKIYE_LIST query error: %w", err)
}
defer rows.Close()
out := make([]mkCariBakiyeLine, 0, 4096)
for rows.Next() {
var r mkCariBakiyeLine
if err := rows.Scan(
&r.CurrAccTypeCode,
&r.CariKodu,
&r.CariDoviz,
&r.SirketKodu,
&r.PislemTipi,
&r.YerelBakiye,
&r.Bakiye,
); err != nil {
return nil, err
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func loadCariMetaMap(ctx context.Context, lines []mkCariBakiyeLine) (map[string]cariMeta, error) {
vendorCodes := make(map[string]struct{})
customerCodes := make(map[string]struct{})
for _, ln := range lines {
code := strings.TrimSpace(ln.CariKodu)
if code == "" {
continue
}
if ln.CurrAccTypeCode == 1 {
vendorCodes[code] = struct{}{}
} else if ln.CurrAccTypeCode == 3 {
customerCodes[code] = struct{}{}
}
}
if len(vendorCodes) == 0 && len(customerCodes) == 0 {
return map[string]cariMeta{}, nil
}
whereParts := make([]string, 0, 2)
if len(vendorCodes) > 0 {
whereParts = append(whereParts, fmt.Sprintf("(c.CurrAccTypeCode=1 AND c.CurrAccCode IN (%s))", quotedInList(vendorCodes)))
}
if len(customerCodes) > 0 {
whereParts = append(whereParts, fmt.Sprintf("(c.CurrAccTypeCode=3 AND c.CurrAccCode IN (%s))", quotedInList(customerCodes)))
}
query := fmt.Sprintf(`
SELECT
c.CurrAccTypeCode,
c.CurrAccCode,
CariDetay = ISNULL(d.CurrAccDescription, ''),
CariTip = CASE WHEN c.CurrAccTypeCode = 1 THEN N'Satıcı' ELSE N'Müşteri' END,
KANAL_1 = ISNULL(CASE WHEN c.CurrAccTypeCode=1 THEN vad.VendorAtt08Desc ELSE cad.CustomerAtt08Desc END, ''),
PIYASA = ISNULL(CASE WHEN c.CurrAccTypeCode=1 THEN vad.VendorAtt01Desc ELSE cad.CustomerAtt01Desc END, ''),
CARI_TEMSILCI = ISNULL(
CASE
WHEN ISNULL(CASE WHEN c.CurrAccTypeCode=1 THEN vad.VendorAtt02Desc ELSE cad.CustomerAtt02Desc END, '') = ''
THEN ISNULL(CASE WHEN c.CurrAccTypeCode=1 THEN va.VendorAtt09 ELSE ca.CustomerAtt09 END, '')
ELSE CASE WHEN c.CurrAccTypeCode=1 THEN vad.VendorAtt02Desc ELSE cad.CustomerAtt02Desc END
END,
''),
ULKE = ISNULL(CASE WHEN c.CurrAccTypeCode=1 THEN vad.VendorAtt05Desc ELSE cad.CustomerAtt05Desc END, ''),
IL = ISNULL(CASE WHEN c.CurrAccTypeCode=1 THEN vad.VendorAtt06Desc ELSE cad.CustomerAtt06Desc END, ''),
ILCE = ISNULL(CASE WHEN c.CurrAccTypeCode=1 THEN vad.VendorAtt07Desc ELSE cad.CustomerAtt07Desc END, ''),
TC = ISNULL(c.IdentityNum, ''),
Risk_Durumu = ISNULL(CASE WHEN c.CurrAccTypeCode=1 THEN vad.VendorAtt03Desc ELSE cad.CustomerAtt03Desc END, '')
FROM cdCurrAcc c WITH(NOLOCK)
LEFT JOIN cdCurrAccDesc d WITH(NOLOCK)
ON d.CurrAccTypeCode = c.CurrAccTypeCode
AND d.CurrAccCode = c.CurrAccCode
AND d.LangCode = 'TR'
LEFT JOIN VendorAttributes va WITH(NOLOCK)
ON va.CurrAccTypeCode = c.CurrAccTypeCode
AND va.CurrAccCode = c.CurrAccCode
LEFT JOIN VendorAttributeDescriptions('TR') vad
ON vad.CurrAccTypeCode = c.CurrAccTypeCode
AND vad.CurrAccCode = c.CurrAccCode
LEFT JOIN CustomerAttributes ca WITH(NOLOCK)
ON ca.CurrAccTypeCode = c.CurrAccTypeCode
AND ca.CurrAccCode = c.CurrAccCode
LEFT JOIN CustomerAttributeDescriptions('TR') cad
ON cad.CurrAccTypeCode = c.CurrAccTypeCode
AND cad.CurrAccCode = c.CurrAccCode
WHERE c.CurrAccTypeCode IN (1,3)
AND (%s)
`, strings.Join(whereParts, " OR "))
rows, err := db.MssqlDB.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("cari meta query error: %w", err)
}
defer rows.Close()
out := make(map[string]cariMeta, len(lines))
for rows.Next() {
var t int
var code string
var m cariMeta
if err := rows.Scan(
&t,
&code,
&m.CariDetay,
&m.CariTip,
&m.Kanal1,
&m.Piyasa,
&m.Temsilci,
&m.Ulke,
&m.Il,
&m.Ilce,
&m.TC,
&m.RiskDurumu,
); err != nil {
return nil, err
}
out[metaKey(t, code)] = m
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func loadGLAccountMap(ctx context.Context, lines []mkCariBakiyeLine) (map[string]string, error) {
vendorCodes := make(map[string]struct{})
customerCodes := make(map[string]struct{})
companyCodes := make(map[int]struct{})
for _, ln := range lines {
code := strings.TrimSpace(ln.CariKodu)
if code == "" {
continue
}
companyCodes[ln.SirketKodu] = struct{}{}
if ln.CurrAccTypeCode == 1 {
vendorCodes[code] = struct{}{}
} else if ln.CurrAccTypeCode == 3 {
customerCodes[code] = struct{}{}
}
}
if len(companyCodes) == 0 || (len(vendorCodes) == 0 && len(customerCodes) == 0) {
return map[string]string{}, nil
}
whereParts := make([]string, 0, 2)
if len(vendorCodes) > 0 {
whereParts = append(whereParts, fmt.Sprintf("(CurrAccTypeCode=1 AND CurrAccCode IN (%s))", quotedInList(vendorCodes)))
}
if len(customerCodes) > 0 {
whereParts = append(whereParts, fmt.Sprintf("(CurrAccTypeCode=3 AND CurrAccCode IN (%s))", quotedInList(customerCodes)))
}
query := fmt.Sprintf(`
SELECT CurrAccTypeCode, CurrAccCode, CompanyCode, GLAccCode
FROM prCurrAccGLAccount WITH(NOLOCK)
WHERE PostAccTypeCode = 100
AND CompanyCode IN (%s)
AND (%s)
`, intInList(companyCodes), strings.Join(whereParts, " OR "))
rows, err := db.MssqlDB.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("gl account query error: %w", err)
}
defer rows.Close()
out := make(map[string]string)
for rows.Next() {
var t int
var code string
var company int
var gl sql.NullString
if err := rows.Scan(&t, &code, &company, &gl); err != nil {
return nil, err
}
out[glKey(t, code, company)] = strings.TrimSpace(gl.String)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func loadCompanyMap(ctx context.Context) (map[int]string, error) {
rows, err := db.MssqlDB.QueryContext(ctx, `SELECT CompanyCode, CompanyName FROM cdCompany WITH(NOLOCK)`)
if err != nil {
return nil, fmt.Errorf("company map query error: %w", err)
}
defer rows.Close()
out := make(map[int]string)
for rows.Next() {
var code int
var name sql.NullString
if err := rows.Scan(&code, &name); err != nil {
return nil, err
}
out[code] = strings.TrimSpace(name.String)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func loadNearestTryRates(ctx context.Context) (map[string]float64, error) {
query := `
WITH Ranked AS (
SELECT
CurrencyCode,
Rate,
rn = ROW_NUMBER() OVER (
PARTITION BY CurrencyCode
ORDER BY ABS(DATEDIFF(DAY, Date, GETDATE())), Date DESC
)
FROM AllExchangeRates
WHERE RelationCurrencyCode = 'TRY'
AND ExchangeTypeCode = 6
AND Rate > 0
)
SELECT CurrencyCode, Rate
FROM Ranked
WHERE rn = 1
`
rows, err := db.MssqlDB.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("exchange rates query error: %w", err)
}
defer rows.Close()
out := map[string]float64{"TRY": 1}
for rows.Next() {
var code string
var rate float64
if err := rows.Scan(&code, &rate); err != nil {
return nil, err
}
code = strings.ToUpper(strings.TrimSpace(code))
if code != "" && rate > 0 {
out[code] = rate
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func toUSD(amount float64, currency string, usdTry float64, rateMap map[string]float64) float64 {
if usdTry <= 0 {
return 0
}
switch currency {
case "USD":
return amount
case "TRY":
return amount / usdTry
default:
currTry := rateMap[currency]
if currTry <= 0 {
return 0
}
return (amount * currTry) / usdTry
}
}
func deriveMasterCari(cari string) string {
cari = strings.TrimSpace(cari)
if cari == "" {
return ""
}
base := cari
if idx := strings.Index(base, "/"); idx > 0 {
base = base[:idx]
}
base = strings.TrimSpace(base)
if len(base) >= 8 {
return strings.TrimSpace(base[:8])
}
return base
}
func buildFilters(params models.CustomerBalanceListParams) balanceFilters {
return balanceFilters{
cariIlkGrup: parseCSVSet(params.CariIlkGrup),
piyasa: parseCSVSet(params.Piyasa),
temsilci: parseCSVSet(params.Temsilci),
riskDurumu: parseCSVSet(params.RiskDurumu),
islemTipi: parseCSVSet(params.IslemTipi),
ulke: parseCSVSet(params.Ulke),
il: parseCSVSet(params.Il),
ilce: parseCSVSet(params.Ilce),
}
}
func (f balanceFilters) matchLine(islemTipi string, m cariMeta) bool {
if !matchSet(f.islemTipi, islemTipi) {
return false
}
if !matchSet(f.cariIlkGrup, m.Kanal1) {
return false
}
if !matchSet(f.piyasa, m.Piyasa) {
return false
}
if !matchSet(f.temsilci, m.Temsilci) {
return false
}
if !matchSet(f.riskDurumu, m.RiskDurumu) {
return false
}
if !matchSet(f.ulke, m.Ulke) {
return false
}
if !matchSet(f.il, m.Il) {
return false
}
if !matchSet(f.ilce, m.Ilce) {
return false
}
return true
}
func matchSet(set map[string]struct{}, value string) bool {
if len(set) == 0 {
return true
}
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return true
}
_, ok := set[trimmed]
return ok
}
func parseCSVSet(v string) map[string]struct{} {
out := make(map[string]struct{})
for _, p := range strings.Split(v, ",") {
t := strings.TrimSpace(p)
if t == "" {
continue
}
out[t] = struct{}{}
}
return out
}
func getAuthorizedPiyasaCodes(ctx context.Context) ([]string, error) {
claims, ok := auth.GetClaimsFromContext(ctx)
if !ok || claims == nil {
return nil, fmt.Errorf("unauthorized: claims not found")
}
if claims.IsAdmin() {
return nil, nil
}
rawCodes := authz.GetPiyasaCodesFromCtx(ctx)
if len(rawCodes) == 0 {
return []string{}, nil
}
unique := make(map[string]struct{}, len(rawCodes))
out := make([]string, 0, len(rawCodes))
for _, code := range rawCodes {
norm := strings.ToUpper(strings.TrimSpace(code))
if norm == "" {
continue
}
if _, exists := unique[norm]; exists {
continue
}
unique[norm] = struct{}{}
out = append(out, norm)
}
if len(out) == 0 {
return []string{}, nil
}
return out, nil
}
func buildPiyasaWhereClause(codes []string, column string) string {
if len(codes) == 0 {
return "1=1"
}
return authz.BuildINClause(column, codes)
}
func metaKey(currType int, code string) string {
return strconv.Itoa(currType) + "|" + strings.TrimSpace(code)
}
func glKey(currType int, code string, company int) string {
return strconv.Itoa(currType) + "|" + strings.TrimSpace(code) + "|" + strconv.Itoa(company)
}
func quotedInList(set map[string]struct{}) string {
vals := make([]string, 0, len(set))
for v := range set {
esc := strings.ReplaceAll(strings.TrimSpace(v), "'", "''")
if esc != "" {
vals = append(vals, "'"+esc+"'")
}
}
if len(vals) == 0 {
return "''"
}
sort.Strings(vals)
return strings.Join(vals, ",")
}
func intInList(set map[int]struct{}) string {
vals := make([]int, 0, len(set))
for v := range set {
vals = append(vals, v)
}
if len(vals) == 0 {
return "0"
}
sort.Ints(vals)
parts := make([]string, 0, len(vals))
for _, v := range vals {
parts = append(parts, strconv.Itoa(v))
}
return strings.Join(parts, ",")
}
func firstNonEmpty(v ...string) string {
for _, s := range v {
if strings.TrimSpace(s) != "" {
return s
}
}
return ""
}

View File

@@ -1,69 +0,0 @@
package queries
import (
"bssapp-backend/db"
"context"
"database/sql"
)
type FastBalanceRow struct {
CariKodu string
CariDoviz string
PislemTipi string
SirketKodu int
YerelBakiye float64
Bakiye float64
IslemTarihi string
IslemKur float64
TLBakiye float64
TLYerel float64
GuncelKur float64
KurBakiye float64
UsdKur float64
}
func getFastBalances(
ctx context.Context,
date string,
) ([]FastBalanceRow, error) {
rows, err := db.MssqlDB.QueryContext(
ctx,
`EXEC dbo.SP_CARI_BAKIYE_API_FAST @Tarih`,
sql.Named("Tarih", date),
)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]FastBalanceRow, 0, 4096)
for rows.Next() {
var r FastBalanceRow
err := rows.Scan(
&r.CariKodu,
&r.CariDoviz,
&r.PislemTipi,
&r.SirketKodu,
&r.YerelBakiye,
&r.Bakiye,
&r.IslemTarihi,
&r.IslemKur,
&r.TLBakiye,
&r.TLYerel,
&r.GuncelKur,
&r.KurBakiye,
&r.UsdKur,
)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, nil
}

View File

@@ -0,0 +1,414 @@
package queries
import (
"bssapp-backend/db"
"bssapp-backend/models"
"context"
"database/sql"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
)
func GetStatementAging(params models.StatementAgingParams) ([]map[string]interface{}, error) {
accountCode := normalizeMasterAccountCode(params.AccountCode)
if strings.TrimSpace(accountCode) == "" {
return nil, fmt.Errorf("accountcode is required")
}
if strings.TrimSpace(params.EndDate) == "" {
return nil, fmt.Errorf("enddate is required")
}
useType2, useType3 := resolveUseTypes(params.Parislemler)
endDate, _ := time.Parse("2006-01-02", strings.TrimSpace(params.EndDate))
rows, err := db.MssqlDB.Query(`
EXEC dbo.SP_FIFO_MATCH_FINAL
@Cari8 = @Cari8,
@SonTarih = @SonTarih,
@UseType2 = @UseType2,
@UseType3 = @UseType3;
`,
sql.Named("Cari8", accountCode),
sql.Named("SonTarih", params.EndDate),
sql.Named("UseType2", useType2),
sql.Named("UseType3", useType3),
)
if err != nil {
return nil, fmt.Errorf("SP_FIFO_MATCH_FINAL query error: %w", err)
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
return nil, fmt.Errorf("columns read error: %w", err)
}
result := make([]map[string]interface{}, 0, 2048)
cari8Set := make(map[string]struct{})
currencySet := make(map[string]struct{})
for rows.Next() {
values := make([]interface{}, len(columns))
scanArgs := make([]interface{}, len(columns))
for i := range values {
scanArgs[i] = &values[i]
}
if err := rows.Scan(scanArgs...); err != nil {
return nil, fmt.Errorf("row scan error: %w", err)
}
row := make(map[string]interface{}, len(columns))
for i, col := range columns {
switch v := values[i].(type) {
case nil:
row[col] = nil
case []byte:
row[col] = string(v)
case time.Time:
row[col] = v.Format("2006-01-02")
default:
row[col] = v
}
}
cari8 := strings.TrimSpace(asString(row["Cari8"]))
if cari8 != "" {
cari8Set[cari8] = struct{}{}
}
curr := strings.ToUpper(strings.TrimSpace(asString(row["DocCurrencyCode"])))
if curr != "" && curr != "TRY" {
currencySet[curr] = struct{}{}
}
currencySet["USD"] = struct{}{}
result = append(result, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows error: %w", err)
}
cariDetailMap, err := loadAgingMasterCariDetailMap(context.Background(), cari8Set)
if err != nil {
return nil, err
}
rateSeriesByCurr, err := loadTryRateSeriesByCurrency(context.Background(), currencySet)
if err != nil {
return nil, err
}
for i := range result {
row := result[i]
cari8 := strings.TrimSpace(asString(row["Cari8"]))
curr := strings.ToUpper(strings.TrimSpace(asString(row["DocCurrencyCode"])))
if curr == "" {
curr = "TRY"
}
aciklama := strings.ToUpper(strings.TrimSpace(asString(row["Aciklama"])))
targetDate := endDate
if aciklama != "ACIKKALEM" {
if odemeTarihi, ok := parseDateOnly(asString(row["OdemeTarihi"])); ok {
targetDate = odemeTarihi
}
}
tutar := asFloat64(row["EslesenTutar"])
currTry := resolveTryRate(curr, targetDate, rateSeriesByCurr)
usdTry := resolveTryRate("USD", targetDate, rateSeriesByCurr)
tryTutar := toTRYByRate(tutar, curr, currTry)
usdTutar := toUSDByRates(tutar, curr, currTry, usdTry)
gunKur := usdRateInCurrency(curr, currTry, usdTry)
row["CariDetay"] = cariDetailMap[cari8]
row["UsdTutar"] = round2(usdTutar)
row["TryTutar"] = round2(tryTutar)
row["GunKur"] = round6(gunKur)
}
return result, nil
}
func resolveUseTypes(parislemler []string) (int, int) {
if len(parislemler) == 0 {
return 1, 0
}
useType2 := 0
useType3 := 0
for _, v := range parislemler {
switch strings.TrimSpace(v) {
case "2":
useType2 = 1
case "3":
useType3 = 1
}
}
if useType2 == 0 && useType3 == 0 {
return 1, 0
}
return useType2, useType3
}
func loadAgingMasterCariDetailMap(ctx context.Context, cari8Set map[string]struct{}) (map[string]string, error) {
if len(cari8Set) == 0 {
return map[string]string{}, nil
}
query := fmt.Sprintf(`
WITH BaseCari AS (
SELECT
CurrAccCode,
CurrAccTypeCode,
MasterCari = LEFT(CurrAccCode, 8),
rn = ROW_NUMBER() OVER (
PARTITION BY LEFT(CurrAccCode, 8)
ORDER BY CurrAccCode
)
FROM cdCurrAcc WITH (NOLOCK)
WHERE CurrAccTypeCode IN (1,3)
AND LEFT(CurrAccCode, 8) IN (%s)
)
SELECT
b.MasterCari,
CariDetay = ISNULL(d.CurrAccDescription, '')
FROM BaseCari b
LEFT JOIN cdCurrAccDesc d WITH (NOLOCK)
ON d.CurrAccTypeCode = b.CurrAccTypeCode
AND d.CurrAccCode = b.CurrAccCode
AND d.LangCode = 'TR'
WHERE b.rn = 1;
`, quotedInList(cari8Set))
rows, err := db.MssqlDB.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("aging cari detail query error: %w", err)
}
defer rows.Close()
out := make(map[string]string, len(cari8Set))
for rows.Next() {
var cari8 string
var detail sql.NullString
if err := rows.Scan(&cari8, &detail); err != nil {
return nil, err
}
out[strings.TrimSpace(cari8)] = strings.TrimSpace(detail.String)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func asString(v interface{}) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
case []byte:
return string(x)
default:
return fmt.Sprint(x)
}
}
func asFloat64(v interface{}) float64 {
switch x := v.(type) {
case nil:
return 0
case float64:
return x
case float32:
return float64(x)
case int64:
return float64(x)
case int32:
return float64(x)
case int:
return float64(x)
case string:
return parseNumberString(x)
case []byte:
return parseNumberString(string(x))
default:
return parseNumberString(fmt.Sprint(x))
}
}
func parseNumberString(s string) float64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
hasComma := strings.Contains(s, ",")
hasDot := strings.Contains(s, ".")
if hasComma && hasDot {
if strings.LastIndex(s, ",") > strings.LastIndex(s, ".") {
s = strings.ReplaceAll(s, ".", "")
s = strings.Replace(s, ",", ".", 1)
} else {
s = strings.ReplaceAll(s, ",", "")
}
} else if hasComma {
s = strings.ReplaceAll(s, ".", "")
s = strings.Replace(s, ",", ".", 1)
}
n, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return n
}
func round2(v float64) float64 {
return math.Round(v*100) / 100
}
func round6(v float64) float64 {
return math.Round(v*1_000_000) / 1_000_000
}
type ratePoint struct {
date time.Time
rate float64
}
func loadTryRateSeriesByCurrency(ctx context.Context, currencies map[string]struct{}) (map[string][]ratePoint, error) {
if len(currencies) == 0 {
return map[string][]ratePoint{}, nil
}
query := fmt.Sprintf(`
SELECT CurrencyCode, Rate, CAST([Date] AS date) AS RateDate
FROM AllExchangeRates
WHERE RelationCurrencyCode = 'TRY'
AND ExchangeTypeCode = 6
AND Rate > 0
AND CurrencyCode IN (%s)
`, quotedInList(currencies))
rows, err := db.MssqlDB.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("aging currency series query error: %w", err)
}
defer rows.Close()
out := make(map[string][]ratePoint, len(currencies))
for rows.Next() {
var code string
var rate float64
var dt time.Time
if err := rows.Scan(&code, &rate, &dt); err != nil {
return nil, err
}
code = strings.ToUpper(strings.TrimSpace(code))
out[code] = append(out[code], ratePoint{date: dt, rate: rate})
}
if err := rows.Err(); err != nil {
return nil, err
}
for c := range out {
sort.Slice(out[c], func(i, j int) bool { return out[c][i].date.Before(out[c][j].date) })
}
return out, nil
}
func resolveTryRate(currency string, target time.Time, series map[string][]ratePoint) float64 {
currency = strings.ToUpper(strings.TrimSpace(currency))
if currency == "" || currency == "TRY" {
return 1
}
points := series[currency]
if len(points) == 0 {
return 0
}
best := points[0]
bestDiff := absDurationDays(points[0].date.Sub(target))
for i := 1; i < len(points); i++ {
diff := absDurationDays(points[i].date.Sub(target))
if diff < bestDiff || (diff == bestDiff && points[i].date.After(best.date)) {
best = points[i]
bestDiff = diff
}
}
return best.rate
}
func absDurationDays(d time.Duration) int64 {
if d < 0 {
d = -d
}
return int64(d.Hours() / 24)
}
func parseDateOnly(v string) (time.Time, bool) {
v = strings.TrimSpace(v)
if v == "" {
return time.Time{}, false
}
t, err := time.Parse("2006-01-02", v)
if err != nil {
return time.Time{}, false
}
return t, true
}
func toTRYByRate(amount float64, currency string, currTry float64) float64 {
currency = strings.ToUpper(strings.TrimSpace(currency))
if currency == "" || currency == "TRY" {
return amount
}
if currTry <= 0 {
return 0
}
return amount * currTry
}
func toUSDByRates(amount float64, currency string, currTry, usdTry float64) float64 {
currency = strings.ToUpper(strings.TrimSpace(currency))
switch currency {
case "USD":
return amount
case "", "TRY":
if usdTry <= 0 {
return 0
}
return amount / usdTry
default:
if currTry <= 0 || usdTry <= 0 {
return 0
}
return (amount * currTry) / usdTry
}
}
// Returns X for "1 USD = X <currency>".
func usdRateInCurrency(currency string, currTry, usdTry float64) float64 {
currency = strings.ToUpper(strings.TrimSpace(currency))
switch currency {
case "", "USD":
return 1
case "TRY":
if usdTry <= 0 {
return 0
}
return usdTry
default:
if currTry <= 0 || usdTry <= 0 {
return 0
}
return usdTry / currTry
}
}

View File

@@ -0,0 +1,145 @@
package routes
import (
"bssapp-backend/auth"
"bssapp-backend/models"
"bssapp-backend/queries"
"database/sql"
"fmt"
"net/http"
"strings"
"time"
"github.com/xuri/excelize/v2"
)
func ExportCustomerBalanceExcelHandler(_ *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
selectedDate := strings.TrimSpace(r.URL.Query().Get("selected_date"))
if selectedDate == "" {
selectedDate = time.Now().Format("2006-01-02")
}
params := models.CustomerBalanceListParams{
SelectedDate: selectedDate,
CariSearch: strings.TrimSpace(r.URL.Query().Get("cari_search")),
CariIlkGrup: strings.TrimSpace(r.URL.Query().Get("cari_ilk_grup")),
Piyasa: strings.TrimSpace(r.URL.Query().Get("piyasa")),
Temsilci: strings.TrimSpace(r.URL.Query().Get("temsilci")),
RiskDurumu: strings.TrimSpace(r.URL.Query().Get("risk_durumu")),
IslemTipi: strings.TrimSpace(r.URL.Query().Get("islem_tipi")),
Ulke: strings.TrimSpace(r.URL.Query().Get("ulke")),
Il: strings.TrimSpace(r.URL.Query().Get("il")),
Ilce: strings.TrimSpace(r.URL.Query().Get("ilce")),
}
excludeZero12 := parseBoolQuery(r.URL.Query().Get("exclude_zero_12"))
excludeZero13 := parseBoolQuery(r.URL.Query().Get("exclude_zero_13"))
rows, err := queries.GetCustomerBalanceList(r.Context(), params)
if err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
rows = filterCustomerBalanceRowsForPDF(rows, excludeZero12, excludeZero13)
summaries, _ := buildCustomerBalancePDFData(rows)
f := excelize.NewFile()
sheet := "CariBakiye"
f.SetSheetName("Sheet1", sheet)
headers := []string{
"Ana Cari Kodu",
"Ana Cari Detay",
"Piyasa",
"Temsilci",
"Risk Durumu",
"1_2 Bakiye Pr.Br",
"1_3 Bakiye Pr.Br",
"1_2 USD Bakiye",
"1_2 TRY Bakiye",
"1_3 USD Bakiye",
"1_3 TRY Bakiye",
}
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
f.SetCellValue(sheet, cell, h)
}
var totalUSD12, totalTRY12, totalUSD13, totalTRY13 float64
totalPrBr12 := map[string]float64{}
totalPrBr13 := map[string]float64{}
for _, s := range summaries {
totalUSD12 += s.USDBakiye12
totalTRY12 += s.TLBakiye12
totalUSD13 += s.USDBakiye13
totalTRY13 += s.TLBakiye13
for k, v := range s.Bakiye12Map {
totalPrBr12[k] += v
}
for k, v := range s.Bakiye13Map {
totalPrBr13[k] += v
}
}
f.SetSheetRow(sheet, "A2", &[]any{
"TOPLAM",
"",
"",
"",
"",
formatCurrencyMapPDF(totalPrBr12),
formatCurrencyMapPDF(totalPrBr13),
totalUSD12,
totalTRY12,
totalUSD13,
totalTRY13,
})
rowNo := 3
for _, s := range summaries {
f.SetSheetRow(sheet, fmt.Sprintf("A%d", rowNo), &[]any{
s.AnaCariKodu,
s.AnaCariAdi,
s.Piyasa,
s.Temsilci,
s.RiskDurumu,
formatCurrencyMapPDF(s.Bakiye12Map),
formatCurrencyMapPDF(s.Bakiye13Map),
s.USDBakiye12,
s.TLBakiye12,
s.USDBakiye13,
s.TLBakiye13,
})
rowNo++
}
_ = f.SetColWidth(sheet, "A", "A", 16)
_ = f.SetColWidth(sheet, "B", "B", 34)
_ = f.SetColWidth(sheet, "C", "E", 18)
_ = f.SetColWidth(sheet, "F", "G", 34)
_ = f.SetColWidth(sheet, "H", "K", 18)
buf, err := f.WriteToBuffer()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
filename := fmt.Sprintf("cari_bakiye_listesi_%s.xlsx", time.Now().Format("20060102_150405"))
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
w.Header().Set("Content-Length", fmt.Sprint(len(buf.Bytes())))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(buf.Bytes())
}
}

View File

@@ -33,6 +33,8 @@ func GetCustomerBalanceListHandler(w http.ResponseWriter, r *http.Request) {
RiskDurumu: strings.TrimSpace(r.URL.Query().Get("risk_durumu")), RiskDurumu: strings.TrimSpace(r.URL.Query().Get("risk_durumu")),
IslemTipi: strings.TrimSpace(r.URL.Query().Get("islem_tipi")), IslemTipi: strings.TrimSpace(r.URL.Query().Get("islem_tipi")),
Ulke: strings.TrimSpace(r.URL.Query().Get("ulke")), Ulke: strings.TrimSpace(r.URL.Query().Get("ulke")),
Il: strings.TrimSpace(r.URL.Query().Get("il")),
Ilce: strings.TrimSpace(r.URL.Query().Get("ilce")),
} }
rows, err := queries.GetCustomerBalanceList(r.Context(), params) rows, err := queries.GetCustomerBalanceList(r.Context(), params)

View File

@@ -0,0 +1,461 @@
package routes
import (
"bssapp-backend/auth"
"bssapp-backend/models"
"bssapp-backend/queries"
"bytes"
"database/sql"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/jung-kurt/gofpdf"
)
type balanceSummaryPDF struct {
AnaCariKodu string
AnaCariAdi string
Piyasa string
Temsilci string
RiskDurumu string
Bakiye12Map map[string]float64
Bakiye13Map map[string]float64
USDBakiye12 float64
TLBakiye12 float64
USDBakiye13 float64
TLBakiye13 float64
}
func ExportCustomerBalancePDFHandler(_ *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
selectedDate := strings.TrimSpace(r.URL.Query().Get("selected_date"))
if selectedDate == "" {
selectedDate = time.Now().Format("2006-01-02")
}
params := models.CustomerBalanceListParams{
SelectedDate: selectedDate,
CariSearch: strings.TrimSpace(r.URL.Query().Get("cari_search")),
CariIlkGrup: strings.TrimSpace(r.URL.Query().Get("cari_ilk_grup")),
Piyasa: strings.TrimSpace(r.URL.Query().Get("piyasa")),
Temsilci: strings.TrimSpace(r.URL.Query().Get("temsilci")),
RiskDurumu: strings.TrimSpace(r.URL.Query().Get("risk_durumu")),
IslemTipi: strings.TrimSpace(r.URL.Query().Get("islem_tipi")),
Ulke: strings.TrimSpace(r.URL.Query().Get("ulke")),
Il: strings.TrimSpace(r.URL.Query().Get("il")),
Ilce: strings.TrimSpace(r.URL.Query().Get("ilce")),
}
detailed := parseBoolQuery(r.URL.Query().Get("detailed"))
excludeZero12 := parseBoolQuery(r.URL.Query().Get("exclude_zero_12"))
excludeZero13 := parseBoolQuery(r.URL.Query().Get("exclude_zero_13"))
rows, err := queries.GetCustomerBalanceList(r.Context(), params)
if err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
rows = filterCustomerBalanceRowsForPDF(rows, excludeZero12, excludeZero13)
summaries, detailsByMaster := buildCustomerBalancePDFData(rows)
pdf := gofpdf.New("L", "mm", "A4", "")
pdf.SetMargins(8, 8, 8)
pdf.SetAutoPageBreak(false, 12)
if err := registerDejavuFonts(pdf, "dejavu"); err != nil {
http.Error(w, "pdf font error: "+err.Error(), http.StatusInternalServerError)
return
}
drawCustomerBalancePDF(
pdf,
selectedDate,
params.CariSearch,
detailed,
summaries,
detailsByMaster,
)
if err := pdf.Error(); err != nil {
http.Error(w, "pdf render error: "+err.Error(), http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
http.Error(w, "pdf output error: "+err.Error(), http.StatusInternalServerError)
return
}
filename := "customer-balance-summary.pdf"
if detailed {
filename = "customer-balance-detailed.pdf"
}
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename))
_, _ = w.Write(buf.Bytes())
}
}
func parseBoolQuery(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func filterCustomerBalanceRowsForPDF(rows []models.CustomerBalanceListRow, excludeZero12, excludeZero13 bool) []models.CustomerBalanceListRow {
out := make([]models.CustomerBalanceListRow, 0, len(rows))
for _, row := range rows {
if excludeZero12 && row.Bakiye12 == 0 {
continue
}
if excludeZero13 && row.Bakiye13 == 0 {
continue
}
out = append(out, row)
}
return out
}
func buildCustomerBalancePDFData(rows []models.CustomerBalanceListRow) ([]balanceSummaryPDF, map[string][]models.CustomerBalanceListRow) {
summaryMap := make(map[string]*balanceSummaryPDF)
detailsByMaster := make(map[string][]models.CustomerBalanceListRow)
for _, row := range rows {
master := strings.TrimSpace(row.AnaCariKodu)
if master == "" {
master = strings.TrimSpace(row.CariKodu)
}
if master == "" {
continue
}
s := summaryMap[master]
if s == nil {
s = &balanceSummaryPDF{
AnaCariKodu: master,
AnaCariAdi: strings.TrimSpace(row.AnaCariAdi),
Piyasa: strings.TrimSpace(row.Piyasa),
Temsilci: strings.TrimSpace(row.Temsilci),
RiskDurumu: strings.TrimSpace(row.RiskDurumu),
Bakiye12Map: map[string]float64{},
Bakiye13Map: map[string]float64{},
}
summaryMap[master] = s
}
if s.AnaCariAdi == "" && strings.TrimSpace(row.AnaCariAdi) != "" {
s.AnaCariAdi = strings.TrimSpace(row.AnaCariAdi)
}
if s.Piyasa == "" && strings.TrimSpace(row.Piyasa) != "" {
s.Piyasa = strings.TrimSpace(row.Piyasa)
}
if s.Temsilci == "" && strings.TrimSpace(row.Temsilci) != "" {
s.Temsilci = strings.TrimSpace(row.Temsilci)
}
if s.RiskDurumu == "" && strings.TrimSpace(row.RiskDurumu) != "" {
s.RiskDurumu = strings.TrimSpace(row.RiskDurumu)
}
curr := strings.ToUpper(strings.TrimSpace(row.CariDoviz))
if curr == "" {
curr = "N/A"
}
s.Bakiye12Map[curr] += row.Bakiye12
s.Bakiye13Map[curr] += row.Bakiye13
s.USDBakiye12 += row.USDBakiye12
s.TLBakiye12 += row.TLBakiye12
s.USDBakiye13 += row.USDBakiye13
s.TLBakiye13 += row.TLBakiye13
detailsByMaster[master] = append(detailsByMaster[master], row)
}
masters := make([]string, 0, len(summaryMap))
for m := range summaryMap {
masters = append(masters, m)
}
sort.Strings(masters)
summaries := make([]balanceSummaryPDF, 0, len(masters))
for _, m := range masters {
summaries = append(summaries, *summaryMap[m])
d := detailsByMaster[m]
sort.SliceStable(d, func(i, j int) bool {
if d[i].CariKodu == d[j].CariKodu {
if d[i].CariDoviz == d[j].CariDoviz {
si, _ := strconv.Atoi(d[i].Sirket)
sj, _ := strconv.Atoi(d[j].Sirket)
return si < sj
}
return d[i].CariDoviz < d[j].CariDoviz
}
return d[i].CariKodu < d[j].CariKodu
})
detailsByMaster[m] = d
}
return summaries, detailsByMaster
}
func drawCustomerBalancePDF(
pdf *gofpdf.Fpdf,
selectedDate string,
searchText string,
detailed bool,
summaries []balanceSummaryPDF,
detailsByMaster map[string][]models.CustomerBalanceListRow,
) {
pageW, _ := pdf.GetPageSize()
marginL, marginT, marginR, marginB := 8.0, 8.0, 8.0, 12.0
tableW := pageW - marginL - marginR
summaryCols := []string{"Ana Cari Kod", "Ana Cari Detay", "Piyasa", "Temsilci", "Risk", "1_2 Pr.Br", "1_3 Pr.Br", "1_2 USD", "1_2 TRY", "1_3 USD", "1_3 TRY"}
summaryW := normalizeWidths([]float64{20, 43, 18, 18, 16, 27, 27, 15, 15, 15, 15}, tableW)
detailCols := []string{"Cari Kod", "Cari Detay", "Sirket", "Muhasebe", "Doviz", "1_2 Pr.Br", "1_3 Pr.Br", "1_2 USD", "1_2 TRY", "1_3 USD", "1_3 TRY"}
detailW := normalizeWidths([]float64{26, 46, 10, 20, 10, 24, 24, 15, 15, 15, 15}, tableW)
header := func() {
pdf.AddPage()
pdf.SetFont("dejavu", "B", 15)
pdf.SetTextColor(149, 113, 22)
pdf.SetXY(marginL, marginT)
pdf.CellFormat(120, 7, "Cari Bakiye Listesi", "", 0, "L", false, 0, "")
pdf.SetFont("dejavu", "", 9)
pdf.SetTextColor(20, 20, 20)
pdf.SetXY(pageW-marginR-80, marginT+1)
pdf.CellFormat(80, 5, "Tarih: "+selectedDate, "", 0, "R", false, 0, "")
mode := "Detaysiz"
if detailed {
mode = "Detayli"
}
pdf.SetXY(pageW-marginR-80, marginT+6)
pdf.CellFormat(80, 5, "Mod: "+mode, "", 0, "R", false, 0, "")
if strings.TrimSpace(searchText) != "" {
pdf.SetXY(marginL, marginT+8)
pdf.CellFormat(tableW, 5, "Arama: "+searchText, "", 0, "L", false, 0, "")
}
pdf.SetDrawColor(149, 113, 22)
pdf.Line(marginL, marginT+14, pageW-marginR, marginT+14)
pdf.SetDrawColor(210, 210, 210)
pdf.SetY(marginT + 17)
}
needPage := func(needH float64) bool {
return pdf.GetY()+needH+marginB > 210.0
}
drawSummaryHeader := func() {
pdf.SetFont("dejavu", "B", 7.5)
pdf.SetFillColor(149, 113, 22)
pdf.SetTextColor(255, 255, 255)
y := pdf.GetY()
x := marginL
for i, c := range summaryCols {
pdf.Rect(x, y, summaryW[i], 7, "DF")
pdf.SetXY(x+1, y+1.2)
pdf.CellFormat(summaryW[i]-2, 4.6, c, "", 0, "C", false, 0, "")
x += summaryW[i]
}
pdf.SetY(y + 7)
}
drawDetailHeader := func() {
pdf.SetFont("dejavu", "B", 7.2)
pdf.SetFillColor(149, 113, 22)
pdf.SetTextColor(255, 255, 255)
y := pdf.GetY()
x := marginL
for i, c := range detailCols {
pdf.Rect(x, y, detailW[i], 6, "DF")
pdf.SetXY(x+1, y+1)
pdf.CellFormat(detailW[i]-2, 4, c, "", 0, "C", false, 0, "")
x += detailW[i]
}
pdf.SetY(y + 6)
}
header()
drawSummaryHeader()
pdf.SetFont("dejavu", "", 7.2)
pdf.SetTextColor(20, 20, 20)
for _, s := range summaries {
if needPage(6.2) {
header()
drawSummaryHeader()
}
row := []string{
s.AnaCariKodu,
s.AnaCariAdi,
s.Piyasa,
s.Temsilci,
s.RiskDurumu,
formatCurrencyMapPDF(s.Bakiye12Map),
formatCurrencyMapPDF(s.Bakiye13Map),
formatMoneyPDF(s.USDBakiye12),
formatMoneyPDF(s.TLBakiye12),
formatMoneyPDF(s.USDBakiye13),
formatMoneyPDF(s.TLBakiye13),
}
y := pdf.GetY()
x := marginL
for i, v := range row {
pdf.Rect(x, y, summaryW[i], 6.2, "")
align := "L"
if i >= 7 {
align = "R"
}
pdf.SetXY(x+1, y+1)
pdf.CellFormat(summaryW[i]-2, 4.2, v, "", 0, align, false, 0, "")
x += summaryW[i]
}
pdf.SetY(y + 6.2)
}
if !detailed {
return
}
pdf.Ln(1.8)
for _, s := range summaries {
rows := detailsByMaster[s.AnaCariKodu]
if len(rows) == 0 {
continue
}
if needPage(12.4) {
header()
}
pdf.SetFont("dejavu", "B", 8)
pdf.SetFillColor(218, 193, 151)
pdf.SetTextColor(20, 20, 20)
y := pdf.GetY()
pdf.Rect(marginL, y, tableW, 6.2, "DF")
pdf.SetXY(marginL+1.5, y+1)
pdf.CellFormat(tableW-3, 4.2, "Detay: "+s.AnaCariKodu, "", 0, "L", false, 0, "")
pdf.SetY(y + 6.2)
drawDetailHeader()
pdf.SetFont("dejavu", "", 7)
pdf.SetTextColor(40, 40, 40)
for _, r := range rows {
if needPage(5.8) {
header()
pdf.SetFont("dejavu", "B", 8)
pdf.SetFillColor(218, 193, 151)
pdf.SetTextColor(20, 20, 20)
y := pdf.GetY()
pdf.Rect(marginL, y, tableW, 6.2, "DF")
pdf.SetXY(marginL+1.5, y+1)
pdf.CellFormat(tableW-3, 4.2, "Detay: "+s.AnaCariKodu, "", 0, "L", false, 0, "")
pdf.SetY(y + 6.2)
drawDetailHeader()
pdf.SetFont("dejavu", "", 7)
pdf.SetTextColor(40, 40, 40)
}
line := []string{
r.CariKodu,
r.CariDetay,
r.Sirket,
r.MuhasebeKodu,
r.CariDoviz,
formatMoneyPDF(r.Bakiye12),
formatMoneyPDF(r.Bakiye13),
formatMoneyPDF(r.USDBakiye12),
formatMoneyPDF(r.TLBakiye12),
formatMoneyPDF(r.USDBakiye13),
formatMoneyPDF(r.TLBakiye13),
}
rowY := pdf.GetY()
rowX := marginL
for i, v := range line {
pdf.Rect(rowX, rowY, detailW[i], 5.8, "")
align := "L"
if i >= 5 {
align = "R"
}
pdf.SetXY(rowX+1, rowY+0.8)
pdf.CellFormat(detailW[i]-2, 4.0, v, "", 0, align, false, 0, "")
rowX += detailW[i]
}
pdf.SetY(rowY + 5.8)
}
pdf.Ln(1.2)
}
}
func formatCurrencyMapPDF(m map[string]float64) string {
if len(m) == 0 {
return "-"
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
if m[k] == 0 {
continue
}
parts = append(parts, k+": "+formatMoneyPDF(m[k]))
}
if len(parts) == 0 {
return "-"
}
return strings.Join(parts, " | ")
}
func formatMoneyPDF(v float64) string {
s := fmt.Sprintf("%.2f", v)
parts := strings.SplitN(s, ".", 2)
intPart, decPart := parts[0], "00"
if len(parts) == 2 {
decPart = parts[1]
}
sign := ""
if strings.HasPrefix(intPart, "-") {
sign = "-"
intPart = strings.TrimPrefix(intPart, "-")
}
var out []string
for len(intPart) > 3 {
out = append([]string{intPart[len(intPart)-3:]}, out...)
intPart = intPart[:len(intPart)-3]
}
if intPart != "" {
out = append([]string{intPart}, out...)
}
return sign + strings.Join(out, ".") + "," + decPart
}

View File

@@ -0,0 +1,41 @@
package routes
import (
"bssapp-backend/auth"
"bssapp-backend/models"
"bssapp-backend/queries"
"encoding/json"
"net/http"
"strings"
)
// GET /api/finance/account-aging-statement
func GetStatementAgingHandler(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
params := models.StatementAgingParams{
AccountCode: strings.TrimSpace(r.URL.Query().Get("accountcode")),
EndDate: strings.TrimSpace(r.URL.Query().Get("enddate")),
Parislemler: r.URL.Query()["parislemler"],
}
if params.AccountCode == "" || params.EndDate == "" {
http.Error(w, "accountcode and enddate are required", http.StatusBadRequest)
return
}
rows, err := queries.GetStatementAging(params)
if err != nil {
http.Error(w, "Error fetching aging statement: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(rows); err != nil {
http.Error(w, "Error encoding response: "+err.Error(), http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,573 @@
package routes
import (
"bssapp-backend/auth"
"bssapp-backend/models"
"bssapp-backend/queries"
"bytes"
"database/sql"
"fmt"
"math"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/jung-kurt/gofpdf"
)
type agingDetailPDF struct {
FaturaCari string
OdemeCari string
Doviz string
FaturaRef string
OdemeRef string
FaturaTarihi string
OdemeTarihi string
OdemeDocDate string
EslesenTutar float64
UsdTutar float64
TryTutar float64
Aciklama string
Gun int
GunBelge int
GunKur float64
odemeDateParsed time.Time
odemeDateEmpty bool
}
type agingCurrencyPDF struct {
Key string
Cari8 string
CariDetay string
Doviz string
AcikKalemTutar float64
AcikKalemUSD float64
AcikKalemTRY float64
OrtGun int
OrtBelgeGun int
weightedBase float64
weightedGunSum float64
weightedDocSum float64
Details []agingDetailPDF
}
type agingMasterPDF struct {
Key string
Cari8 string
CariDetay string
AcikKalemUSD float64
AcikKalemTRY float64
AcikKalemOrtVadeGun int
AcikKalemOrtBelge int
NormalUSD float64
NormalTRY float64
OrtalamaVadeGun int
OrtalamaBelgeGun int
weightedAllBase float64
weightedAllGunSum float64
weightedAllDocSum float64
weightedOpenBase float64
weightedOpenGunSum float64
weightedOpenDocSum float64
Currencies []agingCurrencyPDF
}
func ExportStatementAgingPDFHandler(_ *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
params := models.StatementAgingParams{
AccountCode: strings.TrimSpace(r.URL.Query().Get("accountcode")),
EndDate: strings.TrimSpace(r.URL.Query().Get("enddate")),
Parislemler: r.URL.Query()["parislemler"],
}
if params.AccountCode == "" || params.EndDate == "" {
http.Error(w, "accountcode and enddate are required", http.StatusBadRequest)
return
}
rows, err := queries.GetStatementAging(params)
if err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
masters := buildAgingPDFData(rows)
pdf := gofpdf.New("L", "mm", "A4", "")
pdf.SetMargins(8, 8, 8)
pdf.SetAutoPageBreak(false, 10)
if err := registerDejavuFonts(pdf, "dejavu"); err != nil {
http.Error(w, "pdf font error: "+err.Error(), http.StatusInternalServerError)
return
}
drawStatementAgingPDF(pdf, params, masters)
if err := pdf.Error(); err != nil {
http.Error(w, "pdf render error: "+err.Error(), http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
http.Error(w, "pdf output error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", `inline; filename="account-aging-detailed.pdf"`)
_, _ = w.Write(buf.Bytes())
}
}
func buildAgingPDFData(rows []map[string]interface{}) []agingMasterPDF {
masterMap := make(map[string]*agingMasterPDF)
currMap := make(map[string]*agingCurrencyPDF)
for _, row := range rows {
cari8 := strings.TrimSpace(readStrAny(row["Cari8"], row["cari8"]))
if cari8 == "" {
continue
}
cariDetay := strings.TrimSpace(readStrAny(row["CariDetay"], row["cari_detay"]))
doviz := strings.ToUpper(strings.TrimSpace(readStrAny(row["DocCurrencyCode"], row["doc_currency_code"])))
if doviz == "" {
doviz = "TRY"
}
aciklama := strings.ToUpper(strings.TrimSpace(readStrAny(row["Aciklama"], row["aciklama"])))
isAcik := aciklama == "ACIKKALEM"
eslesen := readFloatAny(row["EslesenTutar"], row["eslesen_tutar"])
usd := readFloatAny(row["UsdTutar"], row["usd_tutar"])
tryVal := readFloatAny(row["TryTutar"], row["try_tutar"])
gun := readIntAny(row["GunSayisi"], row["gun_sayisi"])
gunBelge := readIntAny(row["GunSayisi_DocDate"], row["gun_sayisi_docdate"])
absTry := math.Abs(tryVal)
m := masterMap[cari8]
if m == nil {
m = &agingMasterPDF{Key: cari8, Cari8: cari8, CariDetay: cariDetay}
masterMap[cari8] = m
}
if m.CariDetay == "" && cariDetay != "" {
m.CariDetay = cariDetay
}
ckey := cari8 + "|" + doviz
c := currMap[ckey]
if c == nil {
c = &agingCurrencyPDF{
Key: ckey,
Cari8: cari8,
CariDetay: cariDetay,
Doviz: doviz,
Details: make([]agingDetailPDF, 0, 128),
}
currMap[ckey] = c
}
if c.CariDetay == "" && cariDetay != "" {
c.CariDetay = cariDetay
}
if isAcik {
m.AcikKalemUSD += usd
m.AcikKalemTRY += tryVal
c.AcikKalemTutar += eslesen
c.AcikKalemUSD += usd
c.AcikKalemTRY += tryVal
} else {
m.NormalUSD += usd
m.NormalTRY += tryVal
}
if absTry > 0 {
m.weightedAllBase += absTry
m.weightedAllGunSum += absTry * float64(gun)
m.weightedAllDocSum += absTry * float64(gunBelge)
if isAcik {
m.weightedOpenBase += absTry
m.weightedOpenGunSum += absTry * float64(gun)
m.weightedOpenDocSum += absTry * float64(gunBelge)
c.weightedBase += absTry
c.weightedGunSum += absTry * float64(gun)
c.weightedDocSum += absTry * float64(gunBelge)
}
}
odemeTar := readStrAny(row["OdemeTarihi"], row["odeme_tarihi"])
odemeParsed, ok := parseYMD(odemeTar)
detail := agingDetailPDF{
FaturaCari: readStrAny(row["FaturaCari"], row["fatura_cari"]),
OdemeCari: readStrAny(row["OdemeCari"], row["odeme_cari"]),
Doviz: doviz,
FaturaRef: readStrAny(row["FaturaRef"], row["fatura_ref"]),
OdemeRef: readStrAny(row["OdemeRef"], row["odeme_ref"]),
FaturaTarihi: readStrAny(row["FaturaTarihi"], row["fatura_tarihi"]),
OdemeTarihi: odemeTar,
OdemeDocDate: readStrAny(row["OdemeDocDate"], row["odeme_doc_date"]),
EslesenTutar: eslesen,
UsdTutar: usd,
TryTutar: tryVal,
Aciklama: readStrAny(row["Aciklama"], row["aciklama"]),
Gun: gun,
GunBelge: gunBelge,
GunKur: readFloatAny(row["GunKur"], row["gun_kur"]),
odemeDateParsed: odemeParsed,
odemeDateEmpty: !ok,
}
c.Details = append(c.Details, detail)
}
masters := make([]agingMasterPDF, 0, len(masterMap))
for _, m := range masterMap {
if m.weightedOpenBase > 0 {
m.AcikKalemOrtVadeGun = int(math.Ceil(m.weightedOpenGunSum / m.weightedOpenBase))
m.AcikKalemOrtBelge = int(math.Ceil(m.weightedOpenDocSum / m.weightedOpenBase))
}
if m.weightedAllBase > 0 {
m.OrtalamaVadeGun = int(math.Ceil(m.weightedAllGunSum / m.weightedAllBase))
m.OrtalamaBelgeGun = int(math.Ceil(m.weightedAllDocSum / m.weightedAllBase))
}
currs := make([]agingCurrencyPDF, 0, len(currMap))
for _, c := range currMap {
if c.Cari8 != m.Cari8 {
continue
}
if c.weightedBase > 0 {
c.OrtGun = int(math.Ceil(c.weightedGunSum / c.weightedBase))
c.OrtBelgeGun = int(math.Ceil(c.weightedDocSum / c.weightedBase))
}
sort.SliceStable(c.Details, func(i, j int) bool {
ai := c.Details[i]
aj := c.Details[j]
if ai.odemeDateEmpty && !aj.odemeDateEmpty {
return true
}
if !ai.odemeDateEmpty && aj.odemeDateEmpty {
return false
}
if ai.odemeDateEmpty && aj.odemeDateEmpty {
return false
}
return ai.odemeDateParsed.After(aj.odemeDateParsed)
})
currs = append(currs, *c)
}
sort.SliceStable(currs, func(i, j int) bool { return currs[i].Doviz < currs[j].Doviz })
m.Currencies = currs
masters = append(masters, *m)
}
sort.SliceStable(masters, func(i, j int) bool { return masters[i].Cari8 < masters[j].Cari8 })
return masters
}
func drawStatementAgingPDF(pdf *gofpdf.Fpdf, p models.StatementAgingParams, masters []agingMasterPDF) {
pageW, pageH := pdf.GetPageSize()
marginL, marginR, marginT, marginB := 8.0, 8.0, 8.0, 10.0
tableW := pageW - marginL - marginR
colorPrimary := [3]int{149, 113, 22}
colorLevel2 := [3]int{76, 95, 122}
colorLevel3 := [3]int{31, 59, 91}
level1Cols := []string{"Ana Cari Kod", "Ana Cari Detay", "Açık Kalem USD", "Açık Kalem TRY", "Açık Kalem Ort Vade", "Açık Kalem Ort Belge", "Normal USD", "Normal TRY", "Ortalama Vade", "Ortalama Belge"}
level1W := normalizeWidths([]float64{20, 46, 18, 18, 15, 15, 16, 16, 15, 15}, tableW)
level2Cols := []string{"Ana Cari Kod", "Ana Cari Detay", "Döviz", "Açık Kalem", "Açık Kalem USD", "Açık Kalem TRY", "Ort Gün", "Ort Belge Gün"}
level2W := normalizeWidths([]float64{20, 52, 12, 24, 24, 24, 16, 18}, tableW)
level3Cols := []string{"Fatura Cari", "Ödeme Cari", "Döviz", "Fatura Ref", "Ödeme Ref", "Fatura Tarihi", "Ödeme Vade", "Ödeme DocDate", "Eşleşen", "USD", "TRY", "Açıklama", "Gün", "Gün Belge", "Gün Kur"}
level3W := normalizeWidths([]float64{15, 15, 10, 15, 15, 13, 13, 13, 14, 12, 14, 14, 8, 10, 10}, tableW)
pageHeader := func() {
pdf.AddPage()
pdf.SetFont("dejavu", "B", 15)
pdf.SetTextColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
pdf.SetXY(marginL, marginT)
pdf.CellFormat(150, 7, "Cari Yaşlandırmalı Ekstre", "", 0, "L", false, 0, "")
pdf.SetFont("dejavu", "", 9)
pdf.SetTextColor(20, 20, 20)
pdf.SetXY(pageW-marginR-95, marginT+1)
pdf.CellFormat(95, 5, "Son Tarih: "+p.EndDate, "", 0, "R", false, 0, "")
pdf.SetXY(pageW-marginR-95, marginT+6)
pdf.CellFormat(95, 5, "Cari: "+p.AccountCode, "", 0, "R", false, 0, "")
mode := "1_2"
if len(p.Parislemler) > 0 {
mode = strings.Join(p.Parislemler, ",")
}
pdf.SetXY(pageW-marginR-95, marginT+11)
pdf.CellFormat(95, 5, "Parasal İşlem: "+mode, "", 0, "R", false, 0, "")
pdf.SetDrawColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
pdf.Line(marginL, marginT+16, pageW-marginR, marginT+16)
pdf.SetDrawColor(210, 210, 210)
pdf.SetY(marginT + 19)
}
needPage := func(need float64) bool {
return pdf.GetY()+need+marginB > pageH
}
drawHeaderRow := func(cols []string, widths []float64, rgb [3]int, h float64) {
pdf.SetFont("dejavu", "B", 8.4)
pdf.SetFillColor(rgb[0], rgb[1], rgb[2])
pdf.SetTextColor(255, 255, 255)
y := pdf.GetY()
x := marginL
for i, c := range cols {
pdf.Rect(x, y, widths[i], h, "DF")
pdf.SetXY(x+0.8, y+1.0)
pdf.CellFormat(widths[i]-1.6, h-2.0, c, "", 0, "C", false, 0, "")
x += widths[i]
}
pdf.SetY(y + h)
}
drawRow := func(vals []string, widths []float64, h float64, fill bool, fillRGB [3]int, centerCols map[int]bool, rightCols map[int]bool) {
pdf.SetFont("dejavu", "", 7.6)
pdf.SetTextColor(20, 20, 20)
y := pdf.GetY()
x := marginL
for i, v := range vals {
if fill {
pdf.SetFillColor(fillRGB[0], fillRGB[1], fillRGB[2])
pdf.Rect(x, y, widths[i], h, "DF")
} else {
pdf.Rect(x, y, widths[i], h, "")
}
align := "L"
if rightCols[i] {
align = "R"
} else if centerCols[i] {
align = "C"
}
pdf.SetXY(x+0.8, y+0.8)
pdf.CellFormat(widths[i]-1.6, h-1.6, v, "", 0, align, false, 0, "")
x += widths[i]
}
pdf.SetY(y + h)
}
format2 := func(v float64) string {
return trFormat(v, 2)
}
pageHeader()
for _, m := range masters {
if needPage(7 + 7 + 6) {
pageHeader()
}
drawHeaderRow(level1Cols, level1W, colorPrimary, 7)
drawRow(
[]string{
m.Cari8, m.CariDetay,
format2(m.AcikKalemUSD), format2(m.AcikKalemTRY),
fmt.Sprintf("%d", m.AcikKalemOrtVadeGun), fmt.Sprintf("%d", m.AcikKalemOrtBelge),
format2(m.NormalUSD), format2(m.NormalTRY),
fmt.Sprintf("%d", m.OrtalamaVadeGun), fmt.Sprintf("%d", m.OrtalamaBelgeGun),
},
level1W, 6.4, true, [3]int{250, 246, 238},
map[int]bool{0: true, 4: true, 5: true, 8: true, 9: true},
map[int]bool{2: true, 3: true, 6: true, 7: true},
)
for _, c := range m.Currencies {
if needPage(6 + 6 + 6) {
pageHeader()
drawHeaderRow(level1Cols, level1W, colorPrimary, 7)
drawRow(
[]string{
m.Cari8, m.CariDetay,
format2(m.AcikKalemUSD), format2(m.AcikKalemTRY),
fmt.Sprintf("%d", m.AcikKalemOrtVadeGun), fmt.Sprintf("%d", m.AcikKalemOrtBelge),
format2(m.NormalUSD), format2(m.NormalTRY),
fmt.Sprintf("%d", m.OrtalamaVadeGun), fmt.Sprintf("%d", m.OrtalamaBelgeGun),
},
level1W, 6.4, true, [3]int{250, 246, 238},
map[int]bool{0: true, 4: true, 5: true, 8: true, 9: true},
map[int]bool{2: true, 3: true, 6: true, 7: true},
)
}
drawHeaderRow(level2Cols, level2W, colorLevel2, 6)
drawRow(
[]string{
c.Cari8, c.CariDetay, c.Doviz,
format2(c.AcikKalemTutar), format2(c.AcikKalemUSD), format2(c.AcikKalemTRY),
fmt.Sprintf("%d", c.OrtGun), fmt.Sprintf("%d", c.OrtBelgeGun),
},
level2W, 5.8, true, [3]int{236, 240, 247},
map[int]bool{0: true, 2: true, 6: true, 7: true},
map[int]bool{3: true, 4: true, 5: true},
)
if needPage(5.8) {
pageHeader()
}
drawHeaderRow(level3Cols, level3W, colorLevel3, 5.8)
for _, d := range c.Details {
if needPage(5.2) {
pageHeader()
drawHeaderRow(level3Cols, level3W, colorLevel3, 5.8)
}
drawRow(
[]string{
d.FaturaCari, d.OdemeCari, d.Doviz, d.FaturaRef, d.OdemeRef,
d.FaturaTarihi, d.OdemeTarihi, d.OdemeDocDate,
format2(d.EslesenTutar), format2(d.UsdTutar), format2(d.TryTutar), d.Aciklama,
fmt.Sprintf("%d", d.Gun), fmt.Sprintf("%d", d.GunBelge), trFormat(d.GunKur, 2),
},
level3W, 5.2, false, [3]int{},
map[int]bool{2: true, 5: true, 6: true, 7: true, 11: true, 12: true, 13: true, 14: true},
map[int]bool{8: true, 9: true, 10: true},
)
}
pdf.Ln(1.2)
}
pdf.Ln(1.8)
}
}
func trFormat(v float64, frac int) string {
neg := v < 0
if neg {
v = -v
}
pow := math.Pow(10, float64(frac))
rounded := math.Round(v*pow) / pow
intPart := int64(rounded)
decPart := int64(math.Round((rounded - float64(intPart)) * pow))
intStr := fmt.Sprintf("%d", intPart)
var grouped strings.Builder
for i, r := range intStr {
if i > 0 && (len(intStr)-i)%3 == 0 {
grouped.WriteString(".")
}
grouped.WriteRune(r)
}
out := grouped.String()
if frac > 0 {
decFmt := fmt.Sprintf("%%0%dd", frac)
out += "," + fmt.Sprintf(decFmt, decPart)
}
if neg {
return "-" + out
}
return out
}
func readStrAny(v ...interface{}) string {
for _, x := range v {
switch t := x.(type) {
case nil:
case string:
if strings.TrimSpace(t) != "" {
return t
}
case []byte:
s := strings.TrimSpace(string(t))
if s != "" {
return s
}
case time.Time:
return t.Format("2006-01-02")
default:
s := strings.TrimSpace(fmt.Sprint(t))
if s != "" && s != "<nil>" {
return s
}
}
}
return ""
}
func readFloatAny(v ...interface{}) float64 {
for _, x := range v {
if x == nil {
continue
}
switch t := x.(type) {
case float64:
return t
case float32:
return float64(t)
case int:
return float64(t)
case int32:
return float64(t)
case int64:
return float64(t)
case string:
if n, ok := parseNumberFlexible(t); ok {
return n
}
case []byte:
if n, ok := parseNumberFlexible(string(t)); ok {
return n
}
default:
if n, ok := parseNumberFlexible(fmt.Sprint(t)); ok {
return n
}
}
}
return 0
}
func readIntAny(v ...interface{}) int {
return int(math.Ceil(readFloatAny(v...)))
}
func parseNumberFlexible(s string) (float64, bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, false
}
hasComma := strings.Contains(s, ",")
hasDot := strings.Contains(s, ".")
if hasComma && hasDot {
if strings.LastIndex(s, ",") > strings.LastIndex(s, ".") {
s = strings.ReplaceAll(s, ".", "")
s = strings.Replace(s, ",", ".", 1)
} else {
s = strings.ReplaceAll(s, ",", "")
}
} else if hasComma {
s = strings.ReplaceAll(s, ".", "")
s = strings.Replace(s, ",", ".", 1)
}
n, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return n, true
}
func parseYMD(v string) (time.Time, bool) {
v = strings.TrimSpace(v)
if v == "" {
return time.Time{}, false
}
t, err := time.Parse("2006-01-02", v)
if err != nil {
return time.Time{}, false
}
return t, true
}

View File

@@ -0,0 +1,75 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { Quasar } from 'quasar'
import { markRaw } from 'vue'
import RootComponent from 'app/src/App.vue'
import createStore from 'app/src/stores/index'
import createRouter from 'app/src/router/index'
export default async function (createAppFn, quasarUserOptions) {
// Create the app instance.
// Here we inject into it the Quasar UI, the router & possibly the store.
const app = createAppFn(RootComponent)
app.use(Quasar, quasarUserOptions)
const store = typeof createStore === 'function'
? await createStore({})
: createStore
app.use(store)
const router = markRaw(
typeof createRouter === 'function'
? await createRouter({store})
: createRouter
)
// make router instance available in store
store.use(({ store }) => { store.router = router })
// Expose the app, the router and the store.
// Note that we are not mounting the app here, since bootstrapping will be
// different depending on whether we are in a browser or on the server.
return {
app,
store,
router
}
}

View File

@@ -0,0 +1,154 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { createApp } from 'vue'
import '@quasar/extras/roboto-font/roboto-font.css'
import '@quasar/extras/material-icons/material-icons.css'
// We load Quasar stylesheet file
import 'quasar/dist/quasar.sass'
import 'src/css/app.css'
import createQuasarApp from './app.js'
import quasarUserOptions from './quasar-user-options.js'
const publicPath = `/`
async function start ({
app,
router
, store
}, bootFiles) {
let hasRedirected = false
const getRedirectUrl = url => {
try { return router.resolve(url).href }
catch (err) {}
return Object(url) === url
? null
: url
}
const redirect = url => {
hasRedirected = true
if (typeof url === 'string' && /^https?:\/\//.test(url)) {
window.location.href = url
return
}
const href = getRedirectUrl(url)
// continue if we didn't fail to resolve the url
if (href !== null) {
window.location.href = href
window.location.reload()
}
}
const urlPath = window.location.href.replace(window.location.origin, '')
for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) {
try {
await bootFiles[i]({
app,
router,
store,
ssrContext: null,
redirect,
urlPath,
publicPath
})
}
catch (err) {
if (err && err.url) {
redirect(err.url)
return
}
console.error('[Quasar] boot error:', err)
return
}
}
if (hasRedirected === true) return
app.use(router)
app.mount('#q-app')
}
createQuasarApp(createApp, quasarUserOptions)
.then(app => {
// eventually remove this when Cordova/Capacitor/Electron support becomes old
const [ method, mapFn ] = Promise.allSettled !== void 0
? [
'allSettled',
bootFiles => bootFiles.map(result => {
if (result.status === 'rejected') {
console.error('[Quasar] boot error:', result.reason)
return
}
return result.value.default
})
]
: [
'all',
bootFiles => bootFiles.map(entry => entry.default)
]
return Promise[ method ]([
import(/* webpackMode: "eager" */ 'boot/dayjs')
]).then(bootFiles => {
const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function')
start(app, boot)
})
})

View File

@@ -0,0 +1,116 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import App from 'app/src/App.vue'
let appPrefetch = typeof App.preFetch === 'function'
? App.preFetch
: (
// Class components return the component options (and the preFetch hook) inside __c property
App.__c !== void 0 && typeof App.__c.preFetch === 'function'
? App.__c.preFetch
: false
)
function getMatchedComponents (to, router) {
const route = to
? (to.matched ? to : router.resolve(to).route)
: router.currentRoute.value
if (!route) { return [] }
const matched = route.matched.filter(m => m.components !== void 0)
if (matched.length === 0) { return [] }
return Array.prototype.concat.apply([], matched.map(m => {
return Object.keys(m.components).map(key => {
const comp = m.components[key]
return {
path: m.path,
c: comp
}
})
}))
}
export function addPreFetchHooks ({ router, store, publicPath }) {
// Add router hook for handling preFetch.
// Doing it after initial route is resolved so that we don't double-fetch
// the data that we already have. Using router.beforeResolve() so that all
// async components are resolved.
router.beforeResolve((to, from, next) => {
const
urlPath = window.location.href.replace(window.location.origin, ''),
matched = getMatchedComponents(to, router),
prevMatched = getMatchedComponents(from, router)
let diffed = false
const preFetchList = matched
.filter((m, i) => {
return diffed || (diffed = (
!prevMatched[i] ||
prevMatched[i].c !== m.c ||
m.path.indexOf('/:') > -1 // does it has params?
))
})
.filter(m => m.c !== void 0 && (
typeof m.c.preFetch === 'function'
// Class components return the component options (and the preFetch hook) inside __c property
|| (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function')
))
.map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch)
if (appPrefetch !== false) {
preFetchList.unshift(appPrefetch)
appPrefetch = false
}
if (preFetchList.length === 0) {
return next()
}
let hasRedirected = false
const redirect = url => {
hasRedirected = true
next(url)
}
const proceed = () => {
if (hasRedirected === false) { next() }
}
preFetchList.reduce(
(promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({
store,
currentRoute: to,
previousRoute: from,
redirect,
urlPath,
publicPath
})),
Promise.resolve()
)
.then(proceed)
.catch(e => {
console.error(e)
proceed()
})
})
}

View File

@@ -0,0 +1,23 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import lang from 'quasar/lang/tr.js'
import {Loading,Dialog,Notify} from 'quasar'
export default { config: {"notify":{"position":"top","timeout":2500}},lang,plugins: {Loading,Dialog,Notify} }

View File

@@ -1,4 +1,4 @@
<template> <template>
<q-layout view="hHh Lpr fFf"> <q-layout view="hHh Lpr fFf">
<!-- HEADER --> <!-- HEADER -->
@@ -200,6 +200,16 @@ const menuItems = [
label: 'Cari Bakiye Listesi', label: 'Cari Bakiye Listesi',
to: '/app/customer-balance-list', to: '/app/customer-balance-list',
permission: 'finance:view' permission: 'finance:view'
},
{
label: 'Cari Yaşlandırmalı Ekstre',
to: '/app/account-aging-statement',
permission: 'finance:view'
},
{
label: 'Cari Yaşlandırmalı Cari Bakiye Listesi',
to: '/app/aged-customer-balance-list',
permission: 'finance:view'
} }
] ]
}, },
@@ -308,3 +318,4 @@ const filteredMenu = computed(() => {
.filter(Boolean) .filter(Boolean)
}) })
</script> </script>

View File

@@ -0,0 +1,669 @@
<template>
<q-page v-if="canReadFinance" class="q-px-md q-pb-md q-pt-xs page-col statement-page">
<div class="filter-sticky compact-filter q-pa-sm q-mb-xs">
<div class="row q-col-gutter-sm items-end">
<div class="col-12 col-md-5">
<q-select
v-model="selectedCari"
:options="filteredOptions"
label="Cari kod / isim"
filled
dense
clearable
use-input
input-debounce="300"
@filter="filterCari"
emit-value
map-options
:loading="accountStore.loading"
option-value="value"
option-label="label"
behavior="menu"
:keep-selected="true"
/>
</div>
<div class="col-12 col-sm-6 col-md-2">
<q-input v-model="dateTo" label="Son tarih" filled dense clearable readonly>
<template #append>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date v-model="dateTo" mask="YYYY-MM-DD" locale="tr-TR" />
</q-popup-proxy>
</q-icon>
</template>
</q-input>
</div>
<div class="col-12 col-sm-6 col-md-3">
<q-select
v-model="selectedMonType"
:options="monetaryTypeOptions"
label="Parasal İşlem Tipi"
emit-value
map-options
filled
dense
/>
</div>
<div class="col-auto">
<q-btn color="primary" icon="filter_alt" label="Filtrele" @click="onFilterClick" />
</div>
<div class="col-auto">
<q-btn flat color="grey-8" icon="restart_alt" label="Sıfırla" @click="resetFilters" />
</div>
</div>
</div>
<div class="table-scroll">
<div class="sticky-bar row justify-end items-center q-pa-sm bg-grey-1">
<q-btn-dropdown
v-if="canExportFinance"
flat
color="red"
icon="picture_as_pdf"
label="Yazdır"
class="q-mr-sm"
>
<q-list style="min-width: 220px">
<q-item clickable v-close-popup @click="downloadAgingPDF">
<q-item-section class="text-primary">
Detaylı Yaşlandırma Ekstresi Yazdır
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn
flat
color="secondary"
icon="list"
:label="allDetailsOpen ? 'Tüm Detayları Kapat' : 'Tüm Detayları Aç'"
@click="toggleAllDetails"
/>
</div>
<q-table
class="sticky-table statement-table"
title="Cari Yaşlandırmalı Ekstre"
:rows="agingStore.masterRows"
:columns="masterColumns"
row-key="group_key"
flat
bordered
dense
hide-bottom
wrap-cells
:rows-per-page-options="[0]"
:loading="agingStore.loading"
:table-style="{ tableLayout: 'fixed', width: '100%' }"
>
<template #header="props">
<q-tr :props="props" class="header-row">
<q-th v-for="col in props.cols" :key="col.name" :props="props">{{ col.label }}</q-th>
</q-tr>
</template>
<template #body="props">
<q-tr :props="props" class="master-row">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<q-btn
v-if="col.name === 'expand'"
dense
flat
round
size="sm"
:icon="masterExpanded[props.row.group_key] ? 'expand_less' : 'expand_more'"
@click="toggleMaster(props.row.group_key)"
/>
<span
v-else-if="masterNumericCols.includes(col.name)"
:class="['block', masterCenteredCols.includes(col.name) ? 'text-center' : 'text-right']"
>
{{ masterDayCols.includes(col.name) ? formatDay(props.row[col.field]) : formatAmount(props.row[col.field]) }}
</span>
<span v-else>{{ props.row[col.field] ?? '-' }}</span>
</q-td>
</q-tr>
<q-tr v-if="masterExpanded[props.row.group_key]" class="master-sub-row">
<q-td colspan="100%" class="q-pa-none">
<div class="currency-groups">
<div class="currency-level-head">
<div class="cgh-cell cgh-expand"></div>
<div class="cgh-cell cgh-code">Ana Cari Kod</div>
<div class="cgh-cell cgh-code">Ana Cari Detay</div>
<div class="cgh-cell cgh-code">Döviz Cinsi</div>
<div class="cgh-cell cgh-num">ık Kalem Tutarı</div>
<div class="cgh-cell cgh-num">ık Kalem USD</div>
<div class="cgh-cell cgh-num">ık Kalem TRY</div>
<div class="cgh-cell cgh-center">Ort Gün</div>
<div class="cgh-cell cgh-center">Ort Belge Gün</div>
</div>
<div
v-for="currRow in agingStore.getCurrenciesByMaster(props.row.group_key)"
:key="currRow.group_key"
class="currency-group"
>
<div class="currency-group-header">
<div class="cgh-cell cgh-expand">
<q-btn
dense
flat
round
size="sm"
:icon="currencyExpanded[currRow.group_key] ? 'expand_less' : 'expand_more'"
@click="toggleCurrency(currRow.group_key)"
/>
</div>
<div class="cgh-cell cgh-code">{{ currRow.cari8 }}</div>
<div class="cgh-cell cgh-code">{{ currRow.cari_detay || '-' }}</div>
<div class="cgh-cell cgh-code">{{ currRow.doviz_cinsi }}</div>
<div class="cgh-cell cgh-num">{{ formatAmount(currRow.acik_kalem_tutari) }}</div>
<div class="cgh-cell cgh-num">{{ formatAmount(currRow.acik_kalem_usd) }}</div>
<div class="cgh-cell cgh-num">{{ formatAmount(currRow.acik_kalem_try) }}</div>
<div class="cgh-cell cgh-center">{{ formatDay(currRow.ort_gun) }}</div>
<div class="cgh-cell cgh-center">{{ formatDay(currRow.ort_belge_gun) }}</div>
</div>
<div v-if="currencyExpanded[currRow.group_key]" class="detail-host-row">
<q-table
:rows="agingStore.getDetailsByCurrency(currRow.group_key)"
:columns="detailColumns"
row-key="detail_key"
flat
dense
bordered
hide-bottom
:rows-per-page-options="[0]"
:pagination="{ rowsPerPage: 0 }"
class="detail-subtable"
:table-style="{ minWidth: '1500px' }"
>
<template #body-cell-eslesen_tutar="d">
<q-td :props="d" class="text-right">{{ formatAmount(d.row.eslesen_tutar) }}</q-td>
</template>
<template #body-cell-usd_tutar="d">
<q-td :props="d" class="text-right">{{ formatAmount(d.row.usd_tutar) }}</q-td>
</template>
<template #body-cell-try_tutar="d">
<q-td :props="d" class="text-right">{{ formatAmount(d.row.try_tutar) }}</q-td>
</template>
<template #body-cell-gun_sayisi="d">
<q-td :props="d" class="text-center">{{ formatDay(d.row.gun_sayisi) }}</q-td>
</template>
<template #body-cell-gun_sayisi_docdate="d">
<q-td :props="d" class="text-center">{{ formatDay(d.row.gun_sayisi_docdate) }}</q-td>
</template>
<template #body-cell-gun_kur="d">
<q-td :props="d" class="text-center">{{ formatAmount(d.row.gun_kur, 2) }}</q-td>
</template>
<template #body-cell-aciklama="d">
<q-td :props="d" class="text-center">{{ d.row.aciklama || '-' }}</q-td>
</template>
</q-table>
</div>
</div>
</div>
</q-td>
</q-tr>
</template>
</q-table>
</div>
</q-page>
<q-page v-else class="q-pa-md flex flex-center">
<div class="text-negative text-subtitle1">Bu modüle erişim yetkiniz yok.</div>
</q-page>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useQuasar } from 'quasar'
import dayjs from 'dayjs'
import { usePermission } from 'src/composables/usePermission'
import { useAccountStore } from 'src/stores/accountStore'
import { useStatementAgingStore } from 'src/stores/statementAgingStore'
import { download, extractApiErrorDetail } from 'src/services/api'
const { canRead, canExport } = usePermission()
const canReadFinance = canRead('finance')
const canExportFinance = canExport('finance')
const $q = useQuasar()
const accountStore = useAccountStore()
const agingStore = useStatementAgingStore()
const selectedCari = ref(null)
const filteredOptions = ref([])
const dateTo = ref(dayjs().format('YYYY-MM-DD'))
const masterExpanded = ref({})
const currencyExpanded = ref({})
const allDetailsOpen = ref(false)
const monetaryTypeOptions = [
{ label: '1-2 hesap', value: ['1', '2'] },
{ label: '1-3 r hesap', value: ['1', '3'] }
]
const selectedMonType = ref(monetaryTypeOptions[0].value)
const masterColumns = [
{ name: 'expand', label: '', field: 'expand', align: 'center' },
{ name: 'cari8', label: 'Ana Cari Kod', field: 'cari8', align: 'left', sortable: true },
{ name: 'cari_detay', label: 'Ana Cari Detay', field: 'cari_detay', align: 'left', sortable: true },
{ name: 'acik_kalem_tutari_usd', label: 'Açık Kalem Tutarı USD', field: 'acik_kalem_tutari_usd', align: 'right', sortable: true },
{ name: 'acik_kalem_tutari_try', label: 'Açık Kalem Tutarı TRY', field: 'acik_kalem_tutari_try', align: 'right', sortable: true },
{ name: 'acik_kalem_ort_vade_gun', label: 'Açık Kalem Ort Vade Gün', field: 'acik_kalem_ort_vade_gun', align: 'center', sortable: true },
{ name: 'acik_kalem_ort_belge_gun', label: 'Açık Kalem Ort Belge Gün', field: 'acik_kalem_ort_belge_gun', align: 'center', sortable: true },
{ name: 'normal_usd_tutar', label: 'Normal USD Tutar', field: 'normal_usd_tutar', align: 'right', sortable: true },
{ name: 'normal_try_tutar', label: 'Normal TRY Tutar', field: 'normal_try_tutar', align: 'right', sortable: true },
{ name: 'ortalama_vade_gun', label: 'Ortalama Vade Gün', field: 'ortalama_vade_gun', align: 'center', sortable: true },
{ name: 'ortalama_belge_gun', label: 'Ortalama Belge Gün', field: 'ortalama_belge_gun', align: 'center', sortable: true }
]
const detailColumns = [
{ name: 'fatura_cari', label: 'Fatura Cari', field: 'fatura_cari', align: 'left' },
{ name: 'odeme_cari', label: 'Ödeme Cari', field: 'odeme_cari', align: 'left' },
{ name: 'doc_currency_code', label: 'Döviz Cinsi', field: 'doc_currency_code', align: 'left' },
{ name: 'fatura_ref', label: 'Fatura Ref', field: 'fatura_ref', align: 'left' },
{ name: 'odeme_ref', label: 'Ödeme Ref', field: 'odeme_ref', align: 'left' },
{ name: 'fatura_tarihi', label: 'Fatura Tarihi', field: 'fatura_tarihi', align: 'left' },
{ name: 'odeme_tarihi', label: 'Ödeme Vade', field: 'odeme_tarihi', align: 'left' },
{ name: 'odeme_doc_date', label: 'Ödeme DocDate', field: 'odeme_doc_date', align: 'left' },
{ name: 'eslesen_tutar', label: 'Eşleşen Tutar', field: 'eslesen_tutar', align: 'right' },
{ name: 'usd_tutar', label: 'USD Tutar', field: 'usd_tutar', align: 'right' },
{ name: 'try_tutar', label: 'TRY Tutar', field: 'try_tutar', align: 'right' },
{ name: 'aciklama', label: 'Açıklama', field: 'aciklama', align: 'center' },
{ name: 'gun_sayisi', label: 'Gün', field: 'gun_sayisi', align: 'center' },
{ name: 'gun_sayisi_docdate', label: 'Gün (DocDate)', field: 'gun_sayisi_docdate', align: 'center' },
{ name: 'gun_kur', label: 'Gün Kur', field: 'gun_kur', align: 'center' }
]
const masterNumericCols = ['acik_kalem_tutari_usd', 'acik_kalem_tutari_try', 'acik_kalem_ort_vade_gun', 'acik_kalem_ort_belge_gun', 'normal_usd_tutar', 'normal_try_tutar', 'ortalama_vade_gun', 'ortalama_belge_gun']
const masterDayCols = ['acik_kalem_ort_vade_gun', 'acik_kalem_ort_belge_gun', 'ortalama_vade_gun', 'ortalama_belge_gun']
const masterCenteredCols = ['acik_kalem_ort_vade_gun', 'acik_kalem_ort_belge_gun', 'ortalama_vade_gun', 'ortalama_belge_gun']
function normalizeText(str) {
return (str || '')
.toString()
.toLocaleLowerCase('tr-TR')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
}
function filterCari(val, update) {
const needle = normalizeText(val)
update(() => {
if (!needle) {
filteredOptions.value = accountStore.accountOptions
return
}
filteredOptions.value = accountStore.accountOptions.filter(o => {
const label = normalizeText(o.label)
const value = normalizeText(o.value)
return label.includes(needle) || value.includes(needle)
})
})
}
onMounted(async () => {
await accountStore.fetchAccounts()
filteredOptions.value = accountStore.accountOptions
})
async function onFilterClick() {
if (!selectedCari.value || !dateTo.value) {
$q.notify({
type: 'warning',
message: 'Lütfen cari ve son tarih seçiniz.',
position: 'top-right'
})
return
}
try {
await agingStore.load({
accountcode: selectedCari.value,
enddate: dateTo.value,
parislemler: selectedMonType.value
})
const m = {}
const c = {}
for (const row of agingStore.masterRows) {
m[row.group_key] = true
for (const cr of agingStore.getCurrenciesByMaster(row.group_key)) {
c[cr.group_key] = true
}
}
masterExpanded.value = m
currencyExpanded.value = c
allDetailsOpen.value = agingStore.masterRows.length > 0
} catch (err) {
const msg = await extractApiErrorDetail(err)
$q.notify({
type: 'negative',
message: msg || 'Veriler yüklenemedi',
position: 'top-right'
})
}
}
function resetFilters() {
selectedCari.value = null
dateTo.value = dayjs().format('YYYY-MM-DD')
selectedMonType.value = monetaryTypeOptions[0].value
masterExpanded.value = {}
currencyExpanded.value = {}
allDetailsOpen.value = false
agingStore.reset()
}
function toggleMaster(key) {
masterExpanded.value[key] = !masterExpanded.value[key]
}
function toggleCurrency(key) {
currencyExpanded.value[key] = !currencyExpanded.value[key]
}
function toggleAllDetails() {
allDetailsOpen.value = !allDetailsOpen.value
if (!allDetailsOpen.value) {
masterExpanded.value = {}
currencyExpanded.value = {}
return
}
const m = {}
const c = {}
for (const row of agingStore.masterRows) {
m[row.group_key] = true
for (const cr of agingStore.getCurrenciesByMaster(row.group_key)) {
c[cr.group_key] = true
}
}
masterExpanded.value = m
currencyExpanded.value = c
}
function formatAmount(value, fraction = 2) {
const n = Number(value || 0)
return new Intl.NumberFormat('tr-TR', {
minimumFractionDigits: fraction,
maximumFractionDigits: fraction
}).format(n)
}
function formatDay(value) {
const n = Number(value || 0)
const v = Number.isFinite(n) ? Math.ceil(n) : 0
return new Intl.NumberFormat('tr-TR', {
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(v)
}
async function downloadAgingPDF () {
if (!canExportFinance.value) {
$q.notify({ type: 'negative', message: 'PDF export yetkiniz yok', position: 'top-right' })
return
}
if (!selectedCari.value || !dateTo.value) {
$q.notify({ type: 'warning', message: 'Lütfen cari ve son tarih seçiniz.', position: 'top-right' })
return
}
try {
const blob = await download('/finance/account-aging-statement/export-pdf', {
accountcode: selectedCari.value,
enddate: dateTo.value,
parislemler: selectedMonType.value
})
const pdfUrl = window.URL.createObjectURL(new Blob([blob], { type: 'application/pdf' }))
window.open(pdfUrl, '_blank')
} catch (err) {
const detail = await extractApiErrorDetail(err?.original || err)
$q.notify({
type: 'negative',
message: detail || 'PDF oluşturulamadı',
position: 'top-right'
})
}
}
</script>
<style scoped>
.statement-page {
--master-head-h: 34px;
--lvl2-head-h: 34px;
--lvl3-head-h: 34px;
height: calc(100vh - 56px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.table-scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.compact-filter {
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
background: #fafafa;
}
.sticky-bar {
position: sticky;
top: 0;
z-index: 30;
flex: 0 0 auto;
background: var(--q-secondary);
color: #fff;
border: 1px solid rgba(0, 0, 0, 0.08);
}
.statement-table {
flex: 1;
min-height: 0;
}
.statement-table :deep(.q-table__container) {
height: 100%;
display: flex;
flex-direction: column;
}
.statement-table :deep(.q-table__top) {
flex: 0 0 auto;
position: static;
}
.statement-table :deep(.q-table__middle) {
flex: 1 1 auto;
min-height: 0;
overflow: auto !important;
max-height: none !important;
}
.statement-table :deep(.header-row th) {
position: sticky;
top: 0;
z-index: 30;
height: var(--master-head-h);
background: var(--q-primary);
color: #fff;
font-weight: 600;
border-bottom: 1px solid rgba(255, 255, 255, 0.22);
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.2), 0 1px 0 rgba(0, 0, 0, 0.2);
}
.statement-table :deep(.master-row td) {
background: color-mix(in srgb, var(--q-secondary) 12%, white);
border-bottom: 2px solid rgba(0, 0, 0, 0.18);
font-weight: 600;
}
.statement-table :deep(.master-row td:first-child) {
border-left: 3px solid var(--q-primary);
}
.statement-table :deep(.master-sub-row td) {
background: #f4f6fb;
border-bottom: 8px solid #fff;
vertical-align: top;
padding: 0 !important;
}
.currency-groups {
padding: 6px;
background: #f8faff;
}
.currency-group {
border-left: 4px solid var(--q-secondary);
border-top: 1px solid rgba(0, 0, 0, 0.08);
border-right: 1px solid rgba(0, 0, 0, 0.08);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
margin-bottom: 8px;
background: #fff;
position: relative;
}
.currency-level-head {
position: sticky;
top: var(--master-head-h);
z-index: 27;
display: grid;
grid-template-columns: 48px 120px 280px 110px 1fr 1fr 1fr 120px 150px;
align-items: center;
gap: 0;
background: var(--q-secondary);
color: #fff;
border: 1px solid rgba(0, 0, 0, 0.15);
margin-bottom: 6px;
min-height: var(--lvl2-head-h);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
}
.currency-group-header {
position: sticky;
top: calc(var(--master-head-h) + var(--lvl2-head-h));
z-index: 26;
display: grid;
grid-template-columns: 48px 120px 280px 110px 1fr 1fr 1fr 120px 150px;
align-items: center;
gap: 0;
background: #4c5f7a;
color: #fff;
min-height: var(--lvl3-head-h);
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.18);
}
.cgh-cell {
padding: 6px 8px;
border-right: 1px solid rgba(255, 255, 255, 0.2);
font-weight: 600;
font-size: 11px;
}
.cgh-cell:last-child {
border-right: none;
}
.cgh-num {
text-align: right;
}
.cgh-center {
text-align: center;
}
.cgh-expand {
text-align: center;
}
.detail-host-row :deep(td) {
background: #fdfdfd;
padding: 6px !important;
}
.detail-subtable {
border-left: 4px solid var(--q-primary);
border-top: 1px solid rgba(0, 0, 0, 0.08);
border-right: 1px solid rgba(0, 0, 0, 0.08);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
background: #fff;
}
.statement-table :deep(th),
.statement-table :deep(td),
.detail-subtable :deep(th),
.detail-subtable :deep(td) {
padding: 3px 6px !important;
font-size: 11px !important;
line-height: 1.2 !important;
}
.detail-subtable :deep(.q-table__top) {
position: static;
}
.detail-subtable :deep(.q-table__middle) {
overflow: visible !important;
max-height: none !important;
}
.detail-subtable :deep(thead th) {
position: sticky;
top: calc(var(--master-head-h) + var(--lvl2-head-h) + var(--lvl3-head-h));
z-index: 25;
background: #1f3b5b;
color: #fff;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
}
.detail-subtable :deep(td[data-col="gun_sayisi"]),
.detail-subtable :deep(td[data-col="gun_sayisi_docdate"]),
.detail-subtable :deep(td[data-col="gun_kur"]),
.detail-subtable :deep(td[data-col="aciklama"]),
.detail-subtable :deep(th[data-col="gun_sayisi"]),
.detail-subtable :deep(th[data-col="gun_sayisi_docdate"]),
.detail-subtable :deep(th[data-col="gun_kur"]),
.detail-subtable :deep(th[data-col="aciklama"]),
.statement-table :deep(td[data-col="acik_kalem_ort_vade_gun"]),
.statement-table :deep(td[data-col="acik_kalem_ort_belge_gun"]),
.statement-table :deep(td[data-col="ortalama_vade_gun"]),
.statement-table :deep(td[data-col="ortalama_belge_gun"]),
.statement-table :deep(th[data-col="acik_kalem_ort_vade_gun"]),
.statement-table :deep(th[data-col="acik_kalem_ort_belge_gun"]),
.statement-table :deep(th[data-col="ortalama_vade_gun"]),
.statement-table :deep(th[data-col="ortalama_belge_gun"]) {
text-align: center !important;
}
@media (max-width: 1366px) {
.statement-table :deep(th),
.statement-table :deep(td),
.detail-subtable :deep(th),
.detail-subtable :deep(td) {
font-size: 10px !important;
padding: 2px 4px !important;
}
.currency-group-header {
grid-template-columns: 44px 100px 220px 90px 1fr 1fr 1fr 90px 120px;
}
}
</style>

View File

@@ -0,0 +1,13 @@
<template>
<q-page class="q-pa-md">
<q-card flat bordered class="q-pa-lg">
<div class="text-h6">Cari Yaşlandırmalı Cari Bakiye Listesi</div>
<div class="text-subtitle2 q-mt-sm text-grey-7">
Dummy ekran hazır. Bu modülün filtre ve tablo kurgusu bir sonraki adımda eklenecek.
</div>
</q-card>
</q-page>
</template>
<script setup>
</script>

View File

@@ -0,0 +1,13 @@
<template>
<q-page class="q-pa-md">
<q-card flat bordered class="q-pa-lg">
<div class="text-h6">Cari Yaşlandırmalı Ekstre</div>
<div class="text-subtitle2 q-mt-sm text-grey-7">
Dummy ekran hazır. Bu modülün filtre ve tablo kurgusu bir sonraki adımda eklenecek.
</div>
</q-card>
</q-page>
</template>
<script setup>
</script>

View File

@@ -1,27 +1,7 @@
<template> <template>
<q-page v-if="canReadFinance" class="q-pa-md page-layout"> <q-page v-if="canReadFinance" class="q-pa-md page-layout">
<div class="filter-sticky"> <div class="filter-sticky">
<div class="row q-col-gutter-sm q-mb-md"> <div class="top-actions row q-col-gutter-sm items-end q-mb-sm">
<div class="col-12 col-sm-6 col-md-4">
<q-input
v-model="store.filters.cariSearch"
filled
dense
label="Cari Kodu / Cari Adı"
@keyup.enter="store.applyCariSearch()"
>
<template #append>
<q-btn
dense
flat
round
icon="search"
@click="store.applyCariSearch()"
/>
</template>
</q-input>
</div>
<div class="col-12 col-sm-6 col-md-2"> <div class="col-12 col-sm-6 col-md-2">
<q-input <q-input
v-model="store.filters.selectedDate" v-model="store.filters.selectedDate"
@@ -40,6 +20,56 @@
</q-input> </q-input>
</div> </div>
<div class="col-12 col-sm-6 col-md-3">
<q-toggle
v-model="store.filters.excludeZeroBalance12"
dense
label="1_2 Bakiyesi Sıfır Olanları Alma"
@update:model-value="onToggle12Changed"
/>
</div>
<div class="col-12 col-sm-6 col-md-3">
<q-toggle
v-model="store.filters.excludeZeroBalance13"
dense
label="1_3 Bakiyesi Sıfır Olanları Alma"
@update:model-value="onToggle13Changed"
/>
</div>
<div class="col-auto">
<q-btn
color="primary"
icon="download"
label="Bakiyeleri Getir"
:loading="store.loading"
@click="store.fetchCustomerBalances()"
/>
</div>
<div class="col-auto">
<q-btn
flat
color="grey-8"
icon="restart_alt"
label="Sıfırla"
@click="onReset"
/>
</div>
</div>
<div class="filters-panel q-pa-sm q-mb-md">
<div class="row q-col-gutter-sm">
<div class="col-12 col-sm-6 col-md-4">
<q-input
v-model="store.filters.cariSearch"
filled
dense
label="Cari Kodu / Cari Adı"
/>
</div>
<div class="col-12 col-sm-6 col-md-2"> <div class="col-12 col-sm-6 col-md-2">
<q-select <q-select
v-model="store.filters.cariIlkGrup" v-model="store.filters.cariIlkGrup"
@@ -231,7 +261,7 @@
dense dense
options-dense options-dense
class="compact-select" class="compact-select"
label="Ülke (Özellik05)" label="Ülke"
:display-value="selectionLabel(store.filters.ulke, 'Ülke')" :display-value="selectionLabel(store.filters.ulke, 'Ülke')"
> >
<template #before-options> <template #before-options>
@@ -255,27 +285,78 @@
</template> </template>
</q-select> </q-select>
</div> </div>
<div class="col-12 col-sm-6 col-md-2">
<q-select
v-model="store.filters.il"
:options="store.ilOptions"
multiple
emit-value
map-options
filled
dense
options-dense
class="compact-select"
label="İl"
:display-value="selectionLabel(store.filters.il, 'İl')"
>
<template #before-options>
<q-item clickable dense @click.stop="store.selectAll('il', store.ilOptions)">
<q-item-section>Tümünü Seç</q-item-section>
</q-item>
<q-item clickable dense @click.stop="store.clearAll('il')">
<q-item-section>Tümünü Temizle</q-item-section>
</q-item>
<q-separator />
</template>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar>
<q-checkbox :model-value="scope.selected" />
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt.label }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
</div> </div>
<div class="row q-col-gutter-sm q-mb-md"> <div class="col-12 col-sm-6 col-md-2">
<div class="col-auto"> <q-select
<q-btn v-model="store.filters.ilce"
color="primary" :options="store.ilceOptions"
icon="download" multiple
label="Bakiyeleri Getir" emit-value
:loading="store.loading" map-options
@click="store.fetchCustomerBalances()" filled
/> dense
options-dense
class="compact-select"
label="İlçe"
:display-value="selectionLabel(store.filters.ilce, 'İlçe')"
>
<template #before-options>
<q-item clickable dense @click.stop="store.selectAll('ilce', store.ilceOptions)">
<q-item-section>Tümünü Seç</q-item-section>
</q-item>
<q-item clickable dense @click.stop="store.clearAll('ilce')">
<q-item-section>Tümünü Temizle</q-item-section>
</q-item>
<q-separator />
</template>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar>
<q-checkbox :model-value="scope.selected" />
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt.label }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
</div> </div>
<div class="col-auto">
<q-btn
flat
color="grey-8"
icon="restart_alt"
label="Sıfırla"
@click="onReset"
/>
</div> </div>
</div> </div>
@@ -284,12 +365,14 @@
</q-banner> </q-banner>
<q-banner v-if="!store.hasFetched && !store.loading" class="bg-blue-1 text-primary q-mb-md rounded-borders"> <q-banner v-if="!store.hasFetched && !store.loading" class="bg-blue-1 text-primary q-mb-md rounded-borders">
Bakiyeleri Getir Tuşuna Basmadan Sistem Çalışmaz Bakiyeleri Getir tuşuna basmadan sistem çalışmaz.
</q-banner> </q-banner>
</div> </div>
<div class="table-area"> <div class="table-area">
<div class="sticky-bar row justify-end items-center q-pa-sm bg-grey-1"> <div class="sticky-bar row justify-between items-center q-pa-sm bg-grey-1">
<div />
<div class="row items-center q-gutter-sm">
<q-btn <q-btn
flat flat
color="secondary" color="secondary"
@@ -297,7 +380,37 @@
:label="allDetailsOpen ? 'Tüm Detayları Kapat' : 'Tüm Detayları Aç'" :label="allDetailsOpen ? 'Tüm Detayları Kapat' : 'Tüm Detayları Aç'"
@click="toggleAllDetails" @click="toggleAllDetails"
/> />
<q-btn-dropdown
v-if="canExportFinance"
flat
color="red"
icon="picture_as_pdf"
label="Yazdır"
>
<q-list style="min-width: 240px">
<q-item clickable v-close-popup @click="downloadCustomerBalancePDF(true)">
<q-item-section class="text-primary">
Detaylı Cari Bakiye Listesi Yazdır
</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="downloadCustomerBalancePDF(false)">
<q-item-section class="text-secondary">
Detaysız Cari Bakiye Listesi Yazdır
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn
v-if="canExportFinance"
flat
color="green-8"
icon="table_view"
label="Excel"
@click="downloadCustomerBalanceExcel"
/>
</div> </div>
</div>
<q-table <q-table
title="Cari Bakiye Listesi" title="Cari Bakiye Listesi"
:rows="store.summaryRows" :rows="store.summaryRows"
@@ -353,12 +466,7 @@
<span v-else-if="staticMoneyFields.includes(col.name)" class="text-center block"> <span v-else-if="staticMoneyFields.includes(col.name)" class="text-center block">
{{ formatAmount(props.row[col.field]) }} {{ formatAmount(props.row[col.field]) }}
</span> </span>
<span v-else-if="col.name === 'hesap_alinmayan_gun'" class="text-right block"> <span v-else>{{ props.row[col.field] || '-' }}</span>
-
</span>
<span v-else>
{{ props.row[col.field] || '-' }}
</span>
</q-td> </q-td>
</q-tr> </q-tr>
@@ -397,22 +505,26 @@
<q-page v-else class="q-pa-md flex flex-center"> <q-page v-else class="q-pa-md flex flex-center">
<div class="text-negative text-subtitle1"> <div class="text-negative text-subtitle1">
Bu module erisim yetkiniz yok. Bu modüle erişim yetkiniz yok.
</div> </div>
</q-page> </q-page>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useQuasar } from 'quasar'
import { useCustomerBalanceListStore } from 'src/stores/customerBalanceListStore' import { useCustomerBalanceListStore } from 'src/stores/customerBalanceListStore'
import { usePermission } from 'src/composables/usePermission' import { usePermission } from 'src/composables/usePermission'
import { download, extractApiErrorDetail } from 'src/services/api'
const store = useCustomerBalanceListStore() const store = useCustomerBalanceListStore()
const expanded = ref({}) const expanded = ref({})
const allDetailsOpen = ref(false) const allDetailsOpen = ref(false)
const $q = useQuasar()
const { canRead } = usePermission() const { canRead, canExport } = usePermission()
const canReadFinance = canRead('finance') const canReadFinance = canRead('finance')
const canExportFinance = canExport('finance')
const islemTipiOptions = [ const islemTipiOptions = [
{ label: '1_2 Bakiye Pr.Br', value: 'prbr_1_2' }, { label: '1_2 Bakiye Pr.Br', value: 'prbr_1_2' },
@@ -422,14 +534,49 @@ const islemTipiOptions = [
{ label: '1_3 USD Bakiye', value: 'usd_1_3' }, { label: '1_3 USD Bakiye', value: 'usd_1_3' },
{ label: '1_3 TRY Bakiye', value: 'try_1_3' } { label: '1_3 TRY Bakiye', value: 'try_1_3' }
] ]
const staticMoneyFields = ['usd_bakiye_1_2', 'tl_bakiye_1_2', 'usd_bakiye_1_3', 'tl_bakiye_1_3'] const staticMoneyFields = ['usd_bakiye_1_2', 'tl_bakiye_1_2', 'usd_bakiye_1_3', 'tl_bakiye_1_3']
function toNumericSortValue (value) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0
}
const s = String(value ?? '').trim()
if (!s) return 0
const hasComma = s.includes(',')
const hasDot = s.includes('.')
let normalized = s.replace(/\s+/g, '')
if (hasComma && hasDot) {
const lastComma = normalized.lastIndexOf(',')
const lastDot = normalized.lastIndexOf('.')
if (lastComma > lastDot) {
normalized = normalized.replace(/\./g, '').replace(',', '.')
} else {
normalized = normalized.replace(/,/g, '')
}
} else if (hasComma) {
normalized = normalized.replace(/\./g, '').replace(',', '.')
}
const n = Number.parseFloat(normalized)
return Number.isFinite(n) ? n : 0
}
function sortTextTr (a, b) {
return String(a ?? '').localeCompare(String(b ?? ''), 'tr', { sensitivity: 'base' })
}
const metricDefs = { const metricDefs = {
prbr_1_2: { name: 'prbr_1_2', label: '1_2 Bakiye\nPr.Br', field: 'prbr_1_2', align: 'right', sortable: false }, prbr_1_2: { name: 'prbr_1_2', label: '1_2 Bakiye\nPr.Br', field: 'prbr_1_2', align: 'right', sortable: false },
prbr_1_3: { name: 'prbr_1_3', label: '1_3 Bakiye\nPr.Br', field: 'prbr_1_3', align: 'right', sortable: false }, prbr_1_3: { name: 'prbr_1_3', label: '1_3 Bakiye\nPr.Br', field: 'prbr_1_3', align: 'right', sortable: false },
usd_1_2: { name: 'usd_bakiye_1_2', label: '1_2 USD_BAKIYE', field: 'usd_bakiye_1_2', align: 'center', sortable: true }, usd_1_2: { name: 'usd_bakiye_1_2', label: '1_2 USD_BAKIYE', field: 'usd_bakiye_1_2', align: 'center', sortable: true, sort: (a, b) => toNumericSortValue(a) - toNumericSortValue(b) },
try_1_2: { name: 'tl_bakiye_1_2', label: '1_2 TRY_BAKIYE', field: 'tl_bakiye_1_2', align: 'center', sortable: true }, try_1_2: { name: 'tl_bakiye_1_2', label: '1_2 TRY_BAKIYE', field: 'tl_bakiye_1_2', align: 'center', sortable: true, sort: (a, b) => toNumericSortValue(a) - toNumericSortValue(b) },
usd_1_3: { name: 'usd_bakiye_1_3', label: '1_3 USD_BAKIYE', field: 'usd_bakiye_1_3', align: 'center', sortable: true }, usd_1_3: { name: 'usd_bakiye_1_3', label: '1_3 USD_BAKIYE', field: 'usd_bakiye_1_3', align: 'center', sortable: true, sort: (a, b) => toNumericSortValue(a) - toNumericSortValue(b) },
try_1_3: { name: 'tl_bakiye_1_3', label: '1_3 TRY_BAKIYE', field: 'tl_bakiye_1_3', align: 'center', sortable: true } try_1_3: { name: 'tl_bakiye_1_3', label: '1_3 TRY_BAKIYE', field: 'tl_bakiye_1_3', align: 'center', sortable: true, sort: (a, b) => toNumericSortValue(a) - toNumericSortValue(b) }
} }
const selectedMetricKeys = computed(() => { const selectedMetricKeys = computed(() => {
@@ -440,15 +587,14 @@ const selectedMetricKeys = computed(() => {
const summaryColumns = computed(() => ([ const summaryColumns = computed(() => ([
{ name: 'expand', label: '', field: 'expand', align: 'center', sortable: false }, { name: 'expand', label: '', field: 'expand', align: 'center', sortable: false },
{ name: 'ana_cari_kodu', label: 'Ana Cari Kodu', field: 'ana_cari_kodu', align: 'left', sortable: true }, { name: 'ana_cari_kodu', label: 'Ana Cari Kodu', field: 'ana_cari_kodu', align: 'left', sortable: true, sort: sortTextTr },
{ name: 'ana_cari_adi', label: 'Ana Cari Detay', field: 'ana_cari_adi', align: 'left', sortable: true }, { name: 'ana_cari_adi', label: 'Ana Cari Detay', field: 'ana_cari_adi', align: 'left', sortable: true, sort: sortTextTr },
{ name: 'piyasa', label: 'Piyasa', field: 'piyasa', align: 'left', sortable: true }, { name: 'piyasa', label: 'Piyasa', field: 'piyasa', align: 'left', sortable: true, sort: sortTextTr },
{ name: 'temsilci', label: 'Temsilci', field: 'temsilci', align: 'left', sortable: true }, { name: 'temsilci', label: 'Temsilci', field: 'temsilci', align: 'left', sortable: true, sort: sortTextTr },
{ name: 'risk_durumu', label: 'Risk Durumu', field: 'risk_durumu', align: 'left', sortable: true }, { name: 'risk_durumu', label: 'Risk Durumu', field: 'risk_durumu', align: 'left', sortable: true, sort: sortTextTr },
...selectedMetricKeys.value.map(k => metricDefs[k]), ...selectedMetricKeys.value.map((k) => metricDefs[k])
{ name: 'hesap_alinmayan_gun', label: 'Hesap Alınmayan Gün', field: 'hesap_alinmayan_gun', align: 'right', sortable: false },
{ name: 'kalan_fatura_ortalama_vade_tarihi', label: 'Kalan Fatura Ortalama Vade Tarihi', field: 'kalan_fatura_ortalama_vade_tarihi', align: 'left', sortable: true }
])) ]))
const liveTotals = computed(() => { const liveTotals = computed(() => {
return store.filteredRows.reduce((acc, row) => { return store.filteredRows.reduce((acc, row) => {
acc.usd_bakiye_1_2 += Number(row.usd_bakiye_1_2) || 0 acc.usd_bakiye_1_2 += Number(row.usd_bakiye_1_2) || 0
@@ -468,23 +614,36 @@ const detailColumns = computed(() => [
{ name: 'cari_kodu', label: 'Cari Kodu', field: 'cari_kodu', align: 'left' }, { name: 'cari_kodu', label: 'Cari Kodu', field: 'cari_kodu', align: 'left' },
{ name: 'cari_detay', label: 'Cari Detay', field: 'cari_detay', align: 'left' }, { name: 'cari_detay', label: 'Cari Detay', field: 'cari_detay', align: 'left' },
{ name: 'sirket', label: 'Şirket', field: 'sirket', align: 'left' }, { name: 'sirket', label: 'Şirket', field: 'sirket', align: 'left' },
{ name: 'sirket_detay', label: 'Şirket Detayı', field: 'sirket_detay', align: 'left' },
{ name: 'muhasebe_kodu', label: 'Muhasebe Kodu', field: 'muhasebe_kodu', align: 'left' },
{ name: 'piyasa', label: 'Piyasa', field: 'piyasa', align: 'left' }, { name: 'piyasa', label: 'Piyasa', field: 'piyasa', align: 'left' },
{ name: 'temsilci', label: 'Temsilci', field: 'temsilci', align: 'left' }, { name: 'temsilci', label: 'Temsilci', field: 'temsilci', align: 'left' },
{ name: 'ozellik03', label: 'Risk Durumu', field: 'ozellik03', align: 'left' }, { name: 'risk_durumu', label: 'Risk Durumu', field: 'risk_durumu', align: 'left' },
{ name: 'ozellik05', label: 'Ülke', field: 'ozellik05', align: 'left' }, { name: 'ozellik05', label: 'Ülke', field: 'ozellik05', align: 'left' },
{ name: 'ozellik06', label: 'Özellik06', field: 'ozellik06', align: 'left' }, { name: 'il', label: 'İl', field: 'il', align: 'left' },
{ name: 'ozellik07', label: 'Özellik07', field: 'ozellik07', align: 'left' }, { name: 'ilce', label: 'İlçe', field: 'ilce', align: 'left' },
{ name: 'cari_doviz', label: 'Döviz', field: 'cari_doviz', align: 'left' }, { name: 'cari_doviz', label: 'Döviz', field: 'cari_doviz', align: 'left' },
...selectedMetricKeys.value.map(k => metricDefs[k]), ...selectedMetricKeys.value.map((k) => metricDefs[k])
{ name: 'hesap_alinmayan_gun', label: 'Hesap Alınmayan Gün', field: 'hesap_alinmayan_gun', align: 'right' },
{ name: 'kalan_fatura_ortalama_vade_tarihi', label: 'Kalan Fatura Ortalama Vade Tarihi', field: 'kalan_fatura_ortalama_vade_tarihi', align: 'left' }
]) ])
function onReset () { function onReset () {
store.resetFilters() store.resetFilters()
store.applyCariSearch()
} }
function onToggle12Changed (val) {
if (val) {
store.filters.excludeZeroBalance13 = false
}
}
function onToggle13Changed (val) {
if (val) {
store.filters.excludeZeroBalance12 = false
}
}
function toggleGroup (key) { function toggleGroup (key) {
expanded.value[key] = !expanded.value[key] expanded.value[key] = !expanded.value[key]
if (!expanded.value[key]) { if (!expanded.value[key]) {
@@ -512,6 +671,97 @@ function toggleAllDetails () {
expanded.value = {} expanded.value = {}
} }
async function downloadCustomerBalancePDF (detailed) {
if (!canExportFinance.value) {
$q.notify({ type: 'negative', message: 'PDF export yetkiniz yok', position: 'top-right' })
return
}
if (!store.hasFetched) {
$q.notify({ type: 'warning', message: 'Önce Bakiyeleri Getir ile veri yükleyin.', position: 'top-right' })
return
}
try {
const params = {
selected_date: store.filters.selectedDate,
cari_search: String(store.filters.cariSearch || '').trim(),
cari_ilk_grup: (store.filters.cariIlkGrup || []).join(','),
piyasa: (store.filters.piyasa || []).join(','),
temsilci: (store.filters.temsilci || []).join(','),
risk_durumu: (store.filters.riskDurumu || []).join(','),
islem_tipi: (store.filters.islemTipi || []).join(','),
ulke: (store.filters.ulke || []).join(','),
il: (store.filters.il || []).join(','),
ilce: (store.filters.ilce || []).join(','),
exclude_zero_12: store.filters.excludeZeroBalance12 ? '1' : '0',
exclude_zero_13: store.filters.excludeZeroBalance13 ? '1' : '0',
detailed: detailed ? '1' : '0'
}
const blob = await download('/finance/customer-balances/export-pdf', params)
const pdfUrl = window.URL.createObjectURL(new Blob([blob], { type: 'application/pdf' }))
window.open(pdfUrl, '_blank')
} catch (err) {
const detail = await extractApiErrorDetail(err?.original || err)
$q.notify({
type: 'negative',
message: detail || 'PDF oluşturulamadı',
position: 'top-right'
})
}
}
async function downloadCustomerBalanceExcel () {
if (!canExportFinance.value) {
$q.notify({ type: 'negative', message: 'Excel export yetkiniz yok', position: 'top-right' })
return
}
if (!store.hasFetched) {
$q.notify({ type: 'warning', message: 'Önce Bakiyeleri Getir ile veri yükleyin.', position: 'top-right' })
return
}
try {
const params = {
selected_date: store.filters.selectedDate,
cari_search: String(store.filters.cariSearch || '').trim(),
cari_ilk_grup: (store.filters.cariIlkGrup || []).join(','),
piyasa: (store.filters.piyasa || []).join(','),
temsilci: (store.filters.temsilci || []).join(','),
risk_durumu: (store.filters.riskDurumu || []).join(','),
islem_tipi: (store.filters.islemTipi || []).join(','),
ulke: (store.filters.ulke || []).join(','),
il: (store.filters.il || []).join(','),
ilce: (store.filters.ilce || []).join(','),
exclude_zero_12: store.filters.excludeZeroBalance12 ? '1' : '0',
exclude_zero_13: store.filters.excludeZeroBalance13 ? '1' : '0'
}
const file = await download('/finance/customer-balances/export-excel', params)
const blob = new Blob(
[file],
{ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }
)
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'cari_bakiye_listesi.xlsx'
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
} catch (err) {
const detail = await extractApiErrorDetail(err?.original || err)
$q.notify({
type: 'negative',
message: detail || 'Excel oluşturulamadı',
position: 'top-right'
})
}
}
function formatAmount (value) { function formatAmount (value) {
const n = Number(value || 0) const n = Number(value || 0)
return new Intl.NumberFormat('tr-TR', { return new Intl.NumberFormat('tr-TR', {
@@ -538,7 +788,6 @@ function totalCellValue (colName) {
if (colName === 'tl_bakiye_1_2') return formatAmount(liveTotals.value.tl_bakiye_1_2) if (colName === 'tl_bakiye_1_2') return formatAmount(liveTotals.value.tl_bakiye_1_2)
if (colName === 'usd_bakiye_1_3') return formatAmount(liveTotals.value.usd_bakiye_1_3) if (colName === 'usd_bakiye_1_3') return formatAmount(liveTotals.value.usd_bakiye_1_3)
if (colName === 'tl_bakiye_1_3') return formatAmount(liveTotals.value.tl_bakiye_1_3) if (colName === 'tl_bakiye_1_3') return formatAmount(liveTotals.value.tl_bakiye_1_3)
if (colName === 'hesap_alinmayan_gun') return '-'
return '-' return '-'
} }
@@ -564,7 +813,7 @@ function formatCurrencyMap (mapObj) {
if (!entries.length) return '-' if (!entries.length) return '-'
return entries return entries
.map(([curr, amount]) => `${curr}: ${formatAmount(amount)}`) .map(([curr, amount]) => `${curr}: ${formatAmount(amount)}`)
.join(' | ') .join('\n')
} }
function formatRowPrBr (row, tip) { function formatRowPrBr (row, tip) {
@@ -577,7 +826,6 @@ function formatRowPrBr (row, tip) {
if (amount === 0) return '-' if (amount === 0) return '-'
return `${curr} ${formatAmount(amount)}` return `${curr} ${formatAmount(amount)}`
} }
</script> </script>
<style scoped> <style scoped>
@@ -596,6 +844,12 @@ function formatRowPrBr (row, tip) {
padding-bottom: 6px; padding-bottom: 6px;
} }
.filters-panel {
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
background: #fafafa;
}
.compact-select :deep(.q-field__control) { .compact-select :deep(.q-field__control) {
min-height: 40px; min-height: 40px;
} }
@@ -652,7 +906,7 @@ function formatRowPrBr (row, tip) {
background: var(--q-primary); background: var(--q-primary);
color: #fff; color: #fff;
font-weight: 600; font-weight: 600;
font-family: "Roboto", sans-serif; font-family: 'Roboto', sans-serif;
} }
.balance-table :deep(.totals-row th) { .balance-table :deep(.totals-row th) {
@@ -662,7 +916,7 @@ function formatRowPrBr (row, tip) {
background: var(--q-secondary); background: var(--q-secondary);
color: var(--q-dark); color: var(--q-dark);
font-weight: 700; font-weight: 700;
font-family: "Roboto", sans-serif; font-family: 'Roboto', sans-serif;
border-bottom: 1px solid rgba(0, 0, 0, 0.12); border-bottom: 1px solid rgba(0, 0, 0, 0.12);
} }
@@ -701,7 +955,7 @@ function formatRowPrBr (row, tip) {
} }
.prbr-cell { .prbr-cell {
white-space: normal; white-space: pre-line;
word-break: break-word; word-break: break-word;
line-height: 1.25; line-height: 1.25;
} }

View File

@@ -0,0 +1,13 @@
<template>
<q-page class="q-pa-md">
<q-card flat bordered class="q-pa-lg">
<div class="text-h6">Cari Bakiye Listesi</div>
<div class="text-subtitle2 q-mt-sm text-grey-7">
Dummy ekran hazır. Bu modülün filtre ve tablo kurgusu bir sonraki adımda eklenecek.
</div>
</q-card>
</q-page>
</template>
<script setup>
</script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<q-page v-if="canReadFinance" class="q-pa-md page-col statement-page"> <q-page v-if="canReadFinance" class="q-pa-md page-col statement-page">
<!-- 🔹 Cari Kod / İsim (sabit) --> <!-- Cari Kod / İsim (sabit) -->
<div class="filter-sticky"> <div class="filter-sticky">
<q-select <q-select
v-model="selectedCari" v-model="selectedCari"
@@ -22,7 +22,7 @@
/> />
</div> </div>
<!-- 🔹 Filtre Alanı --> <!-- Filtre Alanı -->
<div class="filter-collapsible"> <div class="filter-collapsible">
<div class="row items-center justify-between q-pa-sm bg-grey-2"> <div class="row items-center justify-between q-pa-sm bg-grey-2">
<div class="text-subtitle1">Filtreler</div> <div class="text-subtitle1">Filtreler</div>
@@ -116,7 +116,7 @@
</q-slide-transition> </q-slide-transition>
</div> </div>
<!-- 🔹 Tablo Alanı --> <!-- Tablo Alanı -->
<div class="table-scroll"> <div class="table-scroll">
<!-- Toggle butonları (sticky üst bar) --> <!-- Toggle butonları (sticky üst bar) -->
@@ -438,7 +438,7 @@ function toggleRowDetails(row) {
expandedRows.value[row.belge_no] = !expandedRows.value[row.belge_no] expandedRows.value[row.belge_no] = !expandedRows.value[row.belge_no]
} }
/* 🔹 Tüm detayları aç/kapat */ /* Tüm detayları aç/kapat */
function toggleAllDetails() { function toggleAllDetails() {
allDetailsOpen.value = !allDetailsOpen.value allDetailsOpen.value = !allDetailsOpen.value
if (allDetailsOpen.value) { if (allDetailsOpen.value) {
@@ -454,7 +454,7 @@ function toggleAllDetails() {
function normalizeText (str) { function normalizeText (str) {
return (str || '') return (str || '')
.toString() .toString()
.toLocaleLowerCase('tr-TR') // 🔥 Türkçe uyumlu .toLocaleLowerCase('tr-TR') // Türkçe uyumlu
.normalize('NFD') // aksan temizleme .normalize('NFD') // aksan temizleme
.replace(/[\u0300-\u036f]/g, '') .replace(/[\u0300-\u036f]/g, '')
.trim() .trim()
@@ -480,7 +480,7 @@ function formatAmount(n) {
const filtersOpen = ref(true) const filtersOpen = ref(true)
/* 🔹 Kolon gizle/göster */ /* Kolon gizle/göster */
const visibleColumns = ref([]) const visibleColumns = ref([])
const showLeftCols = ref(true) const showLeftCols = ref(true)
@@ -501,7 +501,7 @@ function toggleLeftCols() {
showLeftCols.value = !showLeftCols.value showLeftCols.value = !showLeftCols.value
} }
/* 🔹 PDF İndirme Butonuna bağla */ /* PDF İndirme Butonuna bağla */
async function handleDownload() { async function handleDownload() {
if (!canExportFinance.value) { if (!canExportFinance.value) {
$q.notify({ $q.notify({
@@ -536,14 +536,14 @@ async function handleDownload() {
selectedMonType.value // <-- eklendi (['1','2'] veya ['1','3']) selectedMonType.value // <-- eklendi (['1','2'] veya ['1','3'])
) )
console.log("📤 [DEBUG] Storedan gelen result:", result) console.log("[DEBUG] Storedan gelen result:", result)
$q.notify({ $q.notify({
type: result.ok ? 'positive' : 'negative', type: result.ok ? 'positive' : 'negative',
message: result.message, message: result.message,
position: 'top-right' position: 'top-right'
}) })
}/* 🔹 Cari Hesap Ekstresi (2. seçenek) */ }/* Cari Hesap Ekstresi (2. seçenek) */
import { useDownloadstHeadStore } from 'src/stores/downloadstHeadStore' import { useDownloadstHeadStore } from 'src/stores/downloadstHeadStore'
const downloadstHeadStore = useDownloadstHeadStore() const downloadstHeadStore = useDownloadstHeadStore()
@@ -582,7 +582,7 @@ async function CurrheadDownload() {
selectedMonType.value // parasal işlem tipi (parislemler) selectedMonType.value // parasal işlem tipi (parislemler)
) )
console.log("📤 [DEBUG] CurrheadDownloadresult:", result) console.log("[DEBUG] CurrheadDownloadresult:", result)
$q.notify({ $q.notify({
type: result.ok ? 'positive' : 'negative', type: result.ok ? 'positive' : 'negative',
@@ -689,3 +689,4 @@ async function CurrheadDownload() {
} }
} }
</style> </style>

View File

@@ -140,6 +140,18 @@ const routes = [
component: () => import('pages/CustomerBalanceList.vue'), component: () => import('pages/CustomerBalanceList.vue'),
meta: { permission: 'finance:view' } meta: { permission: 'finance:view' }
}, },
{
path: 'account-aging-statement',
name: 'account-aging-statement',
component: () => import('pages/AccountAgingStatement.vue'),
meta: { permission: 'finance:view' }
},
{
path: 'aged-customer-balance-list',
name: 'aged-customer-balance-list',
component: () => import('pages/AgedCustomerBalanceListDummy.vue'),
meta: { permission: 'finance:view' }
},
/* ================= USERS ================= */ /* ================= USERS ================= */

View File

@@ -5,14 +5,17 @@ export const useCustomerBalanceListStore = defineStore('customerBalanceList', {
state: () => ({ state: () => ({
filters: { filters: {
selectedDate: new Date().toISOString().slice(0, 10), selectedDate: new Date().toISOString().slice(0, 10),
excludeZeroBalance12: false,
excludeZeroBalance13: false,
cariSearch: '', cariSearch: '',
appliedCariSearch: '',
cariIlkGrup: [], cariIlkGrup: [],
piyasa: [], piyasa: [],
temsilci: [], temsilci: [],
riskDurumu: [], riskDurumu: [],
islemTipi: [], islemTipi: [],
ulke: [] ulke: [],
il: [],
ilce: []
}, },
rows: [], rows: [],
loading: false, loading: false,
@@ -25,26 +28,47 @@ export const useCustomerBalanceListStore = defineStore('customerBalanceList', {
cariIlkGrupOptions: (state) => uniqueOptions(state.rows, 'cari_ilk_grup'), cariIlkGrupOptions: (state) => uniqueOptions(state.rows, 'cari_ilk_grup'),
piyasaOptions: (state) => uniqueOptions(state.rows, 'piyasa'), piyasaOptions: (state) => uniqueOptions(state.rows, 'piyasa'),
temsilciOptions: (state) => uniqueOptions(state.rows, 'temsilci'), temsilciOptions: (state) => uniqueOptions(state.rows, 'temsilci'),
riskDurumuOptions: (state) => uniqueOptions(state.rows, 'ozellik03'), riskDurumuOptions: (state) => uniqueOptions(state.rows, 'risk_durumu'),
ulkeOptions: (state) => uniqueOptions(state.rows, 'ozellik05'), ulkeOptions: (state) => uniqueOptions(state.rows, 'ozellik05'),
ilOptions: (state) => uniqueOptions(state.rows, 'il'),
ilceOptions: (state) => uniqueOptions(state.rows, 'ilce'),
filteredRows: (state) => { filteredRows: (state) => {
const selectedCariIlkGrup = new Set((state.filters.cariIlkGrup || []).map(v => normalizeText(v)))
const selectedPiyasa = new Set((state.filters.piyasa || []).map(v => normalizeText(v)))
const selectedTemsilci = new Set((state.filters.temsilci || []).map(v => normalizeText(v)))
const selectedRiskDurumu = new Set((state.filters.riskDurumu || []).map(v => normalizeText(v)))
const selectedUlke = new Set((state.filters.ulke || []).map(v => normalizeText(v)))
const selectedIl = new Set((state.filters.il || []).map(v => normalizeText(v)))
const selectedIlce = new Set((state.filters.ilce || []).map(v => normalizeText(v)))
const matchMulti = (selectedSet, value) => {
if (!selectedSet.size) return true
const normalized = normalizeText(value)
if (!normalized) return true
return selectedSet.has(normalized)
}
return state.rows.filter((row) => { return state.rows.filter((row) => {
const bak12 = Number(row.bakiye_1_2) || 0
const bak13 = Number(row.bakiye_1_3) || 0
const usd12 = Number(row.usd_bakiye_1_2) || 0
const try12 = Number(row.tl_bakiye_1_2) || 0
const usd13 = Number(row.usd_bakiye_1_3) || 0
const try13 = Number(row.tl_bakiye_1_3) || 0
const cariSearchNeedle = normalizeText(state.filters.cariSearch || '')
const cariIlkGrupOk = const cariIlkGrupOk =
!state.filters.cariIlkGrup.length || matchMulti(selectedCariIlkGrup, row.cari_ilk_grup)
state.filters.cariIlkGrup.includes(row.cari_ilk_grup)
const piyasaOk = const piyasaOk =
!state.filters.piyasa.length || matchMulti(selectedPiyasa, row.piyasa)
state.filters.piyasa.includes(row.piyasa)
const temsilciOk = const temsilciOk =
!state.filters.temsilci.length || matchMulti(selectedTemsilci, row.temsilci)
state.filters.temsilci.includes(row.temsilci)
const riskDurumuOk = const riskDurumuOk =
!state.filters.riskDurumu.length || matchMulti(selectedRiskDurumu, row.risk_durumu)
state.filters.riskDurumu.includes(row.ozellik03)
const cariText = normalizeText([ const cariText = normalizeText([
row.ana_cari_kodu || '', row.ana_cari_kodu || '',
@@ -52,25 +76,22 @@ export const useCustomerBalanceListStore = defineStore('customerBalanceList', {
row.cari_kodu || '', row.cari_kodu || '',
row.cari_detay || '' row.cari_detay || ''
].join(' ')) ].join(' '))
const cariSearchNeedle = normalizeText(state.filters.appliedCariSearch || '')
const cariSearchOk = const cariSearchOk =
!cariSearchNeedle || !cariSearchNeedle ||
cariText.includes(cariSearchNeedle) cariText.includes(cariSearchNeedle)
const ulkeOk = const ulkeOk =
!state.filters.ulke.length || matchMulti(selectedUlke, row.ozellik05)
state.filters.ulke.includes(row.ozellik05)
const ilOk =
matchMulti(selectedIl, row.il)
const ilceOk =
matchMulti(selectedIlce, row.ilce)
const islemTipiOk = const islemTipiOk =
!state.filters.islemTipi.length || !state.filters.islemTipi.length ||
state.filters.islemTipi.some((t) => { state.filters.islemTipi.some((t) => {
const bak12 = Number(row.bakiye_1_2) || 0
const bak13 = Number(row.bakiye_1_3) || 0
const usd12 = Number(row.usd_bakiye_1_2) || 0
const try12 = Number(row.tl_bakiye_1_2) || 0
const usd13 = Number(row.usd_bakiye_1_3) || 0
const try13 = Number(row.tl_bakiye_1_3) || 0
if (t === 'prbr_1_2') return bak12 !== 0 if (t === 'prbr_1_2') return bak12 !== 0
if (t === 'prbr_1_3') return bak13 !== 0 if (t === 'prbr_1_3') return bak13 !== 0
if (t === 'usd_1_2') return usd12 !== 0 if (t === 'usd_1_2') return usd12 !== 0
@@ -80,7 +101,12 @@ export const useCustomerBalanceListStore = defineStore('customerBalanceList', {
return false return false
}) })
return cariIlkGrupOk && piyasaOk && temsilciOk && riskDurumuOk && cariSearchOk && ulkeOk && islemTipiOk const excludeZero12Ok = !state.filters.excludeZeroBalance12 || bak12 !== 0
const excludeZero13Ok = !state.filters.excludeZeroBalance13 || bak13 !== 0
return cariIlkGrupOk && piyasaOk && temsilciOk && riskDurumuOk &&
cariSearchOk && ulkeOk && ilOk && ilceOk && islemTipiOk &&
excludeZero12Ok && excludeZero13Ok
}) })
}, },
@@ -88,10 +114,10 @@ export const useCustomerBalanceListStore = defineStore('customerBalanceList', {
const grouped = new Map() const grouped = new Map()
for (const row of this.filteredRows) { for (const row of this.filteredRows) {
const key = `${row.ana_cari_kodu || ''}||${row.ana_cari_adi || ''}` const key = String(row.ana_cari_kodu || '').trim()
const current = grouped.get(key) || { const current = grouped.get(key) || {
group_key: key, group_key: key,
ana_cari_kodu: row.ana_cari_kodu || '', ana_cari_kodu: key,
ana_cari_adi: row.ana_cari_adi || '', ana_cari_adi: row.ana_cari_adi || '',
piyasa: '', piyasa: '',
piyasa_set: new Set(), piyasa_set: new Set(),
@@ -104,8 +130,7 @@ export const useCustomerBalanceListStore = defineStore('customerBalanceList', {
usd_bakiye_1_2: 0, usd_bakiye_1_2: 0,
tl_bakiye_1_2: 0, tl_bakiye_1_2: 0,
usd_bakiye_1_3: 0, usd_bakiye_1_3: 0,
tl_bakiye_1_3: 0, tl_bakiye_1_3: 0
kalan_fatura_ortalama_vade_tarihi: ''
} }
current.usd_bakiye_1_2 += Number(row.usd_bakiye_1_2) || 0 current.usd_bakiye_1_2 += Number(row.usd_bakiye_1_2) || 0
@@ -113,6 +138,10 @@ export const useCustomerBalanceListStore = defineStore('customerBalanceList', {
current.usd_bakiye_1_3 += Number(row.usd_bakiye_1_3) || 0 current.usd_bakiye_1_3 += Number(row.usd_bakiye_1_3) || 0
current.tl_bakiye_1_3 += Number(row.tl_bakiye_1_3) || 0 current.tl_bakiye_1_3 += Number(row.tl_bakiye_1_3) || 0
if (!String(current.ana_cari_adi || '').trim() && String(row.ana_cari_adi || '').trim()) {
current.ana_cari_adi = row.ana_cari_adi
}
const curr = String(row.cari_doviz || '').trim().toUpperCase() || 'N/A' const curr = String(row.cari_doviz || '').trim().toUpperCase() || 'N/A'
current.bakiye_1_2_map[curr] = current.bakiye_1_2_map[curr] =
(Number(current.bakiye_1_2_map[curr]) || 0) + (Number(row.bakiye_1_2) || 0) (Number(current.bakiye_1_2_map[curr]) || 0) + (Number(row.bakiye_1_2) || 0)
@@ -125,14 +154,7 @@ export const useCustomerBalanceListStore = defineStore('customerBalanceList', {
const temsilci = String(row.temsilci || '').trim() const temsilci = String(row.temsilci || '').trim()
if (temsilci) current.temsilci_set.add(temsilci) if (temsilci) current.temsilci_set.add(temsilci)
if ( const risk = String(row.risk_durumu || row.ozellik03 || '').trim()
!current.kalan_fatura_ortalama_vade_tarihi &&
row.kalan_fatura_ortalama_vade_tarihi
) {
current.kalan_fatura_ortalama_vade_tarihi = row.kalan_fatura_ortalama_vade_tarihi
}
const risk = String(row.ozellik03 || '').trim()
if (risk) current.risk_set.add(risk) if (risk) current.risk_set.add(risk)
const riskValues = Array.from(current.risk_set) const riskValues = Array.from(current.risk_set)
@@ -172,7 +194,7 @@ export const useCustomerBalanceListStore = defineStore('customerBalanceList', {
const { data } = await api.get('/finance/customer-balances', { const { data } = await api.get('/finance/customer-balances', {
params: { params: {
selected_date: this.filters.selectedDate, selected_date: this.filters.selectedDate,
cari_search: String(this.filters.appliedCariSearch || this.filters.cariSearch || '').trim() cari_search: String(this.filters.cariSearch || '').trim()
} }
}) })
this.rows = Array.isArray(data) ? data : [] this.rows = Array.isArray(data) ? data : []
@@ -195,26 +217,25 @@ export const useCustomerBalanceListStore = defineStore('customerBalanceList', {
getDetailsByGroup (groupKey) { getDetailsByGroup (groupKey) {
return this.filteredRows.filter(r => return this.filteredRows.filter(r =>
`${r.ana_cari_kodu || ''}||${r.ana_cari_adi || ''}` === groupKey String(r.ana_cari_kodu || '').trim() === String(groupKey || '').trim()
) )
}, },
resetFilters () { resetFilters () {
this.filters.excludeZeroBalance12 = false
this.filters.excludeZeroBalance13 = false
this.filters.cariSearch = '' this.filters.cariSearch = ''
this.filters.appliedCariSearch = ''
this.filters.cariIlkGrup = [] this.filters.cariIlkGrup = []
this.filters.piyasa = [] this.filters.piyasa = []
this.filters.temsilci = [] this.filters.temsilci = []
this.filters.riskDurumu = [] this.filters.riskDurumu = []
this.filters.islemTipi = [] this.filters.islemTipi = []
this.filters.ulke = [] this.filters.ulke = []
this.filters.il = []
this.filters.ilce = []
this.defaultsInitialized = false this.defaultsInitialized = false
}, },
applyCariSearch () {
this.filters.appliedCariSearch = String(this.filters.cariSearch || '').trim()
},
selectAll (field, options) { selectAll (field, options) {
this.filters[field] = options.map(o => o.value) this.filters[field] = options.map(o => o.value)
}, },
@@ -224,10 +245,14 @@ export const useCustomerBalanceListStore = defineStore('customerBalanceList', {
}, },
applyInitialFilterDefaults () { applyInitialFilterDefaults () {
const transferKey = normalizeText('transfer') const excludedCariIlkGrup = new Set([
normalizeText('transfer'),
normalizeText('perakende'),
normalizeText('dtf')
])
this.filters.cariIlkGrup = this.cariIlkGrupOptions this.filters.cariIlkGrup = this.cariIlkGrupOptions
.map(o => o.value) .map(o => o.value)
.filter(v => normalizeText(v) !== transferKey) .filter(v => !excludedCariIlkGrup.has(normalizeText(v)))
const excludedRisk = new Set([ const excludedRisk = new Set([
normalizeText('avukat'), normalizeText('avukat'),
@@ -260,3 +285,4 @@ function normalizeText (str) {
.replace(/[\u0300-\u036f]/g, '') .replace(/[\u0300-\u036f]/g, '')
.trim() .trim()
} }

View File

@@ -0,0 +1,222 @@
import { defineStore } from 'pinia'
import api from 'src/services/api'
import qs from 'qs'
export const useStatementAgingStore = defineStore('statementAging', {
state: () => ({
rows: [],
masterRows: [],
currencyRowsByMaster: {},
detailByCurrency: {},
loading: false
}),
actions: {
async load(params = {}) {
this.loading = true
try {
const { data } = await api.get('/finance/account-aging-statement', {
params,
paramsSerializer: p => qs.stringify(p, { arrayFormat: 'repeat' })
})
const base = Array.isArray(data) ? data.map(normalizeRowKeys) : []
this.rows = base.map((r, idx) => ({
...r,
detail_key: `${idx}-${r.cari8 || ''}-${r.doc_currency_code || ''}-${r.fatura_ref || ''}-${r.odeme_ref || ''}`
}))
this.rebuildHierarchical()
} catch (err) {
console.error('Aging statement load failed:', err)
this.reset()
throw err
} finally {
this.loading = false
}
},
rebuildHierarchical() {
const masterMap = {}
const currencyMap = {}
const detailMap = {}
for (const row of this.rows) {
const cari8 = String(row?.cari8 || '').trim()
const curr = String(row?.doc_currency_code || '').trim().toUpperCase() || 'N/A'
if (!cari8) continue
const masterKey = cari8
const currencyKey = `${cari8}|${curr}`
const tutar = Number(row?.eslesen_tutar) || 0
const usd = Number(row?.usd_tutar) || 0
const trY = Number(row?.try_tutar) || 0
const absTry = Math.abs(trY)
const gun = Number(row?.gun_sayisi) || 0
const gunDoc = Number(row?.gun_sayisi_docdate) || 0
const aciklama = String(row?.aciklama || '').toUpperCase()
const isAcik = aciklama === 'ACIKKALEM'
if (!masterMap[masterKey]) {
masterMap[masterKey] = {
group_key: masterKey,
cari8: masterKey,
cari_detay: String(row?.cari_detay || '').trim(),
acik_kalem_tutari_usd: 0,
acik_kalem_tutari_try: 0,
acik_kalem_ort_vade_gun: 0,
acik_kalem_ort_belge_gun: 0,
normal_usd_tutar: 0,
normal_try_tutar: 0,
ortalama_vade_gun: 0,
ortalama_belge_gun: 0,
weighted_all_base: 0,
weighted_all_gun_sum: 0,
weighted_all_doc_sum: 0,
weighted_open_base: 0,
weighted_open_gun_sum: 0,
weighted_open_doc_sum: 0,
}
}
if (!currencyMap[currencyKey]) {
currencyMap[currencyKey] = {
group_key: currencyKey,
master_key: masterKey,
cari8,
cari_detay: String(row?.cari_detay || '').trim(),
doviz_cinsi: curr,
acik_kalem_tutari: 0,
acik_kalem_usd: 0,
acik_kalem_try: 0,
ort_gun: 0,
ort_belge_gun: 0,
weighted_open_base: 0,
weighted_open_gun_sum: 0,
weighted_open_doc_sum: 0
}
}
const m = masterMap[masterKey]
const c = currencyMap[currencyKey]
if (isAcik) {
m.acik_kalem_tutari_usd += usd
m.acik_kalem_tutari_try += trY
c.acik_kalem_tutari += tutar
c.acik_kalem_usd += usd
c.acik_kalem_try += trY
} else {
m.normal_usd_tutar += usd
m.normal_try_tutar += trY
}
if (absTry > 0) {
m.weighted_all_base += absTry
m.weighted_all_gun_sum += absTry * gun
m.weighted_all_doc_sum += absTry * gunDoc
if (isAcik) {
m.weighted_open_base += absTry
m.weighted_open_gun_sum += absTry * gun
m.weighted_open_doc_sum += absTry * gunDoc
c.weighted_open_base += absTry
c.weighted_open_gun_sum += absTry * gun
c.weighted_open_doc_sum += absTry * gunDoc
}
}
if (!detailMap[currencyKey]) detailMap[currencyKey] = []
detailMap[currencyKey].push(row)
}
this.masterRows = Object.values(masterMap)
.map((m) => ({
...m,
acik_kalem_ort_vade_gun: m.weighted_open_base > 0 ? ceilDay(m.weighted_open_gun_sum / m.weighted_open_base) : 0,
acik_kalem_ort_belge_gun: m.weighted_open_base > 0 ? ceilDay(m.weighted_open_doc_sum / m.weighted_open_base) : 0,
ortalama_vade_gun: m.weighted_all_base > 0 ? ceilDay(m.weighted_all_gun_sum / m.weighted_all_base) : 0,
ortalama_belge_gun: m.weighted_all_base > 0 ? ceilDay(m.weighted_all_doc_sum / m.weighted_all_base) : 0
}))
.sort((a, b) => String(a.cari8).localeCompare(String(b.cari8), 'tr', { sensitivity: 'base' }))
const currencyByMaster = {}
for (const c of Object.values(currencyMap)) {
const row = {
...c,
ort_gun: c.weighted_open_base > 0 ? ceilDay(c.weighted_open_gun_sum / c.weighted_open_base) : 0,
ort_belge_gun: c.weighted_open_base > 0 ? ceilDay(c.weighted_open_doc_sum / c.weighted_open_base) : 0
}
if (!currencyByMaster[row.master_key]) currencyByMaster[row.master_key] = []
currencyByMaster[row.master_key].push(row)
}
for (const key of Object.keys(currencyByMaster)) {
currencyByMaster[key].sort((a, b) => String(a.doviz_cinsi).localeCompare(String(b.doviz_cinsi), 'en', { sensitivity: 'base' }))
}
this.currencyRowsByMaster = currencyByMaster
for (const key of Object.keys(detailMap)) {
detailMap[key].sort((a, b) => {
const aEmpty = !a?.odeme_tarihi
const bEmpty = !b?.odeme_tarihi
if (aEmpty && !bEmpty) return -1
if (!aEmpty && bEmpty) return 1
if (aEmpty && bEmpty) return 0
const aTs = Date.parse(a.odeme_tarihi)
const bTs = Date.parse(b.odeme_tarihi)
const aNum = Number.isFinite(aTs) ? aTs : -Infinity
const bNum = Number.isFinite(bTs) ? bTs : -Infinity
return bNum - aNum
})
}
this.detailByCurrency = detailMap
},
reset() {
this.rows = []
this.masterRows = []
this.currencyRowsByMaster = {}
this.detailByCurrency = {}
this.loading = false
},
getCurrenciesByMaster(masterKey) {
return this.currencyRowsByMaster[String(masterKey || '').trim()] || []
},
getDetailsByCurrency(currencyKey) {
return this.detailByCurrency[String(currencyKey || '').trim()] || []
}
}
})
function normalizeRowKeys(row) {
if (!row || typeof row !== 'object') return {}
return {
cari8: row.Cari8 ?? row.cari8 ?? null,
cari_detay: row.CariDetay ?? row.cari_detay ?? null,
fatura_cari: row.FaturaCari ?? row.fatura_cari ?? null,
odeme_cari: row.OdemeCari ?? row.odeme_cari ?? null,
fatura_ref: row.FaturaRef ?? row.fatura_ref ?? null,
odeme_ref: row.OdemeRef ?? row.odeme_ref ?? null,
fatura_tarihi: row.FaturaTarihi ?? row.fatura_tarihi ?? null,
odeme_tarihi: row.OdemeTarihi ?? row.odeme_tarihi ?? null,
odeme_doc_date: row.OdemeDocDate ?? row.odeme_doc_date ?? null,
eslesen_tutar: Number(row.EslesenTutar ?? row.eslesen_tutar ?? 0),
usd_tutar: Number(row.UsdTutar ?? row.usd_tutar ?? 0),
try_tutar: Number(row.TryTutar ?? row.try_tutar ?? 0),
gun_sayisi: Number(row.GunSayisi ?? row.gun_sayisi ?? 0),
gun_sayisi_docdate: Number(row.GunSayisi_DocDate ?? row.gun_sayisi_docdate ?? 0),
gun_kur: Number(row.GunKur ?? row.gun_kur ?? 0),
aciklama: row.Aciklama ?? row.aciklama ?? null,
doc_currency_code: row.DocCurrencyCode ?? row.doc_currency_code ?? null
}
}
function ceilDay(value) {
const n = Number(value)
if (!Number.isFinite(n)) return 0
return Math.ceil(n)
}