Merge remote-tracking branch 'origin/master'

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

0
Iscilik Normal file
View File

View File

@@ -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",

View File

@@ -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"`
}

View File

@@ -171,6 +171,16 @@ type ProductionProductCostingOnMLSaveRequest struct {
type ProductionProductCostingOnMLSaveResponse struct {
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 {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -927,8 +927,8 @@ func GetProductionHasCostDetailRowsByOnMLNo(
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(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,
@@ -954,20 +954,16 @@ func GetProductionHasCostDetailRowsByOnMLNo(
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
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,

View File

@@ -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
}

View File

@@ -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
}

View File

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

View File

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

View File

@@ -3,12 +3,14 @@ package routes
import (
"bssapp-backend/auth"
"bssapp-backend/db"
"bssapp-backend/internal/mailer"
"bssapp-backend/models"
"bssapp-backend/queries"
"bssapp-backend/utils"
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
@@ -1222,8 +1224,82 @@ func PostProductionProductCostingDefaultQuantitiesRefreshHandler(w http.Response
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "top_n": topN})
}
// POST /api/pricing/production-product-costing/tbstok/exists-bulk
// Validates whether given codes exist in URETIM dbo.tbStok (or match sModel rules).
// Used by UI to highlight invalid codes before save.
func PostProductionProductCostingTbStokExistsBulkHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
if uretimDB == nil {
// Non-blocking UX helper: if URETIM isn't reachable in this environment, return empty result.
_ = json.NewEncoder(w).Encode(models.ProductionProductCostingTbStokExistsBulkResponse{
Missing: []string{},
Error: "URETIM baglantisi aktif degil",
})
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.tbstok.exists-bulk")
var req models.ProductionProductCostingTbStokExistsBulkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Gecersiz JSON", http.StatusBadRequest)
return
}
// short timeout: this is a UX helper, must not hang (but should still complete on moderate load)
checkCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// log a small sample to diagnose timeouts without flooding logs
sample := make([]string, 0, 8)
for _, c := range req.Codes {
c = strings.TrimSpace(c)
if c == "" {
continue
}
sample = append(sample, c)
if len(sample) >= 8 {
break
}
}
logger.Info("lookup start", "codes", len(req.Codes), "sample", strings.Join(sample, ","))
existsBy, err := queries.LookupTbStokExistsByCodes(checkCtx, uretimDB, req.Codes)
if err != nil {
logger.Warn("lookup failed", "err", err, "codes", len(req.Codes))
// Non-blocking UX helper: return empty list + error so UI can continue without hard failure.
_ = json.NewEncoder(w).Encode(models.ProductionProductCostingTbStokExistsBulkResponse{
Missing: []string{},
Error: "tbStok sorgu hatasi",
})
return
}
missing := make([]string, 0, 16)
for code, ok := range existsBy {
if !ok && strings.TrimSpace(code) != "" {
missing = append(missing, code)
}
}
_ = json.NewEncoder(w).Encode(models.ProductionProductCostingTbStokExistsBulkResponse{Missing: missing})
}
// POST /api/pricing/production-product-costing/onml/save
func PostProductionProductCostingOnMLSaveHandlerWithMailer(ml *mailer.GraphMailer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
postProductionProductCostingOnMLSaveHandler(w, r, ml)
}
}
// Backward-compatible entrypoint (no mailer).
func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.Request) {
postProductionProductCostingOnMLSaveHandler(w, r, nil)
}
func postProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.Request, ml *mailer.GraphMailer) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
uretimDB := db.GetUretimDB()
@@ -1413,6 +1489,14 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
logger.Warn("tx rollback failed", "trace_id", traceID, "n_onml_no", nOnMLNo, "err", err)
}
}()
warnings := make([]string, 0, 4)
// Determine whether this is a new costing record or an update (before header upsert).
isUpdate := false
{
var flag int
_ = tx.QueryRowContext(ctx, `SELECT CASE WHEN EXISTS (SELECT 1 FROM dbo.spUrtOnMLMas WITH (NOLOCK) WHERE nOnMLNo=@p1) THEN 1 ELSE 0 END`, nOnMLNo).Scan(&flag)
isUpdate = flag == 1
}
// Determine mamul turu inside same tx (to keep create atomic)
mamulLabel := ""
@@ -1512,6 +1596,96 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
sKodu string
}
recipeQtyByKey := map[recipeKey]float64{}
// Bulk resolve stock type id from tbStok (huge performance win vs per-row queries).
// IMPORTANT: Do NOT run tbStok lookups on the transaction connection.
// We have seen network timeouts against the tbStok server poison the tx connection ("driver: bad connection"),
// which then makes rollback/commit impossible and returns 500. Use a separate DB handle + short timeouts.
lookupDB := mssqlDB
if lookupDB == nil {
lookupDB = uretimDB
}
uniqueCodes := make([]string, 0, len(req.Detail.Upserts))
seenCode := map[string]struct{}{}
for _, row := range req.Detail.Upserts {
if row.NOnMLDetNo <= 0 {
continue
}
code := strings.TrimSpace(row.SKodu)
if code == "" {
continue
}
if _, ok := seenCode[code]; ok {
continue
}
seenCode[code] = struct{}{}
uniqueCodes = append(uniqueCodes, code)
}
stockTypeByCode := map[string]int{}
bulkStockTypeLookupFailed := false
if len(uniqueCodes) > 0 {
lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// Build a VALUES list with parameters: (VALUES (@p1), (@p2), ...)
valParts := make([]string, 0, len(uniqueCodes))
args := make([]any, 0, len(uniqueCodes))
for i, code := range uniqueCodes {
// Parameters are 1-based in our SQL style (@p1, @p2, ...)
valParts = append(valParts, fmt.Sprintf("(@p%d)", i+1))
args = append(args, code)
}
sqlText := fmt.Sprintf(`
WITH C AS (
SELECT LTRIM(RTRIM(V.code)) AS code
FROM (VALUES %s) AS V(code)
)
SELECT
C.code,
ISNULL((
SELECT TOP 1 ISNULL(CONVERT(int, ISNULL(S.nStokTipi, 0)), 0) AS nStokTipiID
FROM dbo.tbStok S WITH (NOLOCK)
WHERE ISNULL(S.IsBlocked, 0) = 0
AND (
REPLACE(LTRIM(RTRIM(ISNULL(S.sKodu,''))), ' ', '') = REPLACE(C.code, ' ', '')
OR LTRIM(RTRIM(ISNULL(S.sModel,''))) = C.code
OR C.code LIKE LTRIM(RTRIM(ISNULL(S.sModel,''))) + '%%'
)
ORDER BY
CASE
WHEN REPLACE(LTRIM(RTRIM(ISNULL(S.sKodu,''))), ' ', '') = REPLACE(C.code, ' ', '') THEN 0
WHEN LTRIM(RTRIM(ISNULL(S.sModel,''))) = C.code THEN 1
ELSE 2
END,
S.dteKayitTarihi DESC,
S.nStokID DESC
), 0) AS nStokTipiID
FROM C
`, strings.Join(valParts, ","))
rows, err := lookupDB.QueryContext(lookupCtx, sqlText, args...)
if err != nil {
// Do not fail the whole save for bulk lookup. We'll fallback to per-row queries below.
logger.Error("bulk stok tipi lookup error (fallback to per-row)", "err", err)
bulkStockTypeLookupFailed = true
} else {
for rows.Next() {
var code string
var nStokTipiID int
if err := rows.Scan(&code, &nStokTipiID); err != nil {
_ = rows.Close()
logger.Error("bulk stok tipi scan error (fallback to per-row)", "err", err)
bulkStockTypeLookupFailed = true
break
}
code = strings.TrimSpace(code)
if code != "" {
stockTypeByCode[code] = nStokTipiID
}
}
_ = rows.Close()
}
}
for _, row := range req.Detail.Upserts {
if row.NOnMLDetNo <= 0 {
skippedUpserts += 1
@@ -1640,23 +1814,25 @@ WHERE nHammaddeTuruNo = @p1
lTutar := unitTRY * qty
lDovizTutari := unitUSD * qty
// Debug log for price resolution
logger.Info("price debug",
"s_kodu", strings.TrimSpace(row.SKodu),
"qty", qty,
"fiyat_girilen", row.FiyatGirilen,
"fiyat_doviz", strings.TrimSpace(row.FiyatDoviz),
"unitTRY", unitTRY,
"lTutar", lTutar,
"lDovizTutari", lDovizTutari,
)
// Keep logs lean: per-row price debug was too noisy and slow in large payloads.
// Resolve stock type id from tbStok by sKodu (exact), then fallback to model-based match.
// Note: In this DB, stock type is stored as tbStok.nStokTipi but spUrtOnMLMasDet expects nStokTipiID (int).
rawSKodu := strings.TrimSpace(row.SKodu)
logger.Info("resolving stock type", "s_kodu", rawSKodu)
var nStokTipiID int
err := tx.QueryRowContext(ctx, `
nStokTipiID, ok := stockTypeByCode[rawSKodu]
if !ok || nStokTipiID <= 0 {
// If bulk lookup already failed (usually due to network/driver timeouts), do NOT attempt per-row lookups.
// Per-row fallback would multiply latency and still likely fail, without adding value.
if bulkStockTypeLookupFailed {
nStokTipiID = 1
if rawSKodu != "" {
stockTypeByCode[rawSKodu] = 1
}
} else if rawSKodu != "" {
// Fallback to per-row query. Cache results back into the map.
var tmp int
perRowCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
err := lookupDB.QueryRowContext(perRowCtx, `
SELECT TOP 1 ISNULL(CONVERT(int, ISNULL(S.nStokTipi, 0)), 0) AS nStokTipiID
FROM dbo.tbStok S WITH (NOLOCK)
WHERE ISNULL(S.IsBlocked, 0) = 0
@@ -1673,28 +1849,28 @@ ORDER BY
END,
S.dteKayitTarihi DESC,
S.nStokID DESC
`, rawSKodu).Scan(&nStokTipiID)
if err != nil {
if err == sql.ErrNoRows {
// FALLBACK: If stock item not found in tbStok at all, default to 1.
logger.Warn("stok tipi not found in tbStok, falling back to 1",
"trace_id", traceID,
"n_onml_no", nOnMLNo,
"n_onml_det_no", row.NOnMLDetNo,
"s_kodu", rawSKodu,
)
nStokTipiID = 1
`, 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 {
logger.Error("stok tipi lookup error", "err", err)
http.Error(w, "Stok tipi bulunamadi (tbStok sorgu hatasi)", http.StatusInternalServerError)
return
// 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)
// 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)
}
}
_ = json.NewEncoder(w).Encode(models.ProductionProductCostingOnMLSaveResponse{NOnMLNo: nOnMLNo})
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, 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)
// 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 == "" {
responseChan <- nil
return
continue
}
colorCode := firstNonEmptyString(
normalizeLookupValue(item.ColorCode),
normalizeLookupValue(item.SRenk),
normalizeLookupValue(it.ColorCode),
normalizeLookupValue(it.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
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
}
}
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
result.NOnMLDetNo = strings.TrimSpace(meta.NOnMLDetNo)
result.NHammaddeTuruNo = strings.TrimSpace(meta.NHammaddeTuruNo)
result.SKodu = normalizeLookupValue(meta.SKodu)
response = append(response, result)
}
responseChan <- &result
}(item)
}
response := make([]models.ProductionHasCostDetailBulkPriceRow, 0, itemsCount)
for i := 0; i < itemsCount; i++ {
res := <-responseChan
if res != nil {
response = append(response, *res)
if err := rows.Err(); err != nil {
logger.Warn("bulk rows error", "err", err)
}
}

View File

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

View File

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

View File

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

View File

@@ -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 <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { Quasar } from 'quasar'
import { markRaw } from 'vue'
import RootComponent from 'app/src/App.vue'
import createStore from 'app/src/stores/index'
import createRouter from 'app/src/router/index'
export default async function (createAppFn, quasarUserOptions) {
// Create the app instance.
// Here we inject into it the Quasar UI, the router & possibly the store.
const app = createAppFn(RootComponent)
app.use(Quasar, quasarUserOptions)
const store = typeof createStore === 'function'
? await createStore({})
: createStore
app.use(store)
const router = markRaw(
typeof createRouter === 'function'
? await createRouter({store})
: createRouter
)
// make router instance available in store
store.use(({ store }) => { store.router = router })
// Expose the app, the router and the store.
// Note that we are not mounting the app here, since bootstrapping will be
// different depending on whether we are in a browser or on the server.
return {
app,
store,
router
}
}

View File

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

View File

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

View File

@@ -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 <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import lang from 'quasar/lang/tr.js'
import {Loading,Dialog,Notify} from 'quasar'
export default { config: {"notify":{"position":"top","timeout":2500}},lang,plugins: {Loading,Dialog,Notify} }

View File

@@ -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 @@
<template #body-cell-mail_selector="props">
<q-td :props="props">
<q-select
:model-value="editableByGroup[props.row.urun_ilk_grubu] || []"
:options="mailOptionsByGroup[props.row.urun_ilk_grubu] || allMailOptions"
:model-value="editableByGroup[props.row.group_code] || []"
:options="mailOptionsByGroup[props.row.group_code] || allMailOptions"
option-value="id"
option-label="label"
emit-value
@@ -39,8 +39,8 @@
dense
outlined
label="Mail ara ve sec"
@filter="(val, update) => 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)"
/>
</q-td>
</template>
@@ -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() })
</script>

