Merge remote-tracking branch 'origin/master'

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

View File

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

@@ -912,10 +912,10 @@ func GetProductionHasCostDetailRowsByOnMLNo(
nOnMLNo int,
) (*sql.Rows, error) {
sqlText := `
SELECT
-- Prefer the group label stored on the OnML detail row (D.sAciklama3),
-- because some hammadde type master rows may have empty/legacy group labels.
ISNULL(NULLIF(LTRIM(RTRIM(D.sAciklama3)), ''), ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ')) AS sAciklama3,
SELECT
-- Prefer the group label stored on the OnML detail row (D.sAciklama3),
-- because some hammadde type master rows may have empty/legacy group labels.
ISNULL(NULLIF(LTRIM(RTRIM(D.sAciklama3)), ''), ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ')) AS sAciklama3,
SUM(ISNULL(D.lTutar, 0)) OVER (
PARTITION BY ISNULL(NULLIF(LTRIM(RTRIM(D.sAciklama3)), ''), ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ'))
) AS GroupTotalTutar,
@@ -924,14 +924,14 @@ func GetProductionHasCostDetailRowsByOnMLNo(
) AS GroupTotalUSDTutar,
RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nOnMLNo, 0))) AS nOnMLNo,
RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nOnMLDetNo, 0))) AS nOnMLDetNo,
RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo,
RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nUrtMTBolumID, 0))) AS nUrtMTBolumID,
-- Normalize code to variantless (tbStok.sModel) when D.sKodu is a variant-coded stock record.
ISNULL(NULLIF(LTRIM(RTRIM(SX.sModel)), ''), ISNULL(D.sKodu, '')) AS sKodu,
ISNULL(NULLIF(LTRIM(RTRIM(SX.sAciklama)), ''), ISNULL(D.sAciklama, '')) AS sAciklama,
ISNULL(D.sRenk, '') AS sRenk,
ISNULL(D.sBeden, '') AS sBeden,
ISNULL(D.sAciklama2, '') AS sAciklama2,
RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo,
RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nUrtMTBolumID, 0))) AS nUrtMTBolumID,
-- Normalize code to variantless (tbStok.sModel) when D.sKodu is a variant-coded stock record.
ISNULL(NULLIF(SX.sModel, ''), ISNULL(D.sKodu, '')) AS sKodu,
ISNULL(NULLIF(SX.sAciklama, ''), ISNULL(D.sAciklama, '')) AS sAciklama,
ISNULL(D.sRenk, '') AS sRenk,
ISNULL(D.sBeden, '') AS sBeden,
ISNULL(D.sAciklama2, '') AS sAciklama2,
ISNULL(D.lMiktar, 0) AS lMiktar,
ISNULL(D.lFiyat, 0) AS lFiyat,
ISNULL(D.lTutar, 0) AS lTutar,
@@ -949,25 +949,21 @@ func GetProductionHasCostDetailRowsByOnMLNo(
ISNULL(D.sBirim, '') AS sBirim,
ISNULL(T.sAciklama, '') AS sHammaddeTuruAdi,
ISNULL(B.sAdi, '') AS sParcaAdi
FROM dbo.spUrtOnMLMasDet D
LEFT JOIN dbo.spUrtOnMLHammaddeTuru T
ON T.nHammaddeTuruNo = D.nHammaddeTuruNo
LEFT JOIN dbo.spUrtMTBolum B
ON B.nUrtMTBolumID = D.nUrtMTBolumID
OUTER APPLY (
SELECT TOP 1
LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sModel, '')))) AS sModel,
LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sAciklama, '')))) AS sAciklama
FROM dbo.tbStok S WITH (NOLOCK)
WHERE LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(S.sKodu, '')))) = LTRIM(RTRIM(CONVERT(NVARCHAR(255), ISNULL(D.sKodu, ''))))
) SX
WHERE D.nOnMLNo = @p1
ORDER BY
GroupTotalTutar DESC,
sAciklama3 ASC,
ISNULL(D.lTutar, 0) DESC,
D.nOnMLDetNo ASC
`
FROM dbo.spUrtOnMLMasDet D
LEFT JOIN dbo.spUrtOnMLHammaddeTuru T
ON T.nHammaddeTuruNo = D.nHammaddeTuruNo
LEFT JOIN dbo.spUrtMTBolum B
ON B.nUrtMTBolumID = D.nUrtMTBolumID
LEFT JOIN dbo.tbStok SX WITH (NOLOCK)
ON (SX.IsBlocked = 0 OR SX.IsBlocked IS NULL)
AND ISNULL(SX.sKodu,'') = ISNULL(D.sKodu,'')
WHERE D.nOnMLNo = @p1
ORDER BY
GroupTotalTutar DESC,
sAciklama3 ASC,
ISNULL(D.lTutar, 0) DESC,
D.nOnMLDetNo ASC
`
return uretimDB.QueryContext(ctx, sqlText, nOnMLNo)
}
@@ -2033,6 +2029,111 @@ ORDER BY
return mssqlDB.QueryRowContext(ctx, sqlText, sKodu, costDate, colorCode, itemDim1Code), nil
}
// Bulk version of GetProductionHasCostLatestPurchasePriceForItem.
// Uses OPENJSON to avoid 1-query-per-item fan-out.
// For each requested rowKey, picks the latest purchase invoice before costDate,
// preferring exact ColorCode/ItemDim1Code when provided.
func GetProductionHasCostLatestPurchasePricesForItems(
ctx context.Context,
mssqlDB *sql.DB,
itemsJSON string,
costDate string,
) (*sql.Rows, error) {
itemsJSON = strings.TrimSpace(itemsJSON)
costDate = strings.TrimSpace(costDate)
sqlText := `
DECLARE @targetDate date = TRY_CONVERT(date, NULLIF(@p2, ''), 23);
WITH REQ AS (
SELECT
RowKey,
LTRIM(RTRIM(ISNULL(ItemCode, ''))) AS ItemCode,
LTRIM(RTRIM(ISNULL(ColorCode, ''))) AS ColorCode,
LTRIM(RTRIM(ISNULL(ItemDim1Code, ''))) AS ItemDim1Code
FROM OPENJSON(@p1) WITH (
RowKey NVARCHAR(128) '$.rowKey',
ItemCode NVARCHAR(128) '$.sKodu',
ColorCode NVARCHAR(64) '$.colorCode',
ItemDim1Code NVARCHAR(64) '$.itemDim1Code'
)
WHERE LTRIM(RTRIM(ISNULL(ItemCode, ''))) <> ''
), BASE AS (
SELECT
R.RowKey,
A.InvoiceDate,
A.InvoiceNumber,
A.ItemTypeCode,
A.ItemCode,
A.ColorCode,
A.ItemDim1Code,
A.Qty1,
A.Doc_Price,
A.Doc_CurrencyCode,
CASE
WHEN R.ColorCode <> '' AND LTRIM(RTRIM(ISNULL(A.ColorCode, ''))) = R.ColorCode THEN 0
WHEN R.ColorCode = '' THEN 0
ELSE 1
END AS colorRank,
CASE
WHEN R.ItemDim1Code <> '' AND LTRIM(RTRIM(ISNULL(A.ItemDim1Code, ''))) = R.ItemDim1Code THEN 0
WHEN R.ItemDim1Code = '' THEN 0
ELSE 1
END AS dimRank
FROM REQ R
INNER JOIN AllInvoicesWithAttributes A
ON LTRIM(RTRIM(A.ItemCode)) = R.ItemCode
WHERE A.ProcessCode IN ('BP')
AND A.ATAtt01 IN (1, 2)
AND A.CompanyCode IN (1, 2, 5)
AND A.IsCompleted = 1
AND YEAR(A.InvoiceDate) >= 2022
AND (@targetDate IS NULL OR CONVERT(date, A.InvoiceDate) < @targetDate)
), RANKED AS (
SELECT
B.*,
ROW_NUMBER() OVER (PARTITION BY B.RowKey ORDER BY B.colorRank, B.dimRank, B.InvoiceDate DESC, B.InvoiceNumber DESC) AS rn
FROM BASE B
)
SELECT
R.RowKey,
'MAN' AS priceType,
CONVERT(VARCHAR(16), R.InvoiceDate, 120) AS Tarih,
ISNULL(R.InvoiceNumber, '') AS FaturaKodu,
LTRIM(RTRIM(ISNULL(R.ItemCode, ''))) AS MasrafKodu,
ISNULL(ID.ItemDescription, '') AS MasrafDetay,
ISNULL(R.ColorCode, '') AS ColorCode,
ISNULL(COL.ColorDescription, '') AS ColorDescription,
ISNULL(R.ItemDim1Code, '') AS ItemDim1Code,
ISNULL(DIM1.ItemDim1Description, '') AS ItemDim1Description,
ISNULL(R.Doc_Price, 0) AS EvrakFiyat,
ISNULL(R.Doc_CurrencyCode, '') AS EvrakDoviz
FROM RANKED R
OUTER APPLY (
SELECT TOP 1 ItemDescription
FROM cdItemDesc
WHERE ItemTypeCode = R.ItemTypeCode
AND ItemCode = R.ItemCode
AND LangCode = 'TR'
) ID
OUTER APPLY (
SELECT TOP 1 ItemDim1Description
FROM cdItemDim1Desc
WHERE ItemDim1Code = R.ItemDim1Code
AND LangCode = 'TR'
) DIM1
OUTER APPLY (
SELECT TOP 1 ColorDescription
FROM cdColorDesc
WHERE ColorCode = R.ColorCode
AND LangCode = 'TR'
) COL
WHERE R.rn = 1;
`
return mssqlDB.QueryContext(ctx, sqlText, itemsJSON, costDate)
}
func GetProductionHasCostPurchaseHistoryByExpenseCode(
ctx context.Context,
mssqlDB *sql.DB,

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
}