Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-05-22 14:57:34 +03:00
parent d886fba6de
commit 1f90b9f9ce
25 changed files with 2767 additions and 687 deletions

View File

@@ -8,7 +8,6 @@ import (
"database/sql"
"encoding/json"
"net/http"
"sort"
"strings"
"github.com/gorilla/mux"
@@ -23,6 +22,42 @@ type FirstGroupMailLookupResponse struct {
Mails []models.MailOption `json:"mails"`
}
func ensureFirstGroupMailMappingTables(pg *sql.DB) error {
// Idempotent bootstrap: create tables if they don't exist.
// We keep schema minimal: (group_code, mail_id) + created_at and FK to mk_mail.
stmts := []string{
`
CREATE TABLE IF NOT EXISTS mk_costing_first_group_mail (
urun_ilk_grubu TEXT NOT NULL,
mail_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (urun_ilk_grubu, mail_id),
CONSTRAINT fk_costing_first_group_mail_mail
FOREIGN KEY (mail_id) REFERENCES mk_mail(id) ON DELETE CASCADE
)
`,
`CREATE INDEX IF NOT EXISTS ix_costing_first_group_mail_group ON mk_costing_first_group_mail (urun_ilk_grubu)`,
`
CREATE TABLE IF NOT EXISTS mk_pricing_first_group_mail (
urun_ilk_grubu TEXT NOT NULL,
mail_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (urun_ilk_grubu, mail_id),
CONSTRAINT fk_pricing_first_group_mail_mail
FOREIGN KEY (mail_id) REFERENCES mk_mail(id) ON DELETE CASCADE
)
`,
`CREATE INDEX IF NOT EXISTS ix_pricing_first_group_mail_group ON mk_pricing_first_group_mail (urun_ilk_grubu)`,
}
for _, s := range stmts {
if _, err := pg.Exec(s); err != nil {
return err
}
}
return nil
}
func GetCostingFirstGroupMailMappingLookupsHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -32,6 +67,10 @@ func GetCostingFirstGroupMailMappingLookupsHandler(pg *sql.DB) http.HandlerFunc
http.Error(w, "mssql connection not available", http.StatusServiceUnavailable)
return
}
if err := ensureFirstGroupMailMappingTables(pg); err != nil {
http.Error(w, "mapping table bootstrap error", http.StatusInternalServerError)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
@@ -39,21 +78,23 @@ func GetCostingFirstGroupMailMappingLookupsHandler(pg *sql.DB) http.HandlerFunc
firstGroups := make([]models.FirstGroupOption, 0, 256)
mails := make([]models.MailOption, 0, 256)
fgRows, err := queries.ListProductFirstGroupOptions(ctx, mssql, "", 5000)
fgRows, err := queries.ListProductFirstGroupCodeDescOptions(ctx, mssql, "", 5000)
if err != nil {
http.Error(w, "first group lookup error", http.StatusInternalServerError)
return
}
defer fgRows.Close()
for fgRows.Next() {
var g string
if err := fgRows.Scan(&g); err != nil {
var code string
var title string
if err := fgRows.Scan(&code, &title); err != nil {
http.Error(w, "first group scan error", http.StatusInternalServerError)
return
}
g = strings.TrimSpace(g)
if g != "" {
firstGroups = append(firstGroups, models.FirstGroupOption{ID: g, Label: g})
code = strings.TrimSpace(code)
title = strings.TrimSpace(title)
if code != "" {
firstGroups = append(firstGroups, models.FirstGroupOption{Code: code, Title: title})
}
}
if err := fgRows.Err(); err != nil {
@@ -98,34 +139,44 @@ func GetCostingFirstGroupMailMappingsHandler(pg *sql.DB) http.HandlerFunc {
http.Error(w, "mssql connection not available", http.StatusServiceUnavailable)
return
}
if err := ensureFirstGroupMailMappingTables(pg); err != nil {
http.Error(w, "mapping table bootstrap error", http.StatusInternalServerError)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
// Fetch all first groups from V3 (source of truth for the list)
allGroups := make([]string, 0, 512)
fgRows, err := queries.ListProductFirstGroupOptions(ctx, mssql, "", 5000)
allCodes := make([]string, 0, 512)
titleByCode := make(map[string]string, 512)
fgRows, err := queries.ListProductFirstGroupCodeDescOptions(ctx, mssql, "", 5000)
if err != nil {
http.Error(w, "first group lookup error", http.StatusInternalServerError)
return
}
defer fgRows.Close()
for fgRows.Next() {
var g string
if err := fgRows.Scan(&g); err != nil {
var code string
var title string
if err := fgRows.Scan(&code, &title); err != nil {
http.Error(w, "first group scan error", http.StatusInternalServerError)
return
}
g = strings.TrimSpace(g)
if g != "" {
allGroups = append(allGroups, g)
code = strings.TrimSpace(code)
title = strings.TrimSpace(title)
if code != "" {
allCodes = append(allCodes, code)
if _, ok := titleByCode[code]; !ok {
titleByCode[code] = title
}
}
}
if err := fgRows.Err(); err != nil {
http.Error(w, "first group rows error", http.StatusInternalServerError)
return
}
sort.Strings(allGroups)
allCodes = normalizeIDList(allCodes)
// Fetch mappings from Postgres
rows, err := pg.Query(queries.GetCostingFirstGroupMailMappingRows)
@@ -136,9 +187,11 @@ func GetCostingFirstGroupMailMappingsHandler(pg *sql.DB) http.HandlerFunc {
defer rows.Close()
byGroup := map[string]*models.FirstGroupMailMappingRow{}
for _, g := range allGroups {
byGroup[g] = &models.FirstGroupMailMappingRow{
UrunIlkGrubu: g,
for _, code := range allCodes {
byGroup[code] = &models.FirstGroupMailMappingRow{
UrunIlkGrubu: code,
GroupCode: code,
GroupTitle: titleByCode[code],
MailIDs: make([]string, 0, 8),
Mails: make([]models.FirstGroupMailOption, 0, 8),
}
@@ -153,19 +206,21 @@ func GetCostingFirstGroupMailMappingsHandler(pg *sql.DB) http.HandlerFunc {
http.Error(w, "mapping scan error", http.StatusInternalServerError)
return
}
g := strings.TrimSpace(group.String)
if g == "" {
code := strings.TrimSpace(group.String)
if code == "" {
continue
}
row, ok := byGroup[g]
row, ok := byGroup[code]
if !ok {
row = &models.FirstGroupMailMappingRow{
UrunIlkGrubu: g,
UrunIlkGrubu: code,
GroupCode: code,
GroupTitle: titleByCode[code],
MailIDs: make([]string, 0, 8),
Mails: make([]models.FirstGroupMailOption, 0, 8),
}
byGroup[g] = row
allGroups = append(allGroups, g)
byGroup[code] = row
allCodes = append(allCodes, code)
}
if mailID.Valid && strings.TrimSpace(mailID.String) != "" {
id := strings.TrimSpace(mailID.String)
@@ -182,11 +237,15 @@ func GetCostingFirstGroupMailMappingsHandler(pg *sql.DB) http.HandlerFunc {
return
}
sort.Strings(allGroups)
out := make([]models.FirstGroupMailMappingRow, 0, len(allGroups))
for _, g := range allGroups {
if r := byGroup[g]; r != nil {
allCodes = normalizeIDList(allCodes)
out := make([]models.FirstGroupMailMappingRow, 0, len(allCodes))
for _, code := range allCodes {
if r := byGroup[code]; r != nil {
r.MailIDs = normalizeIDList(r.MailIDs)
// Fill title if missing
if strings.TrimSpace(r.GroupTitle) == "" {
r.GroupTitle = titleByCode[code]
}
out = append(out, *r)
}
}
@@ -203,33 +262,43 @@ func GetPricingFirstGroupMailMappingsHandler(pg *sql.DB) http.HandlerFunc {
http.Error(w, "mssql connection not available", http.StatusServiceUnavailable)
return
}
if err := ensureFirstGroupMailMappingTables(pg); err != nil {
http.Error(w, "mapping table bootstrap error", http.StatusInternalServerError)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
allGroups := make([]string, 0, 512)
fgRows, err := queries.ListProductFirstGroupOptions(ctx, mssql, "", 5000)
allCodes := make([]string, 0, 512)
titleByCode := make(map[string]string, 512)
fgRows, err := queries.ListProductFirstGroupCodeDescOptions(ctx, mssql, "", 5000)
if err != nil {
http.Error(w, "first group lookup error", http.StatusInternalServerError)
return
}
defer fgRows.Close()
for fgRows.Next() {
var g string
if err := fgRows.Scan(&g); err != nil {
var code string
var title string
if err := fgRows.Scan(&code, &title); err != nil {
http.Error(w, "first group scan error", http.StatusInternalServerError)
return
}
g = strings.TrimSpace(g)
if g != "" {
allGroups = append(allGroups, g)
code = strings.TrimSpace(code)
title = strings.TrimSpace(title)
if code != "" {
allCodes = append(allCodes, code)
if _, ok := titleByCode[code]; !ok {
titleByCode[code] = title
}
}
}
if err := fgRows.Err(); err != nil {
http.Error(w, "first group rows error", http.StatusInternalServerError)
return
}
sort.Strings(allGroups)
allCodes = normalizeIDList(allCodes)
rows, err := pg.Query(queries.GetPricingFirstGroupMailMappingRows)
if err != nil {
@@ -239,9 +308,11 @@ func GetPricingFirstGroupMailMappingsHandler(pg *sql.DB) http.HandlerFunc {
defer rows.Close()
byGroup := map[string]*models.FirstGroupMailMappingRow{}
for _, g := range allGroups {
byGroup[g] = &models.FirstGroupMailMappingRow{
UrunIlkGrubu: g,
for _, code := range allCodes {
byGroup[code] = &models.FirstGroupMailMappingRow{
UrunIlkGrubu: code,
GroupCode: code,
GroupTitle: titleByCode[code],
MailIDs: make([]string, 0, 8),
Mails: make([]models.FirstGroupMailOption, 0, 8),
}
@@ -256,19 +327,21 @@ func GetPricingFirstGroupMailMappingsHandler(pg *sql.DB) http.HandlerFunc {
http.Error(w, "mapping scan error", http.StatusInternalServerError)
return
}
g := strings.TrimSpace(group.String)
if g == "" {
code := strings.TrimSpace(group.String)
if code == "" {
continue
}
row, ok := byGroup[g]
row, ok := byGroup[code]
if !ok {
row = &models.FirstGroupMailMappingRow{
UrunIlkGrubu: g,
UrunIlkGrubu: code,
GroupCode: code,
GroupTitle: titleByCode[code],
MailIDs: make([]string, 0, 8),
Mails: make([]models.FirstGroupMailOption, 0, 8),
}
byGroup[g] = row
allGroups = append(allGroups, g)
byGroup[code] = row
allCodes = append(allCodes, code)
}
if mailID.Valid && strings.TrimSpace(mailID.String) != "" {
id := strings.TrimSpace(mailID.String)
@@ -285,11 +358,14 @@ func GetPricingFirstGroupMailMappingsHandler(pg *sql.DB) http.HandlerFunc {
return
}
sort.Strings(allGroups)
out := make([]models.FirstGroupMailMappingRow, 0, len(allGroups))
for _, g := range allGroups {
if r := byGroup[g]; r != nil {
allCodes = normalizeIDList(allCodes)
out := make([]models.FirstGroupMailMappingRow, 0, len(allCodes))
for _, code := range allCodes {
if r := byGroup[code]; r != nil {
r.MailIDs = normalizeIDList(r.MailIDs)
if strings.TrimSpace(r.GroupTitle) == "" {
r.GroupTitle = titleByCode[code]
}
out = append(out, *r)
}
}

View File

@@ -36,31 +36,18 @@ func ensureMkMail(tx *sql.Tx, email string) error {
if errors.Is(err, sql.ErrNoRows) {
newID := utils.NewUUID()
_, err = tx.Exec(`
INSERT INTO mk_mail (
id,
email,
display_name,
"type",
is_primary,
external_id,
is_active,
created_at
)
VALUES ($1, $2, '', 'user', true, true, true, NOW())
`, newID, mail)
// Keep this insert intentionally minimal because mk_mail schema may vary between environments.
// Only rely on columns we already SELECT elsewhere (id/email/display_name/is_active).
_, err = tx.Exec(`INSERT INTO mk_mail (id, email, display_name, is_active) VALUES ($1, $2, '', true)`, newID, mail)
return err
}
// Exists: normalize + activate. Avoid touching created_at.
// Exists: normalize + activate.
_, err = tx.Exec(`
UPDATE mk_mail
SET
email = $2,
display_name = COALESCE(display_name, ''),
"type" = 'user',
is_primary = true,
external_id = true,
is_active = true
WHERE id::text = $1
`, id, mail)

View File

@@ -3,12 +3,14 @@ package routes
import (
"bssapp-backend/auth"
"bssapp-backend/db"
"bssapp-backend/internal/mailer"
"bssapp-backend/models"
"bssapp-backend/queries"
"bssapp-backend/utils"
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
@@ -1222,8 +1224,82 @@ func PostProductionProductCostingDefaultQuantitiesRefreshHandler(w http.Response
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "top_n": topN})
}
// POST /api/pricing/production-product-costing/tbstok/exists-bulk
// Validates whether given codes exist in URETIM dbo.tbStok (or match sModel rules).
// Used by UI to highlight invalid codes before save.
func PostProductionProductCostingTbStokExistsBulkHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
// Non-blocking UX helper: if URETIM isn't reachable in this environment, return empty result.
_ = json.NewEncoder(w).Encode(models.ProductionProductCostingTbStokExistsBulkResponse{
Missing: []string{},
Error: "URETIM baglantisi aktif degil",
})
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.tbstok.exists-bulk")
var req models.ProductionProductCostingTbStokExistsBulkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Gecersiz JSON", http.StatusBadRequest)
return
}
// short timeout: this is a UX helper, must not hang (but should still complete on moderate load)
checkCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// log a small sample to diagnose timeouts without flooding logs
sample := make([]string, 0, 8)
for _, c := range req.Codes {
c = strings.TrimSpace(c)
if c == "" {
continue
}
sample = append(sample, c)
if len(sample) >= 8 {
break
}
}
logger.Info("lookup start", "codes", len(req.Codes), "sample", strings.Join(sample, ","))
existsBy, err := queries.LookupTbStokExistsByCodes(checkCtx, uretimDB, req.Codes)
if err != nil {
logger.Warn("lookup failed", "err", err, "codes", len(req.Codes))
// Non-blocking UX helper: return empty list + error so UI can continue without hard failure.
_ = json.NewEncoder(w).Encode(models.ProductionProductCostingTbStokExistsBulkResponse{
Missing: []string{},
Error: "tbStok sorgu hatasi",
})
return
}
missing := make([]string, 0, 16)
for code, ok := range existsBy {
if !ok && strings.TrimSpace(code) != "" {
missing = append(missing, code)
}
}
_ = json.NewEncoder(w).Encode(models.ProductionProductCostingTbStokExistsBulkResponse{Missing: missing})
}
// POST /api/pricing/production-product-costing/onml/save
func PostProductionProductCostingOnMLSaveHandlerWithMailer(ml *mailer.GraphMailer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
postProductionProductCostingOnMLSaveHandler(w, r, ml)
}
}
// Backward-compatible entrypoint (no mailer).
func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.Request) {
postProductionProductCostingOnMLSaveHandler(w, r, nil)
}
func postProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.Request, ml *mailer.GraphMailer) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
@@ -1413,6 +1489,14 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
logger.Warn("tx rollback failed", "trace_id", traceID, "n_onml_no", nOnMLNo, "err", err)
}
}()
warnings := make([]string, 0, 4)
// Determine whether this is a new costing record or an update (before header upsert).
isUpdate := false
{
var flag int
_ = tx.QueryRowContext(ctx, `SELECT CASE WHEN EXISTS (SELECT 1 FROM dbo.spUrtOnMLMas WITH (NOLOCK) WHERE nOnMLNo=@p1) THEN 1 ELSE 0 END`, nOnMLNo).Scan(&flag)
isUpdate = flag == 1
}
// Determine mamul turu inside same tx (to keep create atomic)
mamulLabel := ""
@@ -1512,6 +1596,96 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
sKodu string
}
recipeQtyByKey := map[recipeKey]float64{}
// Bulk resolve stock type id from tbStok (huge performance win vs per-row queries).
// IMPORTANT: Do NOT run tbStok lookups on the transaction connection.
// We have seen network timeouts against the tbStok server poison the tx connection ("driver: bad connection"),
// which then makes rollback/commit impossible and returns 500. Use a separate DB handle + short timeouts.
lookupDB := mssqlDB
if lookupDB == nil {
lookupDB = uretimDB
}
uniqueCodes := make([]string, 0, len(req.Detail.Upserts))
seenCode := map[string]struct{}{}
for _, row := range req.Detail.Upserts {
if row.NOnMLDetNo <= 0 {
continue
}
code := strings.TrimSpace(row.SKodu)
if code == "" {
continue
}
if _, ok := seenCode[code]; ok {
continue
}
seenCode[code] = struct{}{}
uniqueCodes = append(uniqueCodes, code)
}
stockTypeByCode := map[string]int{}
bulkStockTypeLookupFailed := false
if len(uniqueCodes) > 0 {
lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// Build a VALUES list with parameters: (VALUES (@p1), (@p2), ...)
valParts := make([]string, 0, len(uniqueCodes))
args := make([]any, 0, len(uniqueCodes))
for i, code := range uniqueCodes {
// Parameters are 1-based in our SQL style (@p1, @p2, ...)
valParts = append(valParts, fmt.Sprintf("(@p%d)", i+1))
args = append(args, code)
}
sqlText := fmt.Sprintf(`
WITH C AS (
SELECT LTRIM(RTRIM(V.code)) AS code
FROM (VALUES %s) AS V(code)
)
SELECT
C.code,
ISNULL((
SELECT TOP 1 ISNULL(CONVERT(int, ISNULL(S.nStokTipi, 0)), 0) AS nStokTipiID
FROM dbo.tbStok S WITH (NOLOCK)
WHERE ISNULL(S.IsBlocked, 0) = 0
AND (
REPLACE(LTRIM(RTRIM(ISNULL(S.sKodu,''))), ' ', '') = REPLACE(C.code, ' ', '')
OR LTRIM(RTRIM(ISNULL(S.sModel,''))) = C.code
OR C.code LIKE LTRIM(RTRIM(ISNULL(S.sModel,''))) + '%%'
)
ORDER BY
CASE
WHEN REPLACE(LTRIM(RTRIM(ISNULL(S.sKodu,''))), ' ', '') = REPLACE(C.code, ' ', '') THEN 0
WHEN LTRIM(RTRIM(ISNULL(S.sModel,''))) = C.code THEN 1
ELSE 2
END,
S.dteKayitTarihi DESC,
S.nStokID DESC
), 0) AS nStokTipiID
FROM C
`, strings.Join(valParts, ","))
rows, err := lookupDB.QueryContext(lookupCtx, sqlText, args...)
if err != nil {
// Do not fail the whole save for bulk lookup. We'll fallback to per-row queries below.
logger.Error("bulk stok tipi lookup error (fallback to per-row)", "err", err)
bulkStockTypeLookupFailed = true
} else {
for rows.Next() {
var code string
var nStokTipiID int
if err := rows.Scan(&code, &nStokTipiID); err != nil {
_ = rows.Close()
logger.Error("bulk stok tipi scan error (fallback to per-row)", "err", err)
bulkStockTypeLookupFailed = true
break
}
code = strings.TrimSpace(code)
if code != "" {
stockTypeByCode[code] = nStokTipiID
}
}
_ = rows.Close()
}
}
for _, row := range req.Detail.Upserts {
if row.NOnMLDetNo <= 0 {
skippedUpserts += 1
@@ -1640,23 +1814,25 @@ WHERE nHammaddeTuruNo = @p1
lTutar := unitTRY * qty
lDovizTutari := unitUSD * qty
// Debug log for price resolution
logger.Info("price debug",
"s_kodu", strings.TrimSpace(row.SKodu),
"qty", qty,
"fiyat_girilen", row.FiyatGirilen,
"fiyat_doviz", strings.TrimSpace(row.FiyatDoviz),
"unitTRY", unitTRY,
"lTutar", lTutar,
"lDovizTutari", lDovizTutari,
)
// Keep logs lean: per-row price debug was too noisy and slow in large payloads.
// Resolve stock type id from tbStok by sKodu (exact), then fallback to model-based match.
// Note: In this DB, stock type is stored as tbStok.nStokTipi but spUrtOnMLMasDet expects nStokTipiID (int).
rawSKodu := strings.TrimSpace(row.SKodu)
logger.Info("resolving stock type", "s_kodu", rawSKodu)
var nStokTipiID int
err := tx.QueryRowContext(ctx, `
nStokTipiID, ok := stockTypeByCode[rawSKodu]
if !ok || nStokTipiID <= 0 {
// If bulk lookup already failed (usually due to network/driver timeouts), do NOT attempt per-row lookups.
// Per-row fallback would multiply latency and still likely fail, without adding value.
if bulkStockTypeLookupFailed {
nStokTipiID = 1
if rawSKodu != "" {
stockTypeByCode[rawSKodu] = 1
}
} else if rawSKodu != "" {
// Fallback to per-row query. Cache results back into the map.
var tmp int
perRowCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
err := lookupDB.QueryRowContext(perRowCtx, `
SELECT TOP 1 ISNULL(CONVERT(int, ISNULL(S.nStokTipi, 0)), 0) AS nStokTipiID
FROM dbo.tbStok S WITH (NOLOCK)
WHERE ISNULL(S.IsBlocked, 0) = 0
@@ -1673,28 +1849,28 @@ ORDER BY
END,
S.dteKayitTarihi DESC,
S.nStokID DESC
`, rawSKodu).Scan(&nStokTipiID)
if err != nil {
if err == sql.ErrNoRows {
// FALLBACK: If stock item not found in tbStok at all, default to 1.
logger.Warn("stok tipi not found in tbStok, falling back to 1",
"trace_id", traceID,
"n_onml_no", nOnMLNo,
"n_onml_det_no", row.NOnMLDetNo,
"s_kodu", rawSKodu,
)
nStokTipiID = 1
} else {
logger.Error("stok tipi lookup error", "err", err)
http.Error(w, "Stok tipi bulunamadi (tbStok sorgu hatasi)", http.StatusInternalServerError)
return
`, rawSKodu).Scan(&tmp)
cancel()
if err == nil {
nStokTipiID = tmp
stockTypeByCode[rawSKodu] = nStokTipiID
} else if err == sql.ErrNoRows {
// keep 0 -> will fallback to 1 below
nStokTipiID = 0
stockTypeByCode[rawSKodu] = 0
} else {
// Do not block save for stock type lookup failures.
// Most common cause: tbStok DB is temporarily unreachable (timeouts / bad connection).
logger.Error("stok tipi lookup error (per-row)", "err", err, "s_kodu", rawSKodu)
nStokTipiID = 1
stockTypeByCode[rawSKodu] = 1
}
}
}
logger.Info("stock type resolved", "s_kodu", rawSKodu, "n_stok_tipi_id", nStokTipiID)
if nStokTipiID <= 0 {
// FALLBACK: If stock type is missing or 0 in tbStok, default to 1 (usually 'Raw Material' or 'General').
// This prevents blocking the save process for items not fully configured in tbStok.
logger.Warn("stok tipi <= 0, falling back to 1",
logger.Warn("stok tipi <= 0 (bulk), falling back to 1",
"trace_id", traceID,
"n_onml_no", nOnMLNo,
"n_onml_det_no", row.NOnMLDetNo,
@@ -1886,8 +2062,9 @@ WHERE nUrtReceteID = @p1
AND nUrtMBolumID = @p2
AND LTRIM(RTRIM(ISNULL(nHStokID_G,''))) = @p3
`, req.Header.NUrtReceteID, k.nUrtMBolumID, k.sKodu, q, user); err != nil {
logger.Warn("recipe qty update failed", "trace_id", traceID, "n_urt_recete_id", req.Header.NUrtReceteID, "n_urt_m_bolum_id", k.nUrtMBolumID, "s_kodu", k.sKodu, "err", err)
continue
logger.Error("recipe qty update failed", "trace_id", traceID, "n_urt_recete_id", req.Header.NUrtReceteID, "n_urt_m_bolum_id", k.nUrtMBolumID, "s_kodu", k.sKodu, "err", err)
http.Error(w, "Recete miktar guncellemesi basarisiz", http.StatusInternalServerError)
return
}
updated++
}
@@ -1899,7 +2076,9 @@ WHERE nUrtReceteID = @p1
SELECT ISNULL(MAX(CONVERT(int, nUrtRecMBolumID)), 0) AS MaxID
FROM dbo.spUrtRecMBolum WITH (UPDLOCK, HOLDLOCK)
`).Scan(&baseID); err != nil {
logger.Warn("recipe base id lookup failed (skipping inserts)", "trace_id", traceID, "err", err)
logger.Error("recipe base id lookup failed", "trace_id", traceID, "err", err)
http.Error(w, "Recete insert hazirligi basarisiz", http.StatusInternalServerError)
return
} else {
inserted := 0
nextID := baseID
@@ -1913,8 +2092,9 @@ FROM dbo.spUrtRecMBolum WITH (UPDLOCK, HOLDLOCK)
if err := tx.QueryRowContext(ctx, `
SELECT CASE WHEN EXISTS (SELECT 1 FROM dbo.spUrtMBolum WITH (NOLOCK) WHERE nUrtMBolumID = @p1) THEN 1 ELSE 0 END
`, k.nUrtMBolumID).Scan(&bolumExists); err != nil || bolumExists != 1 {
logger.Warn("recipe insert skipped (missing spUrtMBolum FK)", "trace_id", traceID, "n_urt_m_bolum_id", k.nUrtMBolumID, "s_kodu", k.sKodu)
continue
logger.Error("recipe insert blocked (missing spUrtMBolum FK)", "trace_id", traceID, "n_urt_m_bolum_id", k.nUrtMBolumID, "s_kodu", k.sKodu, "err", err)
http.Error(w, "Recete insert engellendi (bolum FK yok)", http.StatusBadRequest)
return
}
nextID++
@@ -1960,8 +2140,9 @@ VALUES (
@p7,GETDATE()
)
`, nextID, req.Header.NUrtReceteID, nUrtUBolumID, k.nUrtMBolumID, k.sKodu, q, user); err != nil {
logger.Warn("recipe insert failed", "trace_id", traceID, "n_urt_recete_id", req.Header.NUrtReceteID, "n_urt_m_bolum_id", k.nUrtMBolumID, "s_kodu", k.sKodu, "err", err)
continue
logger.Error("recipe insert failed", "trace_id", traceID, "n_urt_recete_id", req.Header.NUrtReceteID, "n_urt_m_bolum_id", k.nUrtMBolumID, "s_kodu", k.sKodu, "err", err)
http.Error(w, "Recete insert basarisiz", http.StatusInternalServerError)
return
}
inserted++
}
@@ -1978,18 +2159,227 @@ VALUES (
committed = true
logger.Info("tx commit ok", "trace_id", traceID, "n_onml_no", nOnMLNo)
// V3: update base price table so pricing screens reflect latest costing.
// Not transactional with URETIM DB; if this fails, URETIM save has already succeeded.
if mssqlDB != nil {
logger.Info("post-commit step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "v3_base_price_upsert")
if err := queries.UpsertV3ItemBasePriceUSD(ctx, mssqlDB, req.Header.UrunKodu, req.Header.MaliyetTarihi, totalUSD, user); err != nil {
logger.Error("v3 base price upsert error", "err", err)
http.Error(w, "URETIM kaydedildi ama V3 maliyet guncellenemedi", http.StatusInternalServerError)
return
// Post-commit async tasks (save latency reduction):
// - V3 base price upsert (MSSQL)
// - Costing mail send (Graph + Postgres mappings)
// - Last10 avg deviation warnings (MSSQL cache -> Postgres panel)
//
// These must NOT block the HTTP response. They are retried with backoff and only logged on failure.
{
reqCopy := req
if len(req.Detail.Upserts) > 0 {
up := make([]models.ProductionProductCostingOnMLSaveDetailUpsertRow, len(req.Detail.Upserts))
copy(up, req.Detail.Upserts)
reqCopy.Detail.Upserts = up
}
if len(req.Detail.Deletes) > 0 {
del := make([]models.ProductionProductCostingOnMLSaveDetailDeleteRow, len(req.Detail.Deletes))
copy(del, req.Detail.Deletes)
reqCopy.Detail.Deletes = del
}
actorUser := user
nOnMLNoLocal := nOnMLNo
isUpdateLocal := isUpdate
urunKoduLocal := strings.TrimSpace(req.Header.UrunKodu)
maliyetTarihiLocal := strings.TrimSpace(req.Header.MaliyetTarihi)
totalUSDLocal := totalUSD
totalTRYLocal := totalTRY
totalEURLocal := totalEUR
usdRateLocal := usdRate
eurRateLocal := eurRate
gbpRateLocal := gbpRate
mssqlLocal := mssqlDB
uretimLocal := uretimDB
pgLocal := db.PgDB
mlLocal := ml
traceIDLocal := traceID
go func() {
bg := context.Background()
bg = utils.ContextWithTraceID(bg, traceIDLocal)
bgLogger := utils.SlogFromContext(bg).With("handler", "production-product-costing.onml.save.post-commit", "n_onml_no", nOnMLNoLocal)
// 1) V3 base price upsert: retry 3 times with backoff.
if mssqlLocal != nil && urunKoduLocal != "" && maliyetTarihiLocal != "" {
backoff := []time.Duration{300 * time.Millisecond, 1200 * time.Millisecond, 3500 * time.Millisecond}
var lastErr error
for attempt := 0; attempt < len(backoff)+1; attempt++ {
if attempt > 0 {
time.Sleep(backoff[attempt-1])
}
stepCtx, cancel := context.WithTimeout(bg, 10*time.Second)
err := queries.UpsertV3ItemBasePriceUSD(stepCtx, mssqlLocal, urunKoduLocal, maliyetTarihiLocal, totalUSDLocal, actorUser)
cancel()
if err == nil {
bgLogger.Info("post-commit ok", "step", "v3_base_price_upsert")
lastErr = nil
break
}
lastErr = err
bgLogger.Warn("post-commit retry", "step", "v3_base_price_upsert", "attempt", attempt+1, "err", err)
}
if lastErr != nil {
bgLogger.Error("post-commit failed", "step", "v3_base_price_upsert", "err", lastErr)
}
} else {
bgLogger.Info("post-commit skipped", "step", "v3_base_price_upsert")
}
// 2) Costing mail: retry 2 times with backoff.
if mlLocal != nil && pgLocal != nil && mssqlLocal != nil {
backoff := []time.Duration{800 * time.Millisecond, 2500 * time.Millisecond}
var lastErr error
for attempt := 0; attempt < len(backoff)+1; attempt++ {
if attempt > 0 {
time.Sleep(backoff[attempt-1])
}
stepCtx, cancel := context.WithTimeout(bg, 25*time.Second)
err := sendCostingSummaryMail(stepCtx, pgLocal, mssqlLocal, uretimLocal, mlLocal, reqCopy, nOnMLNoLocal, isUpdateLocal, usdRateLocal, eurRateLocal, gbpRateLocal, totalUSDLocal, totalTRYLocal, totalEURLocal, actorUser)
cancel()
if err == nil {
bgLogger.Info("post-commit ok", "step", "costing_mail_send")
lastErr = nil
break
}
lastErr = err
bgLogger.Warn("post-commit retry", "step", "costing_mail_send", "attempt", attempt+1, "err", err)
}
if lastErr != nil {
bgLogger.Error("post-commit failed", "step", "costing_mail_send", "err", lastErr)
}
} else {
bgLogger.Info("post-commit skipped", "step", "costing_mail_send")
}
// 3) Last10 avg deviation warnings: dedupe by (code,currency), read from MSSQL cache, write to Postgres table.
// Keep generous timeouts: this is async and should succeed on slow networks.
if pgLocal != nil && mssqlLocal != nil && len(reqCopy.Detail.Upserts) > 0 {
_, cancelBoot := context.WithTimeout(bg, 5*time.Second)
if err := queries.EnsureProductionCostingLast10WarningTables(pgLocal); err != nil {
cancelBoot()
bgLogger.Error("post-commit failed", "step", "last10_warning_bootstrap", "err", err)
return
}
cancelBoot()
// dedupe input by code+currency (USD basis comparison)
inputByKey := map[string]float64{}
inputUSDByKey := map[string]float64{}
codes := make([]string, 0, len(reqCopy.Detail.Upserts))
seenCode := map[string]struct{}{}
for _, row := range reqCopy.Detail.Upserts {
code := strings.TrimSpace(row.SKodu)
if code == "" {
continue
}
cur := strings.ToUpper(strings.TrimSpace(row.FiyatDoviz))
if cur == "" {
cur = "USD"
}
in := row.FiyatGirilen
if in <= 0 {
continue
}
key := code + "|" + cur
if _, ok := inputByKey[key]; ok {
continue
}
inputByKey[key] = in
// input USD basis
inUSD := 0.0
switch cur {
case "USD":
inUSD = in
case "TRY", "TL":
if usdRateLocal > 0 {
inUSD = in / usdRateLocal
}
case "EUR":
if usdRateLocal > 0 && eurRateLocal > 0 {
inUSD = (in * eurRateLocal) / usdRateLocal
}
case "GBP":
if usdRateLocal > 0 && gbpRateLocal > 0 {
inUSD = (in * gbpRateLocal) / usdRateLocal
}
default:
inUSD = in
}
inputUSDByKey[key] = inUSD
if _, ok := seenCode[code]; !ok {
seenCode[code] = struct{}{}
codes = append(codes, code)
}
}
lookupCtx, cancelLookup := context.WithTimeout(bg, 6*time.Second)
avgRows, err := queries.LookupLast10AvgPurchasePriceByItemCodes(lookupCtx, mssqlLocal, codes)
cancelLookup()
if err != nil {
bgLogger.Warn("post-commit failed", "step", "last10_cache_lookup", "err", err)
return
}
avgUSDByKey := map[string]float64{}
for _, ar := range avgRows {
code := strings.TrimSpace(ar.ItemCode)
cur := strings.ToUpper(strings.TrimSpace(ar.CurrencyCode))
if code == "" || cur == "" || ar.SampleCount <= 0 || ar.AvgDocPrice <= 0 {
continue
}
key := code + "|" + cur
avgUSD := 0.0
switch cur {
case "USD":
avgUSD = ar.AvgDocPrice
case "TRY", "TL":
if usdRateLocal > 0 {
avgUSD = ar.AvgDocPrice / usdRateLocal
}
case "EUR":
if usdRateLocal > 0 && eurRateLocal > 0 {
avgUSD = (ar.AvgDocPrice * eurRateLocal) / usdRateLocal
}
case "GBP":
if usdRateLocal > 0 && gbpRateLocal > 0 {
avgUSD = (ar.AvgDocPrice * gbpRateLocal) / usdRateLocal
}
default:
avgUSD = ar.AvgDocPrice
}
avgUSDByKey[key] = avgUSD
}
writeCtx, cancelWrite := context.WithTimeout(bg, 12*time.Second)
err = queries.ReplaceProductionCostingLast10Warnings(
writeCtx,
pgLocal,
nOnMLNoLocal,
urunKoduLocal,
maliyetTarihiLocal,
actorUser,
avgRows,
inputByKey,
inputUSDByKey,
avgUSDByKey,
)
cancelWrite()
if err != nil {
bgLogger.Error("post-commit failed", "step", "last10_warning_write", "err", err)
return
}
bgLogger.Info("post-commit ok", "step", "last10_warning_write", "keys", len(inputByKey), "codes", len(codes), "cache_rows", len(avgRows))
} else {
bgLogger.Info("post-commit skipped", "step", "last10_warning_write")
}
}()
}
_ = json.NewEncoder(w).Encode(models.ProductionProductCostingOnMLSaveResponse{NOnMLNo: nOnMLNo})
_ = json.NewEncoder(w).Encode(models.ProductionProductCostingOnMLSaveResponse{NOnMLNo: nOnMLNo, Warnings: warnings})
}
// POST /api/pricing/production-product-costing/onml/delete
@@ -2206,7 +2596,6 @@ func PostProductionHasCostDetailBulkPricesHandler(w http.ResponseWriter, r *http
costDate := strings.TrimSpace(req.MaliyetTarihi)
itemsCount := len(req.Items)
responseChan := make(chan *models.ProductionHasCostDetailBulkPriceRow, itemsCount)
logger.Info("request start",
"n_onml_no", strings.TrimSpace(req.NOnMLNo),
@@ -2216,38 +2605,112 @@ func PostProductionHasCostDetailBulkPricesHandler(w http.ResponseWriter, r *http
)
log.Printf("[ProductionHasCostDetailBulkPrices] start n_onml_no=%s urun_kodu=%s maliyet_tarihi=%s item_count=%d", strings.TrimSpace(req.NOnMLNo), strings.TrimSpace(req.UrunKodu), costDate, itemsCount)
for _, item := range req.Items {
go func(item models.ProductionHasCostDetailPriceLookupItem) {
sKodu := normalizeLookupValue(item.SKodu)
if sKodu == "" {
responseChan <- nil
return
// Bulk query (single roundtrip): send request items as JSON and resolve latest purchase price before cost date.
type bulkReqItem struct {
RowKey string `json:"rowKey"`
SKodu string `json:"sKodu"`
ColorCode string `json:"colorCode"`
ItemDim1Code string `json:"itemDim1Code"`
}
reqItems := make([]bulkReqItem, 0, itemsCount)
metaByRowKey := map[string]models.ProductionHasCostDetailPriceLookupItem{}
for _, it := range req.Items {
sKodu := normalizeLookupValue(it.SKodu)
if sKodu == "" {
continue
}
colorCode := firstNonEmptyString(
normalizeLookupValue(it.ColorCode),
normalizeLookupValue(it.SRenk),
)
itemDim1Code := firstNonEmptyString(normalizeLookupValue(it.ItemDim1Code))
rowKey := strings.TrimSpace(it.RowKey)
if rowKey == "" {
// keep a stable key even if UI didn't pass it (should not happen).
rowKey = strings.TrimSpace(it.NOnMLDetNo + "|" + sKodu)
}
reqItems = append(reqItems, bulkReqItem{RowKey: rowKey, SKodu: sKodu, ColorCode: colorCode, ItemDim1Code: itemDim1Code})
metaByRowKey[rowKey] = it
}
itemsJSONBytes, err := json.Marshal(reqItems)
if err != nil {
logger.Warn("bulk request invalid", "reason", "items json marshal failed", "err", err)
http.Error(w, "Toplu fiyat verisi hazirlanamadi", http.StatusBadRequest)
return
}
rows, err := queries.GetProductionHasCostLatestPurchasePricesForItems(ctx, mssqlDB, string(itemsJSONBytes), costDate)
response := make([]models.ProductionHasCostDetailBulkPriceRow, 0, len(reqItems))
if err != nil {
// Fallback: some MSSQL instances are on low compatibility level and don't support OPENJSON.
// In that case, fall back to the legacy per-item lookup but with bounded concurrency.
logger.Warn("bulk lookup error (fallback to per-item)", "err", err)
type job struct {
rowKey string
sKodu string
colorCode string
itemDim1Code string
}
jobs := make(chan job, len(reqItems))
results := make(chan *models.ProductionHasCostDetailBulkPriceRow, len(reqItems))
worker := func() {
for j := range jobs {
row, qerr := queries.GetProductionHasCostLatestPurchasePriceForItem(ctx, mssqlDB, j.sKodu, j.colorCode, j.itemDim1Code, costDate)
if qerr != nil {
results <- nil
continue
}
var res models.ProductionHasCostDetailBulkPriceRow
if serr := row.Scan(
&res.PriceType,
&res.Tarih,
&res.FaturaKodu,
&res.MasrafKodu,
&res.MasrafDetay,
&res.ColorCode,
&res.ColorDescription,
&res.ItemDim1Code,
&res.ItemDim1Description,
&res.FiyatGirilen,
&res.FiyatDoviz,
); serr != nil {
results <- nil
continue
}
meta := metaByRowKey[j.rowKey]
res.RowKey = strings.TrimSpace(meta.RowKey)
if res.RowKey == "" {
res.RowKey = j.rowKey
}
res.NOnMLDetNo = strings.TrimSpace(meta.NOnMLDetNo)
res.NHammaddeTuruNo = strings.TrimSpace(meta.NHammaddeTuruNo)
res.SKodu = normalizeLookupValue(meta.SKodu)
results <- &res
}
}
colorCode := firstNonEmptyString(
normalizeLookupValue(item.ColorCode),
normalizeLookupValue(item.SRenk),
)
itemDim1Code := firstNonEmptyString(
normalizeLookupValue(item.ItemDim1Code),
)
row, err := queries.GetProductionHasCostLatestPurchasePriceForItem(
ctx,
mssqlDB,
sKodu,
colorCode,
itemDim1Code,
costDate,
)
if err != nil {
logger.Warn("item lookup error", "s_kodu", sKodu, "color_code", colorCode, "item_dim1_code", itemDim1Code, "err", err)
responseChan <- nil
return
workerCount := 10
for i := 0; i < workerCount; i++ {
go worker()
}
for _, it := range reqItems {
jobs <- job{rowKey: it.RowKey, sKodu: it.SKodu, colorCode: it.ColorCode, itemDim1Code: it.ItemDim1Code}
}
close(jobs)
for i := 0; i < len(reqItems); i++ {
r := <-results
if r != nil {
response = append(response, *r)
}
}
} else {
defer rows.Close()
for rows.Next() {
var rowKey string
var result models.ProductionHasCostDetailBulkPriceRow
if err := row.Scan(
if err := rows.Scan(
&rowKey,
&result.PriceType,
&result.Tarih,
&result.FaturaKodu,
@@ -2260,32 +2723,21 @@ func PostProductionHasCostDetailBulkPricesHandler(w http.ResponseWriter, r *http
&result.FiyatGirilen,
&result.FiyatDoviz,
); err != nil {
logger.Warn("item scan error", "s_kodu", sKodu, "color_code", colorCode, "item_dim1_code", itemDim1Code, "err", err)
responseChan <- nil
return
logger.Warn("bulk scan error", "err", err)
continue
}
result.RowKey = strings.TrimSpace(item.RowKey)
result.NOnMLDetNo = strings.TrimSpace(item.NOnMLDetNo)
result.NHammaddeTuruNo = strings.TrimSpace(item.NHammaddeTuruNo)
result.SKodu = sKodu
if strings.TrimSpace(result.ColorCode) == "" {
result.ColorCode = colorCode
meta := metaByRowKey[rowKey]
result.RowKey = strings.TrimSpace(meta.RowKey)
if result.RowKey == "" {
result.RowKey = rowKey
}
if strings.TrimSpace(result.ItemDim1Code) == "" {
result.ItemDim1Code = itemDim1Code
}
responseChan <- &result
}(item)
}
response := make([]models.ProductionHasCostDetailBulkPriceRow, 0, itemsCount)
for i := 0; i < itemsCount; i++ {
res := <-responseChan
if res != nil {
response = append(response, *res)
result.NOnMLDetNo = strings.TrimSpace(meta.NOnMLDetNo)
result.NHammaddeTuruNo = strings.TrimSpace(meta.NHammaddeTuruNo)
result.SKodu = normalizeLookupValue(meta.SKodu)
response = append(response, result)
}
if err := rows.Err(); err != nil {
logger.Warn("bulk rows error", "err", err)
}
}

View File

@@ -0,0 +1,47 @@
package routes
import (
"bssapp-backend/db"
"bssapp-backend/queries"
"bssapp-backend/utils"
"encoding/json"
"net/http"
"strconv"
"strings"
)
// GET /api/pricing/production-product-costing/last10-warnings?n_onml_no=100001
func GetProductionProductCostingLast10WarningsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
pg := db.PgDB
if pg == nil {
http.Error(w, "Postgres baglantisi aktif degil", http.StatusServiceUnavailable)
return
}
if err := queries.EnsureProductionCostingLast10WarningTables(pg); err != nil {
http.Error(w, "warning table bootstrap error", http.StatusInternalServerError)
return
}
nStr := strings.TrimSpace(r.URL.Query().Get("n_onml_no"))
n, _ := strconv.Atoi(nStr)
if n <= 0 {
http.Error(w, "n_onml_no zorunlu", http.StatusBadRequest)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
list, err := queries.ListProductionCostingLast10WarningsByOnMLNo(ctx, pg, n)
if err != nil {
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"n_onml_no": n,
"count": len(list),
"items": list,
})
}

View File

@@ -0,0 +1,719 @@
package routes
import (
"context"
"database/sql"
"fmt"
"math"
"sort"
"strings"
"time"
"bssapp-backend/internal/mailer"
"bssapp-backend/models"
"bssapp-backend/queries"
)
type mailBucket struct {
InputByCur map[string]float64
USD float64
TRY float64
HasCMT bool
// Fabric-only helpers (for UI parity)
MeterQty float64
MeterUom string
UnitIn float64
UnitCur string
}
func formatDateTR2(t time.Time) string {
if t.IsZero() {
return "-"
}
// dd.MM.yyyy
return t.Format("02.01.2006")
}
func formatDateTimeTR2(t time.Time) string {
if t.IsZero() {
return "-"
}
// dd.MM.yyyy HH:mm
return t.Format("02.01.2006 15:04")
}
func formatAnyDateTimeTR2(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "-"
}
// Common MSSQL string renderings (best-effort).
layouts := []string{
"2006-01-02 15:04:05.9999999",
"2006-01-02 15:04:05.999999",
"2006-01-02 15:04:05.999",
"2006-01-02 15:04:05",
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, s); err == nil {
// Date-only vs datetime
if layout == "2006-01-02" {
return formatDateTR2(t)
}
return formatDateTimeTR2(t)
}
}
return s
}
func addInputAmount(b *mailBucket, cur string, amount float64) {
if math.IsNaN(amount) || math.IsInf(amount, 0) || amount == 0 {
return
}
if b.InputByCur == nil {
b.InputByCur = map[string]float64{}
}
b.InputByCur[cur] = b.InputByCur[cur] + amount
}
func formatMoney2(v float64) string {
if math.IsNaN(v) || math.IsInf(v, 0) {
v = 0
}
// Keep 2 decimals with dot (mail clients).
return fmt.Sprintf("%.2f", v)
}
func formatQty2(v float64) string {
if math.IsNaN(v) || math.IsInf(v, 0) {
v = 0
}
return fmt.Sprintf("%.2f", v)
}
func normalizePartFromMtBolumTitle(title string) string {
v := strings.ToUpper(strings.TrimSpace(title))
if v == "" {
return ""
}
// Keep the part name dynamic (UI shows spUrtMTBolum.sAdi). Still normalize a few aliases.
switch {
case strings.Contains(v, "AKSESUAR") || strings.Contains(v, "AKS"):
return "AKSESUAR"
default:
return v
}
}
func summarizeInputByCurrency(b *mailBucket) (amountLabel string, curLabel string) {
if b == nil || len(b.InputByCur) == 0 {
return "-", "-"
}
curs := make([]string, 0, len(b.InputByCur))
for c := range b.InputByCur {
c = strings.ToUpper(strings.TrimSpace(c))
if c == "" {
continue
}
curs = append(curs, c)
}
sort.Strings(curs)
if len(curs) == 0 {
return "-", "-"
}
if len(curs) == 1 {
c := curs[0]
return formatMoney2(b.InputByCur[c]), c
}
sum := 0.0
for _, c := range curs {
sum += b.InputByCur[c]
}
return formatMoney2(sum), "MIX"
}
func formatMeterLabel(b *mailBucket) string {
if b == nil || !(b.MeterQty > 0) {
return "-"
}
u := strings.TrimSpace(b.MeterUom)
if u == "" {
u = "MT"
}
return fmt.Sprintf("%s %s", formatQty2(b.MeterQty), u)
}
func loadCostingRecipients(pg *sql.DB, firstGroupCode string) ([]string, error) {
rows, err := pg.Query(`
SELECT DISTINCT TRIM(m.email) AS email
FROM mk_costing_first_group_mail f
JOIN mk_mail m
ON m.id = f.mail_id
WHERE m.is_active = true
AND COALESCE(TRIM(m.email), '') <> ''
AND UPPER(TRIM(f.urun_ilk_grubu)) = UPPER(TRIM($1))
ORDER BY email
`, strings.TrimSpace(firstGroupCode))
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0, 16)
for rows.Next() {
var email string
if err := rows.Scan(&email); err != nil {
return nil, err
}
email = strings.TrimSpace(email)
if email != "" {
out = append(out, email)
}
}
return out, rows.Err()
}
func sendCostingSummaryMail(
ctx context.Context,
pg *sql.DB,
mssql *sql.DB,
uretim *sql.DB,
ml *mailer.GraphMailer,
req models.ProductionProductCostingOnMLSaveRequest,
nOnMLNo int,
isUpdate bool,
usdRate float64,
eurRate float64,
gbpRate float64,
totalUSD float64,
totalTRY float64,
totalEUR float64,
actor string,
) error {
if ml == nil {
return fmt.Errorf("mailer not initialized")
}
if pg == nil || mssql == nil {
return fmt.Errorf("db not initialized")
}
// Ensure mapping tables exist (first save can happen before mapping screens are visited).
if err := ensureFirstGroupMailMappingTables(pg); err != nil {
return fmt.Errorf("mapping table bootstrap error: %w", err)
}
firstGroupCode, _, err := queries.GetProductFirstGroupCodeDescByUrunKodu(ctx, mssql, req.Header.UrunKodu)
if err != nil {
return fmt.Errorf("first group resolve error: %w", err)
}
if strings.TrimSpace(firstGroupCode) == "" {
return fmt.Errorf("first group code not found for product")
}
recipients, err := loadCostingRecipients(pg, firstGroupCode)
if err != nil {
return fmt.Errorf("recipient query error: %w", err)
}
if len(recipients) == 0 {
// Don't hard fail; mapping might be intentionally empty.
return fmt.Errorf("no costing mail mapping for first group: %s", firstGroupCode)
}
// Pull the same header payload used by UI (best-effort) so the mail can show every header label.
var uiHeader models.ProductionHasCostDetailHeader
uiHeaderLoaded := false
if uretim != nil && nOnMLNo > 0 {
row, err := queries.GetProductionHasCostDetailHeaderByOnMLNo(ctx, uretim, nOnMLNo)
if err == nil && row != nil {
// Keep scan fields aligned with GetProductionHasCostDetailHeaderHandler
if err := row.Scan(
&uiHeader.UretimiYapanFirma,
&uiHeader.SonIsEmriVeren,
&uiHeader.FirmaKodu,
&uiHeader.NFirmaID,
&uiHeader.NOnMLNo,
&uiHeader.UrunKodu,
&uiHeader.UrunAdi,
&uiHeader.UretimSekliID,
&uiHeader.UretimSekli,
&uiHeader.MaliyetTarihi,
&uiHeader.DteKayitTarihi,
&uiHeader.SKullaniciAdi,
&uiHeader.LTutarTL,
&uiHeader.LTutarUSD,
&uiHeader.LTutarEURO,
&uiHeader.LTutarGBP,
&uiHeader.SDovizCinsi,
&uiHeader.LTutarDoviz,
&uiHeader.DteGuncellemeTarihi,
&uiHeader.SGuncellemeKullaniciAdi,
&uiHeader.NUrtReceteID,
); err == nil {
uiHeaderLoaded = true
if mssql != nil {
ilk, ana, alt, _ := queries.GetProductIlkAnaAltGrupByUrunKodu(ctx, mssql, uiHeader.UrunKodu)
uiHeader.UrunIlkGrubu = ilk
uiHeader.UrunAnaGrubu = ana
uiHeader.UrunAltGrubu = alt
}
}
}
}
// Enrich header (fallback) if UI header wasn't loadable.
ilkGrup, anaGrup, altGrup := "", "", ""
if uiHeaderLoaded {
ilkGrup, anaGrup, altGrup = strings.TrimSpace(uiHeader.UrunIlkGrubu), strings.TrimSpace(uiHeader.UrunAnaGrubu), strings.TrimSpace(uiHeader.UrunAltGrubu)
} else if mssql != nil {
ilkGrup, anaGrup, altGrup, _ = queries.GetProductIlkAnaAltGrupByUrunKodu(ctx, mssql, req.Header.UrunKodu)
}
// Resolve MT bolum titles so we can bucket rows into CEKET/PANTOLON/YELEK/AKSESUAR.
mtTitleByID := map[int]string{}
if uretim != nil {
ids := make([]int, 0, len(req.Detail.Upserts))
seen := map[int]struct{}{}
for _, row := range req.Detail.Upserts {
if row.NUrtMTBolumID <= 0 {
continue
}
if _, ok := seen[row.NUrtMTBolumID]; ok {
continue
}
seen[row.NUrtMTBolumID] = struct{}{}
ids = append(ids, row.NUrtMTBolumID)
}
if len(ids) > 0 {
vals := make([]string, 0, len(ids))
args := make([]any, 0, len(ids))
for i, id := range ids {
vals = append(vals, fmt.Sprintf("(@p%d)", i+1))
args = append(args, id)
}
q := fmt.Sprintf(`
WITH X AS (SELECT CONVERT(int, V.id) AS id FROM (VALUES %s) AS V(id))
SELECT X.id, LTRIM(RTRIM(ISNULL(M.sAdi,''))) AS title
FROM X
LEFT JOIN dbo.spUrtMTBolum M WITH (NOLOCK)
ON M.nUrtMTBolumID = X.id
`, strings.Join(vals, ","))
rows, err := uretim.QueryContext(ctx, q, args...)
if err == nil {
for rows.Next() {
var id int
var title string
if err := rows.Scan(&id, &title); err != nil {
continue
}
mtTitleByID[id] = strings.TrimSpace(title)
}
_ = rows.Close()
}
}
}
// Dynamic part list derived from detail rows.
preferred := []string{"CEKET", "PANTOLON", "YELEK", "AKSESUAR", "YAKA"}
seen := map[string]struct{}{}
dynamic := make([]string, 0, 16)
for _, row := range req.Detail.Upserts {
group := strings.ToUpper(strings.TrimSpace(row.SAciklama3))
included := strings.Contains(group, "CM2") || strings.Contains(group, "CM1") || row.MaliyeteDahil == 1
if !included {
continue
}
part := ""
if t := mtTitleByID[row.NUrtMTBolumID]; t != "" {
part = normalizePartFromMtBolumTitle(t)
}
part = strings.TrimSpace(part)
if part == "" {
continue
}
if _, ok := seen[part]; ok {
continue
}
seen[part] = struct{}{}
dynamic = append(dynamic, part)
}
parts := make([]string, 0, len(dynamic)+len(preferred))
for _, p := range preferred {
if _, ok := seen[p]; ok {
parts = append(parts, p)
}
}
for _, p := range dynamic {
isPreferred := false
for _, pref := range preferred {
if pref == p {
isPreferred = true
break
}
}
if !isPreferred {
parts = append(parts, p)
}
}
if len(parts) == 0 {
parts = []string{"TANIMSIZ"}
}
labor := map[string]*mailBucket{}
material := map[string]*mailBucket{}
fabric := map[string]*mailBucket{}
for _, p := range parts {
labor[p] = &mailBucket{}
material[p] = &mailBucket{}
fabric[p] = &mailBucket{}
}
for _, row := range req.Detail.Upserts {
group := strings.ToUpper(strings.TrimSpace(row.SAciklama3))
cur := strings.ToUpper(strings.TrimSpace(row.FiyatDoviz))
qty := row.LMiktar
if qty < 0 {
qty = 0
}
in := row.FiyatGirilen
// Included rule: CM2 always; others only when maliyete_dahil = 1.
included := strings.Contains(group, "CM2") || strings.Contains(group, "CM1") || row.MaliyeteDahil == 1
if !included {
continue
}
// Part bucket:
part := ""
if t := mtTitleByID[row.NUrtMTBolumID]; t != "" {
part = normalizePartFromMtBolumTitle(t)
}
if part == "" {
continue
}
// Convert input to TRY unit
unitTRY := in
switch cur {
case "USD":
unitTRY = in * usdRate
case "EUR":
unitTRY = in * eurRate
case "GBP":
unitTRY = in * gbpRate
case "TRY", "TL", "":
unitTRY = in
default:
unitTRY = in
}
unitUSD := 0.0
if usdRate > 0 {
unitUSD = unitTRY / usdRate
}
amountTRY := unitTRY * qty
amountUSD := unitUSD * qty
// input totals for display: inputPrice * qty in input currency
inputAmount := in * qty
if strings.Contains(group, "CM2") || strings.Contains(group, "CM1") {
b := labor[part]
b.USD += amountUSD
b.TRY += amountTRY
addInputAmount(b, cur, inputAmount)
// UI rule: tick only when cm_price_type_id == 2 (malzemeli). Nil/empty defaults to 1 (unticked).
if row.CMPriceTypeID != nil && *row.CMPriceTypeID == 2 {
b.HasCMT = true
}
continue
}
if group == "DT" || strings.Contains(group, " DT") || group == "TP" || strings.Contains(group, " TP") {
b := material[part]
b.USD += amountUSD
b.TRY += amountTRY
addInputAmount(b, cur, inputAmount)
continue
}
if group == "FABRIC" || strings.Contains(group, "FABRIC") {
b := fabric[part]
b.USD += amountUSD
b.TRY += amountTRY
addInputAmount(b, cur, inputAmount)
// UI parity: fabric summary shows metraj and a representative unit input price (first non-zero).
if qty > 0 {
b.MeterQty += qty
if strings.TrimSpace(b.MeterUom) == "" {
if u := strings.TrimSpace(row.SBirim); u != "" {
b.MeterUom = u
}
}
}
if b.UnitIn <= 0 && in > 0 {
b.UnitIn = in
b.UnitCur = cur
}
}
}
maliyetTarihi := strings.TrimSpace(req.Header.MaliyetTarihi)
if uiHeaderLoaded {
// Prefer the UI header date (can differ from "today").
if v := strings.TrimSpace(uiHeader.MaliyetTarihi); v != "" {
maliyetTarihi = v
}
}
// Display format: dd.MM.yyyy (mail). Keep the original YYYY-MM-DD for subject readability if present.
maliyetTarihiLabel := maliyetTarihi
if parsed, err := time.Parse("2006-01-02", maliyetTarihi); err == nil {
maliyetTarihiLabel = formatDateTR2(parsed)
} else if maliyetTarihi == "" {
maliyetTarihi = time.Now().Format("2006-01-02")
maliyetTarihiLabel = formatDateTR2(time.Now())
}
titleLabel := "MALIYETI GIRIS YAPILAN URUN"
if isUpdate {
titleLabel = "MALIYETI GUNCELLENEN URUN"
}
subject := fmt.Sprintf("%s | %s | %s | OnML:%d", strings.TrimSpace(req.Header.UrunKodu), titleLabel, maliyetTarihi, nOnMLNo)
if strings.TrimSpace(actor) != "" {
subject = fmt.Sprintf("%s tarafindan %s", strings.TrimSpace(actor), subject)
}
// Build HTML with 4 tables.
var b strings.Builder
b.WriteString(`<div style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#1f2a37;">`)
b.WriteString(fmt.Sprintf(`<h3 style="margin:8px 0 6px;font-size:13px;">%s</h3>`, htmlEsc(titleLabel)))
b.WriteString(`<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;border:1px solid #e5e7eb;min-width:720px;">`)
b.WriteString(`<tr style="background:#f3f4f6;font-weight:bold;"><td style="width:220px;">Alan</td><td>Deger</td></tr>`)
// Prefer resolved UI header (more complete), fallback to request header.
urunKodu := strings.TrimSpace(req.Header.UrunKodu)
urunAdi := strings.TrimSpace(req.Header.UrunAdi)
if uiHeaderLoaded {
if v := strings.TrimSpace(uiHeader.UrunKodu); v != "" {
urunKodu = v
}
if v := strings.TrimSpace(uiHeader.UrunAdi); v != "" {
urunAdi = v
}
}
b.WriteString(fmt.Sprintf(`<tr><td>UrunKodu</td><td>%s</td></tr>`, htmlEsc(urunKodu)))
if urunAdi != "" {
b.WriteString(fmt.Sprintf(`<tr><td>UrunAdi</td><td>%s</td></tr>`, htmlEsc(urunAdi)))
}
if strings.TrimSpace(ilkGrup) != "" || strings.TrimSpace(anaGrup) != "" || strings.TrimSpace(altGrup) != "" {
if strings.TrimSpace(ilkGrup) != "" {
b.WriteString(fmt.Sprintf(`<tr><td>Urun Ilk Grubu</td><td>%s</td></tr>`, htmlEsc(ilkGrup)))
}
if strings.TrimSpace(anaGrup) != "" {
b.WriteString(fmt.Sprintf(`<tr><td>Urun Ana Grubu</td><td>%s</td></tr>`, htmlEsc(anaGrup)))
}
if strings.TrimSpace(altGrup) != "" {
b.WriteString(fmt.Sprintf(`<tr><td>Urun Alt Grubu</td><td>%s</td></tr>`, htmlEsc(altGrup)))
}
}
b.WriteString(fmt.Sprintf(`<tr><td>Maliyet Tarihi</td><td>%s</td></tr>`, htmlEsc(maliyetTarihiLabel)))
// Mirror UI header labels when available:
if uiHeaderLoaded {
if strings.TrimSpace(uiHeader.UretimSekli) != "" {
b.WriteString(fmt.Sprintf(`<tr><td>Uretim Sekli</td><td>%s</td></tr>`, htmlEsc(uiHeader.UretimSekli)))
} else if strings.TrimSpace(uiHeader.UretimSekliID) != "" {
b.WriteString(fmt.Sprintf(`<tr><td>Uretim Sekli ID</td><td>%s</td></tr>`, htmlEsc(uiHeader.UretimSekliID)))
}
if strings.TrimSpace(uiHeader.UretimiYapanFirma) != "" {
b.WriteString(fmt.Sprintf(`<tr><td>Uretimi Yapan Firma</td><td>%s</td></tr>`, htmlEsc(uiHeader.UretimiYapanFirma)))
}
if strings.TrimSpace(uiHeader.SonIsEmriVeren) != "" {
b.WriteString(fmt.Sprintf(`<tr><td>2.Firma</td><td>%s</td></tr>`, htmlEsc(uiHeader.SonIsEmriVeren)))
}
if strings.TrimSpace(uiHeader.NOnMLNo) != "" {
b.WriteString(fmt.Sprintf(`<tr><td>nOnMLNo</td><td>%s</td></tr>`, htmlEsc(uiHeader.NOnMLNo)))
} else {
b.WriteString(fmt.Sprintf(`<tr><td>nOnMLNo</td><td>%d</td></tr>`, nOnMLNo))
}
if strings.TrimSpace(uiHeader.SKullaniciAdi) != "" {
b.WriteString(fmt.Sprintf(`<tr><td>sKullaniciAdi</td><td>%s</td></tr>`, htmlEsc(uiHeader.SKullaniciAdi)))
}
if strings.TrimSpace(uiHeader.DteKayitTarihi) != "" {
b.WriteString(fmt.Sprintf(`<tr><td>Kayit Tarihi</td><td>%s</td></tr>`, htmlEsc(formatAnyDateTimeTR2(uiHeader.DteKayitTarihi))))
}
if strings.TrimSpace(uiHeader.DteGuncellemeTarihi) != "" {
b.WriteString(fmt.Sprintf(`<tr><td>Son Guncelleme Tarihi</td><td>%s</td></tr>`, htmlEsc(formatAnyDateTimeTR2(uiHeader.DteGuncellemeTarihi))))
}
if strings.TrimSpace(uiHeader.SGuncellemeKullaniciAdi) != "" {
b.WriteString(fmt.Sprintf(`<tr><td>sGuncellemeKullaniciAdi</td><td>%s</td></tr>`, htmlEsc(uiHeader.SGuncellemeKullaniciAdi)))
}
if strings.TrimSpace(uiHeader.NUrtReceteID) != "" {
b.WriteString(fmt.Sprintf(`<tr><td>nUrtReceteID</td><td>%s</td></tr>`, htmlEsc(uiHeader.NUrtReceteID)))
}
} else {
b.WriteString(fmt.Sprintf(`<tr><td>nOnMLNo</td><td>%d</td></tr>`, nOnMLNo))
if req.Header.NUrtReceteID > 0 {
b.WriteString(fmt.Sprintf(`<tr><td>nUrtReceteID</td><td>%d</td></tr>`, req.Header.NUrtReceteID))
}
if req.Header.UretimSekliID > 0 {
b.WriteString(fmt.Sprintf(`<tr><td>Uretim Sekli ID</td><td>%d</td></tr>`, req.Header.UretimSekliID))
}
}
// Free text description (from request).
if strings.TrimSpace(req.Header.SAciklama) != "" {
b.WriteString(fmt.Sprintf(`<tr><td>Aciklama</td><td>%s</td></tr>`, htmlEsc(req.Header.SAciklama)))
}
b.WriteString(`</table>`)
// 1) Header totals
b.WriteString(`<h3 style="margin:12px 0 6px;font-size:13px;">Maliyetlere Islenen Toplam Tutar</h3>`)
b.WriteString(`<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;border:1px solid #e5e7eb;min-width:520px;">`)
gbpTotal := 0.0
if gbpRate > 0 {
gbpTotal = totalTRY / gbpRate
}
// UI format (2 rows, key/value pairs)
b.WriteString(fmt.Sprintf(`<tr><td style="font-weight:bold;color:#374151;">USD</td><td style="text-align:right;">%s</td><td style="font-weight:bold;color:#374151;">TRY</td><td style="text-align:right;">%s</td></tr>`,
formatMoney2(totalUSD), formatMoney2(totalTRY)))
b.WriteString(fmt.Sprintf(`<tr><td style="font-weight:bold;color:#374151;">EUR</td><td style="text-align:right;">%s</td><td style="font-weight:bold;color:#374151;">GBP</td><td style="text-align:right;">%s</td></tr>`,
formatMoney2(totalEUR), formatMoney2(gbpTotal)))
b.WriteString(`</table>`)
renderLaborTable := func(title string, m map[string]*mailBucket) {
b.WriteString(fmt.Sprintf(`<h3 style="margin:14px 0 6px;font-size:13px;">%s</h3>`, htmlEsc(title)))
b.WriteString(`<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;border:1px solid #e5e7eb;min-width:720px;">`)
b.WriteString(`<tr style="background:#f3f4f6;font-weight:bold;">`)
b.WriteString(`<td>Parca</td><td style="text-align:right;">Giris</td><td>Pr.Br.</td><td style="text-align:right;">USD Tutar</td><td style="text-align:right;">TRY Tutar</td><td style="text-align:center;">CMT/Malzemeli</td>`)
b.WriteString(`</tr>`)
totalUSD := 0.0
totalTRY := 0.0
for _, p := range parts {
row := m[p]
inAmt, inCur := summarizeInputByCurrency(row)
tick := ""
if row != nil && row.HasCMT {
tick = "&#10003;"
}
if row != nil {
totalUSD += row.USD
totalTRY += row.TRY
}
b.WriteString(`<tr>`)
b.WriteString(fmt.Sprintf(`<td>%s</td>`, htmlEsc(p)))
b.WriteString(fmt.Sprintf(`<td style="text-align:right;">%s</td>`, htmlEsc(inAmt)))
b.WriteString(fmt.Sprintf(`<td>%s</td>`, htmlEsc(inCur)))
b.WriteString(fmt.Sprintf(`<td style="text-align:right;">%s</td>`, formatMoney2(row.USD)))
b.WriteString(fmt.Sprintf(`<td style="text-align:right;">%s</td>`, formatMoney2(row.TRY)))
b.WriteString(fmt.Sprintf(`<td style="text-align:center;">%s</td>`, tick))
b.WriteString(`</tr>`)
}
b.WriteString(`<tr style="border-top:2px solid #e5e7eb;font-weight:bold;background:#fbfbfd;">`)
b.WriteString(`<td>TOPLAM</td><td></td><td></td>`)
b.WriteString(fmt.Sprintf(`<td style="text-align:right;">%s</td>`, formatMoney2(totalUSD)))
b.WriteString(fmt.Sprintf(`<td style="text-align:right;">%s</td>`, formatMoney2(totalTRY)))
b.WriteString(`<td></td>`)
b.WriteString(`</tr>`)
b.WriteString(`</table>`)
}
renderMaterialTable := func(title string, m map[string]*mailBucket) {
b.WriteString(fmt.Sprintf(`<h3 style="margin:14px 0 6px;font-size:13px;">%s</h3>`, htmlEsc(title)))
b.WriteString(`<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;border:1px solid #e5e7eb;min-width:520px;">`)
b.WriteString(`<tr style="background:#f3f4f6;font-weight:bold;"><td>Parca</td><td style="text-align:right;">USD Tutar</td><td style="text-align:right;">TRY Tutar</td></tr>`)
totalUSD := 0.0
totalTRY := 0.0
for _, p := range parts {
row := m[p]
if row != nil {
totalUSD += row.USD
totalTRY += row.TRY
}
b.WriteString(`<tr>`)
b.WriteString(fmt.Sprintf(`<td>%s</td>`, htmlEsc(p)))
b.WriteString(fmt.Sprintf(`<td style="text-align:right;">%s</td>`, formatMoney2(row.USD)))
b.WriteString(fmt.Sprintf(`<td style="text-align:right;">%s</td>`, formatMoney2(row.TRY)))
b.WriteString(`</tr>`)
}
b.WriteString(`<tr style="border-top:2px solid #e5e7eb;font-weight:bold;background:#fbfbfd;">`)
b.WriteString(`<td>TOPLAM</td>`)
b.WriteString(fmt.Sprintf(`<td style="text-align:right;">%s</td>`, formatMoney2(totalUSD)))
b.WriteString(fmt.Sprintf(`<td style="text-align:right;">%s</td>`, formatMoney2(totalTRY)))
b.WriteString(`</tr>`)
b.WriteString(`</table>`)
}
renderFabricTable := func(title string, m map[string]*mailBucket) {
b.WriteString(fmt.Sprintf(`<h3 style="margin:14px 0 6px;font-size:13px;">%s</h3>`, htmlEsc(title)))
b.WriteString(`<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;border:1px solid #e5e7eb;min-width:720px;">`)
b.WriteString(`<tr style="background:#f3f4f6;font-weight:bold;">`)
b.WriteString(`<td>Parca</td><td style="text-align:right;">Metraj</td><td style="text-align:right;">MT Giris Fiyat</td><td>Pr.Br.</td><td style="text-align:right;">USD Tutar</td><td style="text-align:right;">TRY Tutar</td>`)
b.WriteString(`</tr>`)
totalUSD := 0.0
totalTRY := 0.0
totalMeter := 0.0
for _, p := range parts {
row := m[p]
inAmt, inCur := summarizeInputByCurrency(row)
_ = inAmt
unitLabel := "-"
curLabel := inCur
if row != nil && row.UnitIn > 0 {
unitLabel = formatMoney2(row.UnitIn)
if strings.TrimSpace(row.UnitCur) != "" {
curLabel = strings.ToUpper(strings.TrimSpace(row.UnitCur))
}
}
if strings.TrimSpace(curLabel) == "" {
curLabel = "-"
}
if row != nil {
totalUSD += row.USD
totalTRY += row.TRY
totalMeter += row.MeterQty
}
b.WriteString(`<tr>`)
b.WriteString(fmt.Sprintf(`<td>%s</td>`, htmlEsc(p)))
b.WriteString(fmt.Sprintf(`<td style="text-align:right;">%s</td>`, htmlEsc(formatMeterLabel(row))))
b.WriteString(fmt.Sprintf(`<td style="text-align:right;">%s</td>`, htmlEsc(unitLabel)))
b.WriteString(fmt.Sprintf(`<td>%s</td>`, htmlEsc(curLabel)))
b.WriteString(fmt.Sprintf(`<td style="text-align:right;">%s</td>`, formatMoney2(row.USD)))
b.WriteString(fmt.Sprintf(`<td style="text-align:right;">%s</td>`, formatMoney2(row.TRY)))
b.WriteString(`</tr>`)
}
totalMeterLabel := "-"
if totalMeter > 0 {
totalMeterLabel = fmt.Sprintf("%s MT", formatQty2(totalMeter))
}
b.WriteString(`<tr style="border-top:2px solid #e5e7eb;font-weight:bold;background:#fbfbfd;">`)
b.WriteString(`<td>TOPLAM</td>`)
b.WriteString(fmt.Sprintf(`<td style="text-align:right;">%s</td>`, htmlEsc(totalMeterLabel)))
b.WriteString(`<td></td><td></td>`)
b.WriteString(fmt.Sprintf(`<td style="text-align:right;">%s</td>`, formatMoney2(totalUSD)))
b.WriteString(fmt.Sprintf(`<td style="text-align:right;">%s</td>`, formatMoney2(totalTRY)))
b.WriteString(`</tr>`)
b.WriteString(`</table>`)
}
renderLaborTable("Iscilik Fiyatlari (CM2)", labor)
renderMaterialTable("Malzeme Fiyatlari (DT/TP, maliyete dahil)", material)
renderFabricTable("Kumas Fiyatlari (FABRIC, maliyete dahil)", fabric)
b.WriteString(`<p style="margin-top:10px;color:#6b7280;"><i>Bu mail BaggiSS App uzerinden otomatik gonderilmistir.</i></p>`)
b.WriteString(`</div>`)
msg := mailer.Message{
To: recipients,
Subject: subject,
BodyHTML: b.String(),
}
// Graph send
if err := ml.Send(context.Background(), msg); err != nil {
return err
}
return nil
}

View File

@@ -335,7 +335,6 @@ type pdfGroupTotalRow struct {
func (c *costingPDF) drawHeaderSummaryTables() {
pdf := c.pdf
partRows := c.computePartSummary()
groupRows, grandTRY, grandUSD, grandEUR := c.computeGroupTotals()
// Table styling (use same brand palette as statements PDF)
@@ -345,28 +344,6 @@ func (c *costingPDF) drawHeaderSummaryTables() {
pdf.CellFormat(0, 5.5, "Ozet", "", 1, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
// Part-based summary table
pdf.SetFont("dejavu", "B", 8.2)
pdf.CellFormat(0, 4.8, "Parca Bazli Maliyet Ozellikleri", "", 1, "L", false, 0, "")
partCols := []string{"Parca", "TRY", "USD", "EUR"}
partW := []float64{70, 22, 22, 22}
// Add TOTAL row
totalTry, totalUsd, totalEur := 0.0, 0.0, 0.0
for _, r := range partRows {
totalTry += r.try
totalUsd += r.usd
totalEur += r.eur
}
partRowsWithTotal := append(partRows, pdfPartSummaryRow{name: "TOPLAM", try: totalTry, usd: totalUsd, eur: totalEur})
c.drawMiniTable(partCols, partW, func(i int) []string {
if i >= len(partRowsWithTotal) {
return nil
}
r := partRowsWithTotal[i]
return []string{r.name, pdfMoney(r.try), pdfMoney(r.usd), pdfMoney(r.eur)}
}, len(partRowsWithTotal), true, true)
pdf.Ln(2)
// Group totals table
pdf.SetFont("dejavu", "B", 8.2)
pdf.CellFormat(0, 4.8, "Grup Toplamlari", "", 1, "L", false, 0, "")
@@ -515,6 +492,7 @@ func (c *costingPDF) drawMiniTable(cols []string, widths []float64, rowFn func(i
pdf.SetXY(x0, y+rh)
}
pdf.SetTextColor(0, 0, 0)
pdf.SetFont("dejavu", "", 7.4)
}
func formatDateTRDot(s string) string {
@@ -537,6 +515,9 @@ func formatDateTRDot(s string) string {
func (c *costingPDF) drawGroup(g models.ProductionHasCostDetailGroup, firstGroup bool) {
pdf := c.pdf
// Reset any font/color left over from header summary tables.
pdf.SetFont("dejavu", "", 7.2)
pdf.SetTextColor(0, 0, 0)
// Group bar
c.drawGroupBar(g, false)