View File

@@ -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 @@
<template #body-cell-mail_selector="props">
<q-td :props="props">
<q-select
:model-value="editableByGroup[props.row.urun_ilk_grubu] || []"
:options="mailOptionsByGroup[props.row.urun_ilk_grubu] || allMailOptions"
:model-value="editableByGroup[props.row.group_code] || []"
:options="mailOptionsByGroup[props.row.group_code] || allMailOptions"
option-value="id"
option-label="label"
emit-value
@@ -39,8 +39,8 @@
dense
outlined
label="Mail ara ve sec"
@filter="(val, update) => 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)"
/>
</q-td>
</template>
@@ -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() })
</script>

View File

@@ -6,10 +6,16 @@
<div class="pcd-toolbar-left">
<div class="pcd-toolbar-title">Maliyet Detay Sayfasi</div>
<div v-if="detailHeader && !detailLoading" class="pcd-toolbar-summary">
<!-- tbStok kontrolu (exists-bulk) manuel giris kapandigi icin devre disi -->
<div class="pcd-toolbar-summary-row">
<div class="pcd-toolbar-pill pcd-toolbar-pill-emphasis">
<span class="pcd-toolbar-pill-label">USD</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(toolbarSummary.usdTotal) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-emphasis">
<span class="pcd-toolbar-pill-label">TRY</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(toolbarSummary.tryTotal) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-emphasis">
<span class="pcd-toolbar-pill-label">EUR</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(toolbarSummary.eurTotal) }}</span>
@@ -18,6 +24,8 @@
<span class="pcd-toolbar-pill-label">GBP</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(toolbarSummary.gbpTotal) }}</span>
</div>
</div>
<div class="pcd-toolbar-summary-row">
<div class="pcd-toolbar-pill pcd-toolbar-pill-neutral">
<span class="pcd-toolbar-pill-label">USD Kur</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(exchangeRates.usdRate) }}</span>
@@ -32,8 +40,28 @@
</div>
</div>
</div>
</div>
<div class="pcd-toolbar-actions">
<div
v-if="last10WarningCount > 0"
class="pcd-toolbar-pill pcd-toolbar-pill-warn"
style="cursor:pointer;"
title="Son 10 ort. fiyata gore %10+ sapma var"
@click="last10WarningDialogOpen = true"
>
<span class="pcd-toolbar-pill-label">Fiyat Uyari</span>
<span class="pcd-toolbar-pill-value">{{ last10WarningCount }}</span>
</div>
<q-btn
flat
dense
color="grey-7"
class="pcd-toolbar-btn"
:label="summaryOpen ? 'OZET GIZLE' : 'OZET GOSTER'"
:icon="summaryOpen ? 'visibility_off' : 'visibility'"
@click="summaryOpen = !summaryOpen"
/>
<q-btn
flat
dense
@@ -114,6 +142,178 @@
Hata: {{ detailError }}
</q-banner>
<q-expansion-item
v-if="detailHeader && !detailLoading"
v-model="summaryOpen"
dense
expand-separator
class="pcd-summary-expansion q-mx-md q-mb-md"
icon="summarize"
label="Ozet"
>
<div class="row q-col-gutter-md q-pa-sm">
<div class="col-12 col-lg-6">
<div class="pcd-summary-title">Maliyetlere Islenen Toplam Tutar</div>
<q-markup-table dense flat bordered class="pcd-summary-table">
<tbody>
<tr>
<td class="pcd-summary-k">USD</td>
<td class="pcd-summary-v">{{ formatMoney(mailSummary.headerTotals.usd) }}</td>
<td class="pcd-summary-k">TRY</td>
<td class="pcd-summary-v">{{ formatMoney(mailSummary.headerTotals.try) }}</td>
</tr>
<tr>
<td class="pcd-summary-k">EUR</td>
<td class="pcd-summary-v">{{ formatMoney(mailSummary.headerTotals.eur) }}</td>
<td class="pcd-summary-k">GBP</td>
<td class="pcd-summary-v">{{ formatMoney(mailSummary.headerTotals.gbp) }}</td>
</tr>
</tbody>
</q-markup-table>
</div>
<div class="col-12 col-lg-6">
<div class="pcd-summary-title">Iscilik Fiyatlari (CM2)</div>
<q-markup-table dense flat bordered class="pcd-summary-table">
<thead>
<tr>
<th class="text-left">Parca</th>
<th class="text-right">Giris</th>
<th class="text-left">Pr.Br.</th>
<th class="text-right">USD Tutar</th>
<th class="text-right">TRY Tutar</th>
<th class="text-center">CMT/Malzemeli</th>
</tr>
</thead>
<tbody>
<tr v-for="r in mailSummary.laborRows" :key="'labor-'+r.part">
<td class="text-left">{{ r.part }}</td>
<td class="text-right">{{ r.inputAmountLabel }}</td>
<td class="text-left">{{ r.inputCurrencyLabel }}</td>
<td class="text-right">{{ formatMoney(r.usd) }}</td>
<td class="text-right">{{ formatMoney(r.try) }}</td>
<td class="text-center">{{ r.hasCmtOrMalzemeli ? '✓' : '' }}</td>
</tr>
<tr class="pcd-summary-total-row">
<td class="text-left text-weight-bold">TOPLAM</td>
<td></td>
<td></td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.laborRows.reduce((a, x) => a + (x.usd || 0), 0)) }}</td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.laborRows.reduce((a, x) => a + (x.try || 0), 0)) }}</td>
<td></td>
</tr>
</tbody>
</q-markup-table>
</div>
<div class="col-12 col-lg-6">
<div class="pcd-summary-title">Malzeme Fiyatlari (DT/TP, maliyete dahil)</div>
<q-markup-table dense flat bordered class="pcd-summary-table">
<thead>
<tr>
<th class="text-left">Parca</th>
<th class="text-right">USD Tutar</th>
<th class="text-right">TRY Tutar</th>
</tr>
</thead>
<tbody>
<tr v-for="r in mailSummary.materialRows" :key="'mat-'+r.part">
<td class="text-left">{{ r.part }}</td>
<td class="text-right">{{ formatMoney(r.usd) }}</td>
<td class="text-right">{{ formatMoney(r.try) }}</td>
</tr>
<tr class="pcd-summary-total-row">
<td class="text-left text-weight-bold">TOPLAM</td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.materialRows.reduce((a, x) => a + (x.usd || 0), 0)) }}</td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.materialRows.reduce((a, x) => a + (x.try || 0), 0)) }}</td>
</tr>
</tbody>
</q-markup-table>
</div>
<div class="col-12 col-lg-6">
<div class="pcd-summary-title">Kumas Fiyatlari (FABRIC, maliyete dahil)</div>
<q-markup-table dense flat bordered class="pcd-summary-table">
<thead>
<tr>
<th class="text-left">Parca</th>
<th class="text-right">Metraj</th>
<th class="text-right">MT Giris Fiyat</th>
<th class="text-left">Pr.Br.</th>
<th class="text-right">USD Tutar</th>
<th class="text-right">TRY Tutar</th>
</tr>
</thead>
<tbody>
<tr v-for="r in mailSummary.fabricRows" :key="'fab-'+r.part">
<td class="text-left">{{ r.part }}</td>
<td class="text-right">{{ r.meterLabel }}</td>
<td class="text-right">{{ r.inputUnitLabel }}</td>
<td class="text-left">{{ r.inputCurrencyLabel }}</td>
<td class="text-right">{{ formatMoney(r.usd) }}</td>
<td class="text-right">{{ formatMoney(r.try) }}</td>
</tr>
<tr class="pcd-summary-total-row">
<td class="text-left text-weight-bold">TOPLAM</td>
<td class="text-right text-weight-bold">
{{
(() => {
const total = mailSummary.fabricRows.reduce((a, x) => a + (Number(x.meterQty || 0) || 0), 0)
return total > 0 ? `${formatBarQuantity(total)} MT` : '-'
})()
}}
</td>
<td></td>
<td></td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.fabricRows.reduce((a, x) => a + (x.usd || 0), 0)) }}</td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.fabricRows.reduce((a, x) => a + (x.try || 0), 0)) }}</td>
</tr>
</tbody>
</q-markup-table>
</div>
</div>
</q-expansion-item>
<q-dialog v-model="last10WarningDialogOpen" persistent>
<q-card style="min-width: 860px; max-width: 92vw;">
<q-card-section class="row items-center">
<div class="text-h6">Son 10 Ort. Fiyat Sapmalari</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-separator />
<q-card-section style="max-height: 70vh; overflow: auto;">
<q-markup-table dense flat bordered>
<thead>
<tr>
<th class="text-left">Kod</th>
<th class="text-left">Doviz</th>
<th class="text-right">Giris</th>
<th class="text-right">Ort10</th>
<th class="text-right">Sapma %</th>
<th class="text-right">Sample</th>
<th class="text-left">Tarih Araligi</th>
</tr>
</thead>
<tbody>
<tr v-for="w in last10Warnings" :key="w.item_code + '|' + w.currency_code">
<td class="text-left">{{ w.item_code }}</td>
<td class="text-left">{{ w.currency_code }}</td>
<td class="text-right">{{ formatMoney(w.input_price) }}</td>
<td class="text-right">{{ formatMoney(w.avg_doc_price) }}</td>
<td class="text-right">{{ formatPercent(w.diff_ratio) }}</td>
<td class="text-right">{{ w.sample_count }}</td>
<td class="text-left">{{ (w.min_invoice_date || '-') + ' / ' + (w.max_invoice_date || '-') }}</td>
</tr>
<tr v-if="last10Warnings.length === 0">
<td colspan="7" class="text-center text-grey-7">Kayit yok</td>
</tr>
</tbody>
</q-markup-table>
</q-card-section>
</q-card>
</q-dialog>
<div v-if="detailHeader && !detailLoading && !headerInfoCollapsed" class="filter-bar pcd-detail-header-bar q-mx-md q-mb-md">
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-3">
@@ -189,7 +389,7 @@
<q-input dense filled readonly label="nUrtReceteID" :model-value="detailHeader.nUrtReceteID || '-'" />
</div>
<div v-if="partSummary && partSummary.length > 0" class="col-12">
<div v-if="false && partSummary && partSummary.length > 0" class="col-12">
<div class="pcd-part-summary-card">
<div class="pcd-part-summary-title">Parça Bazlı Maliyet Özellikleri</div>
<q-markup-table dense flat bordered separator="cell" class="pcd-part-summary-table">
@@ -386,7 +586,7 @@
<template #body-cell-sKodu="props">
<q-td
:props="props"
:class="resolveAutoOrICodeHighlightClass(props.row)"
:class="[resolveAutoOrICodeHighlightClass(props.row), resolveMissingStockCodeClass(props.row)]"
>
<span>{{ props.value }}</span>
</q-td>
@@ -395,7 +595,7 @@
<template #body-cell-sAciklama="props">
<q-td
:props="props"
:class="resolveAutoOrICodeHighlightClass(props.row)"
:class="[resolveAutoOrICodeHighlightClass(props.row), resolveMissingStockCodeClass(props.row)]"
>
<span>{{ props.value }}</span>
</q-td>
@@ -880,6 +1080,7 @@ const rowEditorHammaddeLoading = ref(false)
const rowEditorItemOptions = ref([])
const rowEditorItemAllOptions = ref([])
const rowEditorItemLoading = ref(false)
const rowEditorLastValidItemValue = ref('')
const rowEditorColorOptions = ref([])
const rowEditorColorAllOptions = ref([])
const rowEditorColorLoading = ref(false)
@@ -894,7 +1095,89 @@ const lineHistoryTargetSummary = ref('')
const lineHistorySearchMode = ref('exact')
const lineHistoryLastPurchaseMatchStage = ref('')
const lineHistoryLastRecipeMatchStage = ref('')
// tbStok validation (missing stock cards)
// Manuel giris kapandigi icin tbStok exists-bulk kontrolunu devre disi biraktik.
// Eski UI/CSS hook'lari kalsin diye state'i tutuyoruz ama request atmayacagiz.
const missingTbStokCodesMap = ref({})
const tbStokValidationLastError = ref('')
const tbStokMissingDialogShown = ref(false)
let tbStokValidationTimer = null
function normalizeStockCodeKey (code) {
return String(code || '').trim().toUpperCase()
}
function openMissingTbStokDialog () {
const missing = Object.keys(missingTbStokCodesMap.value || {})
if (missing.length === 0) return
$q.dialog({
title: 'tbStok Eksik Kodlar',
message: `Asagidaki kodlar tbStok'ta yok. Stok kartlarini duzeltmeden maliyet kaydetmeyin:\n\n${missing.slice(0, 80).join(', ')}${missing.length > 80 ? `\n(+${missing.length - 80} daha)` : ''}`,
ok: { label: 'Tamam' }
})
}
function isTbStokMissingCode (code) {
code = normalizeStockCodeKey(code)
if (!code) return false
return Boolean(missingTbStokCodesMap.value?.[code])
}
function resolveMissingStockCodeClass (row) {
const code = normalizeStockCodeKey(row?.sKodu)
if (!code) return ''
if (isTbStokMissingCode(code)) return 'pcd-missing-stock-code'
return ''
}
function collectUniqueCodesFromRows () {
const out = []
const seen = new Set()
for (const r of flatDetailRows.value || []) {
const code = normalizeStockCodeKey(r?.sKodu)
if (!code) continue
if (seen.has(code)) continue
seen.add(code)
out.push(code)
}
return out
}
async function refreshTbStokMissingCodes () {
tbStokValidationLastError.value = ''
missingTbStokCodesMap.value = {}
}
function formatPercent (ratio) {
const n = Number(ratio)
if (!Number.isFinite(n)) return '-'
return `${(n * 100).toFixed(0)}%`
}
async function refreshLast10Warnings () {
if (!onMLNo.value) {
last10Warnings.value = []
return
}
try {
const resp = await get('/pricing/production-product-costing/last10-warnings', { n_onml_no: onMLNo.value })
const items = Array.isArray(resp?.items) ? resp.items : []
last10Warnings.value = items
} catch (err) {
// non-blocking
}
}
function scheduleTbStokValidation () {
// no-op (tbStok kontrolu devre disi)
if (tbStokValidationTimer) clearTimeout(tbStokValidationTimer)
tbStokValidationTimer = null
}
const purchaseAvgUSDCachedByCode = ref({})
const last10Warnings = ref([])
const last10WarningDialogOpen = ref(false)
const last10WarningCount = computed(() => (Array.isArray(last10Warnings.value) ? last10Warnings.value.length : 0))
const headerInfoCollapsed = ref(false)
const subHeaderTop = ref(140)
const stickyStackRef = ref(null)
@@ -1114,6 +1397,167 @@ const toolbarSummary = computed(() => flatDetailRows.value.reduce((acc, row) =>
gbpTotal: 0
}))
const summaryOpen = ref(false)
function makeEmptySummaryRow (part) {
return {
part,
inputAmountLabel: '-',
inputCurrencyLabel: '-',
inputUnitLabel: '-',
meterLabel: '-',
meterQty: 0,
meterUom: '',
usd: 0,
try: 0,
hasCmtOrMalzemeli: false
}
}
function accumulateInputAmount (bucket, row) {
const qty = resolveNumericRowQuantity(row)
const inputPrice = resolveNumericRowInputPrice(row)
const cur = resolveInputCurrency(row) || 'USD'
const amount = (Number.isFinite(inputPrice) ? inputPrice : 0) * (Number.isFinite(qty) ? qty : 0)
if (!Number.isFinite(amount) || amount === 0) return
if (!bucket.inputByCur) bucket.inputByCur = {}
bucket.inputByCur[cur] = (bucket.inputByCur[cur] || 0) + amount
}
function accumulateUnitPrice (bucket, row) {
// For fabric summary: show a representative unit input price (first non-zero).
const inputPrice = resolveNumericRowInputPrice(row)
const cur = resolveInputCurrency(row) || 'USD'
if (!Number.isFinite(inputPrice) || inputPrice <= 0) return
if (bucket.unitPrice === undefined || bucket.unitPrice === null) {
bucket.unitPrice = inputPrice
bucket.unitCur = cur
}
}
function finalizeInputLabel (bucket) {
if (Number(bucket.meterQty || 0) > 0) {
const uom = String(bucket.meterUom || '').trim()
bucket.meterLabel = `${formatBarQuantity(bucket.meterQty)}${uom ? ' ' + uom : ''}`
} else {
bucket.meterLabel = bucket.meterLabel || '-'
}
const byCur = bucket.inputByCur || {}
const currencies = Object.keys(byCur).filter(Boolean)
if (currencies.length === 0) {
bucket.inputAmountLabel = '-'
bucket.inputCurrencyLabel = '-'
bucket.inputUnitLabel = bucket.inputUnitLabel || '-'
return bucket
}
if (currencies.length === 1) {
const cur = currencies[0]
bucket.inputCurrencyLabel = cur
bucket.inputAmountLabel = formatMoney(byCur[cur] || 0)
if (bucket.unitPrice !== undefined && bucket.unitCur) {
bucket.inputUnitLabel = formatMoney(bucket.unitPrice)
bucket.inputCurrencyLabel = String(bucket.unitCur || cur).toUpperCase()
}
return bucket
}
// Multiple currencies mixed: keep labels short.
bucket.inputCurrencyLabel = 'MIX'
bucket.inputAmountLabel = formatMoney(currencies.reduce((acc, c) => acc + (byCur[c] || 0), 0))
return bucket
}
const mailSummary = computed(() => {
// Parts must be dynamic. Use sParcaAdi coming from backend (spUrtMTBolum.sAdi),
// and keep a stable order: common parts first, then the rest by first appearance.
const preferredParts = ['CEKET', 'PANTOLON', 'YELEK', 'AKSESUAR', 'YAKA']
const seenParts = new Set()
const dynamicParts = []
flatDetailRows.value.forEach((row) => {
const partRaw = normalizeGroupName(row?.sParcaAdi)
const part = String(partRaw || '').trim().toUpperCase()
if (!part) return
if (seenParts.has(part)) return
seenParts.add(part)
dynamicParts.push(part)
})
const parts = [
...preferredParts.filter(p => seenParts.has(p)),
...dynamicParts.filter(p => !preferredParts.includes(p))
]
const base = {
headerTotals: {
usd: toolbarSummary.value.usdTotal || 0,
try: toolbarSummary.value.tryTotal || 0,
eur: toolbarSummary.value.eurTotal || 0,
gbp: toolbarSummary.value.gbpTotal || 0
},
laborByPart: {},
materialByPart: {},
fabricByPart: {}
}
parts.forEach((p) => {
base.laborByPart[p] = makeEmptySummaryRow(p)
base.materialByPart[p] = makeEmptySummaryRow(p)
base.fabricByPart[p] = makeEmptySummaryRow(p)
})
flatDetailRows.value.forEach((row) => {
const partRaw = normalizeGroupName(row?.sParcaAdi)
const part = String(partRaw || '').trim().toUpperCase()
if (!part || !parts.includes(part)) return
const group = String(normalizeGroupName(row?.sAciklama3)).trim().toUpperCase()
const included = Boolean(row?.maliyeteDahil) || isCMGroupName(group)
if (!included) return
const usdAmount = resolveRowUSDTutar(row)
const tryAmount = resolveRowTRYTutar(row)
if (isCMGroupName(group)) {
const b = base.laborByPart[part]
b.usd += usdAmount
b.try += tryAmount
accumulateInputAmount(b, row)
// Tick should reflect actual checkbox state (only when user selected "malzemeli"/type=2).
if (resolveCMPriceTypeChecked(row)) b.hasCmtOrMalzemeli = true
return
}
if (group === 'DT' || group.includes(' DT') || group === 'TP' || group.includes(' TP')) {
const b = base.materialByPart[part]
b.usd += usdAmount
b.try += tryAmount
accumulateInputAmount(b, row)
return
}
if (group === 'FABRIC' || group.includes('FABRIC')) {
const b = base.fabricByPart[part]
b.usd += usdAmount
b.try += tryAmount
accumulateInputAmount(b, row)
accumulateUnitPrice(b, row)
const qty = resolveNumericRowQuantity(row)
if (Number.isFinite(qty) && qty > 0) {
b.meterQty += qty
if (!b.meterUom) b.meterUom = String(row?.sBirim || '').trim()
}
}
})
const laborRows = parts.map((p) => finalizeInputLabel(base.laborByPart[p]))
const materialRows = parts.map((p) => finalizeInputLabel(base.materialByPart[p]))
const fabricRows = parts.map((p) => finalizeInputLabel(base.fabricByPart[p]))
return {
headerTotals: base.headerTotals,
laborRows,
materialRows,
fabricRows
}
})
const partSummary = computed(() => {
const summary = {}
flatDetailRows.value.forEach(row => {
@@ -1130,23 +1574,25 @@ const partSummary = computed(() => {
return Object.entries(summary).map(([name, totals]) => ({ name, ...totals }))
})
const lineHistoryColumns = [
{ name: 'sourceLabel', label: 'Kaynak', field: 'sourceLabel', align: 'left', sortable: false, style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'dateLabel', label: 'Tarih', field: 'dateLabel', align: 'left', sortable: true, style: 'width:7%', headerStyle: 'width:7%' },
{ name: 'invoiceCode', label: 'Fatura/OnML', field: 'invoiceCode', align: 'left', sortable: true, style: 'width:8%', headerStyle: 'width:8%' },
{ name: 'companyCode', label: 'Firma Kodu', field: 'companyCode', align: 'left', sortable: true, style: 'width:8%', headerStyle: 'width:8%' },
{ name: 'companyDescription', label: 'Firma Aciklama', field: 'companyDescription', align: 'left', sortable: true, style: 'width:12%', headerStyle: 'width:12%' },
{ name: 'itemCode', label: 'Masraf/sKodu', field: 'itemCode', align: 'left', sortable: true, style: 'width:8%', headerStyle: 'width:8%' },
{ name: 'itemDescription', label: 'Masraf Detay', field: 'itemDescription', align: 'left', sortable: true, style: 'width:11%', headerStyle: 'width:11%' },
{ name: 'colorCode', label: 'Renk', field: 'colorCode', align: 'left', sortable: true, style: 'width:5%', headerStyle: 'width:5%' },
{ name: 'colorDescription', label: 'Renk Aciklama', field: 'colorDescription', align: 'left', sortable: true, style: 'width:8%', headerStyle: 'width:8%' },
{ name: 'itemDim1Code', label: 'Dim1', field: 'itemDim1Code', align: 'left', sortable: true, style: 'width:5%', headerStyle: 'width:5%' },
{ name: 'itemDim1Description', label: 'Dim1 Aciklama', field: 'itemDim1Description', align: 'left', sortable: true, style: 'width:8%', headerStyle: 'width:8%' },
{ name: 'quantity', label: 'Miktar', field: 'quantity', align: 'right', sortable: true, style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'unit', label: 'Birim', field: 'unit', align: 'left', sortable: true, style: 'width:4%', headerStyle: 'width:4%' },
{ name: 'price', label: 'Fiyat', field: 'price', align: 'right', sortable: true, style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'amount', label: 'Tutar', field: 'amount', align: 'right', sortable: true, style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'currency', label: 'Pr. Br.', field: 'currency', align: 'left', sortable: true, style: 'width:5%', headerStyle: 'width:5%' },
{ name: 'select', label: '', field: 'select', align: 'right', sortable: false, style: 'width:6%', headerStyle: 'width:6%' }
// Not: fixed percentage widths were overflowing at 100% zoom.
// Let the table layout decide widths so the "Sec" column stays visible.
{ name: 'sourceLabel', label: 'Kaynak', field: 'sourceLabel', align: 'left', sortable: false },
{ name: 'dateLabel', label: 'Tarih', field: 'dateLabel', align: 'left', sortable: true },
{ name: 'invoiceCode', label: 'Fatura/OnML', field: 'invoiceCode', align: 'left', sortable: true },
{ name: 'companyCode', label: 'Firma Kodu', field: 'companyCode', align: 'left', sortable: true },
{ name: 'companyDescription', label: 'Firma Aciklama', field: 'companyDescription', align: 'left', sortable: true },
{ name: 'itemCode', label: 'Masraf/sKodu', field: 'itemCode', align: 'left', sortable: true },
{ name: 'itemDescription', label: 'Masraf Detay', field: 'itemDescription', align: 'left', sortable: true },
{ name: 'colorCode', label: 'Renk', field: 'colorCode', align: 'left', sortable: true },
{ name: 'colorDescription', label: 'Renk Aciklama', field: 'colorDescription', align: 'left', sortable: true },
{ name: 'itemDim1Code', label: 'Dim1', field: 'itemDim1Code', align: 'left', sortable: true },
{ name: 'itemDim1Description', label: 'Dim1 Aciklama', field: 'itemDim1Description', align: 'left', sortable: true },
{ name: 'quantity', label: 'Miktar', field: 'quantity', align: 'right', sortable: true },
{ name: 'unit', label: 'Birim', field: 'unit', align: 'left', sortable: true },
{ name: 'price', label: 'Fiyat', field: 'price', align: 'right', sortable: true },
{ name: 'amount', label: 'Tutar', field: 'amount', align: 'right', sortable: true },
{ name: 'currency', label: 'Pr. Br.', field: 'currency', align: 'left', sortable: true },
{ name: 'select', label: '', field: 'select', align: 'right', sortable: false }
]
function resolveLineHistoryRowClass (row) {
@@ -1166,7 +1612,7 @@ const detailColumns = [
{ name: 'sRenk', label: 'Renk', field: 'sRenk', align: 'left', sortable: true },
{ name: 'lMiktar', label: 'Miktar', field: 'lMiktar', align: 'right', sortable: true, format: val => formatQuantity(val), style: 'width: 80px', headerStyle: 'width: 80px' },
{ name: 'inputPrice', label: 'Fiyat Giriş', field: 'inputPrice', align: 'right', sortable: false, style: 'width: 80px', headerStyle: 'width: 80px' },
{ name: 'inputPricePrBr', label: 'Fiyat Giriş Pr.Br.', field: 'inputPricePrBr', align: 'left', sortable: false, style: 'width: 80px', headerStyle: 'width: 80px' },
{ name: 'inputPricePrBr', label: 'Fiyat Giriş Pr.Br.', field: 'inputPricePrBr', align: 'left', sortable: false, style: 'width: 92px', headerStyle: 'width: 92px' },
{ name: 'maliyeteDahil', label: 'Maliyete Dahil', field: 'maliyeteDahil', align: 'center', sortable: false },
{ name: 'cmPriceType', label: 'CMT', field: 'cm_price_type_id', align: 'center', sortable: false, style: 'width: 72px', headerStyle: 'width: 72px' },
{ name: 'lFiyat', label: 'lFiyat', field: 'lFiyat', align: 'right', sortable: true, format: val => formatMoney(val) },
@@ -1290,14 +1736,22 @@ function formatQuantity (value) {
})
}
function formatQuantity2 (value) {
return parseMoneyInput(value).toLocaleString('tr-TR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
function formatBarMoney (value) {
const roundedValue = Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100
return formatMoney(roundedValue)
}
function formatBarQuantity (value) {
const roundedValue = Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000
return formatQuantity(roundedValue)
// Bar/summary quantities: show 2 decimals (requested for MT totals).
const roundedValue = Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100
return formatQuantity2(roundedValue)
}
function convertPriceToUSD (price, currency) {
@@ -1630,8 +2084,15 @@ async function loadRowEditorColorOptions () {
function primeRowEditorOptionsFromForm () {
upsertEditorOption(rowEditorHammaddeAllOptions, buildRowEditorHammaddeOption(rowEditorForm.value))
upsertEditorOption(rowEditorHammaddeOptions, buildRowEditorHammaddeOption(rowEditorForm.value))
upsertEditorOption(rowEditorItemAllOptions, buildRowEditorItemOption(rowEditorForm.value))
upsertEditorOption(rowEditorItemOptions, buildRowEditorItemOption(rowEditorForm.value))
// Do not inject invalid free-typed values (e.g. "GAMBOÇ") into the item select options.
// Item options must represent real tbStok model codes (usually like "M.X", "K.X", "I.X").
const itemCandidate = buildRowEditorItemOption(rowEditorForm.value)
const itemKey = String(itemCandidate?.value || '').trim()
const looksLikeModel = /^[A-Za-z0-9]\./.test(itemKey)
if (looksLikeModel) {
upsertEditorOption(rowEditorItemAllOptions, itemCandidate)
upsertEditorOption(rowEditorItemOptions, itemCandidate)
}
upsertEditorOption(rowEditorColorAllOptions, buildRowEditorColorOption(rowEditorForm.value))
upsertEditorOption(rowEditorColorOptions, buildRowEditorColorOption(rowEditorForm.value))
}
@@ -2402,7 +2863,8 @@ async function fetchDetail (options = {}) {
])
detailHeader.value = headerData && typeof headerData === 'object' ? headerData : null
productionTypes.value = Array.isArray(typesData) ? typesData : []
costDate.value = normalizeDateInput(detailHeader.value?.dteKayitTarihi)
// Prefer true costing date (spUrtOnMLMas.Tarihi) over record create/update timestamps.
costDate.value = normalizeDateInput(detailHeader.value?.maliyetTarihi || detailHeader.value?.dteKayitTarihi)
detailGroups.value = normalizeDetailGroups(groupsData)
initialHeaderSnapshot.value = currentHeaderSnapshot.value
// Optional: hydrate local draft after base data load.
@@ -2437,6 +2899,10 @@ async function fetchDetail (options = {}) {
urun_kodu: detailHeader.value?.UrunKodu || productCode.value,
n_urt_recete_id: detailHeader.value?.nUrtReceteID || ''
})
// tbStok exists-bulk kontrolu devre disi (manual giris kapali).
// Load last10 avg deviation warnings panel (non-blocking).
await refreshLast10Warnings()
// (eski tbStok missing dialog/notify kaldirildi)
} catch (err) {
detailError.value = await extractApiErrorDetail(err)
slog.error('production-product-costing.detail', 'fetch-detail:error', {
@@ -2908,6 +3374,8 @@ function resolveDetailRowClass (row) {
const key = String(row?.__rowKey || '').trim()
if (key && requiredAttentionRowKeys.value?.[key]) return 'pcd-detail-row-required'
if (row?.requiredPlaceholder) return 'pcd-detail-row-required'
const code = String(row?.sKodu || '').trim()
if (code && isTbStokMissingCode(code)) return 'pcd-detail-row-missing-stock'
return row?.draftChanged ? 'pcd-detail-row-secondary' : ''
}
@@ -3011,8 +3479,27 @@ async function applyFabricCopySelection () {
async function onRowEditorItemChange (value) {
const selected = rowEditorItemOptions.value.find(opt => String(opt?.value || '') === String(value || ''))
// Prevent free-typed values from being accepted as "code". User must pick from list.
if (!selected) {
const typed = String(value || '').trim()
// If user cleared the field, allow clearing (other validations will catch blank-on-save if needed).
if (!typed) {
rowEditorForm.value.sKodu = ''
rowEditorLastValidItemValue.value = ''
return
}
// Revert to last valid selection.
rowEditorForm.value.sKodu = String(rowEditorLastValidItemValue.value || '').trim()
$q.notify({
type: 'negative',
message: 'Kod secilmeden serbest metin girilemez. Listeden secim yapin.',
position: 'top-right'
})
return
}
rowEditorForm.value.sKodu = String(value || '').trim()
if (!selected) return
rowEditorLastValidItemValue.value = String(value || '').trim()
const previousColorCode = String(rowEditorForm.value.ColorCode || '').trim()
rowEditorForm.value.nStokID = String(selected.nStokID || '').trim()
rowEditorForm.value.sModel = String(selected.sModel || '').trim()
@@ -3129,6 +3616,7 @@ function applyEditorRowToGroups (nextRow) {
detailGroups.value = sortDetailGroups(nextGroups)
syncAllGroupsOpen()
schedulePersistLocalDraft()
// tbStok validation devre disi
}
function syncAllGroupsOpen () {
@@ -3704,6 +4192,7 @@ function openNewRowDialog () {
maliyeteDahil: true,
sBirim: 'AD'
})
rowEditorLastValidItemValue.value = ''
primeRowEditorOptionsFromForm()
rowEditorDialogOpen.value = true
void bootstrapRowEditorOptions()
@@ -3716,6 +4205,7 @@ function openRowEditorForEdit (row) {
...row,
sAciklama3: row?.sAciklama3 || ''
})
rowEditorLastValidItemValue.value = String(rowEditorForm.value?.sKodu || '').trim()
primeRowEditorOptionsFromForm()
rowEditorDialogOpen.value = true
void bootstrapRowEditorOptions()
@@ -4113,6 +4603,7 @@ async function saveChanges () {
saveLoading.value = true
try {
requiredAttentionRowKeys.value = {}
// tbStok exists-bulk kontrolu devre disi (manual giris kapali): save bloke edilmez.
if (isNoCostDetail.value) {
const missing = computeMissingRequiredSlots()
slog.info('production-product-costing.detail', 'required:missing:computed', {
@@ -4319,6 +4810,10 @@ async function saveChanges () {
// For existing costing, just refresh the detail.
await fetchDetail({ clearDraft: true, hydrateDraft: false })
// last10 warnings are computed async on backend; re-check shortly after save.
window.setTimeout(() => {
try { refreshLast10Warnings() } catch {}
}, 1200)
} catch (e) {
// Surface backend message (http.Error text) when available.
const msg = String(
@@ -4487,13 +4982,21 @@ watch(
.pcd-toolbar-summary {
display: flex;
flex-wrap: nowrap;
align-items: center;
flex-direction: column;
align-items: flex-start;
gap: 6px;
min-width: 0;
flex: 1 1 auto;
}
.pcd-toolbar-summary-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-width: 0;
}
.pcd-toolbar-pill {
display: flex;
align-items: center;
@@ -4516,6 +5019,64 @@ watch(
color: #2b3c54;
}
.pcd-toolbar-pill-warn {
background: #ef6c00;
border-color: #ef6c00;
color: #ffffff;
}
.pcd-summary-expansion :deep(.q-expansion-item__container) {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 6px;
background: #fff;
}
.pcd-missing-stock-code {
background: #c62828 !important;
color: #ffffff !important;
}
.pcd-missing-stock-code :deep(*) {
color: #ffffff !important;
}
.pcd-detail-row-missing-stock td {
background: #c62828 !important;
color: #ffffff !important;
}
.pcd-detail-row-missing-stock td :deep(*) {
color: #ffffff !important;
}
.pcd-summary-title {
font-weight: 800;
font-size: 12px;
color: #2b3c54;
margin: 2px 0 6px;
}
.pcd-summary-table td,
.pcd-summary-table th {
font-size: 12px;
line-height: 1.2;
}
.pcd-summary-total-row td {
border-top: 2px solid rgba(0, 0, 0, 0.15);
background: #fbfbfd;
}
.pcd-summary-k {
width: 60px;
color: #6b7680;
font-weight: 800;
}
.pcd-summary-v {
font-weight: 800;
}
.pcd-toolbar-pill-label {
font-size: 10px;
font-weight: 800;

View File

@@ -191,7 +191,8 @@ import api from 'src/services/api'
import { usePermission } from 'src/composables/usePermission'
const { canUpdate } = usePermission()
const canUpdateUser = canUpdate('user')
// This screen manages system-wide permission sets; gate by system:update.
const canUpdateUser = canUpdate('system')
const route = useRoute()
const router = useRouter()
@@ -394,13 +395,22 @@ async function save () {
const payload = []
// UI action keys -> backend action codes
const toBackendAction = {
write: 'insert',
read: 'view',
delete: 'delete',
update: 'update',
export: 'export'
}
rows.value.forEach(r => {
actions.forEach(a => {
payload.push({
module: r.module,
action: a.key,
action: toBackendAction[a.key] || a.key,
allowed: r[a.key]
})
})