diff --git a/Iscilik b/Iscilik new file mode 100644 index 0000000..e69de29 diff --git a/svc/main.go b/svc/main.go index 823bd34..2ffdecc 100644 --- a/svc/main.go +++ b/svc/main.go @@ -841,7 +841,7 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router bindV3(r, pgDB, "/api/pricing/production-product-costing/onml/save", "POST", "order", "view", - wrapV3(http.HandlerFunc(routes.PostProductionProductCostingOnMLSaveHandler)), + wrapV3(routes.PostProductionProductCostingOnMLSaveHandlerWithMailer(ml)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/onml/pdf", "GET", @@ -883,6 +883,16 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "order", "view", wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesRefreshHandler)), ) + bindV3(r, pgDB, + "/api/pricing/production-product-costing/tbstok/exists-bulk", "POST", + "order", "view", + wrapV3(http.HandlerFunc(routes.PostProductionProductCostingTbStokExistsBulkHandler)), + ) + bindV3(r, pgDB, + "/api/pricing/production-product-costing/last10-warnings", "GET", + "order", "view", + wrapV3(http.HandlerFunc(routes.GetProductionProductCostingLast10WarningsHandler)), + ) bindV3(r, pgDB, "/api/pricing/production-product-costing/options/urun-ana-grup", "GET", "order", "view", diff --git a/svc/models/first_group_mail_mapping.go b/svc/models/first_group_mail_mapping.go index ee8f821..1b753f5 100644 --- a/svc/models/first_group_mail_mapping.go +++ b/svc/models/first_group_mail_mapping.go @@ -1,8 +1,8 @@ package models type FirstGroupOption struct { - ID string `json:"id"` - Label string `json:"label"` + Code string `json:"code"` + Title string `json:"title"` } type FirstGroupMailOption struct { @@ -11,7 +11,9 @@ type FirstGroupMailOption struct { } type FirstGroupMailMappingRow struct { - UrunIlkGrubu string `json:"urun_ilk_grubu"` + UrunIlkGrubu string `json:"urun_ilk_grubu"` // group code (kept for backward compatibility) + GroupCode string `json:"group_code"` + GroupTitle string `json:"group_title"` MailIDs []string `json:"mail_ids"` Mails []FirstGroupMailOption `json:"mails"` } diff --git a/svc/models/production_product_costing.go b/svc/models/production_product_costing.go index 6aca02d..a691294 100644 --- a/svc/models/production_product_costing.go +++ b/svc/models/production_product_costing.go @@ -170,7 +170,17 @@ type ProductionProductCostingOnMLSaveRequest struct { } type ProductionProductCostingOnMLSaveResponse struct { - NOnMLNo int `json:"n_onml_no"` + NOnMLNo int `json:"n_onml_no"` + Warnings []string `json:"warnings,omitempty"` +} + +type ProductionProductCostingTbStokExistsBulkRequest struct { + Codes []string `json:"codes"` +} + +type ProductionProductCostingTbStokExistsBulkResponse struct { + Missing []string `json:"missing"` + Error string `json:"error,omitempty"` } type ProductionProductCostingOnMLDeleteRequest struct { diff --git a/svc/queries/last10_avg_purchase_price_cache.go b/svc/queries/last10_avg_purchase_price_cache.go new file mode 100644 index 0000000..bee2587 --- /dev/null +++ b/svc/queries/last10_avg_purchase_price_cache.go @@ -0,0 +1,86 @@ +package queries + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +type Last10AvgPurchasePriceRow struct { + ItemCode string + CurrencyCode string + SampleCount int + AvgDocPrice float64 + MinInvoiceDate sql.NullString + MaxInvoiceDate sql.NullString +} + +// LookupLast10AvgPurchasePriceByItemCodes reads from dbo.cache_last10_avg_purchase_price (Nebim/V3 MSSQL). +// It is designed to be used in hot paths (save) where live invoice scans are too slow. +func LookupLast10AvgPurchasePriceByItemCodes(ctx context.Context, mssqlDB *sql.DB, itemCodes []string) ([]Last10AvgPurchasePriceRow, error) { + if mssqlDB == nil { + return nil, fmt.Errorf("mssql db is nil") + } + codes := make([]string, 0, len(itemCodes)) + seen := map[string]struct{}{} + for _, c := range itemCodes { + c = strings.TrimSpace(c) + if c == "" { + continue + } + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + codes = append(codes, c) + } + if len(codes) == 0 { + return []Last10AvgPurchasePriceRow{}, nil + } + + valParts := make([]string, 0, len(codes)) + args := make([]any, 0, len(codes)) + for i, code := range codes { + 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 ItemCode + FROM (VALUES %s) AS V(code) +) +SELECT + T.ItemCode, + T.Doc_CurrencyCode, + T.sample_count, + T.avg_doc_price, + CONVERT(varchar(10), T.min_invoice_date, 23) AS min_invoice_date, + CONVERT(varchar(10), T.max_invoice_date, 23) AS max_invoice_date +FROM dbo.cache_last10_avg_purchase_price T WITH (NOLOCK) +INNER JOIN C + ON C.ItemCode = T.ItemCode +`, strings.Join(valParts, ",")) + + rows, err := mssqlDB.QueryContext(ctx, sqlText, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]Last10AvgPurchasePriceRow, 0, len(codes)) + for rows.Next() { + var r Last10AvgPurchasePriceRow + if err := rows.Scan(&r.ItemCode, &r.CurrencyCode, &r.SampleCount, &r.AvgDocPrice, &r.MinInvoiceDate, &r.MaxInvoiceDate); err != nil { + return nil, err + } + r.ItemCode = strings.TrimSpace(r.ItemCode) + r.CurrencyCode = strings.TrimSpace(strings.ToUpper(r.CurrencyCode)) + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} diff --git a/svc/queries/product_first_group_by_product_code.go b/svc/queries/product_first_group_by_product_code.go new file mode 100644 index 0000000..32dbb15 --- /dev/null +++ b/svc/queries/product_first_group_by_product_code.go @@ -0,0 +1,33 @@ +package queries + +import ( + "context" + "database/sql" + "strings" +) + +// GetProductFirstGroupCodeDescByUrunKodu resolves (ProductAtt42, ProductAtt42Desc) for a given product code. +func GetProductFirstGroupCodeDescByUrunKodu(ctx context.Context, mssqlDB *sql.DB, urunKodu string) (code string, title string, err error) { + urunKodu = strings.TrimSpace(urunKodu) + if mssqlDB == nil || urunKodu == "" { + return "", "", nil + } + + sqlText := ` +SELECT TOP 1 + LTRIM(RTRIM(ISNULL(ProductAtt42, ''))) AS group_code, + LTRIM(RTRIM(ISNULL(ProductAtt42Desc, ''))) AS group_title +FROM ProductFilterWithDescription('TR') +WHERE IsBlocked = 0 + AND LTRIM(RTRIM(ProductCode)) = @p1 +ORDER BY ProductCode; +` + row := mssqlDB.QueryRowContext(ctx, sqlText, urunKodu) + if err := row.Scan(&code, &title); err != nil { + if err == sql.ErrNoRows { + return "", "", nil + } + return "", "", err + } + return strings.TrimSpace(code), strings.TrimSpace(title), nil +} diff --git a/svc/queries/product_first_group_code_desc.go b/svc/queries/product_first_group_code_desc.go new file mode 100644 index 0000000..541c5b0 --- /dev/null +++ b/svc/queries/product_first_group_code_desc.go @@ -0,0 +1,43 @@ +package queries + +import ( + "context" + "database/sql" + "strings" +) + +// ListProductFirstGroupCodeDescOptions returns distinct (ProductAtt42, ProductAtt42Desc) values from Nebim V3. +// ProductAtt42: first group code +// ProductAtt42Desc: first group description +func ListProductFirstGroupCodeDescOptions(ctx context.Context, mssqlDB *sql.DB, search string, limit int) (*sql.Rows, error) { + search = strings.TrimSpace(search) + if mssqlDB == nil { + return nil, sql.ErrConnDone + } + if limit <= 0 || limit > 5000 { + limit = 5000 + } + + sqlText := ` +SELECT TOP (@p2) + LTRIM(RTRIM(ISNULL(ProductAtt42, ''))) AS group_code, + LTRIM(RTRIM(ISNULL(ProductAtt42Desc, ''))) AS group_title +FROM ProductFilterWithDescription('TR') +WHERE IsBlocked = 0 + AND LTRIM(RTRIM(ISNULL(ProductAtt42, ''))) <> '' + AND LTRIM(RTRIM(ISNULL(ProductAtt42Desc, ''))) <> '' + AND ( + @p1 = '' + OR LTRIM(RTRIM(ISNULL(ProductAtt42, ''))) LIKE '%' + @p1 + '%' + OR LTRIM(RTRIM(ISNULL(ProductAtt42Desc, ''))) LIKE '%' + @p1 + '%' + ) +GROUP BY + LTRIM(RTRIM(ISNULL(ProductAtt42, ''))), + LTRIM(RTRIM(ISNULL(ProductAtt42Desc, ''))) +ORDER BY + LTRIM(RTRIM(ISNULL(ProductAtt42Desc, ''))), + LTRIM(RTRIM(ISNULL(ProductAtt42, ''))); +` + + return mssqlDB.QueryContext(ctx, sqlText, search, limit) +} diff --git a/svc/queries/production_product_costing.go b/svc/queries/production_product_costing.go index a0784a2..d540119 100644 --- a/svc/queries/production_product_costing.go +++ b/svc/queries/production_product_costing.go @@ -912,10 +912,10 @@ func GetProductionHasCostDetailRowsByOnMLNo( nOnMLNo int, ) (*sql.Rows, error) { sqlText := ` - SELECT - -- Prefer the group label stored on the OnML detail row (D.sAciklama3), - -- because some hammadde type master rows may have empty/legacy group labels. - ISNULL(NULLIF(LTRIM(RTRIM(D.sAciklama3)), ''), ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ')) AS sAciklama3, + SELECT + -- Prefer the group label stored on the OnML detail row (D.sAciklama3), + -- because some hammadde type master rows may have empty/legacy group labels. + ISNULL(NULLIF(LTRIM(RTRIM(D.sAciklama3)), ''), ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ')) AS sAciklama3, SUM(ISNULL(D.lTutar, 0)) OVER ( PARTITION BY ISNULL(NULLIF(LTRIM(RTRIM(D.sAciklama3)), ''), ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ')) ) AS GroupTotalTutar, @@ -924,14 +924,14 @@ func GetProductionHasCostDetailRowsByOnMLNo( ) AS GroupTotalUSDTutar, RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nOnMLNo, 0))) AS nOnMLNo, RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nOnMLDetNo, 0))) AS nOnMLDetNo, - RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo, - RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nUrtMTBolumID, 0))) AS nUrtMTBolumID, - -- Normalize code to variantless (tbStok.sModel) when D.sKodu is a variant-coded stock record. - ISNULL(NULLIF(LTRIM(RTRIM(SX.sModel)), ''), ISNULL(D.sKodu, '')) AS sKodu, - ISNULL(NULLIF(LTRIM(RTRIM(SX.sAciklama)), ''), ISNULL(D.sAciklama, '')) AS sAciklama, - ISNULL(D.sRenk, '') AS sRenk, - ISNULL(D.sBeden, '') AS sBeden, - ISNULL(D.sAciklama2, '') AS sAciklama2, + RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo, + RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nUrtMTBolumID, 0))) AS nUrtMTBolumID, + -- Normalize code to variantless (tbStok.sModel) when D.sKodu is a variant-coded stock record. + ISNULL(NULLIF(SX.sModel, ''), ISNULL(D.sKodu, '')) AS sKodu, + ISNULL(NULLIF(SX.sAciklama, ''), ISNULL(D.sAciklama, '')) AS sAciklama, + ISNULL(D.sRenk, '') AS sRenk, + ISNULL(D.sBeden, '') AS sBeden, + ISNULL(D.sAciklama2, '') AS sAciklama2, ISNULL(D.lMiktar, 0) AS lMiktar, ISNULL(D.lFiyat, 0) AS lFiyat, ISNULL(D.lTutar, 0) AS lTutar, @@ -949,25 +949,21 @@ func GetProductionHasCostDetailRowsByOnMLNo( ISNULL(D.sBirim, '') AS sBirim, ISNULL(T.sAciklama, '') AS sHammaddeTuruAdi, ISNULL(B.sAdi, '') AS sParcaAdi - FROM dbo.spUrtOnMLMasDet D - LEFT JOIN dbo.spUrtOnMLHammaddeTuru T - ON T.nHammaddeTuruNo = D.nHammaddeTuruNo - LEFT JOIN dbo.spUrtMTBolum B - ON B.nUrtMTBolumID = D.nUrtMTBolumID - OUTER APPLY ( - SELECT TOP 1 - LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sModel, '')))) AS sModel, - LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sAciklama, '')))) AS sAciklama - FROM dbo.tbStok S WITH (NOLOCK) - WHERE LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sKodu, '')))) = LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(D.sKodu, '')))) - ) SX - WHERE D.nOnMLNo = @p1 - ORDER BY - GroupTotalTutar DESC, - sAciklama3 ASC, - ISNULL(D.lTutar, 0) DESC, - D.nOnMLDetNo ASC -` + FROM dbo.spUrtOnMLMasDet D + LEFT JOIN dbo.spUrtOnMLHammaddeTuru T + ON T.nHammaddeTuruNo = D.nHammaddeTuruNo + LEFT JOIN dbo.spUrtMTBolum B + ON B.nUrtMTBolumID = D.nUrtMTBolumID + LEFT JOIN dbo.tbStok SX WITH (NOLOCK) + ON (SX.IsBlocked = 0 OR SX.IsBlocked IS NULL) + AND ISNULL(SX.sKodu,'') = ISNULL(D.sKodu,'') + WHERE D.nOnMLNo = @p1 + ORDER BY + GroupTotalTutar DESC, + sAciklama3 ASC, + ISNULL(D.lTutar, 0) DESC, + D.nOnMLDetNo ASC + ` return uretimDB.QueryContext(ctx, sqlText, nOnMLNo) } @@ -2033,6 +2029,111 @@ ORDER BY return mssqlDB.QueryRowContext(ctx, sqlText, sKodu, costDate, colorCode, itemDim1Code), nil } +// Bulk version of GetProductionHasCostLatestPurchasePriceForItem. +// Uses OPENJSON to avoid 1-query-per-item fan-out. +// For each requested rowKey, picks the latest purchase invoice before costDate, +// preferring exact ColorCode/ItemDim1Code when provided. +func GetProductionHasCostLatestPurchasePricesForItems( + ctx context.Context, + mssqlDB *sql.DB, + itemsJSON string, + costDate string, +) (*sql.Rows, error) { + itemsJSON = strings.TrimSpace(itemsJSON) + costDate = strings.TrimSpace(costDate) + + sqlText := ` +DECLARE @targetDate date = TRY_CONVERT(date, NULLIF(@p2, ''), 23); + +WITH REQ AS ( + SELECT + RowKey, + LTRIM(RTRIM(ISNULL(ItemCode, ''))) AS ItemCode, + LTRIM(RTRIM(ISNULL(ColorCode, ''))) AS ColorCode, + LTRIM(RTRIM(ISNULL(ItemDim1Code, ''))) AS ItemDim1Code + FROM OPENJSON(@p1) WITH ( + RowKey NVARCHAR(128) '$.rowKey', + ItemCode NVARCHAR(128) '$.sKodu', + ColorCode NVARCHAR(64) '$.colorCode', + ItemDim1Code NVARCHAR(64) '$.itemDim1Code' + ) + WHERE LTRIM(RTRIM(ISNULL(ItemCode, ''))) <> '' +), BASE AS ( + SELECT + R.RowKey, + A.InvoiceDate, + A.InvoiceNumber, + A.ItemTypeCode, + A.ItemCode, + A.ColorCode, + A.ItemDim1Code, + A.Qty1, + A.Doc_Price, + A.Doc_CurrencyCode, + CASE + WHEN R.ColorCode <> '' AND LTRIM(RTRIM(ISNULL(A.ColorCode, ''))) = R.ColorCode THEN 0 + WHEN R.ColorCode = '' THEN 0 + ELSE 1 + END AS colorRank, + CASE + WHEN R.ItemDim1Code <> '' AND LTRIM(RTRIM(ISNULL(A.ItemDim1Code, ''))) = R.ItemDim1Code THEN 0 + WHEN R.ItemDim1Code = '' THEN 0 + ELSE 1 + END AS dimRank + FROM REQ R + INNER JOIN AllInvoicesWithAttributes A + ON LTRIM(RTRIM(A.ItemCode)) = R.ItemCode + WHERE A.ProcessCode IN ('BP') + AND A.ATAtt01 IN (1, 2) + AND A.CompanyCode IN (1, 2, 5) + AND A.IsCompleted = 1 + AND YEAR(A.InvoiceDate) >= 2022 + AND (@targetDate IS NULL OR CONVERT(date, A.InvoiceDate) < @targetDate) +), RANKED AS ( + SELECT + B.*, + ROW_NUMBER() OVER (PARTITION BY B.RowKey ORDER BY B.colorRank, B.dimRank, B.InvoiceDate DESC, B.InvoiceNumber DESC) AS rn + FROM BASE B +) +SELECT + R.RowKey, + 'MAN' AS priceType, + CONVERT(VARCHAR(16), R.InvoiceDate, 120) AS Tarih, + ISNULL(R.InvoiceNumber, '') AS FaturaKodu, + LTRIM(RTRIM(ISNULL(R.ItemCode, ''))) AS MasrafKodu, + ISNULL(ID.ItemDescription, '') AS MasrafDetay, + ISNULL(R.ColorCode, '') AS ColorCode, + ISNULL(COL.ColorDescription, '') AS ColorDescription, + ISNULL(R.ItemDim1Code, '') AS ItemDim1Code, + ISNULL(DIM1.ItemDim1Description, '') AS ItemDim1Description, + ISNULL(R.Doc_Price, 0) AS EvrakFiyat, + ISNULL(R.Doc_CurrencyCode, '') AS EvrakDoviz +FROM RANKED R +OUTER APPLY ( + SELECT TOP 1 ItemDescription + FROM cdItemDesc + WHERE ItemTypeCode = R.ItemTypeCode + AND ItemCode = R.ItemCode + AND LangCode = 'TR' +) ID +OUTER APPLY ( + SELECT TOP 1 ItemDim1Description + FROM cdItemDim1Desc + WHERE ItemDim1Code = R.ItemDim1Code + AND LangCode = 'TR' +) DIM1 +OUTER APPLY ( + SELECT TOP 1 ColorDescription + FROM cdColorDesc + WHERE ColorCode = R.ColorCode + AND LangCode = 'TR' +) COL +WHERE R.rn = 1; +` + + return mssqlDB.QueryContext(ctx, sqlText, itemsJSON, costDate) +} + func GetProductionHasCostPurchaseHistoryByExpenseCode( ctx context.Context, mssqlDB *sql.DB, diff --git a/svc/queries/production_product_costing_last10_warnings_pg.go b/svc/queries/production_product_costing_last10_warnings_pg.go new file mode 100644 index 0000000..83e3097 --- /dev/null +++ b/svc/queries/production_product_costing_last10_warnings_pg.go @@ -0,0 +1,228 @@ +package queries + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" +) + +type ProductionCostingLast10WarningRow struct { + NOnMLNo int `json:"n_onml_no"` + UrunKodu string `json:"urun_kodu"` + MaliyetTarihi string `json:"maliyet_tarihi"` // YYYY-MM-DD + ItemCode string `json:"item_code"` + CurrencyCode string `json:"currency_code"` + InputPrice float64 `json:"input_price"` + AvgDocPrice float64 `json:"avg_doc_price"` + InputUSD float64 `json:"input_usd"` + AvgUSD float64 `json:"avg_usd"` + DiffRatio float64 `json:"diff_ratio"` // e.g. 0.12 means 12% + SampleCount int `json:"sample_count"` + MinInvoice string `json:"min_invoice_date,omitempty"` + MaxInvoice string `json:"max_invoice_date,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + CreatedBy string `json:"created_by,omitempty"` +} + +func EnsureProductionCostingLast10WarningTables(pg *sql.DB) error { + if pg == nil { + return fmt.Errorf("pg db is nil") + } + stmts := []string{ + ` +CREATE TABLE IF NOT EXISTS mk_costing_last10_warning ( + n_onml_no INT NOT NULL, + item_code TEXT NOT NULL, + currency_code TEXT NOT NULL, + urun_kodu TEXT NOT NULL DEFAULT '', + maliyet_tarihi DATE, + input_price DOUBLE PRECISION NOT NULL DEFAULT 0, + avg_doc_price DOUBLE PRECISION NOT NULL DEFAULT 0, + input_usd DOUBLE PRECISION NOT NULL DEFAULT 0, + avg_usd DOUBLE PRECISION NOT NULL DEFAULT 0, + diff_ratio DOUBLE PRECISION NOT NULL DEFAULT 0, + sample_count INT NOT NULL DEFAULT 0, + min_invoice_date DATE, + max_invoice_date DATE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by TEXT NOT NULL DEFAULT '', + PRIMARY KEY (n_onml_no, item_code, currency_code) +) +`, + `CREATE INDEX IF NOT EXISTS ix_costing_last10_warning_onml ON mk_costing_last10_warning (n_onml_no)`, + `CREATE INDEX IF NOT EXISTS ix_costing_last10_warning_item ON mk_costing_last10_warning (item_code, currency_code)`, + } + for _, s := range stmts { + if _, err := pg.Exec(s); err != nil { + return err + } + } + return nil +} + +func ReplaceProductionCostingLast10Warnings( + ctx context.Context, + pg *sql.DB, + nOnMLNo int, + urunKodu string, + maliyetTarihi string, // YYYY-MM-DD + createdBy string, + items []Last10AvgPurchasePriceRow, + inputByKey map[string]float64, // key = ITEM|CUR (input in doc currency) + inputUSDByKey map[string]float64, // key = ITEM|CUR (input converted to USD basis) + avgUSDByKey map[string]float64, // key = ITEM|CUR (avg converted to USD basis) +) error { + if pg == nil { + return fmt.Errorf("pg db is nil") + } + if nOnMLNo <= 0 { + return fmt.Errorf("invalid n_onml_no") + } + urunKodu = strings.TrimSpace(urunKodu) + createdBy = strings.TrimSpace(createdBy) + maliyetTarihi = strings.TrimSpace(maliyetTarihi) + + // best-effort date parse for insert; allow NULL date if invalid + var mtDate any = nil + if maliyetTarihi != "" { + if t, err := time.Parse("2006-01-02", maliyetTarihi); err == nil { + mtDate = t + } + } + + tx, err := pg.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, `DELETE FROM mk_costing_last10_warning WHERE n_onml_no = $1`, nOnMLNo); err != nil { + return err + } + + // insert only rows that exist in both cache and inputByKey and violate threshold + for _, ar := range items { + code := strings.TrimSpace(ar.ItemCode) + cur := strings.ToUpper(strings.TrimSpace(ar.CurrencyCode)) + if code == "" || cur == "" { + continue + } + key := code + "|" + cur + in := inputByKey[key] + inUSD := inputUSDByKey[key] + avgUSD := avgUSDByKey[key] + if in <= 0 || ar.SampleCount <= 0 || ar.AvgDocPrice <= 0 { + continue + } + // diff ratio is evaluated on USD basis (requested behavior) + if inUSD <= 0 || avgUSD <= 0 { + continue + } + diff := (inUSD - avgUSD) / avgUSD + if diff < 0 { + diff = -diff + } + if diff <= 0.10 { + continue + } + + _, err := tx.ExecContext(ctx, ` +INSERT INTO mk_costing_last10_warning ( + n_onml_no, item_code, currency_code, + urun_kodu, maliyet_tarihi, + input_price, avg_doc_price, input_usd, avg_usd, diff_ratio, + sample_count, min_invoice_date, max_invoice_date, + created_by +) VALUES ( + $1,$2,$3, + $4,$5, + $6,$7,$8,$9,$10, + $11,$12,$13, + $14 +) +ON CONFLICT (n_onml_no, item_code, currency_code) DO UPDATE SET + urun_kodu = EXCLUDED.urun_kodu, + maliyet_tarihi = EXCLUDED.maliyet_tarihi, + input_price = EXCLUDED.input_price, + avg_doc_price = EXCLUDED.avg_doc_price, + input_usd = EXCLUDED.input_usd, + avg_usd = EXCLUDED.avg_usd, + diff_ratio = EXCLUDED.diff_ratio, + sample_count = EXCLUDED.sample_count, + min_invoice_date = EXCLUDED.min_invoice_date, + max_invoice_date = EXCLUDED.max_invoice_date, + created_at = now(), + created_by = EXCLUDED.created_by +`, nOnMLNo, code, cur, urunKodu, mtDate, in, ar.AvgDocPrice, inUSD, avgUSD, diff, ar.SampleCount, ar.MinInvoiceDate, ar.MaxInvoiceDate, createdBy) + if err != nil { + return err + } + } + + if err := tx.Commit(); err != nil { + return err + } + return nil +} + +func ListProductionCostingLast10WarningsByOnMLNo(ctx context.Context, pg *sql.DB, nOnMLNo int) ([]ProductionCostingLast10WarningRow, error) { + if pg == nil { + return nil, fmt.Errorf("pg db is nil") + } + rows, err := pg.QueryContext(ctx, ` +SELECT + n_onml_no, + COALESCE(urun_kodu,'') AS urun_kodu, + COALESCE(TO_CHAR(maliyet_tarihi, 'YYYY-MM-DD'),'') AS maliyet_tarihi, + item_code, + currency_code, + input_price, + avg_doc_price, + input_usd, + avg_usd, + diff_ratio, + sample_count, + COALESCE(TO_CHAR(min_invoice_date, 'YYYY-MM-DD'),'') AS min_invoice_date, + COALESCE(TO_CHAR(max_invoice_date, 'YYYY-MM-DD'),'') AS max_invoice_date, + COALESCE(TO_CHAR(created_at, 'YYYY-MM-DD HH24:MI:SS'),'') AS created_at, + COALESCE(created_by,'') AS created_by +FROM mk_costing_last10_warning +WHERE n_onml_no = $1 +ORDER BY diff_ratio DESC, item_code ASC, currency_code ASC +`, nOnMLNo) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]ProductionCostingLast10WarningRow, 0, 32) + for rows.Next() { + var r ProductionCostingLast10WarningRow + if err := rows.Scan( + &r.NOnMLNo, + &r.UrunKodu, + &r.MaliyetTarihi, + &r.ItemCode, + &r.CurrencyCode, + &r.InputPrice, + &r.AvgDocPrice, + &r.InputUSD, + &r.AvgUSD, + &r.DiffRatio, + &r.SampleCount, + &r.MinInvoice, + &r.MaxInvoice, + &r.CreatedAt, + &r.CreatedBy, + ); err != nil { + return nil, err + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} diff --git a/svc/queries/tbstok_exists_bulk.go b/svc/queries/tbstok_exists_bulk.go new file mode 100644 index 0000000..39eb69b --- /dev/null +++ b/svc/queries/tbstok_exists_bulk.go @@ -0,0 +1,106 @@ +package queries + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +// LookupTbStokExistsByCodes checks if tbStok contains records matching the given codes. +// Match rules are aligned with costing usage: +// - exact sKodu (ignoring spaces) +// - exact sModel +// +// Returns a map[code]exists. +func LookupTbStokExistsByCodes(ctx context.Context, mssqlDB *sql.DB, codes []string) (map[string]bool, error) { + if mssqlDB == nil { + return nil, fmt.Errorf("mssql db is nil") + } + norm := make([]string, 0, len(codes)) + seen := map[string]struct{}{} + for _, c := range codes { + c = strings.TrimSpace(c) + if c == "" { + continue + } + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + norm = append(norm, c) + } + out := map[string]bool{} + for _, c := range norm { + out[c] = false + } + if len(norm) == 0 { + return out, nil + } + + valParts := make([]string, 0, len(norm)) + args := make([]any, 0, len(norm)) + for i, code := range norm { + valParts = append(valParts, fmt.Sprintf("(@p%d)", i+1)) + args = append(args, code) + } + + // NOTE: This endpoint is a UX validation helper and must be fast. + // Keep predicates sargable: no function wrapping on tbStok columns. + // + // For "ignore spaces" behavior, we send both the original code and its no-space variant from input, + // and match via exact equality against tbStok.sKodu. + // + // IMPORTANT: We intentionally do not filter by IsBlocked here because IsBlocked is nullable and + // filtering requires OR logic, which can prevent index seeks and cause timeouts. This is a best-effort + // existence check for UX highlighting. + sqlText := fmt.Sprintf(` +WITH C AS ( + SELECT + LTRIM(RTRIM(V.code)) AS code, + REPLACE(LTRIM(RTRIM(V.code)), ' ', '') AS code_nospace + FROM (VALUES %s) AS V(code) +), +HIT AS ( + SELECT DISTINCT C.code + FROM C + JOIN dbo.tbStok S WITH (NOLOCK) + ON S.sKodu = C.code + OR S.sKodu = C.code_nospace + UNION + SELECT DISTINCT C.code + FROM C + JOIN dbo.tbStok S WITH (NOLOCK) + ON S.sModel = C.code +) +SELECT + C.code, + CASE WHEN H.code IS NULL THEN 0 ELSE 1 END AS existsFlag +FROM C +LEFT JOIN HIT H + ON H.code = C.code +`, strings.Join(valParts, ",")) + + rows, err := mssqlDB.QueryContext(ctx, sqlText, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var code string + var existsFlag int + if err := rows.Scan(&code, &existsFlag); err != nil { + return nil, err + } + code = strings.TrimSpace(code) + if code == "" { + continue + } + out[code] = existsFlag == 1 + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} diff --git a/svc/routes/first_group_mail_mapping.go b/svc/routes/first_group_mail_mapping.go index e3e6ed5..e487ef7 100644 --- a/svc/routes/first_group_mail_mapping.go +++ b/svc/routes/first_group_mail_mapping.go @@ -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) } } diff --git a/svc/routes/mk_mail_helper.go b/svc/routes/mk_mail_helper.go index 743a61c..f147c3e 100644 --- a/svc/routes/mk_mail_helper.go +++ b/svc/routes/mk_mail_helper.go @@ -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) diff --git a/svc/routes/production_product_costing.go b/svc/routes/production_product_costing.go index c5ab1f1..ccba9b8 100644 --- a/svc/routes/production_product_costing.go +++ b/svc/routes/production_product_costing.go @@ -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) } } diff --git a/svc/routes/production_product_costing_last10_warnings.go b/svc/routes/production_product_costing_last10_warnings.go new file mode 100644 index 0000000..0ded402 --- /dev/null +++ b/svc/routes/production_product_costing_last10_warnings.go @@ -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, + }) +} diff --git a/svc/routes/production_product_costing_mail.go b/svc/routes/production_product_costing_mail.go new file mode 100644 index 0000000..1987556 --- /dev/null +++ b/svc/routes/production_product_costing_mail.go @@ -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(`
`) + b.WriteString(fmt.Sprintf(`

%s

`, htmlEsc(titleLabel))) + b.WriteString(``) + b.WriteString(``) + + // 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(``, htmlEsc(urunKodu))) + if urunAdi != "" { + b.WriteString(fmt.Sprintf(``, htmlEsc(urunAdi))) + } + if strings.TrimSpace(ilkGrup) != "" || strings.TrimSpace(anaGrup) != "" || strings.TrimSpace(altGrup) != "" { + if strings.TrimSpace(ilkGrup) != "" { + b.WriteString(fmt.Sprintf(``, htmlEsc(ilkGrup))) + } + if strings.TrimSpace(anaGrup) != "" { + b.WriteString(fmt.Sprintf(``, htmlEsc(anaGrup))) + } + if strings.TrimSpace(altGrup) != "" { + b.WriteString(fmt.Sprintf(``, htmlEsc(altGrup))) + } + } + b.WriteString(fmt.Sprintf(``, htmlEsc(maliyetTarihiLabel))) + + // Mirror UI header labels when available: + if uiHeaderLoaded { + if strings.TrimSpace(uiHeader.UretimSekli) != "" { + b.WriteString(fmt.Sprintf(``, htmlEsc(uiHeader.UretimSekli))) + } else if strings.TrimSpace(uiHeader.UretimSekliID) != "" { + b.WriteString(fmt.Sprintf(``, htmlEsc(uiHeader.UretimSekliID))) + } + if strings.TrimSpace(uiHeader.UretimiYapanFirma) != "" { + b.WriteString(fmt.Sprintf(``, htmlEsc(uiHeader.UretimiYapanFirma))) + } + if strings.TrimSpace(uiHeader.SonIsEmriVeren) != "" { + b.WriteString(fmt.Sprintf(``, htmlEsc(uiHeader.SonIsEmriVeren))) + } + if strings.TrimSpace(uiHeader.NOnMLNo) != "" { + b.WriteString(fmt.Sprintf(``, htmlEsc(uiHeader.NOnMLNo))) + } else { + b.WriteString(fmt.Sprintf(``, nOnMLNo)) + } + if strings.TrimSpace(uiHeader.SKullaniciAdi) != "" { + b.WriteString(fmt.Sprintf(``, htmlEsc(uiHeader.SKullaniciAdi))) + } + if strings.TrimSpace(uiHeader.DteKayitTarihi) != "" { + b.WriteString(fmt.Sprintf(``, htmlEsc(formatAnyDateTimeTR2(uiHeader.DteKayitTarihi)))) + } + if strings.TrimSpace(uiHeader.DteGuncellemeTarihi) != "" { + b.WriteString(fmt.Sprintf(``, htmlEsc(formatAnyDateTimeTR2(uiHeader.DteGuncellemeTarihi)))) + } + if strings.TrimSpace(uiHeader.SGuncellemeKullaniciAdi) != "" { + b.WriteString(fmt.Sprintf(``, htmlEsc(uiHeader.SGuncellemeKullaniciAdi))) + } + if strings.TrimSpace(uiHeader.NUrtReceteID) != "" { + b.WriteString(fmt.Sprintf(``, htmlEsc(uiHeader.NUrtReceteID))) + } + } else { + b.WriteString(fmt.Sprintf(``, nOnMLNo)) + if req.Header.NUrtReceteID > 0 { + b.WriteString(fmt.Sprintf(``, req.Header.NUrtReceteID)) + } + if req.Header.UretimSekliID > 0 { + b.WriteString(fmt.Sprintf(``, req.Header.UretimSekliID)) + } + } + + // Free text description (from request). + if strings.TrimSpace(req.Header.SAciklama) != "" { + b.WriteString(fmt.Sprintf(``, htmlEsc(req.Header.SAciklama))) + } + + b.WriteString(`
AlanDeger
UrunKodu%s
UrunAdi%s
Urun Ilk Grubu%s
Urun Ana Grubu%s
Urun Alt Grubu%s
Maliyet Tarihi%s
Uretim Sekli%s
Uretim Sekli ID%s
Uretimi Yapan Firma%s
2.Firma%s
nOnMLNo%s
nOnMLNo%d
sKullaniciAdi%s
Kayit Tarihi%s
Son Guncelleme Tarihi%s
sGuncellemeKullaniciAdi%s
nUrtReceteID%s
nOnMLNo%d
nUrtReceteID%d
Uretim Sekli ID%d
Aciklama%s
`) + + // 1) Header totals + b.WriteString(`

Maliyetlere Islenen Toplam Tutar

`) + b.WriteString(``) + gbpTotal := 0.0 + if gbpRate > 0 { + gbpTotal = totalTRY / gbpRate + } + // UI format (2 rows, key/value pairs) + b.WriteString(fmt.Sprintf(``, + formatMoney2(totalUSD), formatMoney2(totalTRY))) + b.WriteString(fmt.Sprintf(``, + formatMoney2(totalEUR), formatMoney2(gbpTotal))) + b.WriteString(`
USD%sTRY%s
EUR%sGBP%s
`) + + renderLaborTable := func(title string, m map[string]*mailBucket) { + b.WriteString(fmt.Sprintf(`

%s

`, htmlEsc(title))) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + totalUSD := 0.0 + totalTRY := 0.0 + for _, p := range parts { + row := m[p] + inAmt, inCur := summarizeInputByCurrency(row) + tick := "" + if row != nil && row.HasCMT { + tick = "✓" + } + if row != nil { + totalUSD += row.USD + totalTRY += row.TRY + } + b.WriteString(``) + b.WriteString(fmt.Sprintf(``, htmlEsc(p))) + b.WriteString(fmt.Sprintf(``, htmlEsc(inAmt))) + b.WriteString(fmt.Sprintf(``, htmlEsc(inCur))) + b.WriteString(fmt.Sprintf(``, formatMoney2(row.USD))) + b.WriteString(fmt.Sprintf(``, formatMoney2(row.TRY))) + b.WriteString(fmt.Sprintf(``, tick)) + b.WriteString(``) + } + b.WriteString(``) + b.WriteString(``) + b.WriteString(fmt.Sprintf(``, formatMoney2(totalUSD))) + b.WriteString(fmt.Sprintf(``, formatMoney2(totalTRY))) + b.WriteString(``) + b.WriteString(``) + b.WriteString(`
ParcaGirisPr.Br.USD TutarTRY TutarCMT/Malzemeli
%s%s%s%s%s%s
TOPLAM%s%s
`) + } + + renderMaterialTable := func(title string, m map[string]*mailBucket) { + b.WriteString(fmt.Sprintf(`

%s

`, htmlEsc(title))) + b.WriteString(``) + b.WriteString(``) + totalUSD := 0.0 + totalTRY := 0.0 + for _, p := range parts { + row := m[p] + if row != nil { + totalUSD += row.USD + totalTRY += row.TRY + } + b.WriteString(``) + b.WriteString(fmt.Sprintf(``, htmlEsc(p))) + b.WriteString(fmt.Sprintf(``, formatMoney2(row.USD))) + b.WriteString(fmt.Sprintf(``, formatMoney2(row.TRY))) + b.WriteString(``) + } + b.WriteString(``) + b.WriteString(``) + b.WriteString(fmt.Sprintf(``, formatMoney2(totalUSD))) + b.WriteString(fmt.Sprintf(``, formatMoney2(totalTRY))) + b.WriteString(``) + b.WriteString(`
ParcaUSD TutarTRY Tutar
%s%s%s
TOPLAM%s%s
`) + } + + renderFabricTable := func(title string, m map[string]*mailBucket) { + b.WriteString(fmt.Sprintf(`

%s

`, htmlEsc(title))) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + 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(``) + b.WriteString(fmt.Sprintf(``, htmlEsc(p))) + b.WriteString(fmt.Sprintf(``, htmlEsc(formatMeterLabel(row)))) + b.WriteString(fmt.Sprintf(``, htmlEsc(unitLabel))) + b.WriteString(fmt.Sprintf(``, htmlEsc(curLabel))) + b.WriteString(fmt.Sprintf(``, formatMoney2(row.USD))) + b.WriteString(fmt.Sprintf(``, formatMoney2(row.TRY))) + b.WriteString(``) + } + totalMeterLabel := "-" + if totalMeter > 0 { + totalMeterLabel = fmt.Sprintf("%s MT", formatQty2(totalMeter)) + } + b.WriteString(``) + b.WriteString(``) + b.WriteString(fmt.Sprintf(``, htmlEsc(totalMeterLabel))) + b.WriteString(``) + b.WriteString(fmt.Sprintf(``, formatMoney2(totalUSD))) + b.WriteString(fmt.Sprintf(``, formatMoney2(totalTRY))) + b.WriteString(``) + b.WriteString(`
ParcaMetrajMT Giris FiyatPr.Br.USD TutarTRY Tutar
%s%s%s%s%s%s
TOPLAM%s%s%s
`) + } + + renderLaborTable("Iscilik Fiyatlari (CM2)", labor) + renderMaterialTable("Malzeme Fiyatlari (DT/TP, maliyete dahil)", material) + renderFabricTable("Kumas Fiyatlari (FABRIC, maliyete dahil)", fabric) + + b.WriteString(`

Bu mail BaggiSS App uzerinden otomatik gonderilmistir.

`) + b.WriteString(`
`) + + 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 +} diff --git a/svc/routes/production_product_costing_pdf.go b/svc/routes/production_product_costing_pdf.go index 5721fd3..b6a5dc8 100644 --- a/svc/routes/production_product_costing_pdf.go +++ b/svc/routes/production_product_costing_pdf.go @@ -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) diff --git a/ui/.quasar/prod-spa/app.js b/ui/.quasar/prod-spa/app.js deleted file mode 100644 index caeaac1..0000000 --- a/ui/.quasar/prod-spa/app.js +++ /dev/null @@ -1,75 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - - - - - -import { Quasar } from 'quasar' -import { markRaw } from 'vue' -import RootComponent from 'app/src/App.vue' - -import createStore from 'app/src/stores/index' -import createRouter from 'app/src/router/index' - - - - - -export default async function (createAppFn, quasarUserOptions) { - - - // Create the app instance. - // Here we inject into it the Quasar UI, the router & possibly the store. - const app = createAppFn(RootComponent) - - - - app.use(Quasar, quasarUserOptions) - - - - - const store = typeof createStore === 'function' - ? await createStore({}) - : createStore - - - app.use(store) - - - - - - const router = markRaw( - typeof createRouter === 'function' - ? await createRouter({store}) - : createRouter - ) - - - // make router instance available in store - - store.use(({ store }) => { store.router = router }) - - - - // Expose the app, the router and the store. - // Note that we are not mounting the app here, since bootstrapping will be - // different depending on whether we are in a browser or on the server. - return { - app, - store, - router - } -} diff --git a/ui/.quasar/prod-spa/client-entry.js b/ui/.quasar/prod-spa/client-entry.js deleted file mode 100644 index 5223e2b..0000000 --- a/ui/.quasar/prod-spa/client-entry.js +++ /dev/null @@ -1,158 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - - -import { createApp } from 'vue' - - - - - - - -import '@quasar/extras/roboto-font/roboto-font.css' - -import '@quasar/extras/material-icons/material-icons.css' - - - - -// We load Quasar stylesheet file -import 'quasar/dist/quasar.sass' - - - - -import 'src/css/app.css' - - -import createQuasarApp from './app.js' -import quasarUserOptions from './quasar-user-options.js' - - - - - - - - -const publicPath = `/` - - -async function start ({ - app, - router - , store -}, bootFiles) { - - let hasRedirected = false - const getRedirectUrl = url => { - try { return router.resolve(url).href } - catch (err) {} - - return Object(url) === url - ? null - : url - } - const redirect = url => { - hasRedirected = true - - if (typeof url === 'string' && /^https?:\/\//.test(url)) { - window.location.href = url - return - } - - const href = getRedirectUrl(url) - - // continue if we didn't fail to resolve the url - if (href !== null) { - window.location.href = href - window.location.reload() - } - } - - const urlPath = window.location.href.replace(window.location.origin, '') - - for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) { - try { - await bootFiles[i]({ - app, - router, - store, - ssrContext: null, - redirect, - urlPath, - publicPath - }) - } - catch (err) { - if (err && err.url) { - redirect(err.url) - return - } - - console.error('[Quasar] boot error:', err) - return - } - } - - if (hasRedirected === true) return - - - app.use(router) - - - - - - - app.mount('#q-app') - - - -} - -createQuasarApp(createApp, quasarUserOptions) - - .then(app => { - // eventually remove this when Cordova/Capacitor/Electron support becomes old - const [ method, mapFn ] = Promise.allSettled !== void 0 - ? [ - 'allSettled', - bootFiles => bootFiles.map(result => { - if (result.status === 'rejected') { - console.error('[Quasar] boot error:', result.reason) - return - } - return result.value.default - }) - ] - : [ - 'all', - bootFiles => bootFiles.map(entry => entry.default) - ] - - return Promise[ method ]([ - - import(/* webpackMode: "eager" */ 'boot/dayjs'), - - import(/* webpackMode: "eager" */ 'boot/locale'), - - import(/* webpackMode: "eager" */ 'boot/resizeObserverGuard') - - ]).then(bootFiles => { - const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function') - start(app, boot) - }) - }) - diff --git a/ui/.quasar/prod-spa/client-prefetch.js b/ui/.quasar/prod-spa/client-prefetch.js deleted file mode 100644 index 9bbe3c5..0000000 --- a/ui/.quasar/prod-spa/client-prefetch.js +++ /dev/null @@ -1,116 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - - - -import App from 'app/src/App.vue' -let appPrefetch = typeof App.preFetch === 'function' - ? App.preFetch - : ( - // Class components return the component options (and the preFetch hook) inside __c property - App.__c !== void 0 && typeof App.__c.preFetch === 'function' - ? App.__c.preFetch - : false - ) - - -function getMatchedComponents (to, router) { - const route = to - ? (to.matched ? to : router.resolve(to).route) - : router.currentRoute.value - - if (!route) { return [] } - - const matched = route.matched.filter(m => m.components !== void 0) - - if (matched.length === 0) { return [] } - - return Array.prototype.concat.apply([], matched.map(m => { - return Object.keys(m.components).map(key => { - const comp = m.components[key] - return { - path: m.path, - c: comp - } - }) - })) -} - -export function addPreFetchHooks ({ router, store, publicPath }) { - // Add router hook for handling preFetch. - // Doing it after initial route is resolved so that we don't double-fetch - // the data that we already have. Using router.beforeResolve() so that all - // async components are resolved. - router.beforeResolve((to, from, next) => { - const - urlPath = window.location.href.replace(window.location.origin, ''), - matched = getMatchedComponents(to, router), - prevMatched = getMatchedComponents(from, router) - - let diffed = false - const preFetchList = matched - .filter((m, i) => { - return diffed || (diffed = ( - !prevMatched[i] || - prevMatched[i].c !== m.c || - m.path.indexOf('/:') > -1 // does it has params? - )) - }) - .filter(m => m.c !== void 0 && ( - typeof m.c.preFetch === 'function' - // Class components return the component options (and the preFetch hook) inside __c property - || (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function') - )) - .map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch) - - - if (appPrefetch !== false) { - preFetchList.unshift(appPrefetch) - appPrefetch = false - } - - - if (preFetchList.length === 0) { - return next() - } - - let hasRedirected = false - const redirect = url => { - hasRedirected = true - next(url) - } - const proceed = () => { - - if (hasRedirected === false) { next() } - } - - - - preFetchList.reduce( - (promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({ - store, - currentRoute: to, - previousRoute: from, - redirect, - urlPath, - publicPath - })), - Promise.resolve() - ) - .then(proceed) - .catch(e => { - console.error(e) - proceed() - }) - }) -} diff --git a/ui/.quasar/prod-spa/quasar-user-options.js b/ui/.quasar/prod-spa/quasar-user-options.js deleted file mode 100644 index ac1dae3..0000000 --- a/ui/.quasar/prod-spa/quasar-user-options.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - -import lang from 'quasar/lang/tr.js' - - - -import {Loading,Dialog,Notify} from 'quasar' - - - -export default { config: {"notify":{"position":"top","timeout":2500}},lang,plugins: {Loading,Dialog,Notify} } - diff --git a/ui/quasar.config.js.temporary.compiled.1779349901589.mjs b/ui/quasar.config.js.temporary.compiled.1779449061737.mjs similarity index 100% rename from ui/quasar.config.js.temporary.compiled.1779349901589.mjs rename to ui/quasar.config.js.temporary.compiled.1779449061737.mjs diff --git a/ui/src/pages/CostingMailMapping.vue b/ui/src/pages/CostingMailMapping.vue index 922c598..7294416 100644 --- a/ui/src/pages/CostingMailMapping.vue +++ b/ui/src/pages/CostingMailMapping.vue @@ -15,7 +15,7 @@ flat bordered dense - row-key="urun_ilk_grubu" + row-key="group_code" :loading="store.loading" :rows="store.rows" :columns="columns" @@ -25,8 +25,8 @@ @@ -71,7 +71,8 @@ const originalByGroup = ref({}) const mailOptionsByGroup = ref({}) const columns = [ - { name: 'urun_ilk_grubu', label: 'Urun Ilk Grubu', field: 'urun_ilk_grubu', align: 'left' }, + { name: 'group_code', label: 'Urun Ilk Grup Kodu', field: 'group_code', align: 'left' }, + { name: 'group_title', label: 'Urun Ilk Grup Aciklama', field: 'group_title', align: 'left' }, { name: 'mail_selector', label: 'Maliyet Mail Eslestirme', field: 'mail_selector', align: 'left' } ] @@ -81,7 +82,7 @@ const allMailOptions = computed(() => const changedGroups = computed(() => { return (store.rows || []) - .map((r) => String(r.urun_ilk_grubu || '').trim()) + .map((r) => String(r.group_code || r.urun_ilk_grubu || '').trim()) .filter(Boolean) .filter((g) => { const current = normalizeList(editableByGroup.value[g] || []) @@ -115,7 +116,7 @@ function initEditableState () { const original = {} ;(store.rows || []).forEach((row) => { - const g = String(row.urun_ilk_grubu || '').trim() + const g = String(row.group_code || row.urun_ilk_grubu || '').trim() const selected = normalizeList(row.mail_ids || []) editable[g] = [...selected] original[g] = [...selected] @@ -169,4 +170,3 @@ async function saveChanges () { onMounted(() => { init() }) - diff --git a/ui/src/pages/PricingMailMapping.vue b/ui/src/pages/PricingMailMapping.vue index ce8fb7b..09ed6a2 100644 --- a/ui/src/pages/PricingMailMapping.vue +++ b/ui/src/pages/PricingMailMapping.vue @@ -15,7 +15,7 @@ flat bordered dense - row-key="urun_ilk_grubu" + row-key="group_code" :loading="store.loading" :rows="store.rows" :columns="columns" @@ -25,8 +25,8 @@ @@ -71,7 +71,8 @@ const originalByGroup = ref({}) const mailOptionsByGroup = ref({}) const columns = [ - { name: 'urun_ilk_grubu', label: 'Urun Ilk Grubu', field: 'urun_ilk_grubu', align: 'left' }, + { name: 'group_code', label: 'Urun Ilk Grup Kodu', field: 'group_code', align: 'left' }, + { name: 'group_title', label: 'Urun Ilk Grup Aciklama', field: 'group_title', align: 'left' }, { name: 'mail_selector', label: 'Fiyatlandirma Mail Eslestirme', field: 'mail_selector', align: 'left' } ] @@ -81,7 +82,7 @@ const allMailOptions = computed(() => const changedGroups = computed(() => { return (store.rows || []) - .map((r) => String(r.urun_ilk_grubu || '').trim()) + .map((r) => String(r.group_code || r.urun_ilk_grubu || '').trim()) .filter(Boolean) .filter((g) => { const current = normalizeList(editableByGroup.value[g] || []) @@ -115,7 +116,7 @@ function initEditableState () { const original = {} ;(store.rows || []).forEach((row) => { - const g = String(row.urun_ilk_grubu || '').trim() + const g = String(row.group_code || row.urun_ilk_grubu || '').trim() const selected = normalizeList(row.mail_ids || []) editable[g] = [...selected] original[g] = [...selected] @@ -169,4 +170,3 @@ async function saveChanges () { onMounted(() => { init() }) - diff --git a/ui/src/pages/ProductionProductCostingHasCostDetail.vue b/ui/src/pages/ProductionProductCostingHasCostDetail.vue index bbce129..439c63c 100644 --- a/ui/src/pages/ProductionProductCostingHasCostDetail.vue +++ b/ui/src/pages/ProductionProductCostingHasCostDetail.vue @@ -3,37 +3,65 @@
-
-
Maliyet Detay Sayfasi
-
-
- USD - {{ formatMoney(toolbarSummary.usdTotal) }} -
-
- EUR - {{ formatMoney(toolbarSummary.eurTotal) }} -
-
- GBP - {{ formatMoney(toolbarSummary.gbpTotal) }} -
-
- USD Kur - {{ formatMoney(exchangeRates.usdRate) }} -
-
- EUR Kur - {{ formatMoney(exchangeRates.eurRate) }} -
-
- GBP Kur - {{ formatMoney(exchangeRates.gbpRate) }} -
-
-
+
+
Maliyet Detay Sayfasi
+
+ +
+
+ USD + {{ formatMoney(toolbarSummary.usdTotal) }} +
+
+ TRY + {{ formatMoney(toolbarSummary.tryTotal) }} +
+
+ EUR + {{ formatMoney(toolbarSummary.eurTotal) }} +
+
+ GBP + {{ formatMoney(toolbarSummary.gbpTotal) }} +
+
+
+
+ USD Kur + {{ formatMoney(exchangeRates.usdRate) }} +
+
+ EUR Kur + {{ formatMoney(exchangeRates.eurRate) }} +
+
+ GBP Kur + {{ formatMoney(exchangeRates.gbpRate) }} +
+
+
+
-
+
+
+ Fiyat Uyari + {{ last10WarningCount }} +
+ + +
+
+
Maliyetlere Islenen Toplam Tutar
+ + + + USD + {{ formatMoney(mailSummary.headerTotals.usd) }} + TRY + {{ formatMoney(mailSummary.headerTotals.try) }} + + + EUR + {{ formatMoney(mailSummary.headerTotals.eur) }} + GBP + {{ formatMoney(mailSummary.headerTotals.gbp) }} + + + +
+ +
+
Iscilik Fiyatlari (CM2)
+ + + + Parca + Giris + Pr.Br. + USD Tutar + TRY Tutar + CMT/Malzemeli + + + + + {{ r.part }} + {{ r.inputAmountLabel }} + {{ r.inputCurrencyLabel }} + {{ formatMoney(r.usd) }} + {{ formatMoney(r.try) }} + {{ r.hasCmtOrMalzemeli ? '✓' : '' }} + + + TOPLAM + + + {{ formatMoney(mailSummary.laborRows.reduce((a, x) => a + (x.usd || 0), 0)) }} + {{ formatMoney(mailSummary.laborRows.reduce((a, x) => a + (x.try || 0), 0)) }} + + + + +
+ +
+
Malzeme Fiyatlari (DT/TP, maliyete dahil)
+ + + + Parca + USD Tutar + TRY Tutar + + + + + {{ r.part }} + {{ formatMoney(r.usd) }} + {{ formatMoney(r.try) }} + + + TOPLAM + {{ formatMoney(mailSummary.materialRows.reduce((a, x) => a + (x.usd || 0), 0)) }} + {{ formatMoney(mailSummary.materialRows.reduce((a, x) => a + (x.try || 0), 0)) }} + + + +
+ +
+
Kumas Fiyatlari (FABRIC, maliyete dahil)
+ + + + Parca + Metraj + MT Giris Fiyat + Pr.Br. + USD Tutar + TRY Tutar + + + + + {{ r.part }} + {{ r.meterLabel }} + {{ r.inputUnitLabel }} + {{ r.inputCurrencyLabel }} + {{ formatMoney(r.usd) }} + {{ formatMoney(r.try) }} + + + TOPLAM + + {{ + (() => { + const total = mailSummary.fabricRows.reduce((a, x) => a + (Number(x.meterQty || 0) || 0), 0) + return total > 0 ? `${formatBarQuantity(total)} MT` : '-' + })() + }} + + + + {{ formatMoney(mailSummary.fabricRows.reduce((a, x) => a + (x.usd || 0), 0)) }} + {{ formatMoney(mailSummary.fabricRows.reduce((a, x) => a + (x.try || 0), 0)) }} + + + +
+
+
+ + + + +
Son 10 Ort. Fiyat Sapmalari
+ + +
+ + + + + + Kod + Doviz + Giris + Ort10 + Sapma % + Sample + Tarih Araligi + + + + + {{ w.item_code }} + {{ w.currency_code }} + {{ formatMoney(w.input_price) }} + {{ formatMoney(w.avg_doc_price) }} + {{ formatPercent(w.diff_ratio) }} + {{ w.sample_count }} + {{ (w.min_invoice_date || '-') + ' / ' + (w.max_invoice_date || '-') }} + + + Kayit yok + + + + +
+
+
@@ -189,7 +389,7 @@
-
+
Parça Bazlı Maliyet Özellikleri
@@ -386,7 +586,7 @@