ilk
This commit is contained in:
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
1
.idea/.name
generated
Normal file
1
.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
||||
backend
|
||||
4
.idea/backend.iml
generated
Normal file
4
.idea/backend.iml
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
</module>
|
||||
9
.idea/bssapp-backend.iml
generated
Normal file
9
.idea/bssapp-backend.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
11
.idea/go.imports.xml
generated
Normal file
11
.idea/go.imports.xml
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GoImports">
|
||||
<option name="excludedPackages">
|
||||
<array>
|
||||
<option value="github.com/pkg/errors" />
|
||||
<option value="golang.org/x/net/context" />
|
||||
</array>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="GoCyclicImports" enabled="false" level="ERROR" enabled_by_default="false" />
|
||||
</profile>
|
||||
</component>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/bssapp-backend.iml" filepath="$PROJECT_DIR$/.idea/bssapp-backend.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
8
svc/.env
Normal file
8
svc/.env
Normal file
@@ -0,0 +1,8 @@
|
||||
JWT_SECRET=bssapp_super_secret_key_1234567890
|
||||
PASSWORD_RESET_SECRET=1dc7d6d52fd0459a8b1f288a6590428e760f54339f8e47beb20db36b6df6070b
|
||||
APP_FRONTEND_URL=http://localhost:9000
|
||||
API_URL=http://localhost:8080
|
||||
|
||||
|
||||
|
||||
|
||||
47
svc/auth/claims.go
Normal file
47
svc/auth/claims.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
// ==================================================
|
||||
// 🔑 IDENTITY
|
||||
// ==================================================
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
RoleID int64 `json:"role_id"`
|
||||
|
||||
RoleCode string `json:"role_code"`
|
||||
DepartmentCodes []string `json:"department_codes"`
|
||||
|
||||
// ==================================================
|
||||
// 🧾 NEBIM (frontend filtre & backend guard için)
|
||||
// ==================================================
|
||||
V3Username string `json:"v3_username"`
|
||||
V3UserGroup string `json:"v3_usergroup"`
|
||||
|
||||
// ==================================================
|
||||
// 🔐 SESSION
|
||||
// ==================================================
|
||||
SessionID string `json:"session_id"`
|
||||
|
||||
// ==================================================
|
||||
// ⚠️ SECURITY
|
||||
// ==================================================
|
||||
ForcePasswordChange bool `json:"force_password_change"`
|
||||
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func (c *Claims) IsAdmin() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
role := strings.ToLower(strings.TrimSpace(c.RoleCode))
|
||||
|
||||
return role == "admin"
|
||||
}
|
||||
36
svc/auth/claims_mapper.go
Normal file
36
svc/auth/claims_mapper.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"bssapp-backend/models"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func BuildClaimsFromUser(u *models.MkUser, ttl time.Duration) Claims {
|
||||
now := time.Now()
|
||||
|
||||
return Claims{
|
||||
// 🔴 mk_dfusr.id
|
||||
ID: u.ID,
|
||||
|
||||
Username: u.Username,
|
||||
RoleCode: u.RoleCode,
|
||||
RoleID: u.RoleID,
|
||||
// ✅ BURASI
|
||||
DepartmentCodes: u.DepartmentCodes,
|
||||
|
||||
SessionID: u.SessionID,
|
||||
|
||||
ForcePasswordChange: u.ForcePasswordChange,
|
||||
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: "bssapp",
|
||||
Subject: u.Username,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
},
|
||||
}
|
||||
}
|
||||
15
svc/auth/context.go
Normal file
15
svc/auth/context.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bssapp-backend/ctxkeys"
|
||||
"context"
|
||||
)
|
||||
|
||||
func WithClaims(ctx context.Context, claims *Claims) context.Context {
|
||||
return context.WithValue(ctx, ctxkeys.UserContextKey, claims)
|
||||
}
|
||||
|
||||
func GetClaimsFromContext(ctx context.Context) (*Claims, bool) {
|
||||
claims, ok := ctx.Value(ctxkeys.UserContextKey).(*Claims)
|
||||
return claims, ok
|
||||
}
|
||||
53
svc/auth/jwt.go
Normal file
53
svc/auth/jwt.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// package auth
|
||||
|
||||
func jwtSecret() ([]byte, error) {
|
||||
sec := os.Getenv("JWT_SECRET")
|
||||
if len(sec) < 10 {
|
||||
return nil, errors.New("JWT_SECRET environment boş veya çok kısa")
|
||||
}
|
||||
return []byte(sec), nil
|
||||
}
|
||||
|
||||
// ✅ TEK VE DOĞRU TOKEN ÜRETİCİ
|
||||
func GenerateToken(claims Claims, username string, change bool) (string, error) {
|
||||
secret, err := jwtSecret()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(secret)
|
||||
}
|
||||
|
||||
func ValidateToken(tokenStr string) (*Claims, error) {
|
||||
secret, err := jwtSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(
|
||||
tokenStr,
|
||||
&Claims{},
|
||||
func(token *jwt.Token) (interface{}, error) {
|
||||
return secret, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("token geçersiz")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
44
svc/auth/logout.go
Normal file
44
svc/auth/logout.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bssapp-backend/internal/auditlog"
|
||||
"bssapp-backend/repository"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func LogoutAllHandler(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
claims, ok := GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID := claims.ID
|
||||
|
||||
_ = repository.NewRefreshTokenRepository(db).
|
||||
RevokeAllForUser(userID)
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "mk_refresh",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
auditlog.Write(auditlog.ActivityLog{
|
||||
UserID: auditlog.IntUserIDToUUID(int(userID)),
|
||||
ActionType: "logout_all",
|
||||
ActionCategory: "auth",
|
||||
Description: "user logged out from all devices",
|
||||
IsSuccess: true,
|
||||
})
|
||||
|
||||
_ = json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
||||
}
|
||||
}
|
||||
1
svc/config/config.go
Normal file
1
svc/config/config.go
Normal file
@@ -0,0 +1 @@
|
||||
package config
|
||||
5
svc/ctxkeys/keys.go
Normal file
5
svc/ctxkeys/keys.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package ctxkeys
|
||||
|
||||
type ContextKey string
|
||||
|
||||
const UserContextKey ContextKey = "jwt_claims"
|
||||
31
svc/db/mssql.go
Normal file
31
svc/db/mssql.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
_ "github.com/microsoft/go-mssqldb"
|
||||
)
|
||||
|
||||
var MssqlDB *sql.DB
|
||||
|
||||
func ConnectMSSQL() {
|
||||
connString := "sqlserver://sa:Gil_0150@10.0.0.9:1433?databaseName=BAGGI_V3"
|
||||
|
||||
var err error
|
||||
MssqlDB, err = sql.Open("sqlserver", connString)
|
||||
if err != nil {
|
||||
log.Fatal("MSSQL bağlantı hatası:", err)
|
||||
}
|
||||
|
||||
err = MssqlDB.Ping()
|
||||
if err != nil {
|
||||
log.Fatal("MSSQL erişilemiyor:", err)
|
||||
}
|
||||
|
||||
fmt.Println("✅ MSSQL bağlantısı başarılı!")
|
||||
}
|
||||
func GetDB() *sql.DB {
|
||||
return MssqlDB
|
||||
}
|
||||
68
svc/db/postgres.go
Normal file
68
svc/db/postgres.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
var PgDB *sql.DB
|
||||
|
||||
// ConnectPostgres → PostgreSQL veritabanına bağlanır
|
||||
func ConnectPostgres() (*sql.DB, error) {
|
||||
// Bağlantı stringi (istersen .env’den oku)
|
||||
connStr := os.Getenv("POSTGRES_CONN")
|
||||
if connStr == "" {
|
||||
// fallback → sabit tanımlı bağlantı
|
||||
connStr = "host=172.16.0.3 port=5432 user=postgres password=tayitkan dbname=baggib2b sslmode=disable"
|
||||
}
|
||||
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PostgreSQL bağlantı hatası: %w", err)
|
||||
}
|
||||
|
||||
// =======================================================
|
||||
// 🔹 BAĞLANTI HAVUZU (AUDIT LOG UYUMLU)
|
||||
// =======================================================
|
||||
db.SetMaxOpenConns(30) // audit + api paralel çalışsın
|
||||
db.SetMaxIdleConns(10)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
db.SetConnMaxIdleTime(5 * time.Minute) // 🔥 uzun idle audit bağlantılarını kapat
|
||||
|
||||
// 🔹 Test et
|
||||
if err = db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("PostgreSQL erişilemiyor: %w", err)
|
||||
}
|
||||
|
||||
log.Println("✅ PostgreSQL bağlantısı başarılı!")
|
||||
PgDB = db
|
||||
return db, nil
|
||||
|
||||
}
|
||||
|
||||
// GetPostgresUsers → test amaçlı ilk 5 kullanıcıyı listeler
|
||||
func GetPostgresUsers(db *sql.DB) error {
|
||||
query := `SELECT id, code, email FROM mk_dfusr ORDER BY id LIMIT 5`
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("PostgreSQL sorgu hatası: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fmt.Println("📋 İlk 5 PostgreSQL kullanıcısı:")
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var code, email string
|
||||
if err := rows.Scan(&id, &code, &email); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" ➜ ID: %-4d | USER: %-20s | EMAIL: %s\n", id, code, email)
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
}
|
||||
0
svc/deneme
Normal file
0
svc/deneme
Normal file
BIN
svc/fonts/DejaVuSans-Bold.ttf
Normal file
BIN
svc/fonts/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
svc/fonts/DejaVuSans.ttf
Normal file
BIN
svc/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
0
svc/fonts/FreeSans.ttf
Normal file
0
svc/fonts/FreeSans.ttf
Normal file
0
svc/fonts/FreeSansBold.ttf
Normal file
0
svc/fonts/FreeSansBold.ttf
Normal file
30
svc/go.mod
Normal file
30
svc/go.mod
Normal file
@@ -0,0 +1,30 @@
|
||||
module bssapp-backend
|
||||
|
||||
go 1.24.0 // senin Go versiyonuna göre değişir
|
||||
|
||||
toolchain go1.24.5
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/jung-kurt/gofpdf v1.16.2
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/microsoft/go-mssqldb v1.9.3
|
||||
github.com/xuri/excelize/v2 v2.10.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
)
|
||||
76
svc/go.sum
Normal file
76
svc/go.sum
Normal file
@@ -0,0 +1,76 @@
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
||||
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
|
||||
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/microsoft/go-mssqldb v1.9.3 h1:hy4p+LDC8LIGvI3JATnLVmBOLMJbmn5X400mr5j0lPs=
|
||||
github.com/microsoft/go-mssqldb v1.9.3/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
|
||||
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
|
||||
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
|
||||
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
35
svc/internal/auditlog/events.go
Normal file
35
svc/internal/auditlog/events.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
func ForcePasswordChangeStarted(
|
||||
ctx context.Context,
|
||||
targetUserID int64,
|
||||
reason string, // admin_reset | login_enforced
|
||||
) {
|
||||
Write(ActivityLog{
|
||||
UserID: IntUserIDToUUID(int(targetUserID)),
|
||||
ActionType: "force_password_change_started",
|
||||
ActionCategory: "auth",
|
||||
Description: "kullanıcı için zorunlu parola değişimi başlatıldı",
|
||||
IsSuccess: true,
|
||||
ErrorMessage: reason,
|
||||
})
|
||||
}
|
||||
|
||||
func ForcePasswordChangeCompleted(
|
||||
ctx context.Context,
|
||||
userID int64,
|
||||
source string, // reset_link | self_change | admin_reset
|
||||
) {
|
||||
Write(ActivityLog{
|
||||
UserID: IntUserIDToUUID(int(userID)),
|
||||
ActionType: "force_password_change_completed",
|
||||
ActionCategory: "auth",
|
||||
Description: "kullanıcı parolasını başarıyla güncelledi",
|
||||
IsSuccess: true,
|
||||
ErrorMessage: source,
|
||||
})
|
||||
}
|
||||
73
svc/internal/auditlog/helpers.go
Normal file
73
svc/internal/auditlog/helpers.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
//
|
||||
// =======================================================
|
||||
// 🕵️ AUDIT LOG — HELPER FUNCTIONS
|
||||
// =======================================================
|
||||
// Bu dosya:
|
||||
// - UUID bekleyen kolonlar için int → uuid dönüşümü
|
||||
// - NULL-safe insert yardımcıları
|
||||
// içerir
|
||||
//
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 🔹 IntUserIDToUUID
|
||||
// -------------------------------------------------------
|
||||
// int user_id → deterministic UUID
|
||||
// PostgreSQL uuid kolonu ile %100 uyumlu
|
||||
//
|
||||
// Aynı userID → her zaman aynı UUID
|
||||
func IntUserIDToUUID(userID int) string {
|
||||
if userID <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
sum := md5.Sum([]byte(fmt.Sprintf("bssapp-user-%d", userID)))
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%x-%x-%x-%x-%x",
|
||||
sum[0:4],
|
||||
sum[4:6],
|
||||
sum[6:8],
|
||||
sum[8:10],
|
||||
sum[10:16],
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 🔹 nullIfZeroTime
|
||||
// -------------------------------------------------------
|
||||
// Zero time → NULL (SQL uyumlu)
|
||||
func nullIfZeroTime(t time.Time) interface{} {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 🔹 nullIfZeroInt
|
||||
// -------------------------------------------------------
|
||||
// 0 → NULL (SQL uyumlu)
|
||||
func nullIfZeroInt(v int) interface{} {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func Int64UserIDToUUID(id int64) string {
|
||||
return IntUserIDToUUID(int(id))
|
||||
}
|
||||
func nullIfEmpty(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
30
svc/internal/auditlog/init.go
Normal file
30
svc/internal/auditlog/init.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
logQueue chan ActivityLog
|
||||
dbConn *sql.DB
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// Init → main.go içinden çağrılacak (tek sefer)
|
||||
func Init(db *sql.DB, bufferSize int) {
|
||||
log.Println("🟢 auditlog Init called, buffer:", bufferSize)
|
||||
|
||||
dbConn = db
|
||||
logQueue = make(chan ActivityLog, bufferSize)
|
||||
|
||||
go logWorker()
|
||||
}
|
||||
|
||||
// Optional: app kapanırken flush/stop istersen
|
||||
func Close() {
|
||||
if logQueue != nil {
|
||||
close(logQueue)
|
||||
}
|
||||
}
|
||||
37
svc/internal/auditlog/model.go
Normal file
37
svc/internal/auditlog/model.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package auditlog
|
||||
|
||||
import "time"
|
||||
|
||||
type ActivityLog struct {
|
||||
// identity
|
||||
UserID string // UUID (auth)
|
||||
DfUsrID int64 // DF user id (mk_dfusr.id)
|
||||
|
||||
Username string
|
||||
RoleCode string
|
||||
|
||||
// action
|
||||
ActionType string
|
||||
ActionCategory string
|
||||
ActionTarget string
|
||||
Description string
|
||||
|
||||
// tech
|
||||
IpAddress string
|
||||
UserAgent string
|
||||
SessionID string
|
||||
|
||||
// timing
|
||||
RequestStartedAt time.Time
|
||||
RequestFinishedAt time.Time
|
||||
DurationMs int
|
||||
HttpStatus int
|
||||
|
||||
// result
|
||||
IsSuccess bool
|
||||
ErrorMessage string
|
||||
TargetDfUsrID int64
|
||||
TargetUsername string
|
||||
ChangeBefore any // map[string]any
|
||||
ChangeAfter any
|
||||
}
|
||||
141
svc/internal/auditlog/worker.go
Normal file
141
svc/internal/auditlog/worker.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
)
|
||||
|
||||
func toJSONB(v any) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
// JSON marshal hata olursa log’u bozmayalım
|
||||
log.Println("⚠️ auditlog json marshal error:", err)
|
||||
return nil
|
||||
}
|
||||
return b // pq jsonb için []byte kabul eder
|
||||
}
|
||||
|
||||
func logWorker() {
|
||||
log.Println("🟢 auditlog worker STARTED")
|
||||
|
||||
for entry := range logQueue {
|
||||
|
||||
// ---------- DFUSR_ID ----------
|
||||
var dfusrID any
|
||||
if entry.DfUsrID > 0 {
|
||||
dfusrID = entry.DfUsrID
|
||||
} else {
|
||||
dfusrID = nil
|
||||
}
|
||||
|
||||
// ---------- USERNAME ----------
|
||||
var username any
|
||||
if entry.Username != "" {
|
||||
username = entry.Username
|
||||
} else {
|
||||
username = nil
|
||||
}
|
||||
|
||||
// ---------- ROLE CODE (SNAPSHOT) ----------
|
||||
roleCode := entry.RoleCode
|
||||
if roleCode == "" {
|
||||
roleCode = "public"
|
||||
}
|
||||
|
||||
// ---------- TARGET ----------
|
||||
var targetDfUsrID any
|
||||
if entry.TargetDfUsrID > 0 {
|
||||
targetDfUsrID = entry.TargetDfUsrID
|
||||
} else {
|
||||
targetDfUsrID = nil
|
||||
}
|
||||
targetUsername := nullIfEmpty(entry.TargetUsername)
|
||||
|
||||
log.Printf(
|
||||
"🧾 auditlog INSERT | actor_dfusr=%v actor_user=%v role=%s %s %s target=%v",
|
||||
dfusrID,
|
||||
username,
|
||||
roleCode,
|
||||
entry.ActionCategory,
|
||||
entry.ActionTarget,
|
||||
targetDfUsrID,
|
||||
)
|
||||
|
||||
_, err := dbConn.Exec(`
|
||||
INSERT INTO mk_user_activity_log (
|
||||
log_id,
|
||||
dfusr_id,
|
||||
username,
|
||||
role_code,
|
||||
|
||||
action_type,
|
||||
action_category,
|
||||
action_target,
|
||||
description,
|
||||
|
||||
ip_address,
|
||||
user_agent,
|
||||
session_id,
|
||||
|
||||
request_started_at,
|
||||
request_finished_at,
|
||||
duration_ms,
|
||||
http_status,
|
||||
|
||||
is_success,
|
||||
error_message,
|
||||
|
||||
-- ✅ NEW
|
||||
target_dfusr_id,
|
||||
target_username,
|
||||
change_before,
|
||||
change_after,
|
||||
|
||||
created_at
|
||||
) VALUES (
|
||||
gen_random_uuid(),
|
||||
$1,$2,$3,
|
||||
$4,$5,$6,$7,
|
||||
$8,$9,$10,
|
||||
$11,$12,$13,$14,
|
||||
$15,$16,
|
||||
$17,$18,$19,$20,
|
||||
now()
|
||||
)
|
||||
`,
|
||||
dfusrID,
|
||||
username,
|
||||
roleCode,
|
||||
|
||||
entry.ActionType,
|
||||
entry.ActionCategory,
|
||||
entry.ActionTarget,
|
||||
entry.Description,
|
||||
|
||||
entry.IpAddress,
|
||||
entry.UserAgent,
|
||||
entry.SessionID,
|
||||
|
||||
nullIfZeroTime(entry.RequestStartedAt),
|
||||
nullIfZeroTime(entry.RequestFinishedAt),
|
||||
nullIfZeroInt(entry.DurationMs),
|
||||
nullIfZeroInt(entry.HttpStatus),
|
||||
|
||||
entry.IsSuccess,
|
||||
entry.ErrorMessage,
|
||||
|
||||
// ✅ NEW
|
||||
targetDfUsrID,
|
||||
targetUsername,
|
||||
toJSONB(entry.ChangeBefore),
|
||||
toJSONB(entry.ChangeAfter),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Println("❌ auditlog insert error:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
25
svc/internal/auditlog/writer.go
Normal file
25
svc/internal/auditlog/writer.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package auditlog
|
||||
|
||||
import "context"
|
||||
|
||||
func Write(log ActivityLog) {
|
||||
if logQueue == nil {
|
||||
return // sistem henüz init edilmediyse sessizce çık
|
||||
}
|
||||
|
||||
select {
|
||||
case logQueue <- log:
|
||||
// kuyruğa alındı
|
||||
default:
|
||||
// kuyruk dolu → drop edilir, ana akış bozulmaz
|
||||
}
|
||||
}
|
||||
func Enqueue(ctx context.Context, al ActivityLog) {
|
||||
|
||||
select {
|
||||
case logQueue <- al:
|
||||
// ok
|
||||
default:
|
||||
// queue dolu → drop
|
||||
}
|
||||
}
|
||||
36
svc/internal/authz/context.go
Normal file
36
svc/internal/authz/context.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package authz
|
||||
|
||||
import "context"
|
||||
|
||||
type scopeKey string
|
||||
|
||||
const (
|
||||
CtxDeptCodesKey scopeKey = "authz.dept_codes"
|
||||
CtxPiyasaCodesKey scopeKey = "authz.piyasa_codes"
|
||||
)
|
||||
|
||||
func WithDeptCodes(ctx context.Context, codes []string) context.Context {
|
||||
return context.WithValue(ctx, CtxDeptCodesKey, codes)
|
||||
}
|
||||
|
||||
func WithPiyasaCodes(ctx context.Context, codes []string) context.Context {
|
||||
return context.WithValue(ctx, CtxPiyasaCodesKey, codes)
|
||||
}
|
||||
|
||||
func GetDeptCodesFromCtx(ctx context.Context) []string {
|
||||
if v := ctx.Value(CtxDeptCodesKey); v != nil {
|
||||
if codes, ok := v.([]string); ok {
|
||||
return codes
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetPiyasaCodesFromCtx(ctx context.Context) []string {
|
||||
if v := ctx.Value(CtxPiyasaCodesKey); v != nil {
|
||||
if codes, ok := v.([]string); ok {
|
||||
return codes
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
32
svc/internal/authz/mssql.go
Normal file
32
svc/internal/authz/mssql.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func BuildMSSQLPiyasaFilter(
|
||||
ctx context.Context,
|
||||
column string,
|
||||
) string {
|
||||
|
||||
codes := GetPiyasaCodesFromCtx(ctx)
|
||||
|
||||
if len(codes) == 0 {
|
||||
return "1=1"
|
||||
|
||||
}
|
||||
|
||||
var quoted []string
|
||||
|
||||
for _, c := range codes {
|
||||
quoted = append(quoted, "'"+c+"'")
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%s IN (%s)",
|
||||
column,
|
||||
strings.Join(quoted, ","),
|
||||
)
|
||||
}
|
||||
24
svc/internal/authz/mssql_helpers.go
Normal file
24
svc/internal/authz/mssql_helpers.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func BuildINClause(column string, codes []string) string {
|
||||
if len(codes) == 0 {
|
||||
return "1=0"
|
||||
}
|
||||
var quoted []string
|
||||
for _, c := range codes {
|
||||
c = strings.TrimSpace(strings.ToUpper(c))
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
quoted = append(quoted, "'"+c+"'")
|
||||
}
|
||||
if len(quoted) == 0 {
|
||||
return "1=0"
|
||||
}
|
||||
return fmt.Sprintf("%s IN (%s)", column, strings.Join(quoted, ","))
|
||||
}
|
||||
74
svc/internal/authz/piyasa_repo.go
Normal file
74
svc/internal/authz/piyasa_repo.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// =====================================================
|
||||
// 🧠 PIYASA CACHE (USER → CODES)
|
||||
// =====================================================
|
||||
|
||||
var (
|
||||
piyasaCache = make(map[int][]string)
|
||||
piyasaMu sync.RWMutex
|
||||
)
|
||||
|
||||
// =====================================================
|
||||
// 📌 GET USER PIYASA CODES (CACHED)
|
||||
// =====================================================
|
||||
|
||||
func GetUserPiyasaCodes(pg *sql.DB, userID int) ([]string, error) {
|
||||
|
||||
// -----------------------------
|
||||
// CACHE READ
|
||||
// -----------------------------
|
||||
piyasaMu.RLock()
|
||||
if it, ok := piyasaCache[userID]; ok {
|
||||
piyasaMu.RUnlock()
|
||||
return it, nil
|
||||
}
|
||||
piyasaMu.RUnlock()
|
||||
|
||||
// -----------------------------
|
||||
// DB QUERY
|
||||
// -----------------------------
|
||||
rows, err := pg.Query(`
|
||||
SELECT piyasa_code
|
||||
FROM dfusr_piyasa
|
||||
WHERE dfusr_id = $1
|
||||
AND is_allowed = true
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pg piyasa query error: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var code string
|
||||
if err := rows.Scan(&code); err == nil {
|
||||
out = append(out, code)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// CACHE WRITE
|
||||
// -----------------------------
|
||||
piyasaMu.Lock()
|
||||
piyasaCache[userID] = out
|
||||
piyasaMu.Unlock()
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 🧹 CLEAR USER PIYASA CACHE
|
||||
// =====================================================
|
||||
|
||||
func ClearPiyasaCache(userID int) {
|
||||
piyasaMu.Lock()
|
||||
defer piyasaMu.Unlock()
|
||||
delete(piyasaCache, userID)
|
||||
}
|
||||
50
svc/internal/mailer/config.go
Normal file
50
svc/internal/mailer/config.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
StartTLS bool
|
||||
}
|
||||
|
||||
func ConfigFromEnv() (Config, error) {
|
||||
var cfg Config
|
||||
|
||||
cfg.Host = strings.TrimSpace(os.Getenv("SMTP_HOST"))
|
||||
cfg.Username = strings.TrimSpace(os.Getenv("SMTP_USERNAME"))
|
||||
cfg.Password = os.Getenv("SMTP_PASSWORD")
|
||||
cfg.From = strings.TrimSpace(os.Getenv("SMTP_FROM"))
|
||||
|
||||
portStr := strings.TrimSpace(os.Getenv("SMTP_PORT"))
|
||||
if portStr == "" {
|
||||
cfg.Port = 587
|
||||
} else {
|
||||
p, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Port = p
|
||||
}
|
||||
|
||||
startTLS := strings.TrimSpace(os.Getenv("SMTP_STARTTLS"))
|
||||
if startTLS == "" {
|
||||
cfg.StartTLS = true
|
||||
} else {
|
||||
cfg.StartTLS = strings.EqualFold(startTLS, "true") || startTLS == "1"
|
||||
}
|
||||
|
||||
// minimal validation
|
||||
if cfg.Host == "" || cfg.Username == "" || cfg.Password == "" || cfg.From == "" || cfg.Port <= 0 {
|
||||
return Config{}, ErrInvalidConfig
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
270
svc/internal/mailer/graph_mailer.go
Normal file
270
svc/internal/mailer/graph_mailer.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
)
|
||||
|
||||
type GraphMailer struct {
|
||||
httpClient *http.Client
|
||||
from string // noreply@baggi.com.tr
|
||||
replyTo string // opsiyonel
|
||||
}
|
||||
|
||||
func NewGraphMailer() (*GraphMailer, error) {
|
||||
tenantID := strings.TrimSpace(os.Getenv("AZURE_TENANT_ID"))
|
||||
clientID := strings.TrimSpace(os.Getenv("AZURE_CLIENT_ID"))
|
||||
clientSecret := strings.TrimSpace(os.Getenv("AZURE_CLIENT_SECRET"))
|
||||
from := strings.TrimSpace(os.Getenv("MAIL_FROM"))
|
||||
replyTo := strings.TrimSpace(os.Getenv("MAIL_REPLY_TO")) // opsiyonel
|
||||
|
||||
if tenantID == "" || clientID == "" || clientSecret == "" || from == "" {
|
||||
return nil, fmt.Errorf("azure graph mailer env missing (AZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRET/MAIL_FROM)")
|
||||
}
|
||||
|
||||
conf := clientcredentials.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
TokenURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenantID),
|
||||
Scopes: []string{"https://graph.microsoft.com/.default"},
|
||||
}
|
||||
|
||||
httpClient := conf.Client(context.Background())
|
||||
httpClient.Timeout = 25 * time.Second
|
||||
|
||||
log.Printf("✉️ Graph Mailer hazır (App-only token) | from=%s", from)
|
||||
|
||||
return &GraphMailer{
|
||||
httpClient: httpClient,
|
||||
from: from,
|
||||
replyTo: replyTo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *GraphMailer) Send(ctx context.Context, msg Message) error {
|
||||
start := time.Now()
|
||||
|
||||
// ---------- validate ----------
|
||||
cleanTo := make([]string, 0, len(msg.To))
|
||||
for _, t := range msg.To {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
cleanTo = append(cleanTo, t)
|
||||
}
|
||||
}
|
||||
if len(cleanTo) == 0 {
|
||||
return fmt.Errorf("recipient missing")
|
||||
}
|
||||
subject := strings.TrimSpace(msg.Subject)
|
||||
if subject == "" {
|
||||
return fmt.Errorf("subject missing")
|
||||
}
|
||||
|
||||
// internal-safe adjustments
|
||||
isInternal := allRecipientsInDomain(cleanTo, "baggi.com.tr")
|
||||
if isInternal && !strings.HasPrefix(subject, "[BSSApp]") {
|
||||
subject = "[BSSApp] " + subject
|
||||
}
|
||||
|
||||
html := strings.TrimSpace(msg.BodyHTML)
|
||||
text := strings.TrimSpace(msg.Body)
|
||||
|
||||
// Eğer sadece HTML geldiyse text üret (spam skorunu düşürür)
|
||||
if text == "" && html != "" {
|
||||
text = stripHTMLVerySimple(html)
|
||||
}
|
||||
// Eğer sadece text geldiyse basit HTML üret
|
||||
if html == "" && text != "" {
|
||||
html = "<pre style=\"font-family:Segoe UI, Arial, sans-serif; white-space:pre-wrap;\">" +
|
||||
htmlEscape(text) + "</pre>"
|
||||
}
|
||||
if html == "" {
|
||||
return fmt.Errorf("body missing (Body or BodyHTML)")
|
||||
}
|
||||
|
||||
log.Printf("✉️ [MAIL] SEND START | from=%s | to=%v | internal=%v | subject=%s", g.from, cleanTo, isInternal, subject)
|
||||
|
||||
// ---------- build recipients ----------
|
||||
type recipient struct {
|
||||
EmailAddress struct {
|
||||
Address string `json:"address"`
|
||||
} `json:"emailAddress"`
|
||||
}
|
||||
toRecipients := make([]recipient, 0, len(cleanTo))
|
||||
for _, m := range cleanTo {
|
||||
r := recipient{}
|
||||
r.EmailAddress.Address = m
|
||||
toRecipients = append(toRecipients, r)
|
||||
}
|
||||
|
||||
// ---------- headers to reduce auto-phish/spam signals ----------
|
||||
headers := []map[string]string{
|
||||
{
|
||||
"name": "X-Mailer",
|
||||
"value": "BSSApp Graph Mailer",
|
||||
},
|
||||
{
|
||||
"name": "X-BSSApp-Internal",
|
||||
"value": strconv.FormatBool(isInternal),
|
||||
},
|
||||
}
|
||||
|
||||
// replyTo (opsiyonel)
|
||||
var replyToRecipients []recipient
|
||||
if strings.TrimSpace(g.replyTo) != "" {
|
||||
rt := recipient{}
|
||||
rt.EmailAddress.Address = strings.TrimSpace(g.replyTo)
|
||||
replyToRecipients = []recipient{rt}
|
||||
}
|
||||
|
||||
// ---------- Graph payload ----------
|
||||
message := map[string]any{
|
||||
"subject": subject,
|
||||
"body": map[string]string{
|
||||
"contentType": "HTML",
|
||||
"content": buildHTML(html, text, isInternal),
|
||||
},
|
||||
"toRecipients": toRecipients,
|
||||
"internetMessageHeaders": headers,
|
||||
"importance": "normal",
|
||||
}
|
||||
|
||||
// replyTo SADECE doluysa ekle
|
||||
if len(replyToRecipients) > 0 {
|
||||
message["replyTo"] = replyToRecipients
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"message": message,
|
||||
"saveToSentItems": true,
|
||||
}
|
||||
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("json marshal: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"https://graph.microsoft.com/v1.0/users/%s/sendMail",
|
||||
g.from,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
url,
|
||||
bytes.NewBuffer(b),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
res, err := g.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("graph request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
bodyBytes, _ := io.ReadAll(res.Body)
|
||||
log.Printf(
|
||||
"❌ [MAIL] SEND FAILED | status=%s | body=%s",
|
||||
res.Status,
|
||||
string(bodyBytes),
|
||||
)
|
||||
return fmt.Errorf("graph send mail failed: %s", res.Status)
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"✅ [MAIL] SEND OK | to=%v | duration=%s",
|
||||
cleanTo,
|
||||
time.Since(start),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func allRecipientsInDomain(to []string, domain string) bool {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
for _, addr := range to {
|
||||
addr = strings.ToLower(strings.TrimSpace(addr))
|
||||
if !strings.HasSuffix(addr, "@"+domain) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func buildHTML(htmlBody, textBody string, internal bool) string {
|
||||
// Internal ise daha sade, daha az “marketing-like”
|
||||
if internal {
|
||||
return `
|
||||
<div style="font-family:Segoe UI, Arial, sans-serif; font-size:14px; color:#1f1f1f;">
|
||||
` + htmlBody + `
|
||||
<hr style="border:none;border-top:1px solid #e5e5e5;margin:16px 0;" />
|
||||
<div style="font-size:12px;color:#666;">
|
||||
Bu e-posta BSSApp sistemi tarafından otomatik oluşturulmuştur.
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
// External
|
||||
return `
|
||||
<div style="font-family:Segoe UI, Arial, sans-serif; font-size:14px; color:#1f1f1f;">
|
||||
` + htmlBody + `
|
||||
</div>`
|
||||
}
|
||||
|
||||
// Çok basit “html -> text” (tam değil ama yeterli)
|
||||
func stripHTMLVerySimple(s string) string {
|
||||
s = strings.ReplaceAll(s, "<br>", "\n")
|
||||
s = strings.ReplaceAll(s, "<br/>", "\n")
|
||||
s = strings.ReplaceAll(s, "<br />", "\n")
|
||||
s = strings.ReplaceAll(s, "</p>", "\n\n")
|
||||
// kaba temizlik
|
||||
for {
|
||||
i := strings.Index(s, "<")
|
||||
j := strings.Index(s, ">")
|
||||
if i >= 0 && j > i {
|
||||
s = s[:i] + s[j+1:]
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func htmlEscape(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
"\"", """,
|
||||
"'", "'",
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
func (g *GraphMailer) SendMail(to string, subject string, html string) error {
|
||||
msg := Message{
|
||||
To: []string{to},
|
||||
Subject: subject,
|
||||
BodyHTML: html,
|
||||
}
|
||||
|
||||
// context burada internal olarak veriliyor
|
||||
return g.Send(context.Background(), msg)
|
||||
}
|
||||
148
svc/internal/mailer/mailer.go
Normal file
148
svc/internal/mailer/mailer.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidConfig = errors.New("invalid smtp config")
|
||||
|
||||
type Mailer struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
To []string
|
||||
Subject string
|
||||
Body string
|
||||
BodyHTML string
|
||||
}
|
||||
|
||||
func New(cfg Config) *Mailer {
|
||||
return &Mailer{cfg: cfg}
|
||||
}
|
||||
|
||||
func (m *Mailer) Send(ctx context.Context, msg Message) error {
|
||||
if len(msg.To) == 0 {
|
||||
return errors.New("recipient missing")
|
||||
}
|
||||
if strings.TrimSpace(msg.Subject) == "" {
|
||||
return errors.New("subject missing")
|
||||
}
|
||||
|
||||
// timeout kontrolü (ctx)
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
// default timeout
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
deadline, _ = ctx.Deadline()
|
||||
}
|
||||
timeout := time.Until(deadline)
|
||||
if timeout <= 0 {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port)
|
||||
|
||||
dialer := net.Dialer{Timeout: timeout}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
c, err := smtp.NewClient(conn, m.cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp client: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// STARTTLS
|
||||
if m.cfg.StartTLS {
|
||||
tlsCfg := &tls.Config{
|
||||
ServerName: m.cfg.Host,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
if err := c.StartTLS(tlsCfg); err != nil {
|
||||
return fmt.Errorf("starttls: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AUTH
|
||||
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
|
||||
// MAIL FROM
|
||||
if err := c.Mail(m.cfg.From); err != nil {
|
||||
return fmt.Errorf("mail from: %w", err)
|
||||
}
|
||||
|
||||
// RCPT TO
|
||||
for _, rcpt := range msg.To {
|
||||
rcpt = strings.TrimSpace(rcpt)
|
||||
if rcpt == "" {
|
||||
continue
|
||||
}
|
||||
if err := c.Rcpt(rcpt); err != nil {
|
||||
return fmt.Errorf("rcpt %s: %w", rcpt, err)
|
||||
}
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("data: %w", err)
|
||||
}
|
||||
|
||||
// basit mime
|
||||
contentType := "text/plain; charset=UTF-8"
|
||||
body := msg.Body
|
||||
if strings.TrimSpace(msg.BodyHTML) != "" {
|
||||
contentType = "text/html; charset=UTF-8"
|
||||
body = msg.BodyHTML
|
||||
}
|
||||
|
||||
raw := buildMIME(m.cfg.From, msg.To, msg.Subject, contentType, body)
|
||||
|
||||
_, writeErr := w.Write([]byte(raw))
|
||||
closeErr := w.Close()
|
||||
|
||||
if writeErr != nil {
|
||||
return fmt.Errorf("write body: %w", writeErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close body: %w", closeErr)
|
||||
}
|
||||
|
||||
if err := c.Quit(); err != nil {
|
||||
return fmt.Errorf("quit: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildMIME(from string, to []string, subject, contentType, body string) string {
|
||||
// Subject UTF-8 basit hali (gerekirse sonra MIME encoded-word ekleriz)
|
||||
headers := []string{
|
||||
"From: " + from,
|
||||
"To: " + strings.Join(to, ", "),
|
||||
"Subject: " + subject,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: " + contentType,
|
||||
"",
|
||||
}
|
||||
return strings.Join(headers, "\r\n") + "\r\n" + body + "\r\n"
|
||||
}
|
||||
|
||||
type MailerInterface interface {
|
||||
Send(ctx context.Context, msg Message) error
|
||||
}
|
||||
18
svc/internal/mailer/password_reset.go
Normal file
18
svc/internal/mailer/password_reset.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package mailer
|
||||
|
||||
import "fmt"
|
||||
|
||||
func (m *GraphMailer) SendPasswordResetMail(toEmail string, resetURL string) error {
|
||||
subject := "Parola Sıfırlama"
|
||||
|
||||
html := fmt.Sprintf(`
|
||||
<p>Merhaba,</p>
|
||||
<p>Parolanızı sıfırlamak için aşağıdaki bağlantıya tıklayın:</p>
|
||||
<p>
|
||||
<a href="%s">%s</a>
|
||||
</p>
|
||||
<p>Bu bağlantı <strong>30 dakika</strong> geçerlidir ve tek kullanımlıktır.</p>
|
||||
`, resetURL, resetURL)
|
||||
|
||||
return m.SendMail(toEmail, subject, html)
|
||||
}
|
||||
11
svc/internal/security/errors.go
Normal file
11
svc/internal/security/errors.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package security
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
|
||||
ErrPasswordUpper = errors.New("password must contain an uppercase letter")
|
||||
ErrPasswordLower = errors.New("password must contain a lowercase letter")
|
||||
ErrPasswordDigit = errors.New("password must contain a digit")
|
||||
ErrPasswordSpecial = errors.New("password must contain a special character")
|
||||
)
|
||||
35
svc/internal/security/password_policy.go
Normal file
35
svc/internal/security/password_policy.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
reUpper = regexp.MustCompile(`[A-Z]`)
|
||||
reLower = regexp.MustCompile(`[a-z]`)
|
||||
reDigit = regexp.MustCompile(`[0-9]`)
|
||||
reSpecial = regexp.MustCompile(`[^A-Za-z0-9]`)
|
||||
)
|
||||
|
||||
func ValidatePassword(pw string) error {
|
||||
pw = strings.TrimSpace(pw)
|
||||
|
||||
if len(pw) < 8 {
|
||||
return errors.New("Parola en az 8 karakter olmalı")
|
||||
}
|
||||
if !reUpper.MatchString(pw) {
|
||||
return errors.New("Parola en az 1 büyük harf içermeli")
|
||||
}
|
||||
if !reLower.MatchString(pw) {
|
||||
return errors.New("Parola en az 1 küçük harf içermeli")
|
||||
}
|
||||
if !reDigit.MatchString(pw) {
|
||||
return errors.New("Parola en az 1 rakam içermeli")
|
||||
}
|
||||
if !reSpecial.MatchString(pw) {
|
||||
return errors.New("Parola en az 1 özel karakter içermeli")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
13
svc/internal/security/password_reset.go
Normal file
13
svc/internal/security/password_reset.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func BuildResetURL(token string) string {
|
||||
base := os.Getenv("FRONTEND_URL")
|
||||
if base == "" {
|
||||
base = "http://localhost:9000"
|
||||
}
|
||||
return base + "/password-reset/" + token
|
||||
}
|
||||
23
svc/internal/security/refresh_token.go
Normal file
23
svc/internal/security/refresh_token.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func GenerateRefreshToken() (plain string, hash string, err error) {
|
||||
b := make([]byte, 32) // 256 bit
|
||||
if _, err = rand.Read(b); err != nil {
|
||||
return
|
||||
}
|
||||
plain = hex.EncodeToString(b)
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
hash = hex.EncodeToString(sum[:])
|
||||
return
|
||||
}
|
||||
|
||||
func HashRefreshToken(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
26
svc/internal/security/reset_token.go
Normal file
26
svc/internal/security/reset_token.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func GenerateResetToken() (plain string, hash string, err error) {
|
||||
b := make([]byte, 32) // 256 bit
|
||||
if _, err = rand.Read(b); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
plain = hex.EncodeToString(b)
|
||||
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
hash = hex.EncodeToString(sum[:])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func HashToken(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
4
svc/mail.env
Normal file
4
svc/mail.env
Normal file
@@ -0,0 +1,4 @@
|
||||
AZURE_TENANT_ID=c8e0675d-1f6e-40f3-ba5f-3d1985b92317
|
||||
AZURE_CLIENT_ID=94a134b7-757f-4bcc-9e4b-d577b631a9a3
|
||||
AZURE_CLIENT_SECRET=PaW8Q~9NzYXHrESZcKoP6.hRxS.CyQshvJ0Y0czx
|
||||
MAIL_FROM=baggiss@baggi.com.tr
|
||||
587
svc/main.go
Normal file
587
svc/main.go
Normal file
@@ -0,0 +1,587 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bssapp-backend/db"
|
||||
"bssapp-backend/internal/auditlog"
|
||||
"bssapp-backend/internal/mailer"
|
||||
"bssapp-backend/middlewares"
|
||||
"bssapp-backend/permissions"
|
||||
"bssapp-backend/repository"
|
||||
"bssapp-backend/routes"
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
/*
|
||||
===========================================================
|
||||
✅ CORS
|
||||
===========================================================
|
||||
*/
|
||||
func enableCORS(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:9000")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
===========================================================
|
||||
✅ V3 — Method-aware Route Auto Register
|
||||
- mk_sys_routes: (path, method, module_code, action)
|
||||
- unique: (path, method)
|
||||
- admin auto-allow: mk_sys_role_permissions (role_id=3)
|
||||
===========================================================
|
||||
*/
|
||||
func autoRegisterRouteV3(
|
||||
pg *sql.DB,
|
||||
path string,
|
||||
method string,
|
||||
module string,
|
||||
action string,
|
||||
) {
|
||||
tx, err := pg.Begin()
|
||||
if err != nil {
|
||||
log.Println("❌ TX begin error:", err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// 1) ROUTE REGISTER (path+method)
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO mk_sys_routes
|
||||
(path, method, module_code, action)
|
||||
VALUES
|
||||
($1, $2, $3, $4)
|
||||
ON CONFLICT (path, method) DO UPDATE
|
||||
SET
|
||||
module_code = EXCLUDED.module_code,
|
||||
action = EXCLUDED.action
|
||||
`,
|
||||
path,
|
||||
method,
|
||||
module,
|
||||
action,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("❌ Route register error (%s %s): %v", method, path, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 2) ADMIN AUTO PERMISSION (module+action bazlı)
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO mk_sys_role_permissions
|
||||
(role_id, module_code, action, allowed)
|
||||
SELECT
|
||||
id,
|
||||
$1,
|
||||
$2,
|
||||
true
|
||||
FROM dfrole
|
||||
WHERE id = 3 -- ADMIN
|
||||
ON CONFLICT DO NOTHING
|
||||
`,
|
||||
module,
|
||||
action,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("❌ Admin perm seed error (%s %s): %v", method, path, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Println("❌ TX commit error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ Route+Perm registered → %s %s [%s:%s]",
|
||||
method, path, module, action,
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
===========================================================
|
||||
✅ V3 Route Bind Helper
|
||||
- tek satırda: autoRegister + wrap + Handle
|
||||
===========================================================
|
||||
*/
|
||||
func bindV3(
|
||||
r *mux.Router,
|
||||
pg *sql.DB,
|
||||
path string,
|
||||
method string,
|
||||
module string,
|
||||
action string,
|
||||
h http.Handler,
|
||||
) {
|
||||
// main
|
||||
autoRegisterRouteV3(pg, path, method, module, action)
|
||||
|
||||
r.Handle(path, h).Methods(method, "OPTIONS")
|
||||
}
|
||||
|
||||
/*
|
||||
===========================================================
|
||||
InitRoutes — FULL V3 (Method-aware) PERMISSION EDITION
|
||||
===========================================================
|
||||
*/
|
||||
func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router {
|
||||
|
||||
r := mux.NewRouter()
|
||||
|
||||
/*
|
||||
===========================================================
|
||||
✅ wrapV3 (method-aware):
|
||||
- AuthMiddleware (JWT)
|
||||
- panic recover
|
||||
- AuthzGuardByRoute(pgDB) => route(path+method) lookup
|
||||
===========================================================
|
||||
*/
|
||||
wrapV3 := func(h http.Handler) http.Handler {
|
||||
return middlewares.AuthMiddleware(
|
||||
pgDB,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Printf("🔥 PANIC %s %s\n%v", r.Method, r.URL.Path, rec)
|
||||
debug.PrintStack()
|
||||
http.Error(w, "internal server error", 500)
|
||||
}
|
||||
}()
|
||||
|
||||
// ✅ method-aware route guard
|
||||
middlewares.AuthzGuardByRoute(pgDB)(h).ServeHTTP(w, r)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PUBLIC (NO AUTHZ)
|
||||
// ============================================================
|
||||
bindV3(r, pgDB,
|
||||
"/api/auth/login", "POST",
|
||||
"auth", "login",
|
||||
http.HandlerFunc(routes.LoginHandler(pgDB)),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/auth/refresh", "POST",
|
||||
"auth", "refresh",
|
||||
routes.AuthRefreshHandler(pgDB),
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// SYSTEM
|
||||
// ============================================================
|
||||
bindV3(r, pgDB,
|
||||
"/api/password/change", "POST",
|
||||
"system", "update",
|
||||
wrapV3(http.HandlerFunc(routes.FirstPasswordChangeHandler(pgDB))),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/activity-logs", "GET",
|
||||
"user", "view",
|
||||
wrapV3(routes.AdminActivityLogsHandler(pgDB)),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/test-mail", "POST",
|
||||
"user", "insert",
|
||||
wrapV3(routes.TestMailHandler(ml)),
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// PERMISSIONS
|
||||
// ============================================================
|
||||
rolePerm := "/api/roles/{id}/permissions"
|
||||
|
||||
bindV3(r, pgDB,
|
||||
rolePerm, "GET",
|
||||
"user", "update",
|
||||
wrapV3(routes.GetRolePermissionMatrix(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
rolePerm, "POST",
|
||||
"user", "update",
|
||||
wrapV3(routes.SaveRolePermissionMatrix(pgDB)),
|
||||
)
|
||||
|
||||
userPerm := "/api/users/{id}/permissions"
|
||||
|
||||
bindV3(r, pgDB,
|
||||
userPerm, "GET",
|
||||
"user", "update",
|
||||
wrapV3(routes.GetUserPermissionsHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
userPerm, "POST",
|
||||
"user", "update",
|
||||
wrapV3(routes.SaveUserPermissionsHandler(pgDB)),
|
||||
)
|
||||
|
||||
// ✅ permissions/routes (system:view)
|
||||
bindV3(r, pgDB,
|
||||
"/api/permissions/routes", "GET",
|
||||
"system", "view",
|
||||
wrapV3(routes.GetUserRoutePermissionsHandler(pgDB)),
|
||||
)
|
||||
|
||||
// ✅ permissions/effective (system:view)
|
||||
bindV3(r, pgDB,
|
||||
"/api/permissions/effective", "GET",
|
||||
"system", "view",
|
||||
wrapV3(routes.GetMyEffectivePermissions(pgDB)),
|
||||
)
|
||||
|
||||
// ✅ my permission matrix (system:view) (Senin tabloda user:view görünüyor; düzeltiyoruz)
|
||||
bindV3(r, pgDB,
|
||||
"/api/permissions/matrix", "GET",
|
||||
"system", "view",
|
||||
wrapV3(routes.GetMyPermissionMatrix(pgDB)),
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// ROLE + DEPARTMENT PERMISSIONS
|
||||
// ============================================================
|
||||
rdPerm := "/api/roles/{roleId}/departments/{deptCode}/permissions"
|
||||
rdHandler := routes.NewRoleDepartmentPermissionHandler(pgDB)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
rdPerm, "GET",
|
||||
"user", "update",
|
||||
wrapV3(http.HandlerFunc(rdHandler.Get)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
rdPerm, "POST",
|
||||
"user", "update",
|
||||
wrapV3(http.HandlerFunc(rdHandler.Save)),
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// USERS
|
||||
// ============================================================
|
||||
bindV3(r, pgDB,
|
||||
"/api/users/list", "GET",
|
||||
"user", "view",
|
||||
wrapV3(routes.UserListRoute(pgDB)),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/users", "POST",
|
||||
"user", "insert",
|
||||
wrapV3(routes.UserCreateRoute(pgDB)),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/users/{id}", "GET",
|
||||
"user", "update",
|
||||
wrapV3(routes.UserDetailRoute(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/users/{id}", "PUT",
|
||||
"user", "update",
|
||||
wrapV3(routes.UserDetailRoute(pgDB)),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/users/{id}/admin-reset-password", "POST",
|
||||
"user", "update",
|
||||
wrapV3(routes.AdminResetPasswordHandler(pgDB)),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/users/{id}/send-password-mail", "POST",
|
||||
"user", "update",
|
||||
wrapV3(routes.SendPasswordResetMailHandler(pgDB, ml)),
|
||||
)
|
||||
|
||||
// ✅ eski kısayol create endpoint (senin eski main.go’da vardı)
|
||||
bindV3(r, pgDB,
|
||||
"/api/users/create", "POST",
|
||||
"user", "insert",
|
||||
wrapV3(routes.UserCreateRoute(pgDB)),
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// LOOKUPS
|
||||
// ============================================================
|
||||
lookups := map[string]http.Handler{
|
||||
"/api/lookups/roles": routes.GetRoleLookupRoute(pgDB),
|
||||
"/api/lookups/departments": routes.GetDepartmentLookupRoute(pgDB),
|
||||
"/api/lookups/nebim-users": routes.GetNebimUserLookupRoute(pgDB),
|
||||
"/api/lookups/piyasalar": routes.GetPiyasaLookupRoute(pgDB),
|
||||
"/api/lookups/users-perm": routes.GetUsersForPermissionSelectRoute(pgDB),
|
||||
"/api/lookups/roles-perm": routes.GetRolesForPermissionSelectRoute(pgDB),
|
||||
"/api/lookups/departments-perm": routes.GetDepartmentsForPermissionSelectRoute(pgDB),
|
||||
"/api/lookups/modules": routes.GetModuleLookupRoute(pgDB),
|
||||
}
|
||||
|
||||
for path, handler := range lookups {
|
||||
bindV3(r, pgDB,
|
||||
path, "GET",
|
||||
"user", "view",
|
||||
wrapV3(handler),
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CUSTOMER
|
||||
// ============================================================
|
||||
bindV3(r, pgDB,
|
||||
"/api/accounts", "GET",
|
||||
"customer", "view",
|
||||
wrapV3(http.HandlerFunc(routes.GetAccountsHandler)),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/customer-list", "GET",
|
||||
"customer", "view",
|
||||
wrapV3(http.HandlerFunc(routes.GetCustomerListHandler)),
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// FINANCE
|
||||
// ============================================================
|
||||
bindV3(r, pgDB,
|
||||
"/api/today-currency", "GET",
|
||||
"finance", "view",
|
||||
wrapV3(routes.GetTodayCurrencyV3Handler(mssql)),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/export-pdf", "GET",
|
||||
"finance", "export",
|
||||
wrapV3(routes.ExportPDFHandler(mssql)),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/exportstamentheaderreport-pdf", "GET",
|
||||
"finance", "export",
|
||||
wrapV3(routes.ExportStatementHeaderReportPDFHandler(mssql)),
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// REPORT (STATEMENTS)
|
||||
// ============================================================
|
||||
bindV3(r, pgDB,
|
||||
"/api/statements", "GET",
|
||||
"finance", "view",
|
||||
wrapV3(http.HandlerFunc(routes.GetStatementHeadersHandler)),
|
||||
)
|
||||
|
||||
// ⚠️ Senin handler: GetStatementDetailsHandler vars["accountCode"] bekliyor,
|
||||
// route’da {id} var. Burada, DB route’unu ve path’i bozmayalım diye {id}’yi koruyorum,
|
||||
// ama handler içinde accountCode := mux.Vars(r)["id"] yapman daha doğru.
|
||||
bindV3(r, pgDB,
|
||||
"/api/statements/{id}/details", "GET",
|
||||
"finance", "view",
|
||||
wrapV3(http.HandlerFunc(routes.GetStatementDetailsHandler)),
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// ORDER
|
||||
// ============================================================
|
||||
orderRoutes := []struct {
|
||||
Path string
|
||||
Method string
|
||||
Action string
|
||||
Handle http.Handler
|
||||
}{
|
||||
{"/api/order/create", "POST", "insert", routes.CreateOrderHandler(pgDB, mssql)},
|
||||
{"/api/order/update", "POST", "update", http.HandlerFunc(routes.UpdateOrderHandler)},
|
||||
{"/api/order/get/{id}", "GET", "view", routes.GetOrderByIDHandler(mssql)},
|
||||
{"/api/orders/list", "GET", "view", routes.OrderListRoute(mssql)},
|
||||
{"/api/orders/export", "GET", "export", routes.OrderListExcelRoute(mssql)},
|
||||
{"/api/order/check/{id}", "GET", "view", routes.OrderExistsHandler(mssql)},
|
||||
{"/api/order/validate", "POST", "insert", routes.ValidateOrderHandler(mssql)},
|
||||
{"/api/order/pdf/{id}", "GET", "export", routes.OrderPDFHandler(mssql)},
|
||||
{"/api/order-inventory", "GET", "view", http.HandlerFunc(routes.GetOrderInventoryHandler)},
|
||||
{"/api/orderpricelistb2b", "GET", "view", routes.GetOrderPriceListB2BHandler(pgDB, mssql)},
|
||||
{"/api/min-price", "GET", "view", routes.GetOrderPriceListB2BHandler(pgDB, mssql)},
|
||||
}
|
||||
|
||||
for _, rt := range orderRoutes {
|
||||
bindV3(r, pgDB,
|
||||
rt.Path, rt.Method,
|
||||
"order", rt.Action,
|
||||
wrapV3(rt.Handle),
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PRODUCTS (✅ handler mapping fix)
|
||||
// ============================================================
|
||||
bindV3(r, pgDB,
|
||||
"/api/products", "GET",
|
||||
"order", "view",
|
||||
wrapV3(http.HandlerFunc(routes.GetProductListHandler)),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/product-detail", "GET",
|
||||
"order", "view",
|
||||
wrapV3(http.HandlerFunc(routes.GetProductDetailHandler)),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/product-colors", "GET",
|
||||
"order", "view",
|
||||
wrapV3(http.HandlerFunc(routes.GetProductColorsHandler)),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/product-colorsize", "GET",
|
||||
"order", "view",
|
||||
wrapV3(http.HandlerFunc(routes.GetProductColorSizesHandler)),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/product-secondcolor", "GET",
|
||||
"order", "view",
|
||||
wrapV3(http.HandlerFunc(routes.GetProductSecondColorsHandler)),
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// ROLE MANAGEMENT
|
||||
// ============================================================
|
||||
bindV3(r, pgDB,
|
||||
"/api/roles", "GET",
|
||||
"user", "view",
|
||||
wrapV3(routes.GetRolesHandler(pgDB)),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/departments", "GET",
|
||||
"user", "view",
|
||||
wrapV3(routes.GetDepartmentsHandler(pgDB)),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/piyasalar", "GET",
|
||||
"user", "view",
|
||||
wrapV3(routes.GetPiyasalarHandler(pgDB)),
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// ROLE RELATIONS
|
||||
// ============================================================
|
||||
bindV3(r, pgDB,
|
||||
"/api/roles/{id}/departments", "POST",
|
||||
"user", "update",
|
||||
wrapV3(routes.UpdateRoleDepartmentsHandler(pgDB)),
|
||||
)
|
||||
|
||||
bindV3(r, pgDB,
|
||||
"/api/roles/{id}/piyasalar", "POST",
|
||||
"user", "update",
|
||||
wrapV3(routes.UpdateRolePiyasalarHandler(pgDB)),
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// USER ↔ ROLE
|
||||
// ============================================================
|
||||
bindV3(r, pgDB,
|
||||
"/api/users/{id}/roles", "POST",
|
||||
"user", "update",
|
||||
wrapV3(routes.UpdateUserRolesHandler(pgDB)),
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// ADMIN EXTRA (eski main.go’da vardı, yeni sisteme alındı)
|
||||
// ============================================================
|
||||
bindV3(r, pgDB,
|
||||
"/api/admin/users/{id}/piyasa-sync", "POST",
|
||||
"admin", "user.update",
|
||||
wrapV3(http.HandlerFunc(routes.AdminSyncUserPiyasaHandler)),
|
||||
)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.Println("🔥🔥🔥 BSSAPP BACKEND STARTED — LOGIN ROUTE SHOULD EXIST 🔥🔥🔥")
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 🔑 ENV
|
||||
// -------------------------------------------------------
|
||||
if err := godotenv.Load(".env", "mail.env"); err != nil {
|
||||
log.Println("⚠️ .env / mail.env bulunamadı")
|
||||
}
|
||||
|
||||
jwtSecret := os.Getenv("JWT_SECRET")
|
||||
if len(jwtSecret) < 10 {
|
||||
log.Fatal("❌ JWT_SECRET tanımlı değil veya çok kısa (min 10 karakter)")
|
||||
}
|
||||
log.Println("🔐 JWT_SECRET yüklendi")
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 🔗 DATABASE
|
||||
// -------------------------------------------------------
|
||||
db.ConnectMSSQL()
|
||||
|
||||
pgDB, err := db.ConnectPostgres()
|
||||
if err != nil {
|
||||
log.Fatalf("❌ PostgreSQL bağlantı hatası: %v", err)
|
||||
}
|
||||
defer pgDB.Close()
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 🔐 ADMIN ROLE + DEPARTMENT AUTO SEED
|
||||
// -------------------------------------------------------
|
||||
if err := permissions.SeedAdminRoleDepartments(pgDB); err != nil {
|
||||
log.Println("❌ Admin dept seed failed:", err)
|
||||
} else {
|
||||
log.Println("✅ Admin dept permissions seeded")
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 🕵️ AUDIT LOG INIT
|
||||
// -------------------------------------------------------
|
||||
auditlog.Init(pgDB, 1000)
|
||||
log.Println("🕵️ AuditLog sistemi başlatıldı (buffer=1000)")
|
||||
|
||||
// -------------------------------------------------------
|
||||
// ✉️ MAILER INIT
|
||||
// -------------------------------------------------------
|
||||
graphMailer, err := mailer.NewGraphMailer()
|
||||
if err != nil {
|
||||
log.Fatalf("❌ Graph Mailer init error: %v", err)
|
||||
}
|
||||
log.Println("✉️ Graph Mailer hazır")
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 👤 DEBUG
|
||||
// -------------------------------------------------------
|
||||
repository.NewUserRepository(pgDB).DebugListUsers()
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 🌍 SERVER
|
||||
// -------------------------------------------------------
|
||||
router := InitRoutes(pgDB, db.MssqlDB, graphMailer)
|
||||
|
||||
handler := enableCORS(
|
||||
middlewares.GlobalAuthMiddleware(
|
||||
pgDB,
|
||||
middlewares.RequestLogger(router),
|
||||
),
|
||||
)
|
||||
|
||||
log.Println("✅ Server çalışıyor: http://localhost:8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", handler))
|
||||
}
|
||||
71
svc/middlewares/auditlog_middleware.go
Normal file
71
svc/middlewares/auditlog_middleware.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"bssapp-backend/auth"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"bssapp-backend/internal/auditlog"
|
||||
)
|
||||
|
||||
type ResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func NewResponseWriter(w http.ResponseWriter) *ResponseWriter {
|
||||
return &ResponseWriter{
|
||||
ResponseWriter: w,
|
||||
status: http.StatusOK,
|
||||
}
|
||||
}
|
||||
|
||||
func (rw *ResponseWriter) WriteHeader(code int) {
|
||||
rw.status = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *ResponseWriter) Status() int { return rw.status }
|
||||
|
||||
func Audit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
start := time.Now()
|
||||
rw := NewResponseWriter(w)
|
||||
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
// ✅ AuthMiddleware sonrası burada claims VAR
|
||||
var dfusrID int64
|
||||
var username, roleCode string
|
||||
|
||||
if claims, ok := auth.GetClaimsFromContext(r.Context()); ok && claims != nil {
|
||||
dfusrID = int64(claims.ID)
|
||||
username = claims.Username
|
||||
roleCode = claims.RoleCode // token’da varsa
|
||||
}
|
||||
|
||||
entry := auditlog.ActivityLog{
|
||||
DfUsrID: dfusrID,
|
||||
Username: username,
|
||||
RoleCode: roleCode,
|
||||
|
||||
ActionType: "route_access",
|
||||
ActionCategory: "nav",
|
||||
ActionTarget: r.URL.Path,
|
||||
Description: r.Method + " " + r.URL.Path,
|
||||
|
||||
IpAddress: r.RemoteAddr,
|
||||
UserAgent: r.UserAgent(),
|
||||
SessionID: "",
|
||||
|
||||
RequestStartedAt: start,
|
||||
RequestFinishedAt: time.Now(),
|
||||
DurationMs: int(time.Since(start).Milliseconds()),
|
||||
HttpStatus: rw.Status(),
|
||||
IsSuccess: rw.Status() < 400,
|
||||
}
|
||||
|
||||
auditlog.Write(entry)
|
||||
})
|
||||
}
|
||||
39
svc/middlewares/auth_middleware.go
Normal file
39
svc/middlewares/auth_middleware.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"bssapp-backend/auth"
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func AuthMiddleware(db *sql.DB, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := auth.ValidateToken(parts[1])
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// 🔥 BU SATIR ŞART
|
||||
ctx := auth.WithClaims(r.Context(), claims)
|
||||
|
||||
log.Printf("🔐 AUTH CTX SET user=%d role=%s", claims.ID, claims.RoleCode)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
961
svc/middlewares/authz_v2.go
Normal file
961
svc/middlewares/authz_v2.go
Normal file
@@ -0,0 +1,961 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"bssapp-backend/internal/authz"
|
||||
"bssapp-backend/permissions"
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bssapp-backend/auth"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
/*
|
||||
AuthzGuardV2
|
||||
- module+action role permission check (mk_sys_role_permissions)
|
||||
- optional scope checks (department / piyasa) via intersection:
|
||||
user_allowed ∩ role_allowed
|
||||
- cache with TTL
|
||||
|
||||
Expected:
|
||||
- AuthMiddleware runs before this and sets JWT claims in context.
|
||||
- claims should contain RoleID and UserID.
|
||||
*/
|
||||
|
||||
// =====================================================
|
||||
// 🔧 CONFIG / CONSTANTS
|
||||
// =====================================================
|
||||
|
||||
const (
|
||||
defaultPermTTL = 60 * time.Second
|
||||
defaultScopeTTL = 30 * time.Second
|
||||
|
||||
maxBodyRead = 1 << 20 // 1MB
|
||||
)
|
||||
|
||||
// =====================================================
|
||||
// 🧠 CACHE
|
||||
// =====================================================
|
||||
|
||||
type cacheItem struct {
|
||||
val any
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type ttlCache struct {
|
||||
mu sync.RWMutex
|
||||
ttl time.Duration
|
||||
m map[string]cacheItem
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 🌍 GLOBAL SCOPE CACHE (for invalidation)
|
||||
// =====================================================
|
||||
|
||||
var globalScopeCache *ttlCache
|
||||
|
||||
func newTTLCache(ttl time.Duration) *ttlCache {
|
||||
return &ttlCache{
|
||||
ttl: ttl,
|
||||
m: make(map[string]cacheItem),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ttlCache) get(key string) (any, bool) {
|
||||
now := time.Now()
|
||||
c.mu.RLock()
|
||||
item, ok := c.m[key]
|
||||
c.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if now.After(item.expires) {
|
||||
// lazy delete
|
||||
c.mu.Lock()
|
||||
delete(c.m, key)
|
||||
c.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
return item.val, true
|
||||
}
|
||||
|
||||
func (c *ttlCache) set(key string, val any) {
|
||||
c.mu.Lock()
|
||||
c.m[key] = cacheItem{val: val, expires: time.Now().Add(c.ttl)}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// ✅ MAIN MIDDLEWARE
|
||||
// =====================================================
|
||||
|
||||
type AuthzV2Options struct {
|
||||
// If true, scope checks are attempted when scope can be extracted.
|
||||
EnableScopeChecks bool
|
||||
|
||||
// If true, when scope is required but cannot be extracted, deny.
|
||||
// If false, when scope cannot be extracted, scope check is skipped.
|
||||
StrictScope bool
|
||||
|
||||
// Override TTLs (optional)
|
||||
PermTTL time.Duration
|
||||
ScopeTTL time.Duration
|
||||
|
||||
// Custom extractors (optional). If nil, built-in extractors are used.
|
||||
ExtractDepartmentCodes func(r *http.Request) []string
|
||||
ExtractPiyasaCodes func(r *http.Request) []string
|
||||
|
||||
// Decide whether this request should be treated as scope-sensitive.
|
||||
// If nil, built-in heuristic is used.
|
||||
IsScopeSensitive func(module string, r *http.Request) bool
|
||||
}
|
||||
|
||||
func AuthzGuardV2(pg *sql.DB, module string, action string) func(http.Handler) http.Handler {
|
||||
return AuthzGuardV2WithOptions(pg, module, action, AuthzV2Options{
|
||||
EnableScopeChecks: true,
|
||||
StrictScope: false,
|
||||
})
|
||||
}
|
||||
|
||||
func AuthzGuardV2WithOptions(pg *sql.DB, module string, action string, opt AuthzV2Options) func(http.Handler) http.Handler {
|
||||
|
||||
permTTL := opt.PermTTL
|
||||
if permTTL <= 0 {
|
||||
permTTL = defaultPermTTL
|
||||
}
|
||||
scopeTTL := opt.ScopeTTL
|
||||
if scopeTTL <= 0 {
|
||||
scopeTTL = defaultScopeTTL
|
||||
}
|
||||
|
||||
permCache := newTTLCache(permTTL)
|
||||
if globalScopeCache == nil {
|
||||
globalScopeCache = newTTLCache(scopeTTL)
|
||||
}
|
||||
scopeCache := globalScopeCache
|
||||
|
||||
// default extractors
|
||||
extractDept := opt.ExtractDepartmentCodes
|
||||
if extractDept == nil {
|
||||
extractDept = defaultExtractDepartmentCodes
|
||||
}
|
||||
extractPiy := opt.ExtractPiyasaCodes
|
||||
if extractPiy == nil {
|
||||
extractPiy = defaultExtractPiyasaCodes
|
||||
}
|
||||
|
||||
isScopeSensitive := opt.IsScopeSensitive
|
||||
if isScopeSensitive == nil {
|
||||
isScopeSensitive = defaultIsScopeSensitive
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// OPTIONS passthrough
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
|
||||
userID := claims.ID
|
||||
roleCode := claims.RoleCode
|
||||
|
||||
// ADMIN BYPASS
|
||||
if claims.IsAdmin() {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// resolve role_id from role_code
|
||||
roleID, err := cachedRoleID(pg, permCache, roleCode)
|
||||
if err != nil {
|
||||
log.Println("❌ role resolve error:", err)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// 🔐 PERMISSION RESOLUTION (USER > ROLE > DENY)
|
||||
// --------------------------------------------------
|
||||
|
||||
permRepo := permissions.NewPermissionRepository(pg)
|
||||
|
||||
allowed := false
|
||||
resolved := false // karar verildi mi?
|
||||
|
||||
// --------------------------------------------------
|
||||
// 1️⃣ USER OVERRIDE (ÖNCELİK)
|
||||
// --------------------------------------------------
|
||||
|
||||
overrides, err := permRepo.GetUserOverrides(userID)
|
||||
if err != nil {
|
||||
log.Println("❌ override load error:", err)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
for _, o := range overrides {
|
||||
|
||||
if o.Module == module &&
|
||||
o.Action == action {
|
||||
|
||||
log.Printf(
|
||||
"🔁 USER OVERRIDE → %s:%s = %v",
|
||||
module,
|
||||
action,
|
||||
o.Allowed,
|
||||
)
|
||||
|
||||
allowed = o.Allowed
|
||||
resolved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// 2️⃣ ROLE + DEPARTMENT (NEW SYSTEM)
|
||||
// --------------------------------------------------
|
||||
|
||||
if !resolved {
|
||||
|
||||
deptCodes := claims.DepartmentCodes
|
||||
|
||||
roleDeptAllowed, err := permRepo.ResolvePermissionChain(
|
||||
userID,
|
||||
roleID,
|
||||
deptCodes,
|
||||
module,
|
||||
action,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Println("❌ perm resolve error:", err)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if roleDeptAllowed {
|
||||
|
||||
log.Printf("🆕 ROLE+DEPT → %s:%s = true", module, action)
|
||||
|
||||
allowed = true
|
||||
resolved = true
|
||||
|
||||
} else {
|
||||
|
||||
log.Printf("🆕 ROLE+DEPT → %s:%s = false (try legacy)", module, action)
|
||||
}
|
||||
}
|
||||
// --------------------------------------------------
|
||||
// 3️⃣ ROLE ONLY (LEGACY FALLBACK)
|
||||
// --------------------------------------------------
|
||||
|
||||
if !resolved {
|
||||
|
||||
legacyAllowed, err := cachedRolePermission(
|
||||
pg,
|
||||
permCache,
|
||||
roleID,
|
||||
module,
|
||||
action,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Println("❌ legacy perm error:", err)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🕰️ LEGACY ROLE → %s:%s = %v", module, action, legacyAllowed)
|
||||
|
||||
allowed = legacyAllowed
|
||||
resolved = true
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// 3️⃣ FINAL DECISION
|
||||
// --------------------------------------------------
|
||||
|
||||
if !allowed {
|
||||
|
||||
log.Printf(
|
||||
"⛔ ACCESS DENIED user=%d %s:%s path=%s",
|
||||
claims.ID,
|
||||
module,
|
||||
action,
|
||||
r.URL.Path,
|
||||
)
|
||||
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"✅ ACCESS OK user=%d %s:%s %s",
|
||||
claims.ID,
|
||||
module,
|
||||
action,
|
||||
r.URL.Path,
|
||||
)
|
||||
|
||||
// --------------------------------------------------
|
||||
// 4️⃣ OPTIONAL SCOPE CHECKS (FINAL - SECURE)
|
||||
// --------------------------------------------------
|
||||
|
||||
if opt.EnableScopeChecks && isScopeSensitive(module, r) {
|
||||
|
||||
// 🔹 Request’ten gelenler
|
||||
reqDepts := normalizeCodes(extractDept(r))
|
||||
reqPiy := normalizeCodes(extractPiy(r))
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// 🔹 USER PIYASA (DB’den)
|
||||
userPiy, err := authz.GetUserPiyasaCodes(pg, int(userID))
|
||||
if err != nil {
|
||||
log.Println("❌ piyasa load error:", err)
|
||||
http.Error(w, "forbidden", 403)
|
||||
return
|
||||
}
|
||||
|
||||
userPiy = normalizeCodes(userPiy)
|
||||
|
||||
// ------------------------------------------------
|
||||
// ✅ PIYASA INTERSECTION
|
||||
// ------------------------------------------------
|
||||
|
||||
var effectivePiy []string
|
||||
|
||||
switch {
|
||||
case len(reqPiy) > 0 && len(userPiy) > 0:
|
||||
effectivePiy = intersect(reqPiy, userPiy)
|
||||
|
||||
case len(reqPiy) > 0 && len(userPiy) == 0:
|
||||
// user piyasa tanımlı değilse request'e güvenme → boş kalsın (StrictScope varsa deny)
|
||||
effectivePiy = nil
|
||||
|
||||
case len(reqPiy) == 0 && len(userPiy) > 0:
|
||||
effectivePiy = userPiy
|
||||
}
|
||||
if len(reqPiy) > 0 && len(effectivePiy) == 0 {
|
||||
// request piyasa istiyor ama user scope karşılamıyor
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
// ✅ CONTEXT’E YAZ
|
||||
// ------------------------------------------------
|
||||
|
||||
if len(reqDepts) > 0 {
|
||||
ctx = authz.WithDeptCodes(ctx, reqDepts)
|
||||
}
|
||||
|
||||
if len(effectivePiy) > 0 {
|
||||
ctx = authz.WithPiyasaCodes(ctx, effectivePiy)
|
||||
}
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// ------------------------------------------------
|
||||
// ❗ STRICT MODE
|
||||
// ------------------------------------------------
|
||||
|
||||
if len(reqDepts) == 0 && len(effectivePiy) == 0 {
|
||||
|
||||
if opt.StrictScope {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
// 🔍 DEPARTMENT CHECK
|
||||
// ------------------------------------------------
|
||||
|
||||
if len(reqDepts) > 0 {
|
||||
|
||||
okDept, err := cachedDeptIntersectionAny(
|
||||
pg,
|
||||
scopeCache,
|
||||
userID,
|
||||
roleID,
|
||||
reqDepts,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Println("❌ dept scope error:", err)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !okDept {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
// 🔍 PIYASA CHECK
|
||||
// ------------------------------------------------
|
||||
|
||||
if len(effectivePiy) > 0 {
|
||||
|
||||
okPiy, err := cachedPiyasaIntersectionAny(
|
||||
pg,
|
||||
scopeCache,
|
||||
userID,
|
||||
roleID,
|
||||
effectivePiy,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Println("❌ piyasa scope error:", err)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !okPiy {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// ✅ ALLOW
|
||||
// --------------------------------------------------
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 🔐 PERMISSION CHECK (mk_sys_role_permissions)
|
||||
// =====================================================
|
||||
|
||||
func cachedRolePermission(pg *sql.DB, c *ttlCache, roleID int64, module, action string) (bool, error) {
|
||||
|
||||
key := "perm|" + itoa(roleID) + "|" + module + "|" + action
|
||||
if v, ok := c.get(key); ok {
|
||||
return v.(bool), nil
|
||||
}
|
||||
|
||||
var allowed bool
|
||||
err := pg.QueryRow(`
|
||||
SELECT allowed
|
||||
FROM mk_sys_role_permissions
|
||||
WHERE role_id = $1 AND module_code = $2 AND action = $3
|
||||
`, roleID, module, action).Scan(&allowed)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.set(key, false)
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
c.set(key, allowed)
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 🧩 SCOPE INTERSECTION
|
||||
// user scope ∩ role scope
|
||||
// =====================================================
|
||||
|
||||
func cachedDeptIntersectionAny(pg *sql.DB, c *ttlCache, userID, roleID int64, deptCodes []string) (bool, error) {
|
||||
// cache by exact request list (sorted would be ideal; normalizeCodes already stabilizes somewhat)
|
||||
key := "deptAny|" + itoa(userID) + "|" + itoa(roleID) + "|" + strings.Join(deptCodes, ",")
|
||||
if v, ok := c.get(key); ok {
|
||||
return v.(bool), nil
|
||||
}
|
||||
|
||||
// ANY match: if request wants multiple codes, allow if at least one is in intersection
|
||||
// Intersection query:
|
||||
// user departments: dfusr_dprt -> mk_dprt(code)
|
||||
// role allowed: dfrole_dprt -> mk_dprt(id) OR directly by id
|
||||
okAny := false
|
||||
|
||||
// We do it in a single query with ANY($3)
|
||||
var dummy int
|
||||
err := pg.QueryRow(`
|
||||
SELECT 1
|
||||
FROM dfusr_dprt ud
|
||||
JOIN mk_dprt d ON d.id = ud.dprt_id
|
||||
JOIN dfrole_dprt rd ON rd.dprt_id = ud.dprt_id
|
||||
WHERE ud.dfusr_id = $1
|
||||
AND rd.dfrole_id = $2
|
||||
AND ud.is_active = true
|
||||
AND rd.is_allowed = true
|
||||
AND d.code = ANY($3)
|
||||
LIMIT 1
|
||||
`, userID, roleID, pqArray(deptCodes)).Scan(&dummy)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.set(key, false)
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
okAny = true
|
||||
c.set(key, okAny)
|
||||
return okAny, nil
|
||||
}
|
||||
|
||||
func cachedPiyasaIntersectionAny(pg *sql.DB, c *ttlCache, userID, roleID int64, piyasaCodes []string) (bool, error) {
|
||||
|
||||
key := "piyAny|" + itoa(userID) + "|" + itoa(roleID) + "|" + strings.Join(piyasaCodes, ",")
|
||||
if v, ok := c.get(key); ok {
|
||||
return v.(bool), nil
|
||||
}
|
||||
|
||||
var dummy int
|
||||
err := pg.QueryRow(`
|
||||
SELECT 1
|
||||
FROM dfusr_piyasa up
|
||||
WHERE up.dfusr_id = $1
|
||||
AND up.is_allowed = true
|
||||
AND up.piyasa_code = ANY($2)
|
||||
LIMIT 1
|
||||
`, userID, pqArray(piyasaCodes)).Scan(&dummy)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.set(key, false)
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
c.set(key, true)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 🧲 DEFAULT SCOPE DETECTION
|
||||
// =====================================================
|
||||
|
||||
// defaultIsScopeSensitive decides whether this module likely needs dept/piyasa checks.
|
||||
// You can tighten/extend later.
|
||||
func defaultIsScopeSensitive(module string, r *http.Request) bool {
|
||||
switch module {
|
||||
case "order", "customer", "report", "finance":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 🔎 DEFAULT EXTRACTORS
|
||||
// =====================================================
|
||||
|
||||
// We try to extract scope from:
|
||||
// - query params: dprt, dprt_code, department, department_code, piyasa, piyasa_code, market
|
||||
// - headers: X-Department, X-Piyasa
|
||||
// - json body fields: department / department_code / dprt_code, piyasa / piyasa_code / market_code
|
||||
// (body read is safe: we re-inject the body)
|
||||
func defaultExtractDepartmentCodes(r *http.Request) []string {
|
||||
var out []string
|
||||
|
||||
// query params
|
||||
for _, k := range []string{"dprt", "dprt_code", "department", "department_code"} {
|
||||
out = append(out, splitCSV(r.URL.Query().Get(k))...)
|
||||
}
|
||||
|
||||
// headers
|
||||
out = append(out, splitCSV(r.Header.Get("X-Department"))...)
|
||||
|
||||
// JSON body (if any)
|
||||
out = append(out, extractFromJSONBody(r, []string{
|
||||
"department", "department_code", "dprt", "dprt_code",
|
||||
})...)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func defaultExtractPiyasaCodes(r *http.Request) []string {
|
||||
var out []string
|
||||
|
||||
for _, k := range []string{"piyasa", "piyasa_code", "market", "market_code"} {
|
||||
out = append(out, splitCSV(r.URL.Query().Get(k))...)
|
||||
}
|
||||
|
||||
out = append(out, splitCSV(r.Header.Get("X-Piyasa"))...)
|
||||
|
||||
out = append(out, extractFromJSONBody(r, []string{
|
||||
"piyasa", "piyasa_code", "market", "market_code", "customer_attribute",
|
||||
})...)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func extractFromJSONBody(r *http.Request, keys []string) []string {
|
||||
// Only for methods that might have body
|
||||
switch r.Method {
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch:
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
// read body (and restore)
|
||||
raw, err := readBodyAndRestore(r, maxBodyRead)
|
||||
if err != nil || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// try parse object
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []string
|
||||
for _, k := range keys {
|
||||
if v, ok := obj[k]; ok {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
out = append(out, splitCSV(t)...)
|
||||
case []any:
|
||||
for _, it := range t {
|
||||
if s, ok := it.(string); ok {
|
||||
out = append(out, splitCSV(s)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func readBodyAndRestore(r *http.Request, limit int64) ([]byte, error) {
|
||||
if r.Body == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Read with limit
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// restore
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(raw))
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 🧼 HELPERS
|
||||
// =====================================================
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeCodes(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pqArray: minimal adapter to pass []string as Postgres array.
|
||||
// If you already use lib/pq, replace this with pq.Array.
|
||||
// Here we use a simple JSON array -> Postgres can cast text[] from ARRAY[]? Not directly.
|
||||
// So: YOU SHOULD USE lib/pq in your project. If it's already there, change pqArray() to pq.Array(slice).
|
||||
//
|
||||
// For now, we implement as a driver.Value using "{A,B}" format (Postgres text[] literal).
|
||||
type pgTextArray string
|
||||
|
||||
func (a pgTextArray) Value() (any, error) { return string(a), nil }
|
||||
|
||||
func pqArray(ss []string) any {
|
||||
// produce "{A,B,C}" as text[] literal
|
||||
// escape quotes minimally
|
||||
if len(ss) == 0 {
|
||||
return pgTextArray("{}")
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("{")
|
||||
for i, s := range ss {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
s = strings.ReplaceAll(s, `"`, `\"`)
|
||||
b.WriteString(`"`)
|
||||
b.WriteString(s)
|
||||
b.WriteString(`"`)
|
||||
}
|
||||
b.WriteString("}")
|
||||
return pgTextArray(b.String())
|
||||
}
|
||||
|
||||
func itoa(n int64) string {
|
||||
return strconv.FormatInt(n, 10)
|
||||
}
|
||||
|
||||
// isolate strconv usage without importing it globally in this snippet
|
||||
func strconvFormatInt(n int64) string {
|
||||
// local minimal
|
||||
// NOTE: in real code just import strconv and use strconv.FormatInt(n, 10)
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf [32]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + (n % 10))
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// optional: allow passing scope explicitly from handlers via context (advanced use)
|
||||
type scopeKey string
|
||||
|
||||
// =====================================================
|
||||
// 🔍 ROLE RESOLVER (code -> id) WITH CACHE
|
||||
// =====================================================
|
||||
|
||||
func cachedRoleID(pg *sql.DB, c *ttlCache, roleCode string) (int64, error) {
|
||||
|
||||
key := "role|" + strings.ToLower(roleCode)
|
||||
|
||||
if v, ok := c.get(key); ok {
|
||||
return v.(int64), nil
|
||||
}
|
||||
|
||||
var id int64
|
||||
|
||||
err := pg.QueryRow(`
|
||||
SELECT id
|
||||
FROM dfrole
|
||||
WHERE LOWER(code) = LOWER($1)
|
||||
`, roleCode).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
c.set(key, id)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 🧹 CACHE INVALIDATION (ADMIN)
|
||||
// =====================================================
|
||||
|
||||
func ClearAuthzScopeCacheForUser(userID int64) {
|
||||
|
||||
// NOTE: this clears ALL scope cache.
|
||||
// Simple & safe. Optimize later if needed.
|
||||
|
||||
if globalScopeCache != nil {
|
||||
globalScopeCache.mu.Lock()
|
||||
defer globalScopeCache.mu.Unlock()
|
||||
|
||||
for k := range globalScopeCache.m {
|
||||
if strings.Contains(k, "|"+itoa(userID)+"|") {
|
||||
delete(globalScopeCache.m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// intersect: A ∩ B
|
||||
func intersect(a, b []string) []string {
|
||||
|
||||
set := make(map[string]struct{}, len(a))
|
||||
|
||||
for _, v := range a {
|
||||
set[v] = struct{}{}
|
||||
}
|
||||
|
||||
var out []string
|
||||
|
||||
for _, v := range b {
|
||||
if _, ok := set[v]; ok {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler {
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// =====================================================
|
||||
// 0️⃣ OPTIONS → PASS (CORS preflight)
|
||||
// =====================================================
|
||||
if r.Method == http.MethodOptions {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 1️⃣ AUTH
|
||||
// =====================================================
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 2️⃣ REAL ROUTE TEMPLATE ( /api/users/{id} )
|
||||
// =====================================================
|
||||
route := mux.CurrentRoute(r)
|
||||
|
||||
if route == nil {
|
||||
log.Printf("❌ AUTHZ: route not resolved: %s %s",
|
||||
r.Method, r.URL.Path,
|
||||
)
|
||||
|
||||
http.Error(w, "route not resolved", 403)
|
||||
return
|
||||
}
|
||||
|
||||
pathTemplate, err := route.GetPathTemplate()
|
||||
if err != nil {
|
||||
log.Printf("❌ AUTHZ: path template error: %v", err)
|
||||
|
||||
http.Error(w, "route template error", 403)
|
||||
return
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 3️⃣ ROUTE LOOKUP (path + method)
|
||||
// =====================================================
|
||||
var module, action string
|
||||
|
||||
err = pg.QueryRow(`
|
||||
SELECT module_code, action
|
||||
FROM mk_sys_routes
|
||||
WHERE path = $1
|
||||
AND method = $2
|
||||
`,
|
||||
pathTemplate,
|
||||
r.Method,
|
||||
).Scan(&module, &action)
|
||||
|
||||
if err != nil {
|
||||
|
||||
log.Printf(
|
||||
"❌ AUTHZ: route not registered: %s %s",
|
||||
r.Method,
|
||||
pathTemplate,
|
||||
)
|
||||
|
||||
http.Error(w, "route permission not found", 403)
|
||||
return
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 4️⃣ PERMISSION RESOLVE
|
||||
// =====================================================
|
||||
repo := permissions.NewPermissionRepository(pg)
|
||||
|
||||
allowed, err := repo.ResolvePermissionChain(
|
||||
int64(claims.ID),
|
||||
int64(claims.RoleID),
|
||||
claims.DepartmentCodes,
|
||||
module,
|
||||
action,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
|
||||
log.Printf(
|
||||
"❌ AUTHZ: resolve error user=%d %s:%s err=%v",
|
||||
claims.ID,
|
||||
module,
|
||||
action,
|
||||
err,
|
||||
)
|
||||
|
||||
http.Error(w, "forbidden", 403)
|
||||
return
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
|
||||
log.Printf(
|
||||
"⛔ AUTHZ: denied user=%d %s:%s",
|
||||
claims.ID,
|
||||
module,
|
||||
action,
|
||||
)
|
||||
|
||||
http.Error(w, "forbidden", 403)
|
||||
return
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 5️⃣ PASS
|
||||
// =====================================================
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
24
svc/middlewares/current_user.go
Normal file
24
svc/middlewares/current_user.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"bssapp-backend/auth"
|
||||
"bssapp-backend/models"
|
||||
"bssapp-backend/utils"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func CurrentUser(r *http.Request) (*models.User, bool) {
|
||||
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
|
||||
if !ok || claims == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
user := utils.UserFromClaims(claims)
|
||||
if user == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return user, true
|
||||
}
|
||||
66
svc/middlewares/force_password_change.go
Normal file
66
svc/middlewares/force_password_change.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"bssapp-backend/auth"
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 🔓 force_password_change=true iken izinli endpoint prefixleri
|
||||
var passwordChangeAllowlist = []string{
|
||||
"/api/password/change",
|
||||
"/api/password/reset",
|
||||
"/api/password/reset/validate",
|
||||
"/api/auth/refresh",
|
||||
}
|
||||
|
||||
func ForcePasswordChangeGuard(db *sql.DB) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
log.Println("❌ FPC GUARD: claims NOT FOUND")
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"🛡️ FPC GUARD user=%s force=%v path=%s",
|
||||
claims.Username,
|
||||
claims.ForcePasswordChange,
|
||||
r.URL.Path,
|
||||
)
|
||||
|
||||
// 🔓 Şifre değişimi zorunlu DEĞİL → serbest
|
||||
if !claims.ForcePasswordChange {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 🔐 Şifre değişimi ZORUNLU → allowlist kontrolü
|
||||
for _, allowed := range passwordChangeAllowlist {
|
||||
if strings.HasPrefix(r.URL.Path, allowed) {
|
||||
log.Printf(
|
||||
"✅ FPC GUARD PASS user=%s path=%s",
|
||||
claims.Username,
|
||||
r.URL.Path,
|
||||
)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ⛔ Zorunlu ama yanlış endpoint
|
||||
log.Printf(
|
||||
"⛔ FPC GUARD BLOCK user=%s path=%s",
|
||||
claims.Username,
|
||||
r.URL.Path,
|
||||
)
|
||||
|
||||
http.Error(w, "password change required", http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
}
|
||||
58
svc/middlewares/global_auth.go
Normal file
58
svc/middlewares/global_auth.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"bssapp-backend/auth"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var publicPaths = []string{
|
||||
"/api/auth/login",
|
||||
"/api/auth/refresh",
|
||||
"/api/password/forgot",
|
||||
"/api/password/reset",
|
||||
}
|
||||
|
||||
func GlobalAuthMiddleware(db any, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
path := r.URL.Path
|
||||
|
||||
// PUBLIC ROUTES
|
||||
for _, p := range publicPaths {
|
||||
if strings.HasPrefix(path, p) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// JWT
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := auth.ValidateToken(parts[1])
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := auth.WithClaims(r.Context(), claims)
|
||||
|
||||
log.Printf("🔐 GLOBAL AUTH user=%d role=%s",
|
||||
claims.ID,
|
||||
claims.RoleCode,
|
||||
)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
56
svc/middlewares/helpers.go
Normal file
56
svc/middlewares/helpers.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"bssapp-backend/auth"
|
||||
"bssapp-backend/ctxkeys"
|
||||
"context"
|
||||
)
|
||||
|
||||
func getClaims(ctx context.Context) *auth.Claims {
|
||||
if v := ctx.Value(ctxkeys.UserContextKey); v != nil {
|
||||
if c, ok := v.(*auth.Claims); ok {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// 🔐 SESSION
|
||||
// --------------------------------------------------
|
||||
func GetSessionID(ctx context.Context) string {
|
||||
if c := getClaims(ctx); c != nil {
|
||||
return c.SessionID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// 🔑 USER ID (mk_dfusr.id)
|
||||
// --------------------------------------------------
|
||||
func GetUserID(ctx context.Context) int64 {
|
||||
if c := getClaims(ctx); c != nil {
|
||||
return c.ID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// 👤 USERNAME
|
||||
// --------------------------------------------------
|
||||
func GetUsername(ctx context.Context) string {
|
||||
if c := getClaims(ctx); c != nil {
|
||||
return c.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// 🧩 ROLE
|
||||
// --------------------------------------------------
|
||||
func GetRoleCode(ctx context.Context) string {
|
||||
if c := getClaims(ctx); c != nil && c.RoleCode != "" {
|
||||
return c.RoleCode
|
||||
}
|
||||
return "public"
|
||||
}
|
||||
59
svc/middlewares/rate_limit.go
Normal file
59
svc/middlewares/rate_limit.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rateEntry struct {
|
||||
Count int
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
rateMu sync.Mutex
|
||||
rateDB = make(map[string]*rateEntry)
|
||||
)
|
||||
|
||||
func RateLimit(keyFn func(*http.Request) string, limit int, window time.Duration) func(http.Handler) http.Handler {
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
key := keyFn(r)
|
||||
now := time.Now()
|
||||
|
||||
rateMu.Lock()
|
||||
e, ok := rateDB[key]
|
||||
if !ok || now.After(e.ExpiresAt) {
|
||||
e = &rateEntry{
|
||||
Count: 0,
|
||||
ExpiresAt: now.Add(window),
|
||||
}
|
||||
rateDB[key] = e
|
||||
}
|
||||
|
||||
e.Count++
|
||||
if e.Count > limit {
|
||||
rateMu.Unlock()
|
||||
http.Error(w, "Too many requests", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
rateMu.Unlock()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// helpers
|
||||
func RateByIP(r *http.Request) string {
|
||||
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
return "ip:" + ip
|
||||
}
|
||||
|
||||
func RateByUser(r *http.Request) string {
|
||||
return "user:" + r.URL.Path // id path’ten okunabilir
|
||||
}
|
||||
108
svc/middlewares/request_logger.go
Normal file
108
svc/middlewares/request_logger.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"bssapp-backend/auth"
|
||||
"bssapp-backend/internal/auditlog"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func RequestLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
start := time.Now()
|
||||
|
||||
sw := &statusWriter{
|
||||
ResponseWriter: w,
|
||||
status: 200,
|
||||
}
|
||||
|
||||
// ---------- CLAIMS ----------
|
||||
claims, _ := auth.GetClaimsFromContext(r.Context())
|
||||
|
||||
// ---------- IP ----------
|
||||
ip := r.RemoteAddr
|
||||
if host, _, err := net.SplitHostPort(ip); err == nil {
|
||||
ip = host
|
||||
}
|
||||
|
||||
// ---------- UA ----------
|
||||
ua := r.UserAgent()
|
||||
|
||||
// ---------- SESSION ----------
|
||||
sessionID := ""
|
||||
if claims != nil {
|
||||
sessionID = claims.SessionID
|
||||
}
|
||||
|
||||
hasAuth := r.Header.Get("Authorization") != ""
|
||||
|
||||
log.Printf("➡️ %s %s | auth=%v", r.Method, r.URL.Path, hasAuth)
|
||||
|
||||
// ---------- RUN ----------
|
||||
next.ServeHTTP(sw, r)
|
||||
|
||||
finish := time.Now()
|
||||
dur := int(finish.Sub(start).Milliseconds())
|
||||
|
||||
log.Printf("⬅️ %s %s | status=%d | %s", r.Method, r.URL.Path, sw.status, time.Since(start))
|
||||
|
||||
// ---------- AUDIT (route_access) ----------
|
||||
al := auditlog.ActivityLog{
|
||||
ActionType: "route_access",
|
||||
ActionCategory: "nav",
|
||||
ActionTarget: r.URL.Path,
|
||||
Description: r.Method + " " + r.URL.Path,
|
||||
|
||||
IpAddress: ip,
|
||||
UserAgent: ua,
|
||||
SessionID: sessionID,
|
||||
|
||||
RequestStartedAt: start,
|
||||
RequestFinishedAt: finish,
|
||||
DurationMs: dur,
|
||||
HttpStatus: sw.status,
|
||||
|
||||
IsSuccess: sw.status < 400,
|
||||
}
|
||||
|
||||
// ---------- CLAIMS → LOG ----------
|
||||
if claims != nil {
|
||||
al.Username = claims.Username
|
||||
al.RoleCode = claims.RoleCode
|
||||
al.DfUsrID = int64(claims.ID)
|
||||
|
||||
// Eğer claims içinde UUID varsa ekle (sende varsa aç)
|
||||
// al.UserID = claims.UserUUID
|
||||
} else {
|
||||
al.RoleCode = "public"
|
||||
}
|
||||
|
||||
// ---------- ERROR ----------
|
||||
if sw.status >= 400 {
|
||||
al.ErrorMessage = http.StatusText(sw.status)
|
||||
}
|
||||
|
||||
// ✅ ESKİ: auditlog.Write(al)
|
||||
// ✅ YENİ:
|
||||
auditlog.Enqueue(r.Context(), al)
|
||||
|
||||
if claims == nil {
|
||||
log.Println("⚠️ LOGGER: claims is NIL")
|
||||
} else {
|
||||
log.Printf("✅ LOGGER CLAIMS user=%s role=%s id=%d", claims.Username, claims.RoleCode, claims.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
6
svc/models/NewOrderNumberResponse.go
Normal file
6
svc/models/NewOrderNumberResponse.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package models
|
||||
|
||||
type NewOrderNumberResponse struct {
|
||||
OrderHeaderID string `json:"OrderHeaderID"`
|
||||
OrderNumber string `json:"OrderNumber"`
|
||||
}
|
||||
8
svc/models/account.go
Normal file
8
svc/models/account.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package models
|
||||
|
||||
// Cari hesap modeli
|
||||
type Account struct {
|
||||
DisplayCode string `json:"display_code"` // "ZLA 0127"
|
||||
AccountCode string `json:"account_code"` // "ZLA0127"
|
||||
AccountName string `json:"account_name"` // "EKO TEXTIL COM SRL" ya da ""
|
||||
}
|
||||
45
svc/models/custom_time.go
Normal file
45
svc/models/custom_time.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CustomTime struct {
|
||||
sql.NullTime
|
||||
}
|
||||
|
||||
func (ct *CustomTime) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Trim(string(b), `"`)
|
||||
if s == "" || s == "null" {
|
||||
ct.Valid = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// DESTEKLENEN TÜM FORMATLAR
|
||||
layouts := []string{
|
||||
time.RFC3339, // 2025-11-21T00:10:27Z
|
||||
"2006-01-02T15:04:05", // 2025-11-21T00:10:27
|
||||
"2006-01-02 15:04:05", // 2025-11-21 00:10:27 ← FRONTEND FORMATIN!
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
ct.Time = t
|
||||
ct.Valid = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Hâlâ parse edemediyse → invalid kabul et
|
||||
ct.Valid = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ct CustomTime) MarshalJSON() ([]byte, error) {
|
||||
if ct.Valid {
|
||||
return []byte(`"` + ct.Time.Format("2006-01-02 15:04:05") + `"`), nil
|
||||
}
|
||||
return []byte(`null`), nil
|
||||
}
|
||||
13
svc/models/customerlist.go
Normal file
13
svc/models/customerlist.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// models/customerlist.go
|
||||
package models
|
||||
|
||||
type CustomerList struct {
|
||||
CurrAccTypeCode int `json:"CurrAccTypeCode"` // 🆕 FK için lazım
|
||||
Cari_Kod string `json:"Cari_Kod"`
|
||||
Cari_Ad string `json:"Cari_Ad"`
|
||||
Musteri_Ana_Grubu string `json:"Musteri_Ana_Grubu"`
|
||||
Piyasa string `json:"Piyasa"`
|
||||
Musteri_Temsilcisi string `json:"Musteri_Temsilcisi"`
|
||||
Ulke string `json:"Ulke"`
|
||||
Doviz_cinsi string `json:"Doviz_Cinsi"`
|
||||
}
|
||||
13
svc/models/invalid_variant.go
Normal file
13
svc/models/invalid_variant.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
type InvalidVariant struct {
|
||||
Index int `json:"index"`
|
||||
ClientKey string `json:"clientKey"`
|
||||
ItemCode string `json:"itemCode"`
|
||||
ColorCode string `json:"colorCode"`
|
||||
Dim1 string `json:"dim1"`
|
||||
Dim2 string `json:"dim2"`
|
||||
Qty1 float64 `json:"qty1"`
|
||||
ComboKey string `json:"comboKey,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
57
svc/models/mk_user.go
Normal file
57
svc/models/mk_user.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type MkUser struct {
|
||||
// ==================================================
|
||||
// 🔑 PRIMARY ID (mk_dfusr.id)
|
||||
// ==================================================
|
||||
ID int64 `json:"id"` // mk_dfusr.id
|
||||
RoleID int64 `json:"role_id"`
|
||||
|
||||
// ==================================================
|
||||
// 🔁 LEGACY DFUSR (opsiyonel – migrate/debug)
|
||||
// ==================================================
|
||||
LegacyDfUsrID *int64 `json:"legacy_dfusr_id,omitempty"`
|
||||
|
||||
// ==================================================
|
||||
// 👤 CORE
|
||||
// ==================================================
|
||||
Username string `json:"username"`
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
Mobile string `json:"mobile"`
|
||||
IsActive bool `json:"is_active"`
|
||||
|
||||
// ==================================================
|
||||
// 🔐 AUTH
|
||||
// ==================================================
|
||||
PasswordHash string `json:"-"`
|
||||
ForcePasswordChange bool `json:"force_password_change"`
|
||||
PasswordUpdatedAt *time.Time `json:"password_updated_at,omitempty"`
|
||||
SessionID string `json:"session_id"`
|
||||
|
||||
// ==================================================
|
||||
// 🧩 ROLE
|
||||
// ==================================================
|
||||
RoleCode string `json:"role_code"`
|
||||
|
||||
// ==================================================
|
||||
// 🧾 NEBIM
|
||||
// ==================================================
|
||||
V3Username string `json:"v3_username"`
|
||||
V3UserGroup string `json:"v3_usergroup"`
|
||||
|
||||
// ==================================================
|
||||
// 🏭 RUNTIME SCOPES
|
||||
// ==================================================
|
||||
DepartmentCodes []string `json:"departments"`
|
||||
PiyasaCodes []string `json:"piyasalar"`
|
||||
|
||||
// ==================================================
|
||||
// ⏱ AUDIT
|
||||
// ==================================================
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
}
|
||||
16
svc/models/modelcombo_error.go
Normal file
16
svc/models/modelcombo_error.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// models/errors.go
|
||||
package models
|
||||
|
||||
type ValidationError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
ClientKey string `json:"clientKey,omitempty"`
|
||||
ItemCode string `json:"itemCode,omitempty"`
|
||||
ColorCode string `json:"colorCode,omitempty"`
|
||||
Dim1 string `json:"dim1,omitempty"`
|
||||
Dim2 string `json:"dim2,omitempty"`
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
120
svc/models/null_uuid.go
Normal file
120
svc/models/null_uuid.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 🔹 NullUUID — MSSQL UNIQUEIDENTIFIER için nullable tip
|
||||
// ============================================================
|
||||
type NullUUID struct {
|
||||
UUID string `json:"UUID"`
|
||||
Valid bool `json:"Valid"`
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
//
|
||||
// SQL Valuer (INSERT/UPDATE parametresi)
|
||||
//
|
||||
// ------------------------------------------------------------
|
||||
func (n NullUUID) Value() (driver.Value, error) {
|
||||
if !n.Valid || strings.TrimSpace(n.UUID) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return n.UUID, nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
//
|
||||
// SQL Scanner (SELECT sonucu)
|
||||
//
|
||||
// ------------------------------------------------------------
|
||||
func (n *NullUUID) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
n.UUID = ""
|
||||
n.Valid = false
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
n.UUID = v
|
||||
n.Valid = true
|
||||
case []byte:
|
||||
n.UUID = string(v)
|
||||
n.Valid = true
|
||||
default:
|
||||
n.UUID = ""
|
||||
n.Valid = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
//
|
||||
// JSON Marshal — Go → JSON
|
||||
// Null ise: null
|
||||
// Değer varsa: "guid-string"
|
||||
//
|
||||
// ------------------------------------------------------------
|
||||
func (n NullUUID) MarshalJSON() ([]byte, error) {
|
||||
if !n.Valid || strings.TrimSpace(n.UUID) == "" {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return json.Marshal(n.UUID)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
//
|
||||
// JSON Unmarshal — JSON → Go
|
||||
// Kabul ettiği formatlar:
|
||||
// • null
|
||||
// • "guid-string"
|
||||
// • {"UUID":"...","Valid":true} (ileride istersen)
|
||||
//
|
||||
// ------------------------------------------------------------
|
||||
func (n *NullUUID) UnmarshalJSON(data []byte) error {
|
||||
s := strings.TrimSpace(string(data))
|
||||
|
||||
// null veya boş
|
||||
if s == "null" || s == "" {
|
||||
n.UUID = ""
|
||||
n.Valid = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// Önce string gibi dene: "guid"
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err == nil {
|
||||
str = strings.TrimSpace(str)
|
||||
if str == "" {
|
||||
n.UUID = ""
|
||||
n.Valid = false
|
||||
} else {
|
||||
n.UUID = str
|
||||
n.Valid = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Objeyse: { "UUID":"...", "Valid":true }
|
||||
var aux struct {
|
||||
UUID string `json:"UUID"`
|
||||
Valid bool `json:"Valid"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aux.UUID = strings.TrimSpace(aux.UUID)
|
||||
if aux.UUID == "" || !aux.Valid {
|
||||
n.UUID = ""
|
||||
n.Valid = false
|
||||
} else {
|
||||
n.UUID = aux.UUID
|
||||
n.Valid = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
54
svc/models/order_pdf.go
Normal file
54
svc/models/order_pdf.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package models
|
||||
|
||||
// ===============================
|
||||
// 🔹 ORDER HEADER (PDF için)
|
||||
// ===============================
|
||||
type OrderHeaderPDF struct {
|
||||
OrderHeaderID string `json:"OrderHeaderID"`
|
||||
OrderNumber string `json:"OrderNumber"`
|
||||
CurrAccCode string `json:"CurrAccCode"`
|
||||
CariAdi string `json:"CariAdi"`
|
||||
OrderDate string `json:"OrderDate"` // yyyy-MM-dd
|
||||
AverageDueDate string `json:"AverageDueDate"` // Termin
|
||||
Description string `json:"Description"`
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// 🔹 ORDER LINE (PDF için)
|
||||
// ===============================
|
||||
type OrderLinePDF struct {
|
||||
OrderLineID string `json:"OrderLineID"`
|
||||
OrderHeaderID string `json:"OrderHeaderID"`
|
||||
|
||||
Model string `json:"Model"`
|
||||
Renk string `json:"Renk"`
|
||||
Renk2 string `json:"Renk2"`
|
||||
AnaGrup string `json:"AnaGrup"`
|
||||
AltGrup string `json:"AltGrup"`
|
||||
Aciklama string `json:"Aciklama"`
|
||||
|
||||
Fiyat float64 `json:"Fiyat"`
|
||||
Adet float64 `json:"Adet"`
|
||||
Tutar float64 `json:"Tutar"`
|
||||
PB string `json:"PB"`
|
||||
|
||||
TerminTarihi string `json:"TerminTarihi"`
|
||||
|
||||
// 16 beden alanı
|
||||
Size1 float64 `json:"Size1"`
|
||||
Size2 float64 `json:"Size2"`
|
||||
Size3 float64 `json:"Size3"`
|
||||
Size4 float64 `json:"Size4"`
|
||||
Size5 float64 `json:"Size5"`
|
||||
Size6 float64 `json:"Size6"`
|
||||
Size7 float64 `json:"Size7"`
|
||||
Size8 float64 `json:"Size8"`
|
||||
Size9 float64 `json:"Size9"`
|
||||
Size10 float64 `json:"Size10"`
|
||||
Size11 float64 `json:"Size11"`
|
||||
Size12 float64 `json:"Size12"`
|
||||
Size13 float64 `json:"Size13"`
|
||||
Size14 float64 `json:"Size14"`
|
||||
Size15 float64 `json:"Size15"`
|
||||
Size16 float64 `json:"Size16"`
|
||||
}
|
||||
98
svc/models/orderdetail.go
Normal file
98
svc/models/orderdetail.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package models
|
||||
|
||||
// ============================================================
|
||||
// 📦 ORDER DETAIL (trOrderLine)
|
||||
// ============================================================
|
||||
type OrderDetail struct {
|
||||
OrderLineID string `json:"OrderLineID"`
|
||||
|
||||
// 🔑 Frontend’den gelen benzersiz anahtar (sadece JSON, DB kolonu değil)
|
||||
ClientKey NullString `json:"clientKey"`
|
||||
ComboKey NullString `json:"ComboKey"`
|
||||
SortOrder NullInt32 `json:"SortOrder"`
|
||||
ItemTypeCode NullInt16 `json:"ItemTypeCode"`
|
||||
ItemCode NullString `json:"ItemCode"`
|
||||
ColorCode NullString `json:"ColorCode"`
|
||||
ItemDim1Code NullString `json:"ItemDim1Code"`
|
||||
ItemDim2Code NullString `json:"ItemDim2Code"`
|
||||
ItemDim3Code NullString `json:"ItemDim3Code"`
|
||||
|
||||
Qty1 NullFloat64 `json:"Qty1"`
|
||||
Qty2 NullFloat64 `json:"Qty2"`
|
||||
|
||||
CancelQty1 NullFloat64 `json:"CancelQty1"`
|
||||
CancelQty2 NullFloat64 `json:"CancelQty2"`
|
||||
CancelDate NullTime `json:"CancelDate"`
|
||||
|
||||
OrderCancelReasonCode NullString `json:"OrderCancelReasonCode"`
|
||||
ClosedDate NullTime `json:"ClosedDate"`
|
||||
IsClosed NullBool `json:"IsClosed"`
|
||||
|
||||
SalespersonCode NullString `json:"SalespersonCode"`
|
||||
PaymentPlanCode NullString `json:"PaymentPlanCode"`
|
||||
PurchasePlanCode NullString `json:"PurchasePlanCode"`
|
||||
|
||||
// MSSQL smalldatetime
|
||||
DeliveryDate NullTime `json:"DeliveryDate"`
|
||||
|
||||
// MSSQL date (YYYY-MM-DD)
|
||||
PlannedDateOfLading NullString `json:"PlannedDateOfLading"`
|
||||
|
||||
LineDescription NullString `json:"LineDescription"`
|
||||
UsedBarcode NullString `json:"UsedBarcode"`
|
||||
CostCenterCode NullString `json:"CostCenterCode"`
|
||||
|
||||
VatCode NullString `json:"VatCode"`
|
||||
VatRate NullFloat64 `json:"VatRate"`
|
||||
|
||||
PCTCode NullString `json:"PCTCode"`
|
||||
PCTRate NullFloat64 `json:"PCTRate"`
|
||||
|
||||
LDisRate1 NullFloat64 `json:"LDisRate1"`
|
||||
LDisRate2 NullFloat64 `json:"LDisRate2"`
|
||||
LDisRate3 NullFloat64 `json:"LDisRate3"`
|
||||
LDisRate4 NullFloat64 `json:"LDisRate4"`
|
||||
LDisRate5 NullFloat64 `json:"LDisRate5"`
|
||||
|
||||
DocCurrencyCode NullString `json:"DocCurrencyCode"`
|
||||
PriceCurrencyCode NullString `json:"PriceCurrencyCode"`
|
||||
PriceExchangeRate NullFloat64 `json:"PriceExchangeRate"`
|
||||
|
||||
Price NullFloat64 `json:"Price"`
|
||||
|
||||
PriceListLineID NullString `json:"PriceListLineID"`
|
||||
|
||||
BaseProcessCode NullString `json:"BaseProcessCode"`
|
||||
BaseOrderNumber NullString `json:"BaseOrderNumber"`
|
||||
BaseCustomerTypeCode NullInt32 `json:"BaseCustomerTypeCode"`
|
||||
BaseCustomerCode NullString `json:"BaseCustomerCode"`
|
||||
BaseSubCurrAccID NullString `json:"BaseSubCurrAccID"`
|
||||
BaseStoreCode NullString `json:"BaseStoreCode"`
|
||||
|
||||
SupportRequestHeaderID NullString `json:"SupportRequestHeaderID"`
|
||||
|
||||
OrderHeaderID string `json:"OrderHeaderID"`
|
||||
|
||||
OrderLineSumID NullInt32 `json:"OrderLineSumID"`
|
||||
OrderLineBOMID NullInt32 `json:"OrderLineBOMID"`
|
||||
|
||||
CreatedUserName NullString `json:"CreatedUserName"`
|
||||
CreatedDate NullString `json:"CreatedDate"`
|
||||
LastUpdatedUserName NullString `json:"LastUpdatedUserName"`
|
||||
LastUpdatedDate NullString `json:"LastUpdatedDate"`
|
||||
|
||||
SurplusOrderQtyToleranceRate NullFloat64 `json:"SurplusOrderQtyToleranceRate"`
|
||||
|
||||
PurchaseRequisitionLineID NullString `json:"PurchaseRequisitionLineID"`
|
||||
WithHoldingTaxTypeCode NullString `json:"WithHoldingTaxTypeCode"`
|
||||
|
||||
DOVCode NullString `json:"DOVCode"`
|
||||
OrderLineLinkedProductID NullString `json:"OrderLineLinkedProductID"`
|
||||
|
||||
// JOIN attr
|
||||
UrunIlkGrubu NullString `json:"UrunIlkGrubu"`
|
||||
UrunAnaGrubu NullString `json:"UrunAnaGrubu"`
|
||||
UrunAltGrubu NullString `json:"UrunAltGrubu"`
|
||||
Fit1 NullString `json:"Fit1"`
|
||||
Fit2 NullString `json:"Fit2"`
|
||||
}
|
||||
109
svc/models/orderheader.go
Normal file
109
svc/models/orderheader.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package models
|
||||
|
||||
// ============================================================
|
||||
// 🧾 ORDER HEADER
|
||||
// ============================================================
|
||||
type OrderHeader struct {
|
||||
OrderHeaderID string `json:"OrderHeaderID"`
|
||||
|
||||
OrderTypeCode NullInt16 `json:"OrderTypeCode"`
|
||||
ProcessCode NullString `json:"ProcessCode"`
|
||||
OrderNumber NullString `json:"OrderNumber"`
|
||||
IsCancelOrder NullBool `json:"IsCancelOrder"`
|
||||
|
||||
// 🔥 Date & Time string olarak geliyor (frontend)
|
||||
OrderDate NullString `json:"OrderDate"` // "YYYY-MM-DD"
|
||||
OrderTime NullString `json:"OrderTime"` // "HH:mm:ss"
|
||||
|
||||
DocumentNumber NullString `json:"DocumentNumber"`
|
||||
|
||||
PaymentTerm NullInt16 `json:"PaymentTerm"`
|
||||
AverageDueDate NullString `json:"AverageDueDate"` // "YYYY-MM-DD"
|
||||
|
||||
Description NullString `json:"Description"`
|
||||
InternalDescription NullString `json:"InternalDescription"`
|
||||
|
||||
CurrAccTypeCode NullInt16 `json:"CurrAccTypeCode"`
|
||||
CurrAccCode NullString `json:"CurrAccCode"`
|
||||
|
||||
CurrAccDescription NullString `json:"CurrAccDescription"`
|
||||
|
||||
// 🔥 GUID alanları NullUUID yapıldı
|
||||
SubCurrAccID NullUUID `json:"SubCurrAccID"`
|
||||
ContactID NullUUID `json:"ContactID"`
|
||||
ShipmentMethodCode NullString `json:"ShipmentMethodCode"`
|
||||
ShippingPostalAddressID NullUUID `json:"ShippingPostalAddressID"`
|
||||
BillingPostalAddressID NullUUID `json:"BillingPostalAddressID"`
|
||||
GuarantorContactID NullUUID `json:"GuarantorContactID"`
|
||||
GuarantorContactID2 NullUUID `json:"GuarantorContactID2"`
|
||||
|
||||
RoundsmanCode NullString `json:"RoundsmanCode"`
|
||||
DeliveryCompanyCode NullString `json:"DeliveryCompanyCode"`
|
||||
|
||||
TaxTypeCode NullInt16 `json:"TaxTypeCode"`
|
||||
WithHoldingTaxTypeCode NullString `json:"WithHoldingTaxTypeCode"`
|
||||
DOVCode NullString `json:"DOVCode"`
|
||||
TaxExemptionCode NullInt16 `json:"TaxExemptionCode"`
|
||||
|
||||
CompanyCode NullInt32 `json:"CompanyCode"`
|
||||
OfficeCode NullString `json:"OfficeCode"`
|
||||
StoreTypeCode NullInt16 `json:"StoreTypeCode"`
|
||||
StoreCode NullString `json:"StoreCode"`
|
||||
|
||||
POSTerminalID NullInt16 `json:"POSTerminalID"`
|
||||
WarehouseCode NullString `json:"WarehouseCode"`
|
||||
ToWarehouseCode NullString `json:"ToWarehouseCode"`
|
||||
|
||||
OrdererCompanyCode NullInt32 `json:"OrdererCompanyCode"`
|
||||
OrdererOfficeCode NullString `json:"OrdererOfficeCode"`
|
||||
OrdererStoreCode NullString `json:"OrdererStoreCode"`
|
||||
|
||||
GLTypeCode NullString `json:"GLTypeCode"`
|
||||
DocCurrencyCode NullString `json:"DocCurrencyCode"`
|
||||
LocalCurrencyCode NullString `json:"LocalCurrencyCode"`
|
||||
ExchangeRate NullFloat64 `json:"ExchangeRate"`
|
||||
|
||||
TDisRate1 NullFloat64 `json:"TDisRate1"`
|
||||
TDisRate2 NullFloat64 `json:"TDisRate2"`
|
||||
TDisRate3 NullFloat64 `json:"TDisRate3"`
|
||||
TDisRate4 NullFloat64 `json:"TDisRate4"`
|
||||
TDisRate5 NullFloat64 `json:"TDisRate5"`
|
||||
|
||||
DiscountReasonCode NullInt16 `json:"DiscountReasonCode"`
|
||||
SurplusOrderQtyToleranceRate NullFloat64 `json:"SurplusOrderQtyToleranceRate"`
|
||||
|
||||
ImportFileNumber NullString `json:"ImportFileNumber"`
|
||||
ExportFileNumber NullString `json:"ExportFileNumber"`
|
||||
IncotermCode1 NullString `json:"IncotermCode1"`
|
||||
IncotermCode2 NullString `json:"IncotermCode2"`
|
||||
LettersOfCreditNumber NullString `json:"LettersOfCreditNumber"`
|
||||
PaymentMethodCode NullString `json:"PaymentMethodCode"`
|
||||
|
||||
IsInclutedVat NullBool `json:"IsInclutedVat"`
|
||||
IsCreditSale NullBool `json:"IsCreditSale"`
|
||||
IsCreditableConfirmed NullBool `json:"IsCreditableConfirmed"`
|
||||
|
||||
CreditableConfirmedUser NullString `json:"CreditableConfirmedUser"`
|
||||
// MSSQL datetime
|
||||
CreditableConfirmedDate CustomTime `json:"CreditableConfirmedDate"`
|
||||
|
||||
IsSalesViaInternet NullBool `json:"IsSalesViaInternet"`
|
||||
IsSuspended NullBool `json:"IsSuspended"`
|
||||
IsCompleted NullBool `json:"IsCompleted"`
|
||||
IsPrinted NullBool `json:"IsPrinted"`
|
||||
IsLocked NullBool `json:"IsLocked"`
|
||||
UserLocked NullBool `json:"UserLocked"`
|
||||
IsClosed NullBool `json:"IsClosed"`
|
||||
|
||||
ApplicationCode NullString `json:"ApplicationCode"`
|
||||
ApplicationID NullUUID `json:"ApplicationID"`
|
||||
|
||||
CreatedUserName NullString `json:"CreatedUserName"`
|
||||
// Frontend "YYYY-MM-DD HH:mm:ss" gönderiyor
|
||||
CreatedDate NullString `json:"CreatedDate"`
|
||||
|
||||
LastUpdatedUserName NullString `json:"LastUpdatedUserName"`
|
||||
LastUpdatedDate NullString `json:"LastUpdatedDate"`
|
||||
|
||||
IsProposalBased NullBool `json:"IsProposalBased"`
|
||||
}
|
||||
10
svc/models/orderinventory.go
Normal file
10
svc/models/orderinventory.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package models
|
||||
|
||||
type OrderInventory struct {
|
||||
UrunKodu string `json:"urun_kodu"`
|
||||
RenkKodu string `json:"renk_kodu"`
|
||||
RenkAciklamasi string `json:"renk_aciklamasi"`
|
||||
Beden string `json:"beden"`
|
||||
Yaka string `json:"yaka"`
|
||||
KullanilabilirEnvanter float64 `json:"kullanilabilir_envanter"`
|
||||
}
|
||||
35
svc/models/orderlist.go
Normal file
35
svc/models/orderlist.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package models
|
||||
|
||||
// ========================================================
|
||||
// 📌 OrderList — Sipariş Listeleme Modeli (FINAL & UI SAFE)
|
||||
// ========================================================
|
||||
type OrderList struct {
|
||||
|
||||
// 🆔 Sipariş Bilgileri
|
||||
OrderHeaderID string `json:"OrderHeaderID"`
|
||||
OrderNumber string `json:"OrderNumber"`
|
||||
OrderDate string `json:"OrderDate"`
|
||||
|
||||
// 🧾 Cari Bilgileri
|
||||
CurrAccCode string `json:"CurrAccCode"`
|
||||
CurrAccDescription string `json:"CurrAccDescription"`
|
||||
|
||||
// 👤 Müşteri Tanımları
|
||||
MusteriTemsilcisi string `json:"MusteriTemsilcisi"`
|
||||
Piyasa string `json:"Piyasa"`
|
||||
|
||||
// ℹ️ Sipariş Durumu
|
||||
CreditableConfirmedDate string `json:"CreditableConfirmedDate"`
|
||||
IsCreditableConfirmed bool `json:"IsCreditableConfirmed"`
|
||||
|
||||
// 💱 Para Birimi
|
||||
DocCurrencyCode string `json:"DocCurrencyCode"`
|
||||
|
||||
// 💰 Tutarlar
|
||||
TotalAmount float64 `json:"TotalAmount"`
|
||||
TotalAmountUSD float64 `json:"TotalAmountUSD"`
|
||||
|
||||
// 📝 Açıklama
|
||||
Description string `json:"Description"`
|
||||
ExchangeRateUSD float64 `json:"ExchangeRateUSD"`
|
||||
}
|
||||
179
svc/models/ordernull_types.go
Normal file
179
svc/models/ordernull_types.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
/* ==============================================================
|
||||
🔹 NULL TYPE WRAPPERS
|
||||
MSSQL sql.Null* tiplerini JSON'a dönüştürürken:
|
||||
- null yerine "", 0 veya false döndürür
|
||||
- frontend'de (Vue/Quasar) doğrudan okunabilir hale getirir
|
||||
============================================================== */
|
||||
|
||||
// 🟦 STRING
|
||||
type NullString struct {
|
||||
sql.NullString
|
||||
}
|
||||
|
||||
func (ns *NullString) MarshalJSON() ([]byte, error) {
|
||||
if ns.Valid {
|
||||
return json.Marshal(ns.String)
|
||||
}
|
||||
return json.Marshal("")
|
||||
}
|
||||
|
||||
func (ns *NullString) UnmarshalJSON(b []byte) error {
|
||||
var s *string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
if s != nil {
|
||||
ns.Valid = true
|
||||
ns.String = *s
|
||||
} else {
|
||||
ns.Valid = false
|
||||
ns.String = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 🟩 INT32
|
||||
type NullInt32 struct {
|
||||
sql.NullInt32
|
||||
}
|
||||
|
||||
func (ni *NullInt32) MarshalJSON() ([]byte, error) {
|
||||
if ni.Valid {
|
||||
return json.Marshal(ni.Int32)
|
||||
}
|
||||
return json.Marshal(0)
|
||||
}
|
||||
|
||||
func (ni *NullInt32) UnmarshalJSON(b []byte) error {
|
||||
var n *int32
|
||||
if err := json.Unmarshal(b, &n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n != nil {
|
||||
ni.Valid = true
|
||||
ni.Int32 = *n
|
||||
} else {
|
||||
ni.Valid = false
|
||||
ni.Int32 = 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 🟧 INT16
|
||||
type NullInt16 struct {
|
||||
sql.NullInt16
|
||||
}
|
||||
|
||||
func (ni *NullInt16) MarshalJSON() ([]byte, error) {
|
||||
if ni.Valid {
|
||||
return json.Marshal(ni.Int16)
|
||||
}
|
||||
return json.Marshal(0)
|
||||
}
|
||||
|
||||
func (ni *NullInt16) UnmarshalJSON(b []byte) error {
|
||||
var n *int16
|
||||
if err := json.Unmarshal(b, &n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n != nil {
|
||||
ni.Valid = true
|
||||
ni.Int16 = *n
|
||||
} else {
|
||||
ni.Valid = false
|
||||
ni.Int16 = 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 🟨 FLOAT64
|
||||
type NullFloat64 struct {
|
||||
sql.NullFloat64
|
||||
}
|
||||
|
||||
func (nf *NullFloat64) MarshalJSON() ([]byte, error) {
|
||||
if nf.Valid {
|
||||
return json.Marshal(nf.Float64)
|
||||
}
|
||||
return json.Marshal(0.0)
|
||||
}
|
||||
|
||||
func (nf *NullFloat64) UnmarshalJSON(b []byte) error {
|
||||
var f *float64
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
return err
|
||||
}
|
||||
if f != nil {
|
||||
nf.Valid = true
|
||||
nf.Float64 = *f
|
||||
} else {
|
||||
nf.Valid = false
|
||||
nf.Float64 = 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 🟥 BOOL
|
||||
type NullBool struct {
|
||||
sql.NullBool
|
||||
}
|
||||
|
||||
func (nb *NullBool) MarshalJSON() ([]byte, error) {
|
||||
if nb.Valid {
|
||||
return json.Marshal(nb.Bool)
|
||||
}
|
||||
return json.Marshal(false)
|
||||
}
|
||||
|
||||
func (nb *NullBool) UnmarshalJSON(b []byte) error {
|
||||
var v *bool
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
if v != nil {
|
||||
nb.Valid = true
|
||||
nb.Bool = *v
|
||||
} else {
|
||||
nb.Valid = false
|
||||
nb.Bool = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 🟪 TIME
|
||||
type NullTime struct {
|
||||
sql.NullTime
|
||||
}
|
||||
|
||||
func (nt *NullTime) MarshalJSON() ([]byte, error) {
|
||||
if nt.Valid {
|
||||
return json.Marshal(nt.Time.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
return json.Marshal(nil)
|
||||
}
|
||||
|
||||
func (nt *NullTime) UnmarshalJSON(b []byte) error {
|
||||
var s *string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
if s != nil && *s != "" {
|
||||
t, err := time.Parse("2006-01-02 15:04:05", *s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nt.Valid = true
|
||||
nt.Time = t
|
||||
} else {
|
||||
nt.Valid = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
13
svc/models/orderpricelistb2b.go
Normal file
13
svc/models/orderpricelistb2b.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
// OrderPriceListB2B B2B ürün fiyat listesini temsil eder
|
||||
type OrderPriceListB2B struct {
|
||||
ModelCode string `json:"modelCode"`
|
||||
CurrencyCode string `json:"currencyCode"`
|
||||
Price float64 `json:"price"`
|
||||
PriceGroupID int `json:"priceGroupId"`
|
||||
LastUpdate string `json:"lastUpdate"`
|
||||
RateToTRY float64 `json:"rateToTRY"`
|
||||
PriceTRY float64 `json:"priceTRY"`
|
||||
BaseCurrency string `json:"baseCurrency"`
|
||||
}
|
||||
5
svc/models/product.go
Normal file
5
svc/models/product.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package models
|
||||
|
||||
type Product struct {
|
||||
ProductCode string `json:"ProductCode"` // ✅ büyük P harf ile
|
||||
}
|
||||
7
svc/models/productcolor.go
Normal file
7
svc/models/productcolor.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package models
|
||||
|
||||
type ProductColor struct {
|
||||
ProductCode string `json:"product_code"`
|
||||
ColorCode string `json:"color_code"`
|
||||
ColorDescription string `json:"color_description"`
|
||||
}
|
||||
9
svc/models/productcolorsize.go
Normal file
9
svc/models/productcolorsize.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
// 🔹 Ürün renk + beden varyasyon listesi (açıklamalar çıkarıldı)
|
||||
type ProductColorSize struct {
|
||||
ProductCode string `json:"product_code"`
|
||||
ColorCode string `json:"color_code"`
|
||||
ItemDim1Code string `json:"item_dim1_code"`
|
||||
ItemDim2Code string `json:"item_dim2_code"`
|
||||
}
|
||||
14
svc/models/productdetail.go
Normal file
14
svc/models/productdetail.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
type ProductDetail struct {
|
||||
ProductCode string `json:"ProductCode"`
|
||||
UrunIlkGrubu string `json:"UrunIlkGrubu"`
|
||||
UrunAnaGrubu string `json:"UrunAnaGrubu"`
|
||||
UrunAltGrubu string `json:"UrunAltGrubu"`
|
||||
UrunIcerik string `json:"UrunIcerik"`
|
||||
Drop string `json:"Drop"`
|
||||
Kategori string `json:"Kategori"`
|
||||
AskiliYan string `json:"AskiliYan"`
|
||||
Fit1 string `json:"Fit1"`
|
||||
Fit2 string `json:"Fit2"`
|
||||
}
|
||||
7
svc/models/productsecondcolor.go
Normal file
7
svc/models/productsecondcolor.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package models
|
||||
|
||||
type ProductSecondColor struct {
|
||||
ProductCode string `json:"product_code"`
|
||||
ColorCode string `json:"color_code"`
|
||||
ItemDim2Code string `json:"item_dim2_code"`
|
||||
}
|
||||
17
svc/models/statement_detail.go
Normal file
17
svc/models/statement_detail.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
// Detay tablo (alt satır)
|
||||
type StatementDetail struct {
|
||||
BelgeTarihi string `json:"belge_tarihi"` // Belge tarihi
|
||||
BelgeRefNumarasi string `json:"belge_ref_numarasi"` // Belge referans numarası
|
||||
UrunAnaGrubu string `json:"urun_ana_grubu"` // Ürün ana grubu
|
||||
UrunAltGrubu string `json:"urun_alt_grubu"` // Ürün alt grubu
|
||||
YetiskinGarson string `json:"yetiskin_garson"` // Yetişkin/Çocuk (garson)
|
||||
Fit string `json:"fit"` // Fit bilgisi
|
||||
Icerik string `json:"icerik"` // İçerik
|
||||
UrunKodu string `json:"urun_kodu"` // Ürün kodu
|
||||
UrunRengi string `json:"urun_rengi"` // Ürün rengi
|
||||
ToplamAdet float64 `json:"toplam_adet"` // Toplam adet
|
||||
ToplamFiyat float64 `json:"toplam_fiyat"` // Birim fiyat
|
||||
ToplamTutar float64 `json:"toplam_tutar"` // Toplam tutar
|
||||
}
|
||||
41
svc/models/statement_header.go
Normal file
41
svc/models/statement_header.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type StatementHeader struct {
|
||||
CariKod string `json:"cari_kod"`
|
||||
CariIsim string `json:"cari_isim"`
|
||||
BelgeTarihi string `json:"belge_tarihi"`
|
||||
VadeTarihi string `json:"vade_tarihi"`
|
||||
BelgeNo string `json:"belge_no"`
|
||||
IslemTipi string `json:"islem_tipi"`
|
||||
Aciklama string `json:"aciklama"`
|
||||
ParaBirimi string `json:"para_birimi"`
|
||||
Borc float64 `json:"borc"`
|
||||
Alacak float64 `json:"alacak"`
|
||||
Bakiye float64 `json:"bakiye"`
|
||||
Parislemler sql.NullString `json:"parislemler"`
|
||||
|
||||
// 🔹 PDF için detaylar
|
||||
Details []StatementDetail `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// JSON dönüşümünde NULL değerleri "" yap
|
||||
func (s StatementHeader) MarshalJSON() ([]byte, error) {
|
||||
type Alias StatementHeader
|
||||
return json.Marshal(&struct {
|
||||
Parislemler string `json:"parislemler"`
|
||||
*Alias
|
||||
}{
|
||||
Parislemler: func() string {
|
||||
if s.Parislemler.Valid {
|
||||
return s.Parislemler.String
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
Alias: (*Alias)(&s),
|
||||
})
|
||||
}
|
||||
10
svc/models/statements_params.go
Normal file
10
svc/models/statements_params.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package models
|
||||
|
||||
type StatementParams struct {
|
||||
CariKod string `json:"cari_kod"`
|
||||
StartDate string `json:"startdate"`
|
||||
EndDate string `json:"enddate"`
|
||||
AccountCode string `json:"accountcode"`
|
||||
LangCode string `json:"langcode"`
|
||||
Parislemler []string `json:"parislemler"` // ✅ slice olmalı
|
||||
}
|
||||
10
svc/models/todaycurrencyv3.go
Normal file
10
svc/models/todaycurrencyv3.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package models
|
||||
|
||||
// TodayCurrencyV3 sistemdeki döviz kurlarını temsil eder
|
||||
type TodayCurrencyV3 struct {
|
||||
CurrencyCode string `json:"currencyCode"`
|
||||
RelationCurrencyCode string `json:"relationCurrencyCode"`
|
||||
ExchangeTypeCode int `json:"exchangeTypeCode"`
|
||||
Rate float64 `json:"rate"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
88
svc/models/user_detail.go
Normal file
88
svc/models/user_detail.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package models
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ======================================================
|
||||
// 👤 USER DETAIL — GET RESPONSE
|
||||
// UserDetail.vue formuna birebir
|
||||
// ======================================================
|
||||
type UserDetail struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
IsActive bool `json:"is_active"`
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
Mobile string `json:"mobile"`
|
||||
Address string `json:"address"`
|
||||
|
||||
HasPassword bool `json:"has_password"` // 🔐 SADECE DURUM
|
||||
|
||||
// ===== İLİŞKİLER =====
|
||||
Roles []string `json:"roles"`
|
||||
Departments []DeptOption `json:"departments"`
|
||||
Piyasalar []DeptOption `json:"piyasalar"`
|
||||
NebimUsers []NebimOption `json:"nebim_users"`
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// ✍️ USER WRITE — PUT PAYLOAD
|
||||
// ======================================================
|
||||
type UserWrite struct {
|
||||
Code string `json:"code"`
|
||||
IsActive bool `json:"is_active"`
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
Mobile string `json:"mobile"`
|
||||
Address string `json:"address"`
|
||||
|
||||
Roles []string `json:"roles"`
|
||||
Departments []DeptOption `json:"departments"`
|
||||
Piyasalar []DeptOption `json:"piyasalar"`
|
||||
NebimUsers []NebimOption `json:"nebim_users"`
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// 🔹 COMMON OPTION MODELS
|
||||
// ======================================================
|
||||
type DeptOption struct {
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// Flexible JSON decode
|
||||
func (d *DeptOption) UnmarshalJSON(data []byte) error {
|
||||
|
||||
var raw struct {
|
||||
Code any `json:"code"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.Title = raw.Title
|
||||
|
||||
switch v := raw.Code.(type) {
|
||||
|
||||
case string:
|
||||
d.Code = v
|
||||
|
||||
case []any:
|
||||
if len(v) > 0 {
|
||||
if s, ok := v[0].(string); ok {
|
||||
d.Code = s
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
d.Code = ""
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type NebimOption struct {
|
||||
Username string `json:"username"`
|
||||
UserGroupCode string `json:"user_group_code"`
|
||||
}
|
||||
18
svc/models/user_list.go
Normal file
18
svc/models/user_list.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
// UserListRow — UserList.vue satır modeli (FINAL)
|
||||
type UserListRow struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
IsActive bool `json:"is_active"`
|
||||
|
||||
// Nebim eşleşmesi (ID yok)
|
||||
NebimUsername *string `json:"nebim_username,omitempty"`
|
||||
UserGroupCode *string `json:"user_group_code,omitempty"`
|
||||
|
||||
// UI’da gösterilecek toplu alanlar
|
||||
RoleNames string `json:"role_names"` // "ADMIN, USER"
|
||||
DepartmentNames string `json:"department_names"` // "UST YONETIM, ..."
|
||||
PiyasaNames string `json:"piyasa_names"` // "AVRUPA, LALELI"
|
||||
|
||||
}
|
||||
20
svc/models/users.go
Normal file
20
svc/models/users.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
type User struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
|
||||
IsActive bool `json:"is_active"`
|
||||
Email string `json:"email"`
|
||||
|
||||
RoleID int `json:"role_id"`
|
||||
RoleCode string `json:"role_code"`
|
||||
FullName string
|
||||
Mobile string
|
||||
Address string
|
||||
V3Username string `json:"v3_username"`
|
||||
V3UserGroup int `json:"v3_usergroup"`
|
||||
ForcePasswordChange bool `json:"force_password_change"`
|
||||
|
||||
Upass string // 🔐 dfusr.upass (TEK KAYNAK)
|
||||
}
|
||||
83
svc/permissions/models.go
Normal file
83
svc/permissions/models.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package permissions
|
||||
|
||||
/* =====================================================
|
||||
ROLE PERMISSION MATRIX (READ)
|
||||
- Role bazlı matrix ekranı için: role_id + role_code + module/action/allowed
|
||||
- UI isterse "source" alanı ile satırın nereden geldiğini gösterebilir.
|
||||
===================================================== */
|
||||
|
||||
type PermissionMatrixRow struct {
|
||||
// Role tarafı
|
||||
RoleID int `json:"role_id,omitempty"`
|
||||
RoleCode string `json:"role_code,omitempty"`
|
||||
|
||||
// User override tarafı
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
|
||||
// Ortak alanlar
|
||||
Module string `json:"module"`
|
||||
ModuleCode string `json:"module_code"`
|
||||
Action string `json:"action"`
|
||||
Allowed bool `json:"allowed"`
|
||||
|
||||
// role | user
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
ROLE PERMISSION UPDATE (WRITE)
|
||||
- /api/permissions/matrix POST gibi update endpoint'lerinde kullanılır
|
||||
===================================================== */
|
||||
|
||||
type PermissionUpdateRequest struct {
|
||||
RoleID int `json:"role_id"`
|
||||
Module string `json:"module"`
|
||||
Action string `json:"action"`
|
||||
Allowed bool `json:"allowed"`
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
USER OVERRIDE MODELS
|
||||
- mk_sys_user_permissions tablosu için
|
||||
===================================================== */
|
||||
|
||||
// DB’den okurken döndüğümüz satır (GET /api/users/{id}/permissions)
|
||||
type UserOverrideRow struct {
|
||||
Module string `json:"module"`
|
||||
Action string `json:"action"`
|
||||
Allowed bool `json:"allowed"`
|
||||
}
|
||||
|
||||
// Middleware içinde hızlı okuma için de kullanılabilir (GetUserOverrides)
|
||||
type UserPermissionOverride struct {
|
||||
Module string `json:"module"`
|
||||
Action string `json:"action"`
|
||||
Allowed bool `json:"allowed"`
|
||||
}
|
||||
|
||||
// Repo “upsert/insert” gibi operasyonlarda kullanılacak internal model
|
||||
type UserPermission struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Module string `json:"module"`
|
||||
Action string `json:"action"`
|
||||
Allowed bool `json:"allowed"`
|
||||
}
|
||||
|
||||
// POST payload için tip (SaveUserOverrides input)
|
||||
type UserPermissionRequest struct {
|
||||
Module string `json:"module"`
|
||||
Action string `json:"action"`
|
||||
Allowed bool `json:"allowed"`
|
||||
}
|
||||
|
||||
// Role + Department Permission Row
|
||||
type RoleDepartmentPermission struct {
|
||||
RoleID int `json:"role_id"`
|
||||
DepartmentCode string `json:"department_code"`
|
||||
|
||||
Module string `json:"module"`
|
||||
Action string `json:"action"`
|
||||
Allowed bool `json:"allowed"`
|
||||
|
||||
Source string `json:"source"` // "role_dept"
|
||||
}
|
||||
601
svc/permissions/repository.go
Normal file
601
svc/permissions/repository.go
Normal file
@@ -0,0 +1,601 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type PermissionRepository struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func NewPermissionRepository(db *sql.DB) *PermissionRepository {
|
||||
return &PermissionRepository{DB: db}
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
MATRIX READ (V2) - ROLE BASED
|
||||
===================================================== */
|
||||
|
||||
func (r *PermissionRepository) GetPermissionMatrixForRoles(
|
||||
roleIDs []int,
|
||||
) ([]PermissionMatrixRow, error) {
|
||||
|
||||
if len(roleIDs) == 0 {
|
||||
return []PermissionMatrixRow{}, nil
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
rp.role_id,
|
||||
rol.code,
|
||||
rp.module_code,
|
||||
rp.action,
|
||||
rp.allowed
|
||||
FROM mk_sys_role_permissions rp
|
||||
JOIN dfrole rol ON rol.id = rp.role_id
|
||||
WHERE rp.role_id = ANY($1)
|
||||
ORDER BY rol.id, rp.module_code, rp.action
|
||||
`
|
||||
|
||||
rows, err := r.DB.Query(query, pq.Array(roleIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
list := make([]PermissionMatrixRow, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var row PermissionMatrixRow
|
||||
if err := rows.Scan(
|
||||
&row.RoleID,
|
||||
&row.RoleCode,
|
||||
&row.Module,
|
||||
&row.Action,
|
||||
&row.Allowed,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row.Source = "role"
|
||||
list = append(list, row)
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
MATRIX UPDATE (V2) - ROLE BASED
|
||||
===================================================== */
|
||||
|
||||
func (r *PermissionRepository) UpdatePermissions(
|
||||
list []PermissionUpdateRequest,
|
||||
) error {
|
||||
|
||||
tx, err := r.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO mk_sys_role_permissions
|
||||
(role_id, module_code, action, allowed)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (role_id, module_code, action)
|
||||
DO UPDATE SET allowed = EXCLUDED.allowed
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, p := range list {
|
||||
if _, err := stmt.Exec(
|
||||
p.RoleID,
|
||||
p.Module,
|
||||
p.Action,
|
||||
p.Allowed,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
USER OVERRIDES - READ
|
||||
GET /api/users/{id}/permissions
|
||||
===================================================== */
|
||||
|
||||
// Tek tip: PermissionMatrixRow döndürüyoruz (source=user)
|
||||
func (r *PermissionRepository) GetUserOverridesByUserID(
|
||||
userID int64,
|
||||
) ([]PermissionMatrixRow, error) {
|
||||
|
||||
rows, err := r.DB.Query(`
|
||||
SELECT
|
||||
user_id,
|
||||
module_code,
|
||||
action,
|
||||
allowed
|
||||
FROM mk_sys_user_permissions
|
||||
WHERE user_id = $1
|
||||
ORDER BY module_code, action
|
||||
`, userID)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
list := make([]PermissionMatrixRow, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var row PermissionMatrixRow
|
||||
if err := rows.Scan(
|
||||
&row.UserID,
|
||||
&row.Module,
|
||||
&row.Action,
|
||||
&row.Allowed,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row.Source = "user"
|
||||
list = append(list, row)
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
USER OVERRIDES - UPSERT SAVE (typed)
|
||||
POST /api/users/{id}/permissions
|
||||
===================================================== */
|
||||
|
||||
func (r *PermissionRepository) SaveUserOverrides(
|
||||
userID int64,
|
||||
list []UserPermissionRequest,
|
||||
) error {
|
||||
|
||||
log.Println("➡️ REPO SaveUserOverrides START")
|
||||
log.Println("USER:", userID)
|
||||
log.Println("ROWS:", len(list))
|
||||
|
||||
tx, err := r.DB.Begin()
|
||||
if err != nil {
|
||||
log.Println("❌ TX BEGIN ERROR:", err)
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// önce sil
|
||||
_, err = tx.Exec(`
|
||||
DELETE FROM mk_sys_user_permissions
|
||||
WHERE user_id = $1
|
||||
`, userID)
|
||||
|
||||
if err != nil {
|
||||
log.Println("❌ DELETE ERROR:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO mk_sys_user_permissions
|
||||
(user_id, module_code, action, allowed)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
`)
|
||||
|
||||
if err != nil {
|
||||
log.Println("❌ PREPARE ERROR:", err)
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, p := range list {
|
||||
if strings.TrimSpace(p.Module) == "" {
|
||||
log.Printf("⚠️ SKIP EMPTY MODULE user=%d action=%s",
|
||||
userID,
|
||||
p.Action,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
_, err := stmt.Exec(
|
||||
userID,
|
||||
p.Module,
|
||||
p.Action,
|
||||
p.Allowed,
|
||||
)
|
||||
if err != nil {
|
||||
log.Println("❌ INSERT ERROR:", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Println("❌ COMMIT ERROR:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("✅ REPO SaveUserOverrides DONE")
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
RESOLUTION HELPERS (middleware için)
|
||||
===================================================== */
|
||||
|
||||
// user override var mı? varsa *bool döner, yoksa nil
|
||||
func (r *PermissionRepository) HasUserOverride(
|
||||
userID int64,
|
||||
module string,
|
||||
action string,
|
||||
) (*bool, error) {
|
||||
|
||||
var allowed bool
|
||||
|
||||
err := r.DB.QueryRow(`
|
||||
SELECT allowed
|
||||
FROM mk_sys_user_permissions
|
||||
WHERE user_id=$1
|
||||
AND module_code=$2
|
||||
AND action=$3
|
||||
`,
|
||||
userID,
|
||||
module,
|
||||
action,
|
||||
).Scan(&allowed)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &allowed, nil
|
||||
}
|
||||
|
||||
// roleIDs içinden OR logic: herhangi biri allowed=true ise true
|
||||
func (r *PermissionRepository) HasRoleAccess(
|
||||
roleIDs []int,
|
||||
module string,
|
||||
action string,
|
||||
) (bool, error) {
|
||||
|
||||
if len(roleIDs) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var count int
|
||||
|
||||
err := r.DB.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM mk_sys_role_permissions
|
||||
WHERE role_id = ANY($1)
|
||||
AND module_code=$2
|
||||
AND action=$3
|
||||
AND allowed=true
|
||||
`,
|
||||
pq.Array(roleIDs),
|
||||
module,
|
||||
action,
|
||||
).Scan(&count)
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// Final decision: user override varsa onu uygula, yoksa role bazlı
|
||||
func (r *PermissionRepository) ResolvePermission(
|
||||
userID int64,
|
||||
roleIDs []int,
|
||||
module string,
|
||||
action string,
|
||||
) (bool, error) {
|
||||
|
||||
override, err := r.HasUserOverride(userID, module, action)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if override != nil {
|
||||
return *override, nil
|
||||
}
|
||||
|
||||
return r.HasRoleAccess(roleIDs, module, action)
|
||||
}
|
||||
func (r *PermissionRepository) GetUserOverrides(
|
||||
userID int64,
|
||||
) ([]UserPermissionOverride, error) {
|
||||
|
||||
rows, err := r.DB.Query(`
|
||||
SELECT module_code, action, allowed
|
||||
FROM mk_sys_user_permissions
|
||||
WHERE user_id = $1
|
||||
`, userID)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []UserPermissionOverride
|
||||
|
||||
for rows.Next() {
|
||||
|
||||
var o UserPermissionOverride
|
||||
|
||||
if err := rows.Scan(
|
||||
&o.Module,
|
||||
&o.Action,
|
||||
&o.Allowed,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list = append(list, o)
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
func (r *PermissionRepository) UpdateUserOverrides(
|
||||
list []UserPermission,
|
||||
) error {
|
||||
|
||||
tx, err := r.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO mk_sys_user_permissions
|
||||
(user_id, module_code, action, allowed)
|
||||
|
||||
VALUES ($1,$2,$3,$4)
|
||||
|
||||
ON CONFLICT (user_id, module_code, action)
|
||||
DO UPDATE SET allowed = EXCLUDED.allowed
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, p := range list {
|
||||
|
||||
_, err := stmt.Exec(
|
||||
p.UserID,
|
||||
p.Module,
|
||||
p.Action,
|
||||
p.Allowed,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
func (r *PermissionRepository) ResolveEffectivePermission(
|
||||
userID int64,
|
||||
roleID int64,
|
||||
deptCode string,
|
||||
module string,
|
||||
action string,
|
||||
) (bool, error) {
|
||||
|
||||
// 1️⃣ USER OVERRIDE
|
||||
var allowed bool
|
||||
|
||||
err := r.DB.QueryRow(`
|
||||
SELECT allowed
|
||||
FROM mk_sys_user_permissions
|
||||
WHERE user_id = $1
|
||||
AND module_code = $2
|
||||
AND action = $3
|
||||
`,
|
||||
userID, module, action,
|
||||
).Scan(&allowed)
|
||||
|
||||
if err == nil {
|
||||
return allowed, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// ==================================================
|
||||
// 2️⃣ ROLE + DEPARTMENT
|
||||
// ==================================================
|
||||
|
||||
if len(deptCode) > 0 {
|
||||
|
||||
var allowed bool
|
||||
|
||||
err = r.DB.QueryRow(` -- 🔥 := DEĞİL =
|
||||
SELECT allowed
|
||||
FROM vw_role_dept_permissions
|
||||
WHERE role_id = $1
|
||||
AND department_code = ANY($2)
|
||||
AND module_code = $3
|
||||
AND action = $4
|
||||
ORDER BY allowed DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
roleID,
|
||||
pq.Array([]string{deptCode}),
|
||||
module,
|
||||
action,
|
||||
).Scan(&allowed)
|
||||
|
||||
if err == nil {
|
||||
|
||||
log.Printf(
|
||||
" ↳ ROLE+DEPT OVERRIDE = %v",
|
||||
allowed,
|
||||
)
|
||||
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
if err != sql.ErrNoRows {
|
||||
log.Println("❌ ROLE+DEPT ERR:", err)
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// 3️⃣ ROLE DEFAULT
|
||||
err = r.DB.QueryRow(`
|
||||
SELECT allowed
|
||||
FROM mk_sys_role_permissions
|
||||
WHERE role_id = $1
|
||||
AND module_code = $2
|
||||
AND action = $3
|
||||
`,
|
||||
roleID, module, action,
|
||||
).Scan(&allowed)
|
||||
|
||||
if err == nil {
|
||||
return allowed, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// 4️⃣ DENY
|
||||
return false, nil
|
||||
}
|
||||
func (r *PermissionRepository) ResolvePermissionChain(
|
||||
userID int64,
|
||||
roleID int64,
|
||||
deptCodes []string,
|
||||
module string,
|
||||
action string,
|
||||
) (bool, error) {
|
||||
|
||||
log.Printf(
|
||||
"🔐 PERM CHECK user=%d role=%d dept=%v %s:%s",
|
||||
userID,
|
||||
roleID,
|
||||
deptCodes,
|
||||
module,
|
||||
action,
|
||||
)
|
||||
|
||||
// ==================================================
|
||||
// 1️⃣ USER OVERRIDE
|
||||
// ==================================================
|
||||
|
||||
override, err := r.HasUserOverride(userID, module, action)
|
||||
if err != nil {
|
||||
log.Println("❌ USER OVERRIDE ERR:", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
if override != nil {
|
||||
|
||||
log.Printf(
|
||||
" ↳ USER OVERRIDE = %v",
|
||||
*override,
|
||||
)
|
||||
|
||||
return *override, nil
|
||||
}
|
||||
|
||||
// ==================================================
|
||||
// 2️⃣ ROLE + DEPARTMENT
|
||||
// ==================================================
|
||||
|
||||
if len(deptCodes) > 0 {
|
||||
|
||||
var allowed bool
|
||||
|
||||
err := r.DB.QueryRow(`
|
||||
SELECT allowed
|
||||
FROM vw_role_dept_permissions
|
||||
WHERE role_id = $1
|
||||
AND department_code IN (
|
||||
SELECT UNNEST($2::text[])
|
||||
)
|
||||
AND module_code = $3
|
||||
AND action = $4
|
||||
ORDER BY allowed DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
roleID,
|
||||
pq.Array(deptCodes),
|
||||
module,
|
||||
action,
|
||||
).Scan(&allowed)
|
||||
|
||||
if err == nil {
|
||||
|
||||
log.Printf(
|
||||
" ↳ ROLE+DEPT OVERRIDE = %v",
|
||||
allowed,
|
||||
)
|
||||
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
if err != sql.ErrNoRows {
|
||||
log.Println("❌ ROLE+DEPT ERR:", err)
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================
|
||||
// 3️⃣ ROLE DEFAULT
|
||||
// ==================================================
|
||||
|
||||
var roleAllowed bool
|
||||
|
||||
err = r.DB.QueryRow(`
|
||||
SELECT allowed
|
||||
FROM mk_sys_role_permissions
|
||||
WHERE role_id = $1
|
||||
AND module_code = $2
|
||||
AND action = $3
|
||||
`,
|
||||
roleID,
|
||||
module,
|
||||
action,
|
||||
).Scan(&roleAllowed)
|
||||
|
||||
if err == nil {
|
||||
|
||||
log.Printf(
|
||||
" ↳ ROLE DEFAULT = %v",
|
||||
roleAllowed,
|
||||
)
|
||||
|
||||
return roleAllowed, nil
|
||||
}
|
||||
|
||||
if err != sql.ErrNoRows {
|
||||
log.Println("❌ ROLE DEFAULT ERR:", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
// ==================================================
|
||||
// 4️⃣ DENY
|
||||
// ==================================================
|
||||
|
||||
log.Println(" ↳ NO RULE → DENY")
|
||||
|
||||
return false, nil
|
||||
}
|
||||
123
svc/permissions/role_department_repo.go
Normal file
123
svc/permissions/role_department_repo.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"bssapp-backend/queries"
|
||||
"database/sql"
|
||||
"log"
|
||||
)
|
||||
|
||||
type RoleDepartmentPermissionRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRoleDepartmentPermissionRepo(db *sql.DB) *RoleDepartmentPermissionRepo {
|
||||
return &RoleDepartmentPermissionRepo{db: db}
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
GET
|
||||
====================================================== */
|
||||
|
||||
func (r *RoleDepartmentPermissionRepo) Get(
|
||||
roleID int,
|
||||
deptCode string,
|
||||
) ([]RoleDepartmentPermission, error) {
|
||||
|
||||
rows, err := r.db.Query(
|
||||
queries.GetRoleDepartmentPermissions,
|
||||
roleID,
|
||||
deptCode,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []RoleDepartmentPermission
|
||||
|
||||
for rows.Next() {
|
||||
|
||||
var p RoleDepartmentPermission
|
||||
|
||||
if err := rows.Scan(
|
||||
&p.Module,
|
||||
&p.Action,
|
||||
&p.Allowed,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.RoleID = roleID
|
||||
p.DepartmentCode = deptCode
|
||||
p.Source = "role_department"
|
||||
|
||||
list = append(list, p)
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
SAVE
|
||||
====================================================== */
|
||||
|
||||
func (r *RoleDepartmentPermissionRepo) Save(
|
||||
roleID int,
|
||||
deptCode string,
|
||||
list []RoleDepartmentPermission,
|
||||
) error {
|
||||
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(
|
||||
queries.UpsertRoleDepartmentPermission,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, p := range list {
|
||||
|
||||
if p.Module == "" {
|
||||
log.Printf(
|
||||
"⚠️ SKIP EMPTY MODULE role=%v dept=%v action=%v",
|
||||
roleID,
|
||||
deptCode,
|
||||
p.Action,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
_, err := stmt.Exec(
|
||||
roleID,
|
||||
deptCode,
|
||||
p.Module,
|
||||
p.Action,
|
||||
p.Allowed,
|
||||
)
|
||||
|
||||
// 🔴🔴🔴 KRİTİK DEBUG LOG
|
||||
if err != nil {
|
||||
|
||||
log.Printf(
|
||||
"❌ UPSERT FAIL role=%v dept=%v module=%v action=%v allowed=%v err=%v",
|
||||
roleID,
|
||||
deptCode,
|
||||
p.Module,
|
||||
p.Action,
|
||||
p.Allowed,
|
||||
err,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
33
svc/permissions/role_policy.go
Normal file
33
svc/permissions/role_policy.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package permissions
|
||||
|
||||
// 🔥 SYSTEM / ADMIN ROLE IDS
|
||||
var AdminRoleIDs = map[int]struct{}{
|
||||
1: {},
|
||||
3: {},
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 🧠 ROLE POLICY
|
||||
// -------------------------------------------------------
|
||||
func ResolveEffectiveRoles(
|
||||
roleIDs []int,
|
||||
) (effectiveRoleIDs []int, isAdmin bool) {
|
||||
|
||||
roleMap := make(map[int]struct{})
|
||||
|
||||
for _, roleID := range roleIDs {
|
||||
|
||||
// 🔥 SYSTEM / ADMIN OVERRIDE (1,3,…)
|
||||
if _, ok := AdminRoleIDs[roleID]; ok {
|
||||
return []int{roleID}, true
|
||||
}
|
||||
|
||||
roleMap[roleID] = struct{}{}
|
||||
}
|
||||
|
||||
for id := range roleMap {
|
||||
effectiveRoleIDs = append(effectiveRoleIDs, id)
|
||||
}
|
||||
|
||||
return effectiveRoleIDs, false
|
||||
}
|
||||
40
svc/permissions/seed.go
Normal file
40
svc/permissions/seed.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package permissions
|
||||
|
||||
import "database/sql"
|
||||
|
||||
func SeedAdminRoleDepartments(db *sql.DB) error {
|
||||
|
||||
var adminID int
|
||||
var err error
|
||||
|
||||
// Admin role id al
|
||||
err = db.QueryRow(`
|
||||
SELECT id
|
||||
FROM dfrole
|
||||
WHERE code = 'admin'
|
||||
`).Scan(&adminID)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Seed
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO mk_sys_role_department_permissions
|
||||
(role_id, department_code, module_code, action, allowed)
|
||||
|
||||
SELECT
|
||||
$1,
|
||||
d.code,
|
||||
r.module_code,
|
||||
r.action,
|
||||
true
|
||||
FROM mk_dprt d
|
||||
CROSS JOIN mk_sys_routes r
|
||||
|
||||
ON CONFLICT (role_id, department_code, module_code, action)
|
||||
DO NOTHING
|
||||
`, adminID)
|
||||
|
||||
return err
|
||||
}
|
||||
BIN
svc/public/Baggi-Tekstil-A.s-Logolu.jpeg
Normal file
BIN
svc/public/Baggi-Tekstil-A.s-Logolu.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
74
svc/queries/account.go
Normal file
74
svc/queries/account.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"bssapp-backend/db"
|
||||
"bssapp-backend/internal/authz"
|
||||
"bssapp-backend/models"
|
||||
)
|
||||
|
||||
func GetAccounts(ctx context.Context) ([]models.Account, error) {
|
||||
|
||||
piyasaFilter := authz.BuildMSSQLPiyasaFilter(ctx, "f2.CustomerAtt01")
|
||||
|
||||
if strings.TrimSpace(piyasaFilter) == "" {
|
||||
piyasaFilter = "1=1"
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
x.AccountCode,
|
||||
MAX(x.AccountName) AS AccountName
|
||||
FROM (
|
||||
SELECT
|
||||
LEFT(b.CurrAccCode, 8) AS AccountCode,
|
||||
COALESCE(d.CurrAccDescription, '') AS AccountName
|
||||
FROM trCurrAccBook b
|
||||
LEFT JOIN cdCurrAccDesc d
|
||||
ON d.CurrAccCode = b.CurrAccCode
|
||||
JOIN CustomerAttributesFilter f2
|
||||
ON f2.CurrAccCode = b.CurrAccCode
|
||||
WHERE %s
|
||||
) x
|
||||
GROUP BY x.AccountCode
|
||||
ORDER BY x.AccountCode
|
||||
`, piyasaFilter)
|
||||
|
||||
log.Println("🔎 ACCOUNT PIYASA FILTER =", piyasaFilter)
|
||||
log.Println("🔎 ACCOUNT QUERY =", query)
|
||||
|
||||
rows, err := db.MssqlDB.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MSSQL query error: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var accounts []models.Account
|
||||
|
||||
for rows.Next() {
|
||||
|
||||
var acc models.Account
|
||||
|
||||
if err := rows.Scan(
|
||||
&acc.AccountCode,
|
||||
&acc.AccountName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(acc.AccountCode) >= 4 {
|
||||
acc.DisplayCode =
|
||||
strings.TrimSpace(acc.AccountCode[:3] + " " + acc.AccountCode[3:])
|
||||
} else {
|
||||
acc.DisplayCode = acc.AccountCode
|
||||
}
|
||||
|
||||
accounts = append(accounts, acc)
|
||||
}
|
||||
|
||||
return accounts, rows.Err()
|
||||
}
|
||||
64
svc/queries/currency_cache.go
Normal file
64
svc/queries/currency_cache.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"bssapp-backend/models"
|
||||
"database/sql"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
/* ===============================
|
||||
CACHE STRUCT
|
||||
================================ */
|
||||
|
||||
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
|
||||
}
|
||||
114
svc/queries/customerlist.go
Normal file
114
svc/queries/customerlist.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"bssapp-backend/db"
|
||||
"bssapp-backend/internal/authz"
|
||||
"bssapp-backend/models"
|
||||
)
|
||||
|
||||
func GetCustomerList(ctx context.Context) ([]models.CustomerList, error) {
|
||||
|
||||
piyasaFilter := authz.BuildMSSQLPiyasaFilter(
|
||||
ctx,
|
||||
"f.CustomerAtt01",
|
||||
)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
c.CurrAccTypeCode,
|
||||
c.CurrAccCode,
|
||||
ISNULL(d.CurrAccDescription, ''),
|
||||
|
||||
dbo.HG_Temizlik(
|
||||
ISNULL((
|
||||
SELECT AttributeDescription
|
||||
FROM cdCurrAccAttributeDesc WITH(NOLOCK)
|
||||
WHERE CurrAccTypeCode = 3
|
||||
AND AttributeTypeCode = 8
|
||||
AND AttributeCode = f.CustomerAtt08
|
||||
AND LangCode = 'TR'
|
||||
), SPACE(0))
|
||||
),
|
||||
|
||||
dbo.HG_Temizlik(
|
||||
ISNULL((
|
||||
SELECT AttributeDescription
|
||||
FROM cdCurrAccAttributeDesc WITH(NOLOCK)
|
||||
WHERE CurrAccTypeCode = 3
|
||||
AND AttributeTypeCode = 1
|
||||
AND AttributeCode = f.CustomerAtt01
|
||||
AND LangCode = 'TR'
|
||||
), SPACE(0))
|
||||
),
|
||||
|
||||
dbo.HG_Temizlik(
|
||||
ISNULL((
|
||||
SELECT AttributeDescription
|
||||
FROM cdCurrAccAttributeDesc WITH(NOLOCK)
|
||||
WHERE CurrAccTypeCode = 3
|
||||
AND AttributeTypeCode = 2
|
||||
AND AttributeCode = f.CustomerAtt02
|
||||
AND LangCode = 'TR'
|
||||
), SPACE(0))
|
||||
),
|
||||
|
||||
dbo.HG_Temizlik(
|
||||
ISNULL((
|
||||
SELECT AttributeDescription
|
||||
FROM cdCurrAccAttributeDesc WITH(NOLOCK)
|
||||
WHERE CurrAccTypeCode = 3
|
||||
AND AttributeTypeCode = 5
|
||||
AND AttributeCode = f.CustomerAtt05
|
||||
AND LangCode = 'TR'
|
||||
), SPACE(0))
|
||||
),
|
||||
|
||||
ISNULL(c.CurrencyCode, '')
|
||||
|
||||
FROM cdCurrAcc c
|
||||
LEFT JOIN cdCurrAccDesc d
|
||||
ON c.CurrAccCode = d.CurrAccCode
|
||||
LEFT JOIN CustomerAttributesFilter f
|
||||
ON c.CurrAccCode = f.CurrAccCode
|
||||
|
||||
WHERE
|
||||
c.CompanyCode = 1
|
||||
AND c.CurrAccTypeCode = 3
|
||||
AND c.IsBlocked = 0
|
||||
AND %s
|
||||
|
||||
ORDER BY d.CurrAccDescription
|
||||
`, piyasaFilter)
|
||||
|
||||
rows, err := db.MssqlDB.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []models.CustomerList
|
||||
|
||||
for rows.Next() {
|
||||
var c models.CustomerList
|
||||
|
||||
if err := rows.Scan(
|
||||
&c.CurrAccTypeCode,
|
||||
&c.Cari_Kod,
|
||||
&c.Cari_Ad,
|
||||
&c.Musteri_Ana_Grubu,
|
||||
&c.Piyasa,
|
||||
&c.Musteri_Temsilcisi,
|
||||
&c.Ulke,
|
||||
&c.Doviz_cinsi,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list = append(list, c)
|
||||
}
|
||||
|
||||
return list, rows.Err()
|
||||
}
|
||||
52
svc/queries/get_order_list_excel.go
Normal file
52
svc/queries/get_order_list_excel.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func GetOrderListExcel(
|
||||
db *sql.DB,
|
||||
search string,
|
||||
currAcc string,
|
||||
orderDate string,
|
||||
) (*sql.Rows, error) {
|
||||
|
||||
q := OrderListBaseQuery + " AND 1=1 "
|
||||
args := []interface{}{}
|
||||
|
||||
// SEARCH
|
||||
if search != "" {
|
||||
q += `
|
||||
AND (
|
||||
LOWER(h.OrderNumber) LIKE LOWER(@p1) OR
|
||||
LOWER(h.CurrAccCode) LIKE LOWER(@p1) OR
|
||||
LOWER(ca.CurrAccDescription) LIKE LOWER(@p1) OR
|
||||
LOWER(h.Description) LIKE LOWER(@p1) OR
|
||||
LOWER(mt.AttributeDescription) LIKE LOWER(@p1) OR
|
||||
LOWER(py.AttributeDescription) LIKE LOWER(@p1)
|
||||
)
|
||||
`
|
||||
args = append(args, "%"+search+"%")
|
||||
}
|
||||
|
||||
// CURRACC
|
||||
if currAcc != "" {
|
||||
q += fmt.Sprintf(" AND h.CurrAccCode = @p%d ", len(args)+1)
|
||||
args = append(args, currAcc)
|
||||
}
|
||||
|
||||
// DATE
|
||||
if orderDate != "" {
|
||||
q += fmt.Sprintf(
|
||||
" AND CONVERT(varchar, h.OrderDate, 23) = @p%d ",
|
||||
len(args)+1,
|
||||
)
|
||||
args = append(args, orderDate)
|
||||
}
|
||||
|
||||
// ORDER BY SONDA
|
||||
q += " ORDER BY h.CreatedDate DESC "
|
||||
|
||||
return db.Query(q, args...)
|
||||
}
|
||||
174
svc/queries/helpers.go
Normal file
174
svc/queries/helpers.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"bssapp-backend/models"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 🔥 UNIVERSAL DATETIME PARSER
|
||||
// ============================================================
|
||||
func parseDateTime(str string) (time.Time, error) {
|
||||
layouts := []string{
|
||||
"2006-01-02 15:04:05", // "2025-11-21 12:37:21"
|
||||
time.RFC3339, // "2025-11-21T12:37:21Z"
|
||||
"2006-01-02", // "2025-11-21"
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, str); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("datetime parse edilemedi: %s", str)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 📌 DATE (YYYY-MM-DD) → sql.NullTime
|
||||
// ============================================================
|
||||
func nullableDateString(ns models.NullString) sql.NullTime {
|
||||
if ns.Valid && strings.TrimSpace(ns.String) != "" {
|
||||
t, err := time.Parse("2006-01-02", ns.String)
|
||||
if err == nil {
|
||||
return sql.NullTime{Valid: true, Time: t}
|
||||
}
|
||||
}
|
||||
return sql.NullTime{}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 📌 TIME (HH:mm:ss) → sql.NullString
|
||||
// ============================================================
|
||||
func nullableTimeString(ns models.NullString) sql.NullString {
|
||||
if ns.Valid && strings.TrimSpace(ns.String) != "" {
|
||||
return sql.NullString{String: ns.String, Valid: true}
|
||||
}
|
||||
return sql.NullString{}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 📌 DATETIME (CustomTime → sql.NullTime)
|
||||
// ============================================================
|
||||
func nullableDateTime(ct models.CustomTime, fallback time.Time) sql.NullTime {
|
||||
if ct.Valid && !ct.Time.IsZero() {
|
||||
return sql.NullTime{Valid: true, Time: ct.Time}
|
||||
}
|
||||
return sql.NullTime{Valid: true, Time: fallback}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 📌 DATETIME (NullTime → sql.NullTime)
|
||||
// ============================================================
|
||||
func nullableTime(nt models.NullTime, fallback time.Time) sql.NullTime {
|
||||
if nt.Valid && !nt.Time.IsZero() {
|
||||
return sql.NullTime{Valid: true, Time: nt.Time}
|
||||
}
|
||||
return sql.NullTime{Valid: true, Time: fallback}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 📌 DATETIME (NullString → sql.NullTime)
|
||||
// ============================================================
|
||||
func nullableDateTimeString(ns models.NullString, fallback time.Time) sql.NullTime {
|
||||
if !ns.Valid || strings.TrimSpace(ns.String) == "" {
|
||||
return sql.NullTime{Time: fallback, Valid: true}
|
||||
}
|
||||
t, err := parseDateTime(ns.String)
|
||||
if err != nil {
|
||||
return sql.NullTime{Time: fallback, Valid: true}
|
||||
}
|
||||
return sql.NullTime{Time: t, Valid: true}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 📌 STRING → sql.NullString
|
||||
// ============================================================
|
||||
func nullableString(val models.NullString, fallback string) sql.NullString {
|
||||
if val.Valid && strings.TrimSpace(val.String) != "" {
|
||||
return sql.NullString{String: val.String, Valid: true}
|
||||
}
|
||||
return sql.NullString{String: fallback, Valid: true}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 📌 GUID (UNIQUEIDENTIFIER) → interface{}
|
||||
// ============================================================
|
||||
func nullableUUID(v interface{}) interface{} {
|
||||
switch x := v.(type) {
|
||||
|
||||
// 1️⃣ models.NullUUID → direkt geri dön
|
||||
case models.NullUUID:
|
||||
if !x.Valid {
|
||||
return nil
|
||||
}
|
||||
return x.UUID
|
||||
|
||||
// 2️⃣ models.NullString → GUID parse et
|
||||
case models.NullString:
|
||||
if !x.Valid || strings.TrimSpace(x.String) == "" {
|
||||
return nil
|
||||
}
|
||||
id, err := uuid.Parse(x.String)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
|
||||
// 3️⃣ string → GUID parse et
|
||||
case string:
|
||||
id, err := uuid.Parse(x)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 📌 NUMERIC → sql.NullX
|
||||
// ============================================================
|
||||
func nullableFloat64(val models.NullFloat64, fallback float64) sql.NullFloat64 {
|
||||
if val.Valid {
|
||||
return sql.NullFloat64{Float64: val.Float64, Valid: true}
|
||||
}
|
||||
return sql.NullFloat64{Float64: fallback, Valid: true}
|
||||
}
|
||||
|
||||
func nullableInt16(val models.NullInt16, fallback int16) sql.NullInt16 {
|
||||
if val.Valid {
|
||||
return sql.NullInt16{Int16: val.Int16, Valid: true}
|
||||
}
|
||||
return sql.NullInt16{Int16: fallback, Valid: true}
|
||||
}
|
||||
|
||||
func nullableInt32(val models.NullInt32, fallback int32) sql.NullInt32 {
|
||||
if val.Valid {
|
||||
return sql.NullInt32{Int32: val.Int32, Valid: true}
|
||||
}
|
||||
return sql.NullInt32{Int32: fallback, Valid: true}
|
||||
}
|
||||
|
||||
func nullableInt32ToInt16(val models.NullInt32, fallback int16) sql.NullInt16 {
|
||||
if val.Valid {
|
||||
return sql.NullInt16{Int16: int16(val.Int32), Valid: true}
|
||||
}
|
||||
return sql.NullInt16{Int16: fallback, Valid: true}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 📌 BOOL → sql.NullBool
|
||||
// ============================================================
|
||||
func nullableBool(val models.NullBool, fallback bool) sql.NullBool {
|
||||
if val.Valid {
|
||||
return sql.NullBool{Bool: val.Bool, Valid: true}
|
||||
}
|
||||
return sql.NullBool{Bool: fallback, Valid: true}
|
||||
}
|
||||
|
||||
var logger = log.Default()
|
||||
93
svc/queries/inventoryproduct.go
Normal file
93
svc/queries/inventoryproduct.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package queries
|
||||
|
||||
// 🔹 Ürüne göre stok detay sorgusu
|
||||
const GetInventoryProduct = `
|
||||
-- 🔹 Ürüne göre stok detay sorgusu (Nebim V3 uyumlu, parametre güvenli)
|
||||
SELECT
|
||||
bsItemTypeDesc.ItemTypeDescription AS InventoryType,
|
||||
inv.WarehouseCode AS Depo_Kodu,
|
||||
wh.WarehouseDescription AS Depo_Adi,
|
||||
inv.ItemCode AS Urun_Kodu,
|
||||
ISNULL(descItem.ItemDescription, '') AS Madde_Aciklamasi,
|
||||
inv.ColorCode AS Renk_Kodu,
|
||||
ISNULL(descColor.ColorDescription, '') AS Renk_Aciklamasi,
|
||||
inv.ItemDim1Code AS Beden,
|
||||
attr.ProductAtt01 AS Urun_Grubu,
|
||||
attr.ProductAtt02 AS Urun_Alt_Grubu,
|
||||
attr.ProductAtt41 AS Kisa_Karisim,
|
||||
attr.ProductAtt42 AS SERI,
|
||||
attr.ProductAtt43 AS FASON_ISCLIK,
|
||||
attr.ProductAtt45 AS ASKILI_YAN,
|
||||
inv.ItemDim2Code AS YAKA,
|
||||
attr.ProductAtt44 AS GARSON_YETISKIN,
|
||||
attr.ProductAtt10 AS MarkaKodu,
|
||||
ROUND(
|
||||
SUM(inv.InventoryQty1)
|
||||
- (SUM(inv.ReserveQty1) + SUM(inv.DispOrderQty1) + SUM(inv.PickingQty1)),
|
||||
cdUnitOfMeasure.RoundDigit
|
||||
) AS Kullanilabilir_Envanter
|
||||
FROM cdItem WITH (NOLOCK)
|
||||
JOIN cdUnitOfMeasure WITH (NOLOCK)
|
||||
ON cdItem.UnitOfMeasureCode1 = cdUnitOfMeasure.UnitOfMeasureCode
|
||||
JOIN (
|
||||
SELECT
|
||||
CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
|
||||
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code,
|
||||
SUM(CASE WHEN SourceTable = 'PickingStates' THEN Qty1 ELSE 0 END) AS PickingQty1,
|
||||
SUM(CASE WHEN SourceTable = 'ReserveStates' THEN Qty1 ELSE 0 END) AS ReserveQty1,
|
||||
SUM(CASE WHEN SourceTable = 'DispOrderStates' THEN Qty1 ELSE 0 END) AS DispOrderQty1,
|
||||
SUM(CASE WHEN SourceTable = 'trStock' THEN (In_Qty1 - Out_Qty1) ELSE 0 END) AS InventoryQty1
|
||||
FROM (
|
||||
SELECT 'PickingStates' AS SourceTable, CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
|
||||
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code, Qty1, 0 AS In_Qty1, 0 AS Out_Qty1
|
||||
FROM PickingStates WITH (NOLOCK)
|
||||
UNION ALL
|
||||
SELECT 'ReserveStates', CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
|
||||
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code, Qty1, 0, 0
|
||||
FROM ReserveStates WITH (NOLOCK)
|
||||
UNION ALL
|
||||
SELECT 'DispOrderStates', CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
|
||||
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code, Qty1, 0, 0
|
||||
FROM DispOrderStates WITH (NOLOCK)
|
||||
UNION ALL
|
||||
SELECT 'trStock', CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
|
||||
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code, 0, SUM(In_Qty1), SUM(Out_Qty1)
|
||||
FROM trStock WITH (NOLOCK)
|
||||
GROUP BY CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
|
||||
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code
|
||||
) AS src
|
||||
GROUP BY CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
|
||||
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code
|
||||
) AS inv
|
||||
ON cdItem.ItemTypeCode = inv.ItemTypeCode
|
||||
AND cdItem.ItemCode = inv.ItemCode
|
||||
LEFT JOIN ProductAttributesFilter AS attr WITH (NOLOCK)
|
||||
ON attr.ItemCode = inv.ItemCode
|
||||
LEFT JOIN bsItemTypeDesc WITH (NOLOCK)
|
||||
ON bsItemTypeDesc.ItemTypeCode = inv.ItemTypeCode
|
||||
AND bsItemTypeDesc.LangCode = 'TR'
|
||||
LEFT JOIN cdWarehouseDesc AS wh WITH (NOLOCK)
|
||||
ON wh.WarehouseCode = inv.WarehouseCode
|
||||
LEFT JOIN cdItemDesc AS descItem WITH (NOLOCK)
|
||||
ON descItem.ItemTypeCode = inv.ItemTypeCode
|
||||
AND descItem.ItemCode = inv.ItemCode
|
||||
AND descItem.LangCode = 'TR'
|
||||
LEFT JOIN cdColorDesc AS descColor WITH (NOLOCK)
|
||||
ON descColor.ColorCode = inv.ColorCode
|
||||
AND descColor.LangCode = 'TR'
|
||||
WHERE
|
||||
inv.ItemTypeCode IN (1)
|
||||
AND inv.WarehouseCode IN ('1-0-12','1-0-21','1-0-10','1-0-2','1-1-3','1-2-4','1-2-5','')
|
||||
AND inv.InventoryQty1 >= 0
|
||||
AND cdItem.IsBlocked = 0
|
||||
AND inv.ItemCode = @p1 -- ✅ doğrudan parametre, DECLARE yok
|
||||
GROUP BY
|
||||
inv.CompanyCode, inv.OfficeCode, inv.StoreTypeCode, inv.StoreCode,
|
||||
inv.WarehouseCode, inv.ItemTypeCode, inv.ItemCode, inv.ColorCode,
|
||||
inv.ItemDim1Code, inv.ItemDim2Code, inv.ItemDim3Code,
|
||||
cdUnitOfMeasure.RoundDigit, attr.ProductAtt01, attr.ProductAtt02,
|
||||
attr.ProductAtt41, attr.ProductAtt42, attr.ProductAtt43, attr.ProductAtt44,
|
||||
attr.ProductAtt45, attr.ProductAtt10, bsItemTypeDesc.ItemTypeDescription,
|
||||
wh.WarehouseDescription, descItem.ItemDescription, descColor.ColorDescription;
|
||||
|
||||
`
|
||||
280
svc/queries/order_get.go
Normal file
280
svc/queries/order_get.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"bssapp-backend/db"
|
||||
"bssapp-backend/models"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GetOrderByID — Sipariş başlığı (header) ve satırlarını (lines) getirir.
|
||||
func GetOrderByID(orderID string) (*models.OrderHeader, []models.OrderDetail, error) {
|
||||
conn := db.GetDB()
|
||||
|
||||
logger.Printf("🧾 [GetOrderByID] begin • id=%s", orderID)
|
||||
|
||||
// =====================================================
|
||||
// HEADER (Cari adı join'li)
|
||||
// =====================================================
|
||||
var header models.OrderHeader
|
||||
qHeader := `
|
||||
SELECT
|
||||
CAST(h.OrderHeaderID AS varchar(36)) AS OrderHeaderID,
|
||||
h.OrderTypeCode,
|
||||
h.ProcessCode,
|
||||
h.OrderNumber,
|
||||
h.IsCancelOrder,
|
||||
h.OrderDate,
|
||||
h.OrderTime,
|
||||
h.DocumentNumber,
|
||||
h.PaymentTerm,
|
||||
h.AverageDueDate,
|
||||
h.Description,
|
||||
h.InternalDescription,
|
||||
h.CurrAccTypeCode,
|
||||
h.CurrAccCode,
|
||||
d.CurrAccDescription,
|
||||
h.SubCurrAccID,
|
||||
h.ContactID,
|
||||
h.ShipmentMethodCode,
|
||||
h.ShippingPostalAddressID,
|
||||
h.BillingPostalAddressID,
|
||||
h.GuarantorContactID,
|
||||
h.GuarantorContactID2,
|
||||
h.RoundsmanCode,
|
||||
h.DeliveryCompanyCode,
|
||||
h.TaxTypeCode,
|
||||
h.WithHoldingTaxTypeCode,
|
||||
h.DOVCode,
|
||||
h.TaxExemptionCode,
|
||||
h.CompanyCode,
|
||||
h.OfficeCode,
|
||||
h.StoreTypeCode,
|
||||
h.StoreCode,
|
||||
h.POSTerminalID,
|
||||
h.WarehouseCode,
|
||||
h.ToWarehouseCode,
|
||||
h.OrdererCompanyCode,
|
||||
h.OrdererOfficeCode,
|
||||
h.OrdererStoreCode,
|
||||
h.GLTypeCode,
|
||||
h.DocCurrencyCode,
|
||||
h.LocalCurrencyCode,
|
||||
h.ExchangeRate,
|
||||
h.TDisRate1,
|
||||
h.TDisRate2,
|
||||
h.TDisRate3,
|
||||
h.TDisRate4,
|
||||
h.TDisRate5,
|
||||
h.DiscountReasonCode,
|
||||
h.SurplusOrderQtyToleranceRate,
|
||||
h.ImportFileNumber,
|
||||
h.ExportFileNumber,
|
||||
h.IncotermCode1,
|
||||
h.IncotermCode2,
|
||||
h.LettersOfCreditNumber,
|
||||
h.PaymentMethodCode,
|
||||
h.IsInclutedVat,
|
||||
h.IsCreditSale,
|
||||
h.IsCreditableConfirmed,
|
||||
h.CreditableConfirmedUser,
|
||||
h.CreditableConfirmedDate,
|
||||
h.IsSalesViaInternet,
|
||||
h.IsSuspended,
|
||||
h.IsCompleted,
|
||||
h.IsPrinted,
|
||||
h.IsLocked,
|
||||
h.UserLocked,
|
||||
h.IsClosed,
|
||||
h.ApplicationCode,
|
||||
h.ApplicationID,
|
||||
h.CreatedUserName,
|
||||
h.CreatedDate,
|
||||
h.LastUpdatedUserName,
|
||||
h.LastUpdatedDate,
|
||||
h.IsProposalBased
|
||||
FROM BAGGI_V3.dbo.trOrderHeader AS h
|
||||
LEFT JOIN BAGGI_V3.dbo.cdCurrAccDesc AS d
|
||||
ON h.CurrAccCode = d.CurrAccCode
|
||||
WHERE h.OrderHeaderID = @p1;
|
||||
`
|
||||
|
||||
err := conn.QueryRow(qHeader, orderID).Scan(
|
||||
&header.OrderHeaderID,
|
||||
&header.OrderTypeCode,
|
||||
&header.ProcessCode,
|
||||
&header.OrderNumber,
|
||||
&header.IsCancelOrder,
|
||||
&header.OrderDate,
|
||||
&header.OrderTime,
|
||||
&header.DocumentNumber,
|
||||
&header.PaymentTerm,
|
||||
&header.AverageDueDate,
|
||||
&header.Description,
|
||||
&header.InternalDescription,
|
||||
&header.CurrAccTypeCode,
|
||||
&header.CurrAccCode,
|
||||
&header.CurrAccDescription,
|
||||
&header.SubCurrAccID,
|
||||
&header.ContactID,
|
||||
&header.ShipmentMethodCode,
|
||||
&header.ShippingPostalAddressID,
|
||||
&header.BillingPostalAddressID,
|
||||
&header.GuarantorContactID,
|
||||
&header.GuarantorContactID2,
|
||||
&header.RoundsmanCode,
|
||||
&header.DeliveryCompanyCode,
|
||||
&header.TaxTypeCode,
|
||||
&header.WithHoldingTaxTypeCode,
|
||||
&header.DOVCode,
|
||||
&header.TaxExemptionCode,
|
||||
&header.CompanyCode,
|
||||
&header.OfficeCode,
|
||||
&header.StoreTypeCode,
|
||||
&header.StoreCode,
|
||||
&header.POSTerminalID,
|
||||
&header.WarehouseCode,
|
||||
&header.ToWarehouseCode,
|
||||
&header.OrdererCompanyCode,
|
||||
&header.OrdererOfficeCode,
|
||||
&header.OrdererStoreCode,
|
||||
&header.GLTypeCode,
|
||||
&header.DocCurrencyCode,
|
||||
&header.LocalCurrencyCode,
|
||||
&header.ExchangeRate,
|
||||
&header.TDisRate1,
|
||||
&header.TDisRate2,
|
||||
&header.TDisRate3,
|
||||
&header.TDisRate4,
|
||||
&header.TDisRate5,
|
||||
&header.DiscountReasonCode,
|
||||
&header.SurplusOrderQtyToleranceRate,
|
||||
&header.ImportFileNumber,
|
||||
&header.ExportFileNumber,
|
||||
&header.IncotermCode1,
|
||||
&header.IncotermCode2,
|
||||
&header.LettersOfCreditNumber,
|
||||
&header.PaymentMethodCode,
|
||||
&header.IsInclutedVat,
|
||||
&header.IsCreditSale,
|
||||
&header.IsCreditableConfirmed,
|
||||
&header.CreditableConfirmedUser,
|
||||
&header.CreditableConfirmedDate,
|
||||
&header.IsSalesViaInternet,
|
||||
&header.IsSuspended,
|
||||
&header.IsCompleted,
|
||||
&header.IsPrinted,
|
||||
&header.IsLocked,
|
||||
&header.UserLocked,
|
||||
&header.IsClosed,
|
||||
&header.ApplicationCode,
|
||||
&header.ApplicationID,
|
||||
&header.CreatedUserName,
|
||||
&header.CreatedDate,
|
||||
&header.LastUpdatedUserName,
|
||||
&header.LastUpdatedDate,
|
||||
&header.IsProposalBased,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
logger.Printf("⚠️ [GetOrderByID] sipariş bulunamadı: %s", orderID)
|
||||
return nil, nil, sql.ErrNoRows
|
||||
}
|
||||
logger.Printf("❌ [GetOrderByID] header sorgu hatası: %v", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
logger.Printf("✅ [GetOrderByID] header loaded • orderNo=%v currAcc=%v",
|
||||
header.OrderNumber, header.CurrAccCode.String)
|
||||
|
||||
// =====================================================
|
||||
// LINES
|
||||
// =====================================================
|
||||
qLines := `
|
||||
SELECT
|
||||
CAST(L.OrderLineID AS varchar(36)) AS OrderLineID,
|
||||
L.SortOrder,
|
||||
L.ItemTypeCode,
|
||||
L.ItemCode,
|
||||
L.ColorCode,
|
||||
L.ItemDim1Code,
|
||||
L.ItemDim2Code,
|
||||
L.ItemDim3Code,
|
||||
L.Qty1,
|
||||
L.Qty2,
|
||||
L.Price,
|
||||
L.VatRate,
|
||||
L.PCTRate,
|
||||
L.DocCurrencyCode,
|
||||
L.DeliveryDate,
|
||||
L.PlannedDateOfLading,
|
||||
L.LineDescription,
|
||||
L.IsClosed,
|
||||
L.CreatedUserName,
|
||||
L.CreatedDate,
|
||||
L.LastUpdatedUserName,
|
||||
L.LastUpdatedDate,
|
||||
P.ProductAtt42Desc AS UrunIlkGrubu,
|
||||
P.ProductAtt01Desc AS UrunAnaGrubu,
|
||||
P.ProductAtt02Desc AS UrunAltGrubu,
|
||||
P.ProductAtt38Desc AS Fit1,
|
||||
P.ProductAtt39Desc AS Fit2
|
||||
FROM BAGGI_V3.dbo.trOrderLine AS L
|
||||
LEFT JOIN ProductFilterWithDescription('TR') AS P
|
||||
ON LTRIM(RTRIM(P.ProductCode)) = LTRIM(RTRIM(L.ItemCode))
|
||||
WHERE L.OrderHeaderID = @p1
|
||||
ORDER BY L.SortOrder ASC;
|
||||
`
|
||||
|
||||
rows, err := conn.Query(qLines, orderID)
|
||||
if err != nil {
|
||||
logger.Printf("❌ [GetOrderByID] line sorgu hatası: %v", err)
|
||||
return &header, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
lines := make([]models.OrderDetail, 0, 32)
|
||||
for rows.Next() {
|
||||
var ln models.OrderDetail
|
||||
if err := rows.Scan(
|
||||
&ln.OrderLineID,
|
||||
&ln.SortOrder,
|
||||
&ln.ItemTypeCode,
|
||||
&ln.ItemCode,
|
||||
&ln.ColorCode,
|
||||
&ln.ItemDim1Code,
|
||||
&ln.ItemDim2Code,
|
||||
&ln.ItemDim3Code,
|
||||
&ln.Qty1,
|
||||
&ln.Qty2,
|
||||
&ln.Price,
|
||||
&ln.VatRate,
|
||||
&ln.PCTRate,
|
||||
&ln.DocCurrencyCode,
|
||||
&ln.DeliveryDate,
|
||||
&ln.PlannedDateOfLading,
|
||||
&ln.LineDescription,
|
||||
&ln.IsClosed,
|
||||
&ln.CreatedUserName,
|
||||
&ln.CreatedDate,
|
||||
&ln.LastUpdatedUserName,
|
||||
&ln.LastUpdatedDate,
|
||||
&ln.UrunIlkGrubu,
|
||||
&ln.UrunAnaGrubu,
|
||||
&ln.UrunAltGrubu,
|
||||
&ln.Fit1,
|
||||
&ln.Fit2,
|
||||
); err != nil {
|
||||
return &header, nil, fmt.Errorf("line scan hatası: %w", err)
|
||||
}
|
||||
lines = append(lines, ln)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return &header, nil, fmt.Errorf("line rows hatası: %w", err)
|
||||
}
|
||||
|
||||
logger.Printf("📦 [GetOrderByID] lines loaded • count=%d", len(lines))
|
||||
return &header, lines, nil
|
||||
}
|
||||
161
svc/queries/order_pdf.go
Normal file
161
svc/queries/order_pdf.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
/*
|
||||
============================================================
|
||||
|
||||
HEADER GETIRME — OrderHeader struct’ına uygun
|
||||
============================================================
|
||||
*/
|
||||
type OrderHeaderDB struct {
|
||||
OrderHeaderID string
|
||||
OrderNumber string
|
||||
CurrAccCode string
|
||||
CurrAccName string
|
||||
DocCurrency string
|
||||
OrderDate sql.NullTime
|
||||
Description sql.NullString
|
||||
InternalDesc sql.NullString
|
||||
OfficeCode sql.NullString
|
||||
CreatedUser sql.NullString
|
||||
}
|
||||
|
||||
func GetOrderHeaderDB(ctx context.Context, db *sql.DB, id string) (*OrderHeaderDB, error) {
|
||||
q := `
|
||||
SELECT
|
||||
CAST(h.OrderHeaderID AS varchar(36)),
|
||||
h.OrderNumber,
|
||||
h.CurrAccCode,
|
||||
d.CurrAccDescription,
|
||||
h.DocCurrencyCode,
|
||||
h.OrderDate,
|
||||
h.Description,
|
||||
h.InternalDescription,
|
||||
h.OfficeCode,
|
||||
h.CreatedUserName
|
||||
FROM BAGGI_V3.dbo.trOrderHeader AS h
|
||||
LEFT JOIN BAGGI_V3.dbo.cdCurrAccDesc AS d
|
||||
ON h.CurrAccCode = d.CurrAccCode
|
||||
WHERE h.OrderHeaderID = @p1
|
||||
`
|
||||
row := db.QueryRowContext(ctx, q, id)
|
||||
|
||||
var h OrderHeaderDB
|
||||
err := row.Scan(
|
||||
&h.OrderHeaderID,
|
||||
&h.OrderNumber,
|
||||
&h.CurrAccCode,
|
||||
&h.CurrAccName,
|
||||
&h.DocCurrency,
|
||||
&h.OrderDate,
|
||||
&h.Description,
|
||||
&h.InternalDesc,
|
||||
&h.OfficeCode,
|
||||
&h.CreatedUser,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &h, nil
|
||||
}
|
||||
|
||||
/*
|
||||
============================================================
|
||||
|
||||
SATIRLARI GETIRME — OrderLineRaw struct’ına uygun
|
||||
============================================================
|
||||
*/
|
||||
type OrderLineRawDB struct {
|
||||
OrderLineID sql.NullString
|
||||
ItemCode string
|
||||
ColorCode string
|
||||
ItemDim1Code sql.NullString
|
||||
ItemDim2Code sql.NullString
|
||||
Qty1 sql.NullFloat64
|
||||
Price sql.NullFloat64
|
||||
DocCurrencyCode sql.NullString
|
||||
DeliveryDate sql.NullTime
|
||||
LineDescription sql.NullString
|
||||
UrunAnaGrubu sql.NullString
|
||||
UrunAltGrubu sql.NullString
|
||||
IsClosed sql.NullBool
|
||||
WithHoldingTaxType sql.NullString
|
||||
DOVCode sql.NullString
|
||||
PlannedDateOfLading sql.NullTime
|
||||
CostCenterCode sql.NullString
|
||||
VatCode sql.NullString
|
||||
VatRate sql.NullFloat64
|
||||
}
|
||||
|
||||
func GetOrderLinesDB(ctx context.Context, db *sql.DB, id string) ([]OrderLineRawDB, error) {
|
||||
|
||||
q := `
|
||||
SELECT
|
||||
CAST(L.OrderLineID AS varchar(36)),
|
||||
L.ItemCode,
|
||||
L.ColorCode,
|
||||
L.ItemDim1Code,
|
||||
L.ItemDim2Code,
|
||||
L.Qty1,
|
||||
L.Price,
|
||||
L.DocCurrencyCode,
|
||||
L.DeliveryDate,
|
||||
L.LineDescription,
|
||||
P.ProductAtt01Desc,
|
||||
P.ProductAtt02Desc,
|
||||
L.IsClosed,
|
||||
L.WithHoldingTaxTypeCode,
|
||||
L.DOVCode,
|
||||
L.PlannedDateOfLading,
|
||||
L.CostCenterCode,
|
||||
L.VatCode,
|
||||
L.VatRate
|
||||
FROM BAGGI_V3.dbo.trOrderLine AS L
|
||||
LEFT JOIN ProductFilterWithDescription('TR') AS P
|
||||
ON LTRIM(RTRIM(P.ProductCode)) = LTRIM(RTRIM(L.ItemCode))
|
||||
WHERE L.OrderHeaderID = @p1
|
||||
ORDER BY L.SortOrder, L.OrderLineID
|
||||
`
|
||||
|
||||
rows, err := db.QueryContext(ctx, q, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []OrderLineRawDB
|
||||
|
||||
for rows.Next() {
|
||||
var r OrderLineRawDB
|
||||
err = rows.Scan(
|
||||
&r.OrderLineID,
|
||||
&r.ItemCode,
|
||||
&r.ColorCode,
|
||||
&r.ItemDim1Code,
|
||||
&r.ItemDim2Code,
|
||||
&r.Qty1,
|
||||
&r.Price,
|
||||
&r.DocCurrencyCode,
|
||||
&r.DeliveryDate,
|
||||
&r.LineDescription,
|
||||
&r.UrunAnaGrubu,
|
||||
&r.UrunAltGrubu,
|
||||
&r.IsClosed,
|
||||
&r.WithHoldingTaxType,
|
||||
&r.DOVCode,
|
||||
&r.PlannedDateOfLading,
|
||||
&r.CostCenterCode,
|
||||
&r.VatCode,
|
||||
&r.VatRate,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
1079
svc/queries/order_write.go
Normal file
1079
svc/queries/order_write.go
Normal file
@@ -0,0 +1,1079 @@
|
||||
// ===================== PART 1 (Satır 1-300) =====================
|
||||
// =======================================================
|
||||
// order_write.go — v4.3 FINAL (Insert + Update + Delete)
|
||||
// - PCTCode her zaman "%0" olarak yazılır (cdPCT FK uyumlu)
|
||||
// - INSERT/UPDATE öncesi ItemVariant Guard + Duplicate Guard (payload içi)
|
||||
// - UpdateOrder: DELETE öncesi child tablolar (trOrderLineCurrency) temizlenir
|
||||
// =======================================================
|
||||
|
||||
package queries
|
||||
|
||||
import (
|
||||
"bssapp-backend/db"
|
||||
"bssapp-backend/models"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func nf0(v models.NullFloat64) float64 {
|
||||
if !v.Valid {
|
||||
return 0
|
||||
}
|
||||
return v.Float64
|
||||
}
|
||||
|
||||
// =======================================================
|
||||
// COMBO KEY & STRING HELPERS
|
||||
// =======================================================
|
||||
func normalizeComboKey(s string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
// makeComboKey: frontend tarafında NullString olmayan struct’lar için
|
||||
func makeComboKey(ln models.OrderDetail) string {
|
||||
model := safeNS(ln.ItemCode)
|
||||
renk := safeNS(ln.ColorCode)
|
||||
renk2 := safeNS(ln.ItemDim2Code)
|
||||
beden := safeNS(ln.ItemDim1Code)
|
||||
|
||||
return normalizeComboKey(
|
||||
fmt.Sprintf("%s||%s||%s||%s", model, renk, beden, renk2),
|
||||
)
|
||||
}
|
||||
|
||||
// qtyValue → NullFloat64 güvenli float64
|
||||
func qtyValue(q models.NullFloat64) float64 {
|
||||
if !q.Valid {
|
||||
return 0
|
||||
}
|
||||
return q.Float64
|
||||
}
|
||||
|
||||
// VatCode: NullString → string
|
||||
// - NULL → ""
|
||||
// - "0" → "" (FK patlamasın, sadece anlamlı kodlar gönderiyoruz)
|
||||
// - "%0", "%10" vb → trimlenmiş hali
|
||||
func normalizeVatCode(ns models.NullString) string {
|
||||
if !ns.Valid {
|
||||
return ""
|
||||
}
|
||||
s := strings.TrimSpace(ns.String)
|
||||
if s == "0" {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// PCTCode: NullString → string
|
||||
// - Artık her durumda "%0" döndürür (tek PCT tipi kullanımı)
|
||||
// NOT: SQL tarafında da "%0" cdPCT içinde tanımlı olmalı
|
||||
func normalizePCTCode(ns models.NullString) string {
|
||||
return "%0"
|
||||
}
|
||||
|
||||
// models.NullString → trimlenmiş string (NULL ise "")
|
||||
func safeNS(ns models.NullString) string {
|
||||
if !ns.Valid {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(ns.String)
|
||||
}
|
||||
|
||||
// =======================================================
|
||||
// COMBO KEY HELPERS (DB alanlarından)
|
||||
// =======================================================
|
||||
|
||||
// makeComboKeyParts: düz string alanlardan comboKey üret
|
||||
// comboKey = model || renk || beden || renk2
|
||||
func makeComboKeyParts(item, color, dim1, dim2 string) string {
|
||||
item = strings.TrimSpace(item)
|
||||
color = strings.TrimSpace(color)
|
||||
dim1 = strings.TrimSpace(dim1)
|
||||
dim2 = strings.TrimSpace(dim2)
|
||||
|
||||
if item == "" && color == "" && dim1 == "" && dim2 == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return normalizeComboKey(item + "||" + color + "||" + dim1 + "||" + dim2)
|
||||
}
|
||||
|
||||
// comboFromNulls: NullString alanlardan comboKey üret
|
||||
func comboFromNulls(item, color, dim1, dim2 models.NullString) string {
|
||||
return makeComboKeyParts(
|
||||
safeNS(item),
|
||||
safeNS(color),
|
||||
safeNS(dim1),
|
||||
safeNS(dim2),
|
||||
)
|
||||
}
|
||||
|
||||
// =======================================================
|
||||
// ✅ ItemVariant Guard — INSERT / UPDATE öncesi
|
||||
// =======================================================
|
||||
|
||||
// normalizeKeyPart: NullString → trim + UPPER
|
||||
func normalizeKeyPart(ns models.NullString) string {
|
||||
s := strings.TrimSpace(safeNS(ns))
|
||||
return strings.ToUpper(s)
|
||||
}
|
||||
|
||||
// =======================================================
|
||||
// AKSBIR DETECTION
|
||||
// =======================================================
|
||||
|
||||
// =======================================================
|
||||
// COMBO KEY BUILDER (AKSBIR AWARE)
|
||||
// =======================================================
|
||||
|
||||
// Variant check: ItemCode + ColorCode + Dim1 + Dim2
|
||||
func ValidateItemVariant(tx *sql.Tx, ln models.OrderDetail) error {
|
||||
fmt.Printf(
|
||||
"🧪 VARIANT GUARD INPUT | ClientKey=%s Item=%q Color=%q Dim1=%q Dim2=%q Dim3=%q Qty1=%v\n",
|
||||
safeNS(ln.ClientKey),
|
||||
safeNS(ln.ItemCode),
|
||||
safeNS(ln.ColorCode),
|
||||
safeNS(ln.ItemDim1Code),
|
||||
safeNS(ln.ItemDim2Code),
|
||||
safeNS(ln.ItemDim3Code),
|
||||
nf0(ln.Qty1),
|
||||
)
|
||||
|
||||
item := normalizeKeyPart(ln.ItemCode)
|
||||
color := normalizeKeyPart(ln.ColorCode)
|
||||
dim1 := normalizeKeyPart(ln.ItemDim1Code)
|
||||
dim2 := normalizeKeyPart(ln.ItemDim2Code)
|
||||
|
||||
// ✅ Placeholder/boş standardizasyon (SENDE "_" geliyor)
|
||||
normalizeEmpty := func(s string) string {
|
||||
s = strings.TrimSpace(strings.ToUpper(s))
|
||||
if s == "_" || s == "-" {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
item = normalizeEmpty(item)
|
||||
color = normalizeEmpty(color)
|
||||
dim1 = normalizeEmpty(dim1)
|
||||
dim2 = normalizeEmpty(dim2)
|
||||
|
||||
if item == "" {
|
||||
return fmt.Errorf(
|
||||
"ItemCode boş olamaz (ClientKey=%s)",
|
||||
safeNS(ln.ClientKey),
|
||||
)
|
||||
fmt.Printf(
|
||||
"🧪 VARIANT NORMALIZED | Item=%q Color=%q Dim1=%q Dim2=%q\n",
|
||||
item, color, dim1, dim2,
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
// İstersen debug:
|
||||
// fmt.Printf("🧪 VARIANT CHECK item=%q color=%q dim1=%q dim2=%q clientKey=%s\n", item, color, dim1, dim2, safeNS(ln.ClientKey))
|
||||
|
||||
var exists int
|
||||
err := tx.QueryRow(`
|
||||
SELECT CASE WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM BAGGI_V3.dbo.prItemVariant V WITH (NOLOCK)
|
||||
WHERE ISNULL(LTRIM(RTRIM(V.ItemCode)),'') = @p1
|
||||
AND ISNULL(LTRIM(RTRIM(V.ColorCode)),'') = @p2
|
||||
AND ISNULL(LTRIM(RTRIM(V.ItemDim1Code)),'') = @p3
|
||||
AND ISNULL(LTRIM(RTRIM(V.ItemDim2Code)),'') = @p4
|
||||
) THEN 1 ELSE 0 END
|
||||
`, item, color, dim1, dim2).Scan(&exists)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("ItemVariant kontrol query hatası: %w", err)
|
||||
}
|
||||
|
||||
if exists != 1 {
|
||||
return &models.ValidationError{
|
||||
Code: "INVALID_ITEM_VARIANT",
|
||||
Message: "Tanımsız ürün kombinasyonu",
|
||||
ClientKey: safeNS(ln.ClientKey),
|
||||
ItemCode: item,
|
||||
ColorCode: color,
|
||||
Dim1: dim1,
|
||||
Dim2: dim2,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateOrderVariants: save/update öncesi payload satırlarını prItemVariant'a göre doğrular.
|
||||
// invalid döner; error sadece DB/prepare/query hatalarında döner.
|
||||
func ValidateOrderVariants(db *sql.DB, lines []models.OrderDetail) ([]models.InvalidVariant, error) {
|
||||
|
||||
normalizeEmpty := func(s string) string {
|
||||
s = strings.TrimSpace(strings.ToUpper(s))
|
||||
if s == "_" || s == "-" {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
stmt, err := db.Prepare(`
|
||||
SELECT CASE WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM BAGGI_V3.dbo.prItemVariant V WITH (NOLOCK)
|
||||
WHERE ISNULL(LTRIM(RTRIM(V.ItemCode)),'') = @p1
|
||||
AND ISNULL(LTRIM(RTRIM(V.ColorCode)),'') = @p2
|
||||
AND ISNULL(LTRIM(RTRIM(V.ItemDim1Code)),'') = @p3
|
||||
AND ISNULL(LTRIM(RTRIM(V.ItemDim2Code)),'') = @p4
|
||||
) THEN 1 ELSE 0 END
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validate prepare hatası: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
invalid := make([]models.InvalidVariant, 0)
|
||||
|
||||
for i, ln := range lines {
|
||||
qty := qtyValue(ln.Qty1)
|
||||
if qty <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
item := normalizeEmpty(normalizeKeyPart(ln.ItemCode))
|
||||
color := normalizeEmpty(normalizeKeyPart(ln.ColorCode))
|
||||
dim1 := normalizeEmpty(normalizeKeyPart(ln.ItemDim1Code))
|
||||
dim2 := normalizeEmpty(normalizeKeyPart(ln.ItemDim2Code))
|
||||
|
||||
// ItemCode boş ise invalid
|
||||
if strings.TrimSpace(item) == "" {
|
||||
invalid = append(invalid, models.InvalidVariant{
|
||||
Index: i,
|
||||
ClientKey: safeNS(ln.ClientKey),
|
||||
ItemCode: item,
|
||||
ColorCode: color,
|
||||
Dim1: dim1,
|
||||
Dim2: dim2,
|
||||
Qty1: qty,
|
||||
ComboKey: safeNS(ln.ComboKey),
|
||||
Reason: "ItemCode boş",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
var exists int
|
||||
if err := stmt.QueryRow(item, color, dim1, dim2).Scan(&exists); err != nil {
|
||||
return nil, fmt.Errorf("validate query hatası (i=%d): %w", i, err)
|
||||
}
|
||||
|
||||
if exists != 1 {
|
||||
invalid = append(invalid, models.InvalidVariant{
|
||||
Index: i,
|
||||
ClientKey: safeNS(ln.ClientKey),
|
||||
ItemCode: item,
|
||||
ColorCode: color,
|
||||
Dim1: dim1,
|
||||
Dim2: dim2,
|
||||
Qty1: qty,
|
||||
ComboKey: safeNS(ln.ComboKey),
|
||||
Reason: "prItemVariant’ta yok",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return invalid, nil
|
||||
}
|
||||
|
||||
// =======================================================
|
||||
// LineResult → frontend senkronu için
|
||||
// =======================================================
|
||||
|
||||
type OrderLineResult struct {
|
||||
ClientKey string `json:"clientKey"`
|
||||
OrderLineID string `json:"orderLineID"`
|
||||
}
|
||||
|
||||
// =======================================================
|
||||
// PART 1 — InsertOrder (header + lines insert) — FINAL v5.1
|
||||
// ✔ OrderHeaderID backend üretir
|
||||
// ✔ LOCAL-... numara gelirse gerçek WS numarası üretir
|
||||
// ✔ Full debug
|
||||
// ✔ Tüm satırlar INSERT edilir
|
||||
// ✔ INSERT öncesi ItemVariant Guard
|
||||
// ✔ Payload içi Duplicate Guard (comboKey)
|
||||
// =======================================================
|
||||
|
||||
func InsertOrder(header models.OrderHeader, lines []models.OrderDetail, user *models.User) (string, []OrderLineResult, error) {
|
||||
conn := db.GetDB()
|
||||
|
||||
fmt.Println("🟦 InsertOrder() BAŞLADI -----------------------------")
|
||||
|
||||
tx, err := conn.Begin()
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("tx baslatilamadi: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
now := time.Now()
|
||||
v3User := fmt.Sprintf("V3U%d-%s", user.V3UserGroup, user.V3Username)
|
||||
|
||||
// =======================================================
|
||||
// 1) BACKEND — OrderHeaderID üretimi (HER ZAMAN)
|
||||
// =======================================================
|
||||
realHeaderID := uuid.New().String()
|
||||
|
||||
fmt.Println("🟩 Backend yeni OrderHeaderID üretti:", realHeaderID)
|
||||
|
||||
header.OrderHeaderID = realHeaderID
|
||||
|
||||
// =======================================================
|
||||
// 2) OrderNumber üretimi (LOCAL-* ise gerçek WS numarası)
|
||||
// =======================================================
|
||||
if !header.OrderNumber.Valid ||
|
||||
strings.HasPrefix(header.OrderNumber.String, "LOCAL-") ||
|
||||
len(strings.TrimSpace(header.OrderNumber.String)) == 0 {
|
||||
|
||||
fmt.Println("🟨 LOCAL numara geldi → gerçek WS numarası üretilecek...")
|
||||
|
||||
var realNumber string
|
||||
err := tx.QueryRow(`
|
||||
SELECT CONCAT(
|
||||
'1-WS-3-',
|
||||
RIGHT('00000' + CAST(NEXT VALUE FOR BAGGI_V3.dbo.Seq_OrderNumber_WS AS VARCHAR(10)), 5)
|
||||
)
|
||||
`).Scan(&realNumber)
|
||||
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("Gerçek sipariş numarası üretilemedi: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("🟩 Üretilen gerçek WS numarası:", realNumber)
|
||||
|
||||
header.OrderNumber.String = realNumber
|
||||
header.OrderNumber.Valid = true
|
||||
}
|
||||
|
||||
newID := realHeaderID // artık DB’ye bu yazılacak
|
||||
|
||||
// =======================================================
|
||||
// 3) Döviz kuru çözümü
|
||||
// =======================================================
|
||||
exRate := 1.0
|
||||
if header.DocCurrencyCode.Valid && header.DocCurrencyCode.String != "TRY" {
|
||||
if c, err := GetTodayCurrencyV3(conn, header.DocCurrencyCode.String); err == nil {
|
||||
if c.Rate > 0 {
|
||||
exRate = c.Rate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================
|
||||
// 4) HEADER INSERT
|
||||
// =======================================================
|
||||
|
||||
queryHeader := `
|
||||
INSERT INTO BAGGI_V3.dbo.trOrderHeader (
|
||||
OrderHeaderID, OrderTypeCode, ProcessCode, OrderNumber, IsCancelOrder,
|
||||
OrderDate, OrderTime, DocumentNumber, PaymentTerm,
|
||||
AverageDueDate, Description, InternalDescription,
|
||||
CurrAccTypeCode, CurrAccCode, SubCurrAccID, ContactID,
|
||||
ShipmentMethodCode, ShippingPostalAddressID, BillingPostalAddressID,
|
||||
GuarantorContactID, GuarantorContactID2, RoundsmanCode,
|
||||
DeliveryCompanyCode, TaxTypeCode, WithHoldingTaxTypeCode, DOVCode,
|
||||
TaxExemptionCode, CompanyCode, OfficeCode, StoreTypeCode, StoreCode,
|
||||
POSTerminalID, WarehouseCode, ToWarehouseCode,
|
||||
OrdererCompanyCode, OrdererOfficeCode, OrdererStoreCode,
|
||||
GLTypeCode, DocCurrencyCode, LocalCurrencyCode, ExchangeRate,
|
||||
TDisRate1, TDisRate2, TDisRate3, TDisRate4, TDisRate5,
|
||||
DiscountReasonCode, SurplusOrderQtyToleranceRate,
|
||||
ImportFileNumber, ExportFileNumber,
|
||||
IncotermCode1, IncotermCode2, LettersOfCreditNumber,
|
||||
PaymentMethodCode, IsInclutedVat, IsCreditSale, IsCreditableConfirmed,
|
||||
CreditableConfirmedUser, CreditableConfirmedDate,
|
||||
IsSalesViaInternet, IsSuspended, IsCompleted, IsPrinted,
|
||||
IsLocked, UserLocked, IsClosed,
|
||||
ApplicationCode, ApplicationID,
|
||||
CreatedUserName, CreatedDate, LastUpdatedUserName, LastUpdatedDate,
|
||||
IsProposalBased
|
||||
)
|
||||
VALUES (
|
||||
@p1,@p2,@p3,@p4,
|
||||
@p5,@p6,@p7,@p8,
|
||||
@p9,@p10,@p11,
|
||||
@p12,@p13,@p14,@p15,
|
||||
@p16,@p17,@p18,
|
||||
@p19,@p20,@p21,
|
||||
@p22,@p23,@p24,@p25,
|
||||
@p26,@p27,@p28,@p29,@p30,
|
||||
@p31,@p32,@p33,
|
||||
@p34,@p35,@p36,
|
||||
@p37,@p38,@p39,@p40,
|
||||
@p41,@p42,@p43,@p44,@p45,
|
||||
@p46,@p47,
|
||||
@p48,@p49,
|
||||
@p50,@p51,@p52,
|
||||
@p53,@p54,@p55,@p56,@p57,
|
||||
@p58,@p59,@p60,@p61,@p62,
|
||||
@p63,@p64,@p65,
|
||||
@p66,@p67,@p68,@p69,@p70,@p71,@p72,@p73
|
||||
);
|
||||
`
|
||||
|
||||
fmt.Println("🟪 HEADER INSERT ÇALIŞIYOR...")
|
||||
|
||||
headerParams := []any{
|
||||
header.OrderHeaderID,
|
||||
nullableInt16(header.OrderTypeCode, 1),
|
||||
nullableString(header.ProcessCode, "WS"),
|
||||
nullableString(header.OrderNumber, ""),
|
||||
nullableBool(header.IsCancelOrder, false),
|
||||
|
||||
nullableDateString(header.OrderDate),
|
||||
nullableTimeString(header.OrderTime),
|
||||
nullableString(header.DocumentNumber, ""),
|
||||
nullableInt16(header.PaymentTerm, 0),
|
||||
|
||||
nullableDateString(header.AverageDueDate),
|
||||
nullableString(header.Description, ""),
|
||||
nullableString(header.InternalDescription, ""),
|
||||
|
||||
nullableInt16(header.CurrAccTypeCode, 0),
|
||||
nullableString(header.CurrAccCode, ""),
|
||||
nullableUUID(header.SubCurrAccID),
|
||||
nullableUUID(header.ContactID),
|
||||
|
||||
nullableString(header.ShipmentMethodCode, ""),
|
||||
nullableUUID(header.ShippingPostalAddressID),
|
||||
nullableUUID(header.BillingPostalAddressID),
|
||||
|
||||
nullableUUID(header.GuarantorContactID),
|
||||
nullableUUID(header.GuarantorContactID2),
|
||||
nullableString(header.RoundsmanCode, ""),
|
||||
|
||||
nullableString(header.DeliveryCompanyCode, ""),
|
||||
nullableInt16(header.TaxTypeCode, 0),
|
||||
nullableString(header.WithHoldingTaxTypeCode, ""),
|
||||
nullableString(header.DOVCode, ""),
|
||||
|
||||
nullableInt16(header.TaxExemptionCode, 0),
|
||||
nullableInt32ToInt16(header.CompanyCode, 1),
|
||||
nullableString(header.OfficeCode, "101"),
|
||||
nullableInt16(header.StoreTypeCode, 5),
|
||||
nullableString(header.StoreCode, ""),
|
||||
|
||||
nullableInt16(header.POSTerminalID, 0),
|
||||
nullableString(header.WarehouseCode, "1-0-12"),
|
||||
nullableString(header.ToWarehouseCode, ""),
|
||||
|
||||
nullableInt32ToInt16(header.OrdererCompanyCode, 1),
|
||||
nullableString(header.OrdererOfficeCode, "101"),
|
||||
nullableString(header.OrdererStoreCode, ""),
|
||||
|
||||
nullableString(header.GLTypeCode, ""),
|
||||
nullableString(header.DocCurrencyCode, "TRY"),
|
||||
nullableString(header.LocalCurrencyCode, "TRY"),
|
||||
nullableFloat64(header.ExchangeRate, exRate),
|
||||
|
||||
nullableFloat64(header.TDisRate1, 0),
|
||||
nullableFloat64(header.TDisRate2, 0),
|
||||
nullableFloat64(header.TDisRate3, 0),
|
||||
nullableFloat64(header.TDisRate4, 0),
|
||||
nullableFloat64(header.TDisRate5, 0),
|
||||
|
||||
nullableInt16(header.DiscountReasonCode, 0),
|
||||
nullableFloat64(header.SurplusOrderQtyToleranceRate, 0),
|
||||
|
||||
nullableString(header.ImportFileNumber, ""),
|
||||
nullableString(header.ExportFileNumber, ""),
|
||||
|
||||
nullableString(header.IncotermCode1, ""),
|
||||
nullableString(header.IncotermCode2, ""),
|
||||
nullableString(header.LettersOfCreditNumber, ""),
|
||||
|
||||
nullableString(header.PaymentMethodCode, ""),
|
||||
nullableBool(header.IsInclutedVat, false),
|
||||
nullableBool(header.IsCreditSale, true),
|
||||
nullableBool(header.IsCreditableConfirmed, true),
|
||||
|
||||
nullableString(header.CreditableConfirmedUser, v3User),
|
||||
nullableDateTime(header.CreditableConfirmedDate, now),
|
||||
|
||||
nullableBool(header.IsSalesViaInternet, false),
|
||||
nullableBool(header.IsSuspended, false),
|
||||
nullableBool(header.IsCompleted, false),
|
||||
nullableBool(header.IsPrinted, false),
|
||||
|
||||
nullableBool(header.IsLocked, false),
|
||||
nullableBool(header.UserLocked, false),
|
||||
nullableBool(header.IsClosed, false),
|
||||
|
||||
nullableString(header.ApplicationCode, "Order"),
|
||||
nullableUUID(header.ApplicationID),
|
||||
|
||||
nullableString(header.CreatedUserName, v3User),
|
||||
nullableDateTimeString(header.CreatedDate, now),
|
||||
nullableString(header.LastUpdatedUserName, v3User),
|
||||
nullableDateTimeString(header.LastUpdatedDate, now),
|
||||
|
||||
nullableBool(header.IsProposalBased, false),
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(queryHeader, headerParams...); err != nil {
|
||||
fmt.Println("❌ HEADER INSERT ERROR:", err)
|
||||
return "", nil, fmt.Errorf("header insert hatasi: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("🟩 HEADER INSERT OK — ID:", newID)
|
||||
|
||||
// =======================================================
|
||||
// 5) LINE INSERT
|
||||
// =======================================================
|
||||
|
||||
insStmt, err := tx.Prepare(`
|
||||
INSERT INTO BAGGI_V3.dbo.trOrderLine (
|
||||
OrderLineID,
|
||||
SortOrder, ItemTypeCode, ItemCode, ColorCode,
|
||||
ItemDim1Code, ItemDim2Code, ItemDim3Code,
|
||||
Qty1, Qty2,
|
||||
CancelQty1, CancelQty2,
|
||||
OrderCancelReasonCode,
|
||||
DeliveryDate, PlannedDateOfLading,
|
||||
LineDescription,
|
||||
UsedBarcode, CostCenterCode,
|
||||
VatCode, VatRate, PCTCode, PCTRate,
|
||||
LDisRate1, LDisRate2, LDisRate3, LDisRate4, LDisRate5,
|
||||
DocCurrencyCode, PriceCurrencyCode, PriceExchangeRate,
|
||||
Price,
|
||||
BaseProcessCode, BaseOrderNumber,
|
||||
BaseCustomerTypeCode, BaseCustomerCode,
|
||||
BaseSubCurrAccID, BaseStoreCode,
|
||||
OrderHeaderID,
|
||||
CreatedUserName, CreatedDate,
|
||||
LastUpdatedUserName, LastUpdatedDate,
|
||||
SurplusOrderQtyToleranceRate,
|
||||
WithHoldingTaxTypeCode,
|
||||
DOVCode
|
||||
)
|
||||
VALUES (
|
||||
@p1,@p2,@p3,@p4,
|
||||
@p5,@p6,@p7,@p8,
|
||||
@p9,@p10,
|
||||
@p11,@p12,
|
||||
@p13,
|
||||
@p14,@p15,
|
||||
@p16,
|
||||
@p17,@p18,
|
||||
@p19,@p20,@p21,@p22,
|
||||
@p23,@p24,@p25,@p26,@p27,
|
||||
@p28,@p29,@p30,
|
||||
@p31,
|
||||
@p32,@p33,
|
||||
@p34,@p35,
|
||||
@p36,@p37,
|
||||
@p38,
|
||||
@p39,@p40,
|
||||
@p41,@p42,
|
||||
@p43,
|
||||
@p44,
|
||||
@p45
|
||||
)`)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("line insert stmt hazirlanamadi: %w", err)
|
||||
}
|
||||
defer insStmt.Close()
|
||||
|
||||
lineResults := make([]OrderLineResult, 0, len(lines))
|
||||
|
||||
// ✅ Duplicate Guard (payload içi)
|
||||
seenCombo := make(map[string]bool)
|
||||
|
||||
for i, ln := range lines {
|
||||
// ===================== PART 2 (Satır 301-600) =====================
|
||||
fmt.Println("────────────────────────────────────")
|
||||
fmt.Printf("🟨 [INSERT] LINE %d — gelen OrderLineID=%s\n", i+1, ln.OrderLineID)
|
||||
|
||||
// Her satır için yeni GUID
|
||||
if ln.OrderLineID == "" {
|
||||
newLineID := uuid.New().String()
|
||||
fmt.Println("🆕 Yeni LineID üretildi:", newLineID)
|
||||
ln.OrderLineID = newLineID
|
||||
}
|
||||
|
||||
// ✅ Duplicate Guard (comboKey)
|
||||
comboKey := normalizeComboKey(safeNS(ln.ComboKey))
|
||||
if comboKey == "" {
|
||||
comboKey = makeComboKey(ln)
|
||||
}
|
||||
|
||||
if qtyValue(ln.Qty1) > 0 && comboKey != "" {
|
||||
if seenCombo[comboKey] {
|
||||
return "", nil, fmt.Errorf(
|
||||
"Duplicate satır (comboKey=%s, ClientKey=%s)",
|
||||
comboKey,
|
||||
safeNS(ln.ClientKey),
|
||||
)
|
||||
}
|
||||
seenCombo[comboKey] = true
|
||||
}
|
||||
|
||||
// V2 Logic → %0 PCT
|
||||
vatCode := normalizeVatCode(ln.VatCode)
|
||||
pctCode := normalizePCTCode(ln.PCTCode)
|
||||
|
||||
var pctParam any
|
||||
if pctCode == "" {
|
||||
pctParam = nil
|
||||
} else {
|
||||
pctParam = pctCode
|
||||
}
|
||||
|
||||
planned := nullableDateString(ln.PlannedDateOfLading)
|
||||
// ✅ INSERT ÖNCESİ ItemVariant GUARD
|
||||
if qtyValue(ln.Qty1) > 0 {
|
||||
if err := ValidateItemVariant(tx, ln); err != nil {
|
||||
fmt.Println("❌ VARIANT GUARD (INSERT):", err)
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
fmt.Printf(
|
||||
"🚨 INSERT LINE[%d] | LineID=%s ClientKey=%s Item=%q Color=%q Dim1=%q Dim2=%q Dim3=%q Qty1=%v\n",
|
||||
i+1,
|
||||
ln.OrderLineID,
|
||||
safeNS(ln.ClientKey),
|
||||
safeNS(ln.ItemCode),
|
||||
safeNS(ln.ColorCode),
|
||||
safeNS(ln.ItemDim1Code),
|
||||
safeNS(ln.ItemDim2Code),
|
||||
safeNS(ln.ItemDim3Code),
|
||||
nf0(ln.Qty1),
|
||||
)
|
||||
|
||||
_, err := insStmt.Exec(
|
||||
ln.OrderLineID,
|
||||
ln.SortOrder,
|
||||
ln.ItemTypeCode,
|
||||
nullableString(ln.ItemCode, ""),
|
||||
safeNS(ln.ColorCode),
|
||||
safeNS(ln.ItemDim1Code),
|
||||
safeNS(ln.ItemDim2Code),
|
||||
nullableString(ln.ItemDim3Code, ""),
|
||||
ln.Qty1, ln.Qty2,
|
||||
ln.CancelQty1, ln.CancelQty2,
|
||||
nullableString(ln.OrderCancelReasonCode, ""),
|
||||
nullableTime(ln.DeliveryDate, now),
|
||||
planned,
|
||||
nullableString(ln.LineDescription, ""),
|
||||
nullableString(ln.UsedBarcode, ""),
|
||||
nullableString(ln.CostCenterCode, ""),
|
||||
vatCode, nf0(ln.VatRate),
|
||||
pctParam, nf0(ln.PCTRate),
|
||||
nf0(ln.LDisRate1),
|
||||
nf0(ln.LDisRate2),
|
||||
nf0(ln.LDisRate3),
|
||||
nf0(ln.LDisRate4),
|
||||
nf0(ln.LDisRate5),
|
||||
nullableString(ln.DocCurrencyCode, "TRY"),
|
||||
nullableString(ln.PriceCurrencyCode, "TRY"),
|
||||
nf0(ln.PriceExchangeRate),
|
||||
nf0(ln.Price),
|
||||
nullableString(ln.BaseProcessCode, ""),
|
||||
nullableString(ln.BaseOrderNumber, ""),
|
||||
ln.BaseCustomerTypeCode,
|
||||
nullableString(ln.BaseCustomerCode, ""),
|
||||
nullableUUID(ln.BaseSubCurrAccID),
|
||||
nullableString(ln.BaseStoreCode, ""),
|
||||
header.OrderHeaderID,
|
||||
v3User, now,
|
||||
v3User, now,
|
||||
nf0(ln.SurplusOrderQtyToleranceRate),
|
||||
nullableString(ln.WithHoldingTaxTypeCode, ""),
|
||||
nullableString(ln.DOVCode, ""),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("❌ INSERT LINE ERROR")
|
||||
fmt.Printf(
|
||||
"💥 FAILED LINE | LineID=%s ClientKey=%s Item=%q Color=%q Dim1=%q Dim2=%q Dim3=%q\n",
|
||||
ln.OrderLineID,
|
||||
safeNS(ln.ClientKey),
|
||||
safeNS(ln.ItemCode),
|
||||
safeNS(ln.ColorCode),
|
||||
safeNS(ln.ItemDim1Code),
|
||||
safeNS(ln.ItemDim2Code),
|
||||
safeNS(ln.ItemDim3Code),
|
||||
)
|
||||
fmt.Println("SQL ERROR:", err)
|
||||
return "", nil, fmt.Errorf("line insert hatasi: %w", err)
|
||||
}
|
||||
|
||||
if ln.ClientKey.Valid && ln.ClientKey.String != "" {
|
||||
lineResults = append(lineResults, OrderLineResult{
|
||||
ClientKey: ln.ClientKey.String,
|
||||
OrderLineID: ln.OrderLineID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================
|
||||
// 6) COMMIT
|
||||
// =======================================================
|
||||
fmt.Println("🟨 COMMIT EDİLİYOR...")
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
fmt.Println("❌ COMMIT ERROR:", err)
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
fmt.Println("✅ COMMIT OK — INSERT ORDER BİTTİ")
|
||||
fmt.Println("────────────────────────────────────")
|
||||
|
||||
return newID, lineResults, nil
|
||||
}
|
||||
|
||||
// =======================================================
|
||||
// PART 2 — UpdateOrder FULL DEBUG (v4.3)
|
||||
// ✔ ComboKey ile açık satır eşleştirme
|
||||
// ✔ Kapalı satırları korur
|
||||
// ✔ Payload içi Duplicate Guard
|
||||
// ✔ INSERT/UPDATE öncesi ItemVariant Guard (tek noktada)
|
||||
// ✔ Grid’de olmayan açık satırları siler (önce child)
|
||||
// =======================================================
|
||||
|
||||
func UpdateOrder(header models.OrderHeader, lines []models.OrderDetail, user *models.User) ([]OrderLineResult, error) {
|
||||
conn := db.GetDB()
|
||||
|
||||
// ======================================================
|
||||
// 🔍 SCAN DEBUG — HEADER bilgisi
|
||||
// ======================================================
|
||||
fmt.Println("══════════════════════════════════════")
|
||||
fmt.Println("🔍 [DEBUG] UpdateOrder çağrıldı")
|
||||
fmt.Printf("🔍 HeaderID: %v\n", header.OrderHeaderID)
|
||||
fmt.Printf("🔍 Line sayısı: %v\n", len(lines))
|
||||
fmt.Printf("🔍 User: %v (V3: %s/%d)\n",
|
||||
user.Username, user.V3Username, user.V3UserGroup)
|
||||
fmt.Println("══════════════════════════════════════")
|
||||
|
||||
tx, err := conn.Begin()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tx baslatilamadi: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
now := time.Now()
|
||||
v3User := fmt.Sprintf("V3U%d-%s", user.V3UserGroup, user.V3Username)
|
||||
|
||||
// Döviz kuru
|
||||
exRate := 1.0
|
||||
if header.DocCurrencyCode.Valid && header.DocCurrencyCode.String != "TRY" {
|
||||
if c, err := GetTodayCurrencyV3(conn, header.DocCurrencyCode.String); err == nil && c.Rate > 0 {
|
||||
exRate = c.Rate
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================
|
||||
// 0) Mevcut satırları oku (GUID STRING olarak!)
|
||||
// =======================================================
|
||||
|
||||
existingOpen := make(map[string]bool)
|
||||
existingClosed := make(map[string]bool)
|
||||
existingOpenCombo := make(map[string]string)
|
||||
existingClosedCombo := make(map[string]string)
|
||||
|
||||
rows, err := tx.Query(`
|
||||
SELECT
|
||||
CONVERT(varchar(36), OrderLineID),
|
||||
ISNULL(IsClosed, 0),
|
||||
ISNULL(ItemCode,''),
|
||||
ISNULL(ColorCode,''),
|
||||
ISNULL(ItemDim1Code,''),
|
||||
ISNULL(ItemDim2Code,'')
|
||||
FROM BAGGI_V3.dbo.trOrderLine
|
||||
WHERE OrderHeaderID=@p1
|
||||
`, header.OrderHeaderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mevcut satirlar okunamadi: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id, item, color, dim1, dim2 string
|
||||
var closed bool
|
||||
|
||||
if err := rows.Scan(&id, &closed, &item, &color, &dim1, &dim2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
combo := makeComboKeyParts(item, color, dim1, dim2)
|
||||
|
||||
if closed {
|
||||
existingClosed[id] = true
|
||||
if combo != "" {
|
||||
existingClosedCombo[combo] = id
|
||||
}
|
||||
} else {
|
||||
existingOpen[id] = true
|
||||
if combo != "" {
|
||||
existingOpenCombo[combo] = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// HEADER UPDATE
|
||||
// ======================================================
|
||||
|
||||
_, err = tx.Exec(`
|
||||
UPDATE BAGGI_V3.dbo.trOrderHeader SET
|
||||
OrderDate=@p1,
|
||||
OrderTime=@p2,
|
||||
AverageDueDate=@p3,
|
||||
Description=@p4,
|
||||
InternalDescription=@p5,
|
||||
DocCurrencyCode=@p6,
|
||||
LocalCurrencyCode=@p7,
|
||||
ExchangeRate=@p8,
|
||||
LastUpdatedUserName=@p9,
|
||||
LastUpdatedDate=@p10
|
||||
WHERE OrderHeaderID=@p11
|
||||
`,
|
||||
nullableDateString(header.OrderDate),
|
||||
nullableTimeString(header.OrderTime),
|
||||
nullableDateString(header.AverageDueDate),
|
||||
nullableString(header.Description, ""),
|
||||
nullableString(header.InternalDescription, ""),
|
||||
nullableString(header.DocCurrencyCode, "TRY"),
|
||||
nullableString(header.LocalCurrencyCode, "TRY"),
|
||||
nullableFloat64(header.ExchangeRate, exRate),
|
||||
v3User,
|
||||
now,
|
||||
header.OrderHeaderID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// ======================================================
|
||||
// PREPARE STATEMENTS
|
||||
// ======================================================
|
||||
|
||||
insStmt, err := tx.Prepare(`INSERT INTO BAGGI_V3.dbo.trOrderLine (
|
||||
OrderLineID, SortOrder, ItemTypeCode, ItemCode, ColorCode,
|
||||
ItemDim1Code, ItemDim2Code, ItemDim3Code,
|
||||
Qty1, Qty2, CancelQty1, CancelQty2, OrderCancelReasonCode,
|
||||
DeliveryDate, PlannedDateOfLading, LineDescription,
|
||||
UsedBarcode, CostCenterCode,
|
||||
VatCode, VatRate, PCTCode, PCTRate,
|
||||
LDisRate1, LDisRate2, LDisRate3, LDisRate4, LDisRate5,
|
||||
DocCurrencyCode, PriceCurrencyCode, PriceExchangeRate,
|
||||
Price, BaseProcessCode, BaseOrderNumber,
|
||||
BaseCustomerTypeCode, BaseCustomerCode,
|
||||
BaseSubCurrAccID, BaseStoreCode,
|
||||
OrderHeaderID, CreatedUserName, CreatedDate,
|
||||
LastUpdatedUserName, LastUpdatedDate,
|
||||
SurplusOrderQtyToleranceRate,
|
||||
WithHoldingTaxTypeCode, DOVCode)
|
||||
VALUES (
|
||||
@p1,@p2,@p3,@p4,@p5,@p6,@p7,@p8,@p9,@p10,
|
||||
@p11,@p12,@p13,@p14,@p15,@p16,@p17,@p18,
|
||||
@p19,@p20,@p21,@p22,@p23,@p24,@p25,@p26,@p27,
|
||||
@p28,@p29,@p30,@p31,@p32,@p33,@p34,@p35,
|
||||
@p36,@p37,@p38,@p39,@p40,@p41,@p42,@p43,
|
||||
@p44,@p45)`)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer insStmt.Close()
|
||||
|
||||
updStmt, err := tx.Prepare(`UPDATE BAGGI_V3.dbo.trOrderLine SET
|
||||
SortOrder=@p1, ItemTypeCode=@p2, ItemCode=@p3, ColorCode=@p4,
|
||||
ItemDim1Code=@p5, ItemDim2Code=@p6, ItemDim3Code=@p7,
|
||||
Qty1=@p8, Qty2=@p9, CancelQty1=@p10, CancelQty2=@p11,
|
||||
OrderCancelReasonCode=@p12,
|
||||
DeliveryDate=@p13, PlannedDateOfLading=@p14,
|
||||
LineDescription=@p15, UsedBarcode=@p16, CostCenterCode=@p17,
|
||||
VatCode=@p18, VatRate=@p19, PCTCode=@p20, PCTRate=@p21,
|
||||
LDisRate1=@p22, LDisRate2=@p23, LDisRate3=@p24,
|
||||
LDisRate4=@p25, LDisRate5=@p26,
|
||||
DocCurrencyCode=@p27, PriceCurrencyCode=@p28,
|
||||
PriceExchangeRate=@p29, Price=@p30,
|
||||
BaseProcessCode=@p31, BaseOrderNumber=@p32,
|
||||
BaseCustomerTypeCode=@p33, BaseCustomerCode=@p34,
|
||||
BaseSubCurrAccID=@p35, BaseStoreCode=@p36,
|
||||
LastUpdatedUserName=@p37, LastUpdatedDate=@p38,
|
||||
SurplusOrderQtyToleranceRate=@p39,
|
||||
WithHoldingTaxTypeCode=@p40, DOVCode=@p41
|
||||
WHERE OrderLineID=@p42 AND ISNULL(IsClosed,0)=0`)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer updStmt.Close()
|
||||
|
||||
lineResults := make([]OrderLineResult, 0)
|
||||
seenCombo := make(map[string]bool)
|
||||
|
||||
for _, ln := range lines {
|
||||
|
||||
comboKey := normalizeComboKey(safeNS(ln.ComboKey))
|
||||
if comboKey == "" {
|
||||
comboKey = makeComboKey(ln)
|
||||
}
|
||||
|
||||
// Duplicate guard (SADECE aktif)
|
||||
if qtyValue(ln.Qty1) > 0 && comboKey != "" {
|
||||
if seenCombo[comboKey] {
|
||||
return nil, fmt.Errorf("Duplicate satır: %s", comboKey)
|
||||
}
|
||||
seenCombo[comboKey] = true
|
||||
}
|
||||
|
||||
// Kapalı satır
|
||||
if ln.OrderLineID != "" && existingClosed[ln.OrderLineID] {
|
||||
continue
|
||||
}
|
||||
if comboKey != "" {
|
||||
if _, ok := existingClosedCombo[comboKey]; ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE SIGNAL
|
||||
if ln.OrderLineID != "" && qtyValue(ln.Qty1) <= 0 {
|
||||
_, err := tx.Exec(`DELETE FROM BAGGI_V3.dbo.trOrderLineCurrency WHERE OrderLineID=@p1`, ln.OrderLineID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(`
|
||||
DELETE FROM BAGGI_V3.dbo.trOrderLine
|
||||
WHERE OrderHeaderID=@p1 AND OrderLineID=@p2 AND ISNULL(IsClosed,0)=0
|
||||
`, header.OrderHeaderID, ln.OrderLineID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delete(existingOpen, ln.OrderLineID)
|
||||
delete(existingOpenCombo, comboKey)
|
||||
continue
|
||||
}
|
||||
isNew := false
|
||||
|
||||
if ln.OrderLineID == "" {
|
||||
if dbID, ok := existingOpenCombo[comboKey]; ok {
|
||||
ln.OrderLineID = dbID
|
||||
} else {
|
||||
ln.OrderLineID = uuid.New().String()
|
||||
isNew = true
|
||||
}
|
||||
}
|
||||
|
||||
if qtyValue(ln.Qty1) > 0 {
|
||||
if err := ValidateItemVariant(tx, ln); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if isNew {
|
||||
_, err := insStmt.Exec(
|
||||
ln.OrderLineID, ln.SortOrder, ln.ItemTypeCode,
|
||||
nullableString(ln.ItemCode, ""), safeNS(ln.ColorCode),
|
||||
safeNS(ln.ItemDim1Code), safeNS(ln.ItemDim2Code),
|
||||
nullableString(ln.ItemDim3Code, ""),
|
||||
ln.Qty1, ln.Qty2, ln.CancelQty1, ln.CancelQty2,
|
||||
nullableString(ln.OrderCancelReasonCode, ""),
|
||||
nullableTime(ln.DeliveryDate, now),
|
||||
nullableDateString(ln.PlannedDateOfLading),
|
||||
nullableString(ln.LineDescription, ""),
|
||||
nullableString(ln.UsedBarcode, ""),
|
||||
nullableString(ln.CostCenterCode, ""),
|
||||
normalizeVatCode(ln.VatCode), nf0(ln.VatRate),
|
||||
normalizePCTCode(ln.PCTCode), nf0(ln.PCTRate),
|
||||
nf0(ln.LDisRate1), nf0(ln.LDisRate2),
|
||||
nf0(ln.LDisRate3), nf0(ln.LDisRate4), nf0(ln.LDisRate5),
|
||||
nullableString(ln.DocCurrencyCode, "TRY"),
|
||||
nullableString(ln.PriceCurrencyCode, "TRY"),
|
||||
nf0(ln.PriceExchangeRate), nf0(ln.Price),
|
||||
nullableString(ln.BaseProcessCode, ""),
|
||||
nullableString(ln.BaseOrderNumber, ""),
|
||||
ln.BaseCustomerTypeCode,
|
||||
nullableString(ln.BaseCustomerCode, ""),
|
||||
nullableUUID(ln.BaseSubCurrAccID),
|
||||
nullableString(ln.BaseStoreCode, ""),
|
||||
header.OrderHeaderID,
|
||||
v3User, now, v3User, now,
|
||||
nf0(ln.SurplusOrderQtyToleranceRate),
|
||||
nullableString(ln.WithHoldingTaxTypeCode, ""),
|
||||
nullableString(ln.DOVCode, ""),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
_, err := updStmt.Exec(
|
||||
ln.SortOrder, ln.ItemTypeCode,
|
||||
nullableString(ln.ItemCode, ""),
|
||||
safeNS(ln.ColorCode),
|
||||
safeNS(ln.ItemDim1Code),
|
||||
safeNS(ln.ItemDim2Code),
|
||||
nullableString(ln.ItemDim3Code, ""),
|
||||
nf0(ln.Qty1), nf0(ln.Qty2),
|
||||
nf0(ln.CancelQty1), nf0(ln.CancelQty2),
|
||||
nullableString(ln.OrderCancelReasonCode, ""),
|
||||
nullableTime(ln.DeliveryDate, now),
|
||||
nullableDateString(ln.PlannedDateOfLading),
|
||||
nullableString(ln.LineDescription, ""),
|
||||
nullableString(ln.UsedBarcode, ""),
|
||||
nullableString(ln.CostCenterCode, ""),
|
||||
normalizeVatCode(ln.VatCode), nf0(ln.VatRate),
|
||||
normalizePCTCode(ln.PCTCode), nf0(ln.PCTRate),
|
||||
nf0(ln.LDisRate1), nf0(ln.LDisRate2),
|
||||
nf0(ln.LDisRate3), nf0(ln.LDisRate4), nf0(ln.LDisRate5),
|
||||
nullableString(ln.DocCurrencyCode, "TRY"),
|
||||
nullableString(ln.PriceCurrencyCode, "TRY"),
|
||||
nf0(ln.PriceExchangeRate), nf0(ln.Price),
|
||||
nullableString(ln.BaseProcessCode, ""),
|
||||
nullableString(ln.BaseOrderNumber, ""),
|
||||
ln.BaseCustomerTypeCode,
|
||||
nullableString(ln.BaseCustomerCode, ""),
|
||||
nullableUUID(ln.BaseSubCurrAccID),
|
||||
nullableString(ln.BaseStoreCode, ""),
|
||||
v3User, now,
|
||||
nf0(ln.SurplusOrderQtyToleranceRate),
|
||||
nullableString(ln.WithHoldingTaxTypeCode, ""),
|
||||
nullableString(ln.DOVCode, ""),
|
||||
ln.OrderLineID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
delete(existingOpen, ln.OrderLineID)
|
||||
delete(existingOpenCombo, comboKey)
|
||||
|
||||
if ln.ClientKey.Valid {
|
||||
lineResults = append(lineResults, OrderLineResult{
|
||||
ClientKey: ln.ClientKey.String,
|
||||
OrderLineID: ln.OrderLineID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Grid dışı kalan açık satırlar
|
||||
for id := range existingOpen {
|
||||
_, err := tx.Exec(`DELETE FROM BAGGI_V3.dbo.trOrderLineCurrency WHERE OrderLineID=@p1`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(`DELETE FROM BAGGI_V3.dbo.trOrderLine WHERE OrderLineID=@p1 AND ISNULL(IsClosed,0)=0`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return lineResults, nil
|
||||
}
|
||||
109
svc/queries/orderinventory.go
Normal file
109
svc/queries/orderinventory.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package queries
|
||||
|
||||
// 🔹 Sipariş ekranı stok kontrolü (ürün + renk + 2. renk + beden)
|
||||
// 🔧 Renk1 boşsa (aksesuar vb.) renksiz stokları da çeker
|
||||
const GetOrderInventory = `
|
||||
DECLARE @ProductCode NVARCHAR(30) = @p1;
|
||||
DECLARE @ColorCode NVARCHAR(30) = @p2;
|
||||
DECLARE @ColorCode2 NVARCHAR(30) = @p3;
|
||||
|
||||
-- 🔧 Normalize giriş parametreleri
|
||||
SET @ColorCode = LTRIM(RTRIM(ISNULL(@ColorCode, '')));
|
||||
SET @ColorCode2 = LTRIM(RTRIM(ISNULL(@ColorCode2, '')));
|
||||
|
||||
IF @ColorCode = ''
|
||||
BEGIN
|
||||
SET @ColorCode = '';
|
||||
END
|
||||
|
||||
IF @ColorCode2 = ''
|
||||
BEGIN
|
||||
SET @ColorCode2 = NULL;
|
||||
END
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 🔹 Ana sorgu
|
||||
------------------------------------------------------------
|
||||
SELECT
|
||||
Inventory.ItemCode AS Urun_Kodu,
|
||||
Inventory.ColorCode AS Renk_Kodu,
|
||||
ISNULL((
|
||||
SELECT TOP 1 ColorDescription
|
||||
FROM cdColorDesc WITH(NOLOCK)
|
||||
WHERE cdColorDesc.ColorCode = Inventory.ColorCode
|
||||
AND cdColorDesc.LangCode = N'TR'
|
||||
), '') AS Renk_Aciklamasi,
|
||||
|
||||
-- ✅ NULL bedenleri boş string olarak getir
|
||||
ISNULL(Inventory.ItemDim1Code, '') AS Beden,
|
||||
ISNULL(Inventory.ItemDim2Code, '') AS Yaka,
|
||||
|
||||
ROUND(
|
||||
SUM(Inventory.InventoryQty1) -
|
||||
(SUM(Inventory.ReserveQty1) + SUM(Inventory.DispOrderQty1) + SUM(Inventory.PickingQty1)),
|
||||
cdUnitOfMeasure.RoundDigit
|
||||
) AS Kullanilabilir_Envanter
|
||||
|
||||
FROM cdItem WITH (NOLOCK)
|
||||
JOIN cdUnitOfMeasure WITH (NOLOCK)
|
||||
ON cdItem.UnitOfMeasureCode1 = cdUnitOfMeasure.UnitOfMeasureCode
|
||||
JOIN (
|
||||
SELECT
|
||||
CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
|
||||
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code,
|
||||
SUM(CASE WHEN SourceTable = 'PickingStates' THEN Qty1 ELSE 0 END) AS PickingQty1,
|
||||
SUM(CASE WHEN SourceTable = 'ReserveStates' THEN Qty1 ELSE 0 END) AS ReserveQty1,
|
||||
SUM(CASE WHEN SourceTable = 'DispOrderStates' THEN Qty1 ELSE 0 END) AS DispOrderQty1,
|
||||
SUM(CASE WHEN SourceTable = 'trStock' THEN (In_Qty1 - Out_Qty1) ELSE 0 END) AS InventoryQty1
|
||||
FROM (
|
||||
SELECT 'PickingStates' AS SourceTable, CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
|
||||
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code,
|
||||
Qty1, 0 AS In_Qty1, 0 AS Out_Qty1
|
||||
FROM PickingStates
|
||||
UNION ALL
|
||||
SELECT 'ReserveStates', CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
|
||||
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code,
|
||||
Qty1, 0, 0
|
||||
FROM ReserveStates
|
||||
UNION ALL
|
||||
SELECT 'DispOrderStates', CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
|
||||
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code,
|
||||
Qty1, 0, 0
|
||||
FROM DispOrderStates
|
||||
UNION ALL
|
||||
SELECT 'trStock', CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
|
||||
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code,
|
||||
0, SUM(In_Qty1), SUM(Out_Qty1)
|
||||
FROM trStock WITH (NOLOCK)
|
||||
GROUP BY CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
|
||||
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code
|
||||
) AS SourceData
|
||||
GROUP BY CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
|
||||
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code
|
||||
) AS Inventory
|
||||
ON cdItem.ItemTypeCode = Inventory.ItemTypeCode
|
||||
AND cdItem.ItemCode = Inventory.ItemCode
|
||||
LEFT JOIN ProductAttributesFilter
|
||||
ON ProductAttributesFilter.ItemCode = Inventory.ItemCode
|
||||
WHERE
|
||||
Inventory.ItemTypeCode IN (1)
|
||||
AND Inventory.WarehouseCode IN ('1-0-12','1-0-21','1-0-10','1-0-2','1-1-3','1-2-4','1-2-5')
|
||||
AND cdItem.IsBlocked = 0
|
||||
AND Inventory.ItemCode = @ProductCode
|
||||
AND (
|
||||
-- 🔹 Eğer renk girilmişse o renk; boşsa renksiz stokları getir
|
||||
(LTRIM(RTRIM(@ColorCode)) <> '' AND Inventory.ColorCode = @ColorCode)
|
||||
OR (LTRIM(RTRIM(@ColorCode)) = '' AND (Inventory.ColorCode IS NULL OR LTRIM(RTRIM(Inventory.ColorCode)) = ''))
|
||||
)
|
||||
AND (
|
||||
-- 🔹 2. renk sadece doluysa filtrelensin
|
||||
@ColorCode2 IS NULL
|
||||
OR Inventory.ItemDim2Code = @ColorCode2
|
||||
)
|
||||
GROUP BY
|
||||
Inventory.ItemCode,
|
||||
Inventory.ColorCode,
|
||||
ISNULL(Inventory.ItemDim1Code, ''), -- ✅ NULL bedenleri boş string olarak gruplar
|
||||
ISNULL(Inventory.ItemDim2Code, ''), -- ✅ NULL yakaları boş string olarak gruplar
|
||||
cdUnitOfMeasure.RoundDigit;
|
||||
`
|
||||
213
svc/queries/orderlist.go
Normal file
213
svc/queries/orderlist.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"bssapp-backend/auth"
|
||||
"bssapp-backend/internal/authz"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ========================================================
|
||||
// 📌 GetOrderList — FINAL + CURRENCY SAFE + PIYASA AUTHZ
|
||||
// ========================================================
|
||||
func GetOrderList(
|
||||
ctx context.Context,
|
||||
mssql *sql.DB,
|
||||
pg *sql.DB,
|
||||
search string,
|
||||
) (*sql.Rows, error) {
|
||||
|
||||
claims, ok := auth.GetClaimsFromContext(ctx)
|
||||
if !ok || claims == nil {
|
||||
return nil, fmt.Errorf("unauthorized: claims not found")
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 🔐 PIYASA FILTER (ADMIN BYPASS)
|
||||
// ----------------------------------------------------
|
||||
piyasaWhere := "1=1"
|
||||
|
||||
if !claims.IsAdmin() {
|
||||
|
||||
codes, err := authz.GetUserPiyasaCodes(pg, int(claims.ID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("piyasa codes load error: %w", err)
|
||||
}
|
||||
|
||||
if len(codes) == 0 {
|
||||
// hiç yetkisi yok → hiç kayıt dönmesin
|
||||
piyasaWhere = "1=0"
|
||||
} else {
|
||||
// ⚠️ EXISTS içinde kullanılacak
|
||||
piyasaWhere = authz.BuildINClause(
|
||||
"UPPER(f2.CustomerAtt01)",
|
||||
codes,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 📄 BASE QUERY
|
||||
// ----------------------------------------------------
|
||||
baseQuery := fmt.Sprintf(`
|
||||
SELECT
|
||||
CAST(h.OrderHeaderID AS NVARCHAR(50)) AS OrderHeaderID,
|
||||
ISNULL(h.OrderNumber, '') AS OrderNumber,
|
||||
CONVERT(varchar, h.OrderDate, 23) AS OrderDate,
|
||||
|
||||
ISNULL(h.CurrAccCode, '') AS CurrAccCode,
|
||||
ISNULL(ca.CurrAccDescription, '') AS CurrAccDescription,
|
||||
|
||||
ISNULL(mt.AttributeDescription, '') AS MusteriTemsilcisi,
|
||||
ISNULL(py.AttributeDescription, '') AS Piyasa,
|
||||
|
||||
CONVERT(varchar, h.CreditableConfirmedDate,23) AS CreditableConfirmedDate,
|
||||
ISNULL(h.DocCurrencyCode,'TRY') AS DocCurrencyCode,
|
||||
|
||||
ISNULL(l.TotalAmount,0) AS TotalAmount,
|
||||
|
||||
----------------------------------------------------------------
|
||||
-- USD HESABI (TRY / EUR / GBP / USD DESTEKLİ)
|
||||
----------------------------------------------------------------
|
||||
CASE
|
||||
WHEN h.DocCurrencyCode = 'USD'
|
||||
THEN ISNULL(l.TotalAmount,0)
|
||||
|
||||
WHEN h.DocCurrencyCode = 'TRY'
|
||||
AND usd.Rate > 0
|
||||
THEN ISNULL(l.TotalAmount,0) / usd.Rate
|
||||
|
||||
WHEN h.DocCurrencyCode IN ('EUR','GBP')
|
||||
AND cur.Rate > 0
|
||||
AND usd.Rate > 0
|
||||
THEN (ISNULL(l.TotalAmount,0) * cur.Rate) / usd.Rate
|
||||
|
||||
ELSE 0
|
||||
END AS TotalAmountUSD,
|
||||
|
||||
ISNULL(h.IsCreditableConfirmed,0) AS IsCreditableConfirmed,
|
||||
ISNULL(h.Description,'') AS Description,
|
||||
|
||||
usd.Rate AS ExchangeRateUSD
|
||||
|
||||
FROM dbo.trOrderHeader h
|
||||
|
||||
JOIN (
|
||||
SELECT
|
||||
OrderHeaderID,
|
||||
SUM(Qty1 * Price) AS TotalAmount
|
||||
FROM dbo.trOrderLine
|
||||
GROUP BY OrderHeaderID
|
||||
) l
|
||||
ON l.OrderHeaderID = h.OrderHeaderID
|
||||
|
||||
LEFT JOIN dbo.cdCurrAccDesc ca
|
||||
ON ca.CurrAccCode = h.CurrAccCode
|
||||
AND ca.LangCode = 'TR'
|
||||
|
||||
-- müşteri temsilcisi + piyasa açıklamaları
|
||||
LEFT JOIN dbo.CustomerAttributesFilter f
|
||||
ON f.CurrAccCode = h.CurrAccCode
|
||||
|
||||
LEFT JOIN dbo.cdCurrAccAttributeDesc mt
|
||||
ON mt.CurrAccTypeCode = 3
|
||||
AND mt.AttributeTypeCode = 2
|
||||
AND mt.AttributeCode = f.CustomerAtt02
|
||||
AND mt.LangCode = 'TR'
|
||||
|
||||
LEFT JOIN dbo.cdCurrAccAttributeDesc py
|
||||
ON py.CurrAccTypeCode = 3
|
||||
AND py.AttributeTypeCode = 1
|
||||
AND py.AttributeCode = f.CustomerAtt01
|
||||
AND py.LangCode = 'TR'
|
||||
|
||||
----------------------------------------------------------------
|
||||
-- USD → TRY
|
||||
----------------------------------------------------------------
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 Rate
|
||||
FROM dbo.AllExchangeRates
|
||||
WHERE CurrencyCode = 'USD'
|
||||
AND RelationCurrencyCode = 'TRY'
|
||||
AND ExchangeTypeCode = 6
|
||||
AND Rate > 0
|
||||
AND Date <= CAST(GETDATE() AS date)
|
||||
ORDER BY Date DESC
|
||||
) usd
|
||||
|
||||
----------------------------------------------------------------
|
||||
-- ORDER PB → TRY
|
||||
----------------------------------------------------------------
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 Rate
|
||||
FROM dbo.AllExchangeRates
|
||||
WHERE CurrencyCode = h.DocCurrencyCode
|
||||
AND RelationCurrencyCode = 'TRY'
|
||||
AND ExchangeTypeCode = 6
|
||||
AND Rate > 0
|
||||
AND Date <= CAST(GETDATE() AS date)
|
||||
ORDER BY Date DESC
|
||||
) cur
|
||||
|
||||
WHERE
|
||||
ISNULL(h.IsCancelOrder,0) = 0
|
||||
AND h.OrderTypeCode = 1
|
||||
AND h.ProcessCode = 'WS'
|
||||
AND h.IsClosed = 0
|
||||
|
||||
-- 🔐 PIYASA AUTHZ (EXISTS — SAĞLAM YOL)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM dbo.CustomerAttributesFilter f2
|
||||
WHERE f2.CurrAccCode = h.CurrAccCode
|
||||
AND %s
|
||||
)
|
||||
`, piyasaWhere)
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 🔍 SEARCH FILTER (CASE + TR SAFE)
|
||||
// ----------------------------------------------------
|
||||
if search != "" {
|
||||
baseQuery += `
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM dbo.trOrderHeader h2
|
||||
LEFT JOIN dbo.cdCurrAccDesc ca2
|
||||
ON ca2.CurrAccCode = h2.CurrAccCode
|
||||
AND ca2.LangCode = 'TR'
|
||||
WHERE h2.OrderHeaderID = h.OrderHeaderID
|
||||
AND (
|
||||
LOWER(REPLACE(REPLACE(h2.OrderNumber,'İ','I'),'ı','i'))
|
||||
COLLATE Latin1_General_CI_AI LIKE LOWER(@p1)
|
||||
|
||||
OR LOWER(REPLACE(REPLACE(h2.CurrAccCode,'İ','I'),'ı','i'))
|
||||
COLLATE Latin1_General_CI_AI LIKE LOWER(@p1)
|
||||
|
||||
OR LOWER(REPLACE(REPLACE(ca2.CurrAccDescription,'İ','I'),'ı','i'))
|
||||
COLLATE Latin1_General_CI_AI LIKE LOWER(@p1)
|
||||
|
||||
OR LOWER(REPLACE(REPLACE(h2.Description,'İ','I'),'ı','i'))
|
||||
COLLATE Latin1_General_CI_AI LIKE LOWER(@p1)
|
||||
)
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 📌 ORDER
|
||||
// ----------------------------------------------------
|
||||
baseQuery += `
|
||||
ORDER BY h.CreatedDate DESC
|
||||
`
|
||||
|
||||
// ----------------------------------------------------
|
||||
// ▶ EXECUTE
|
||||
// ----------------------------------------------------
|
||||
if search != "" {
|
||||
searchLike := fmt.Sprintf("%%%s%%", search)
|
||||
return mssql.Query(baseQuery, searchLike)
|
||||
}
|
||||
|
||||
return mssql.Query(baseQuery)
|
||||
}
|
||||
75
svc/queries/orderlist_base.go
Normal file
75
svc/queries/orderlist_base.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package queries
|
||||
|
||||
const OrderListBaseQuery = `
|
||||
SELECT
|
||||
CAST(h.OrderHeaderID AS NVARCHAR(50)) AS OrderHeaderID,
|
||||
ISNULL(h.OrderNumber, '') AS OrderNumber,
|
||||
CONVERT(varchar, h.OrderDate, 23) AS OrderDate,
|
||||
|
||||
ISNULL(h.CurrAccCode, '') AS CurrAccCode,
|
||||
ISNULL(ca.CurrAccDescription, '') AS CurrAccDescription,
|
||||
|
||||
ISNULL(mt.AttributeDescription, '') AS MusteriTemsilcisi,
|
||||
ISNULL(py.AttributeDescription, '') AS Piyasa,
|
||||
|
||||
CONVERT(varchar, h.CreditableConfirmedDate, 23) AS CreditableConfirmedDate,
|
||||
ISNULL(h.DocCurrencyCode, 'TRY') AS DocCurrencyCode,
|
||||
|
||||
ISNULL(l.TotalAmount, 0) AS TotalAmount,
|
||||
|
||||
CASE
|
||||
WHEN h.DocCurrencyCode = 'USD'
|
||||
THEN ISNULL(l.TotalAmount, 0)
|
||||
WHEN h.DocCurrencyCode = 'TRY'
|
||||
THEN ISNULL(l.TotalAmount, 0) / NULLIF(er.Rate, 1)
|
||||
ELSE 0
|
||||
END AS TotalAmountUSD,
|
||||
|
||||
ISNULL(h.IsCreditableConfirmed, 0) AS IsCreditableConfirmed,
|
||||
ISNULL(h.Description, '') AS Description,
|
||||
|
||||
ISNULL(er.Rate, 1) AS ExchangeRateUSD
|
||||
|
||||
FROM dbo.trOrderHeader h
|
||||
|
||||
JOIN (
|
||||
SELECT OrderHeaderID, SUM(Qty1 * Price) AS TotalAmount
|
||||
FROM dbo.trOrderLine
|
||||
GROUP BY OrderHeaderID
|
||||
) l ON l.OrderHeaderID = h.OrderHeaderID
|
||||
|
||||
LEFT JOIN dbo.cdCurrAccDesc ca
|
||||
ON ca.CurrAccCode = h.CurrAccCode AND ca.LangCode = 'TR'
|
||||
|
||||
LEFT JOIN dbo.CustomerAttributes f
|
||||
ON f.CurrAccTypeCode = h.CurrAccTypeCode
|
||||
AND f.CurrAccCode = h.CurrAccCode
|
||||
|
||||
LEFT JOIN dbo.cdCurrAccAttributeDesc mt
|
||||
ON mt.CurrAccTypeCode = f.CurrAccTypeCode
|
||||
AND mt.AttributeTypeCode = 2
|
||||
AND mt.AttributeCode = f.CustomerAtt02
|
||||
AND mt.LangCode = 'TR'
|
||||
|
||||
LEFT JOIN dbo.cdCurrAccAttributeDesc py
|
||||
ON py.CurrAccTypeCode = f.CurrAccTypeCode
|
||||
AND py.AttributeTypeCode = 3
|
||||
AND py.AttributeCode = f.CustomerAtt03
|
||||
AND py.LangCode = 'TR'
|
||||
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 Rate
|
||||
FROM dbo.AllExchangeRates er
|
||||
WHERE er.CurrencyCode = 'USD'
|
||||
AND er.RelationCurrencyCode = 'TRY'
|
||||
AND er.ExchangeTypeCode = 6
|
||||
AND er.Rate > 0
|
||||
ORDER BY ABS(DATEDIFF(DAY, er.Date, GETDATE()))
|
||||
) er
|
||||
|
||||
WHERE
|
||||
ISNULL(h.IsCancelOrder, 0) = 0
|
||||
AND h.OrderTypeCode = 1
|
||||
AND h.ProcessCode = 'WS'
|
||||
AND h.IsClosed = 0
|
||||
`
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user