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(`| Alan | Deger |
`)
+
+ // 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(`| UrunKodu | %s |
`, htmlEsc(urunKodu)))
+ if urunAdi != "" {
+ b.WriteString(fmt.Sprintf(`| UrunAdi | %s |
`, htmlEsc(urunAdi)))
+ }
+ if strings.TrimSpace(ilkGrup) != "" || strings.TrimSpace(anaGrup) != "" || strings.TrimSpace(altGrup) != "" {
+ if strings.TrimSpace(ilkGrup) != "" {
+ b.WriteString(fmt.Sprintf(`| Urun Ilk Grubu | %s |
`, htmlEsc(ilkGrup)))
+ }
+ if strings.TrimSpace(anaGrup) != "" {
+ b.WriteString(fmt.Sprintf(`| Urun Ana Grubu | %s |
`, htmlEsc(anaGrup)))
+ }
+ if strings.TrimSpace(altGrup) != "" {
+ b.WriteString(fmt.Sprintf(`| Urun Alt Grubu | %s |
`, htmlEsc(altGrup)))
+ }
+ }
+ b.WriteString(fmt.Sprintf(`| Maliyet Tarihi | %s |
`, htmlEsc(maliyetTarihiLabel)))
+
+ // Mirror UI header labels when available:
+ if uiHeaderLoaded {
+ if strings.TrimSpace(uiHeader.UretimSekli) != "" {
+ b.WriteString(fmt.Sprintf(`| Uretim Sekli | %s |
`, htmlEsc(uiHeader.UretimSekli)))
+ } else if strings.TrimSpace(uiHeader.UretimSekliID) != "" {
+ b.WriteString(fmt.Sprintf(`| Uretim Sekli ID | %s |
`, htmlEsc(uiHeader.UretimSekliID)))
+ }
+ if strings.TrimSpace(uiHeader.UretimiYapanFirma) != "" {
+ b.WriteString(fmt.Sprintf(`| Uretimi Yapan Firma | %s |
`, htmlEsc(uiHeader.UretimiYapanFirma)))
+ }
+ if strings.TrimSpace(uiHeader.SonIsEmriVeren) != "" {
+ b.WriteString(fmt.Sprintf(`| 2.Firma | %s |
`, htmlEsc(uiHeader.SonIsEmriVeren)))
+ }
+ if strings.TrimSpace(uiHeader.NOnMLNo) != "" {
+ b.WriteString(fmt.Sprintf(`| nOnMLNo | %s |
`, htmlEsc(uiHeader.NOnMLNo)))
+ } else {
+ b.WriteString(fmt.Sprintf(`| nOnMLNo | %d |
`, nOnMLNo))
+ }
+ if strings.TrimSpace(uiHeader.SKullaniciAdi) != "" {
+ b.WriteString(fmt.Sprintf(`| sKullaniciAdi | %s |
`, htmlEsc(uiHeader.SKullaniciAdi)))
+ }
+ if strings.TrimSpace(uiHeader.DteKayitTarihi) != "" {
+ b.WriteString(fmt.Sprintf(`| Kayit Tarihi | %s |
`, htmlEsc(formatAnyDateTimeTR2(uiHeader.DteKayitTarihi))))
+ }
+ if strings.TrimSpace(uiHeader.DteGuncellemeTarihi) != "" {
+ b.WriteString(fmt.Sprintf(`| Son Guncelleme Tarihi | %s |
`, htmlEsc(formatAnyDateTimeTR2(uiHeader.DteGuncellemeTarihi))))
+ }
+ if strings.TrimSpace(uiHeader.SGuncellemeKullaniciAdi) != "" {
+ b.WriteString(fmt.Sprintf(`| sGuncellemeKullaniciAdi | %s |
`, htmlEsc(uiHeader.SGuncellemeKullaniciAdi)))
+ }
+ if strings.TrimSpace(uiHeader.NUrtReceteID) != "" {
+ b.WriteString(fmt.Sprintf(`| nUrtReceteID | %s |
`, htmlEsc(uiHeader.NUrtReceteID)))
+ }
+ } else {
+ b.WriteString(fmt.Sprintf(`| nOnMLNo | %d |
`, nOnMLNo))
+ if req.Header.NUrtReceteID > 0 {
+ b.WriteString(fmt.Sprintf(`| nUrtReceteID | %d |
`, req.Header.NUrtReceteID))
+ }
+ if req.Header.UretimSekliID > 0 {
+ b.WriteString(fmt.Sprintf(`| Uretim Sekli ID | %d |
`, req.Header.UretimSekliID))
+ }
+ }
+
+ // Free text description (from request).
+ if strings.TrimSpace(req.Header.SAciklama) != "" {
+ b.WriteString(fmt.Sprintf(`| Aciklama | %s |
`, htmlEsc(req.Header.SAciklama)))
+ }
+
+ b.WriteString(`
`)
+
+ // 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(`| USD | %s | TRY | %s |
`,
+ formatMoney2(totalUSD), formatMoney2(totalTRY)))
+ b.WriteString(fmt.Sprintf(`| EUR | %s | GBP | %s |
`,
+ formatMoney2(totalEUR), formatMoney2(gbpTotal)))
+ b.WriteString(`
`)
+
+ renderLaborTable := func(title string, m map[string]*mailBucket) {
+ b.WriteString(fmt.Sprintf(`
%s
`, htmlEsc(title)))
+ b.WriteString(`
`)
+ b.WriteString(``)
+ b.WriteString(`| Parca | Giris | Pr.Br. | USD Tutar | TRY Tutar | CMT/Malzemeli | `)
+ 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(`| %s | `, htmlEsc(p)))
+ b.WriteString(fmt.Sprintf(`%s | `, htmlEsc(inAmt)))
+ b.WriteString(fmt.Sprintf(`%s | `, htmlEsc(inCur)))
+ b.WriteString(fmt.Sprintf(`%s | `, formatMoney2(row.USD)))
+ b.WriteString(fmt.Sprintf(`%s | `, formatMoney2(row.TRY)))
+ b.WriteString(fmt.Sprintf(`%s | `, tick))
+ b.WriteString(`
`)
+ }
+ b.WriteString(``)
+ b.WriteString(`| TOPLAM | | | `)
+ b.WriteString(fmt.Sprintf(`%s | `, formatMoney2(totalUSD)))
+ b.WriteString(fmt.Sprintf(`%s | `, formatMoney2(totalTRY)))
+ b.WriteString(` | `)
+ b.WriteString(`
`)
+ b.WriteString(`
`)
+ }
+
+ renderMaterialTable := func(title string, m map[string]*mailBucket) {
+ b.WriteString(fmt.Sprintf(`
%s
`, htmlEsc(title)))
+ b.WriteString(`
`)
+ b.WriteString(`| Parca | USD Tutar | TRY Tutar |
`)
+ 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(`| %s | `, htmlEsc(p)))
+ b.WriteString(fmt.Sprintf(`%s | `, formatMoney2(row.USD)))
+ b.WriteString(fmt.Sprintf(`%s | `, formatMoney2(row.TRY)))
+ b.WriteString(`
`)
+ }
+ b.WriteString(``)
+ b.WriteString(`| TOPLAM | `)
+ b.WriteString(fmt.Sprintf(`%s | `, formatMoney2(totalUSD)))
+ b.WriteString(fmt.Sprintf(`%s | `, formatMoney2(totalTRY)))
+ b.WriteString(`
`)
+ b.WriteString(`
`)
+ }
+
+ renderFabricTable := func(title string, m map[string]*mailBucket) {
+ b.WriteString(fmt.Sprintf(`
%s
`, htmlEsc(title)))
+ b.WriteString(`
`)
+ b.WriteString(``)
+ b.WriteString(`| Parca | Metraj | MT Giris Fiyat | Pr.Br. | USD Tutar | TRY Tutar | `)
+ 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(`| %s | `, htmlEsc(p)))
+ b.WriteString(fmt.Sprintf(`%s | `, htmlEsc(formatMeterLabel(row))))
+ b.WriteString(fmt.Sprintf(`%s | `, htmlEsc(unitLabel)))
+ b.WriteString(fmt.Sprintf(`%s | `, htmlEsc(curLabel)))
+ b.WriteString(fmt.Sprintf(`%s | `, formatMoney2(row.USD)))
+ b.WriteString(fmt.Sprintf(`%s | `, formatMoney2(row.TRY)))
+ b.WriteString(`
`)
+ }
+ totalMeterLabel := "-"
+ if totalMeter > 0 {
+ totalMeterLabel = fmt.Sprintf("%s MT", formatQty2(totalMeter))
+ }
+ b.WriteString(``)
+ b.WriteString(`| TOPLAM | `)
+ b.WriteString(fmt.Sprintf(`%s | `, htmlEsc(totalMeterLabel)))
+ b.WriteString(` | | `)
+ b.WriteString(fmt.Sprintf(`%s | `, formatMoney2(totalUSD)))
+ b.WriteString(fmt.Sprintf(`%s | `, formatMoney2(totalTRY)))
+ b.WriteString(`
`)
+ b.WriteString(`
`)
+ }
+
+ 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 @@
filterMailOptions(props.row.urun_ilk_grubu, val, update)"
- @update:model-value="(val) => updateRowSelection(props.row.urun_ilk_grubu, val)"
+ @filter="(val, update) => filterMailOptions(props.row.group_code, val, update)"
+ @update:model-value="(val) => updateRowSelection(props.row.group_code, val)"
/>
@@ -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 @@
filterMailOptions(props.row.urun_ilk_grubu, val, update)"
- @update:model-value="(val) => updateRowSelection(props.row.urun_ilk_grubu, val)"
+ @filter="(val, update) => filterMailOptions(props.row.group_code, val, update)"
+ @update:model-value="(val) => updateRowSelection(props.row.group_code, val)"
/>
@@ -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 @@