diff --git a/svc/main.go b/svc/main.go index 03a7b28..be8dee7 100644 --- a/svc/main.go +++ b/svc/main.go @@ -796,6 +796,41 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "order", "view", wrapV3(http.HandlerFunc(routes.PostProductionHasCostDetailBulkPricesHandler)), ) + bindV3(r, pgDB, + "/api/pricing/production-product-costing/onml/save", "POST", + "order", "view", + wrapV3(http.HandlerFunc(routes.PostProductionProductCostingOnMLSaveHandler)), + ) + bindV3(r, pgDB, + "/api/pricing/production-product-costing/default-quantities", "GET", + "order", "view", + wrapV3(http.HandlerFunc(routes.GetProductionProductCostingDefaultQuantitiesHandler)), + ) + bindV3(r, pgDB, + "/api/pricing/production-product-costing/default-quantities/upsert", "POST", + "order", "view", + wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesUpsertHandler)), + ) + bindV3(r, pgDB, + "/api/pricing/production-product-costing/default-quantities/update-bulk", "POST", + "order", "view", + wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesBulkUpdateHandler)), + ) + bindV3(r, pgDB, + "/api/pricing/production-product-costing/default-quantities/calc-avg", "POST", + "order", "view", + wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesCalcAvgHandler)), + ) + bindV3(r, pgDB, + "/api/pricing/production-product-costing/default-quantities/lookup", "POST", + "order", "view", + wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesLookupHandler)), + ) + bindV3(r, pgDB, + "/api/pricing/production-product-costing/default-quantities/refresh", "POST", + "order", "view", + wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesRefreshHandler)), + ) bindV3(r, pgDB, "/api/pricing/production-product-costing/options/urun-ana-grup", "GET", "order", "view", diff --git a/svc/models/production_product_costing.go b/svc/models/production_product_costing.go index ef7d5fc..fbdc0f7 100644 --- a/svc/models/production_product_costing.go +++ b/svc/models/production_product_costing.go @@ -103,6 +103,8 @@ type ProductionHasCostDetailHeader struct { UrunAltGrubu string `json:"UrunAltGrubu"` UretimSekliID string `json:"UretimSekliID"` UretimSekli string `json:"UretimSekli"` + FirmaKodu string `json:"FirmaKodu"` + NFirmaID int `json:"nFirmaID"` DteKayitTarihi string `json:"dteKayitTarihi"` SKullaniciAdi string `json:"sKullaniciAdi"` LTutarTL float64 `json:"lTutarTL"` @@ -116,6 +118,100 @@ type ProductionHasCostDetailHeader struct { NUrtReceteID string `json:"nUrtReceteID"` } +// ============================================================ +// Save (INSERT/UPDATE/DELETE/UPSERT) spUrtOnMLMas + spUrtOnMLMasDet +// ============================================================ + +type ProductionProductCostingOnMLSaveHeader struct { + NOnMLNo int `json:"n_onml_no"` + UrunKodu string `json:"urun_kodu"` + UrunAdi string `json:"urun_adi"` + MaliyetTarihi string `json:"maliyet_tarihi"` // YYYY-MM-DD + NUrtReceteID int `json:"n_urt_recete_id"` + UretimSekliID int `json:"uretim_sekli_id"` + SAciklama string `json:"s_aciklama"` + FirmaKodu string `json:"firma_kodu"` + NFirmaID int `json:"n_firma_id"` +} + +type ProductionProductCostingOnMLSaveDetailUpsertRow struct { + NOnMLDetNo int `json:"n_onml_det_no"` + NHammaddeTuruNo int `json:"n_hammadde_turu_no"` + NUrtMTBolumID int `json:"n_urt_mt_bolum_id"` + SKodu string `json:"s_kodu"` + SAciklama string `json:"s_aciklama"` + SRenk string `json:"s_renk"` + SBeden string `json:"s_beden"` + SAciklama2 string `json:"s_aciklama2"` + SBirim string `json:"s_birim"` + LMiktar float64 `json:"l_miktar"` + FiyatGirilen float64 `json:"fiyat_girilen"` + FiyatDoviz string `json:"fiyat_doviz"` + MaliyeteDahil int `json:"maliyete_dahil"` + CMPriceTypeID *int `json:"cm_price_type_id"` +} + +type ProductionProductCostingOnMLSaveDetailDeleteRow struct { + NOnMLDetNo int `json:"n_onml_det_no"` +} + +type ProductionProductCostingOnMLSaveDetail struct { + Upserts []ProductionProductCostingOnMLSaveDetailUpsertRow `json:"upserts"` + Deletes []ProductionProductCostingOnMLSaveDetailDeleteRow `json:"deletes"` +} + +type ProductionProductCostingOnMLSaveRequest struct { + DetailSource string `json:"detail_source"` + Header ProductionProductCostingOnMLSaveHeader `json:"header"` + Detail ProductionProductCostingOnMLSaveDetail `json:"detail"` +} + +type ProductionProductCostingOnMLSaveResponse struct { + NOnMLNo int `json:"n_onml_no"` +} + +// ============================================================ +// Default quantities (URETIM): mk_MaliyetParcaEslestirme_vmiktarlar +// ============================================================ + +type ProductionProductCostingDefaultQtyRow struct { + NHammaddeTuruNo int `json:"nHammaddeTuruNo"` + SAciklama string `json:"sAciklama"` + LDefaultMiktar float64 `json:"lDefaultMiktar"` + DteCalcTarihi string `json:"dteCalcTarihi"` + BAktif bool `json:"bAktif"` +} + +type ProductionProductCostingDefaultQtyUpdateRequest struct { + NHammaddeTuruNo int `json:"nHammaddeTuruNo"` + LDefaultMiktar float64 `json:"lDefaultMiktar"` + BAktif *bool `json:"bAktif"` +} + +type ProductionProductCostingDefaultQtyBulkUpdateRequest struct { + Items []ProductionProductCostingDefaultQtyUpdateRequest `json:"items"` +} + +type ProductionProductCostingDefaultQtyCalcRequest struct { + NHammaddeTuruNo int `json:"nHammaddeTuruNo"` + TopN int `json:"topN"` +} + +type ProductionProductCostingDefaultQtyCalcResponse struct { + NHammaddeTuruNo int `json:"nHammaddeTuruNo"` + LDefaultMiktar float64 `json:"lDefaultMiktar"` + NSampleCount int `json:"nSampleCount"` +} + +type ProductionProductCostingDefaultQtyLookupRequest struct { + NHammaddeTuruNos []int `json:"nHammaddeTuruNos"` +} + +type ProductionProductCostingDefaultQtyLookupItem struct { + NHammaddeTuruNo int `json:"nHammaddeTuruNo"` + LDefaultMiktar float64 `json:"lDefaultMiktar"` +} + type ProductionHasCostDetailExchangeRates struct { RateDate string `json:"rateDate"` TRYRate float64 `json:"tryRate"` diff --git a/svc/queries/production_product_costing.go b/svc/queries/production_product_costing.go index 0f2422c..7faca79 100644 --- a/svc/queries/production_product_costing.go +++ b/svc/queries/production_product_costing.go @@ -7,9 +7,269 @@ import ( "fmt" "strconv" "strings" + "time" "unicode" ) +func GetNextOnMLNoFrom100k(ctx context.Context, uretimDB *sql.DB) (int, error) { + // nOnMLNo is NOT identity. Generate next number safely. + // Requirement: always continue from MAX, but never below 100001. + sqlText := ` +SELECT + ISNULL(MAX(M.nOnMLNo), 100000) + 1 AS NextNo +FROM dbo.spUrtOnMLMas M WITH (UPDLOCK, HOLDLOCK) +` + var next int + if err := uretimDB.QueryRowContext(ctx, sqlText).Scan(&next); err != nil { + return 0, err + } + if next < 100001 { + next = 100001 + } + return next, nil +} + +func GetOnMLMamulTuruNoByAciklama(ctx context.Context, tx *sql.Tx, sAciklama string) (int, error) { + sAciklama = strings.TrimSpace(sAciklama) + if sAciklama == "" { + return 1, nil + } + + var existing int + err := tx.QueryRowContext(ctx, ` +SELECT TOP 1 ISNULL(nMamulTuruNo, 0) +FROM dbo.spUrtOnMLMamulTuru WITH (UPDLOCK, HOLDLOCK) +WHERE LTRIM(RTRIM(ISNULL(sAciklama,''))) = @p1 +ORDER BY nMamulTuruNo +`, sAciklama).Scan(&existing) + if err == sql.ErrNoRows { + return 0, nil + } + if err != nil { + return 0, err + } + return existing, nil +} + +func LookupFirmaIDByKodu(ctx context.Context, uretimDB *sql.DB, firmaKodu string) (int, error) { + firmaKodu = strings.TrimSpace(firmaKodu) + if firmaKodu == "" { + return 0, nil + } + sqlText := ` +SELECT TOP 1 ISNULL(F.nFirmaID, 0) AS nFirmaID +FROM dbo.tbFirma F WITH (NOLOCK) +WHERE LTRIM(RTRIM(ISNULL(F.sKodu, ''))) = @p1 +ORDER BY F.nFirmaID +` + var id int + if err := uretimDB.QueryRowContext(ctx, sqlText, firmaKodu).Scan(&id); err != nil { + if err == sql.ErrNoRows { + return 0, nil + } + return 0, err + } + return id, nil +} + +type OnMLHeaderUpsertArgs struct { + NOnMLNo int + UrunKodu string + UrunAdi string + Tarihi time.Time + NMamulTuruNo int + NUrtReceteID sql.NullInt64 + UretimSekliID sql.NullInt64 + SAciklama sql.NullString + NFirmaID int + SUser string + LTutarTL float64 + LTutarUSD float64 + LTutarEURO float64 + SDovizCinsi string + LTutarDoviz float64 +} + +func UpsertOnMLHeader(tx *sql.Tx, ctx context.Context, args OnMLHeaderUpsertArgs) error { + // Note: Many NOT NULL columns exist. We use dummy/system values as per business rules. + sqlText := ` +IF EXISTS (SELECT 1 FROM dbo.spUrtOnMLMas WITH (UPDLOCK, HOLDLOCK) WHERE nOnMLNo = @p1) +BEGIN + UPDATE dbo.spUrtOnMLMas + SET + UrunKodu = @p2, + UrunAdi = @p3, + Tarihi = @p4, + nDonemNo = 1, + nMamulTuruNo = @p16, + nBolgeNo = 2, + nZorlukNo = 1, + bDurum = 1, + lTutarTL = @p5, + lTutarUSD = @p6, + lTutarEURO = @p7, + sDovizCinsi = @p8, + lTutarDoviz = @p9, + bSablon = 0, + dteGuncellemeTarihi = GETDATE(), + sGuncellemeKullaniciAdi = @p10, + nUrtReceteID = @p11, + sAciklama = @p12, + lMasMiktar = 0, + sRenk = NULLIF(LTRIM(RTRIM(@p13)), ''), + nFirmaID = @p14, + uretim_sekli_id = @p15 + WHERE nOnMLNo = @p1 +END +ELSE +BEGIN + INSERT INTO dbo.spUrtOnMLMas ( + nOnMLNo, + UrunKodu, + UrunAdi, + Tarihi, + nDonemNo, + nMamulTuruNo, + nBolgeNo, + nZorlukNo, + dteKayitTarihi, + sKullaniciAdi, + bDurum, + lTutarTL, + lTutarUSD, + lTutarEURO, + sDovizCinsi, + lTutarDoviz, + bSablon, + dteGuncellemeTarihi, + sGuncellemeKullaniciAdi, + nUrtReceteID, + sAciklama, + lMasMiktar, + nFirmaID, + bVarsayilan, + bOnay, + bIptal, + bRParcaTakip, + uretim_sekli_id + ) + VALUES ( + @p1, + @p2, + @p3, + @p4, + 1, + @p16, + 2, + 1, + GETDATE(), + @p10, + 1, + @p5, + @p6, + @p7, + @p8, + @p9, + 0, + GETDATE(), + @p10, + @p11, + @p12, + 0, + @p14, + 0, + 0, + 0, + 0, + @p15 + ) +END +` + _, err := tx.ExecContext( + ctx, + sqlText, + args.NOnMLNo, + strings.TrimSpace(args.UrunKodu), + strings.TrimSpace(args.UrunAdi), + args.Tarihi, + args.LTutarTL, + args.LTutarUSD, + args.LTutarEURO, + strings.TrimSpace(args.SDovizCinsi), + args.LTutarDoviz, + strings.TrimSpace(args.SUser), + args.NUrtReceteID, + args.SAciklama, + "", // sRenk (header) currently not driven from UI + args.NFirmaID, + args.UretimSekliID, + args.NMamulTuruNo, + ) + return err +} + +// ============================================================ +// V3 (Nebim) base price update +// ============================================================ + +func UpsertV3ItemBasePriceUSD( + ctx context.Context, + mssqlDB *sql.DB, + itemCode string, + priceDate string, // YYYY-MM-DD + priceUSD float64, + user string, +) error { + itemCode = strings.TrimSpace(itemCode) + priceDate = strings.TrimSpace(priceDate) + user = strings.TrimSpace(user) + if mssqlDB == nil || itemCode == "" || priceDate == "" { + return fmt.Errorf("missing params for base price upsert") + } + + // NOTE: In this DB, PRIMARY KEY is on: + // (ItemTypeCode, ItemCode, CountryCode, SeasonCode, BasePriceCode) + // so we cannot insert multiple rows for different dates under the same base price. + // We update the single row's PriceDate/Price to reflect latest costing. + sqlText := ` +MERGE dbo.prItemBasePrice AS T +USING ( + SELECT + @p1 AS ItemTypeCode, + @p2 AS ItemCode, + 'TR' AS CountryCode, + '' AS SeasonCode, + 1 AS BasePriceCode, + CONVERT(date, @p3, 23) AS PriceDate, + 'USD' AS CurrencyCode +) AS S +ON T.ItemTypeCode = S.ItemTypeCode + AND LTRIM(RTRIM(T.ItemCode)) = LTRIM(RTRIM(S.ItemCode)) + AND ISNULL(T.CountryCode,'') = S.CountryCode + AND ISNULL(T.SeasonCode,'') = S.SeasonCode + AND ISNULL(T.BasePriceCode,0) = S.BasePriceCode +WHEN MATCHED THEN + UPDATE SET + PriceDate = S.PriceDate, + CurrencyCode = S.CurrencyCode, + Price = @p4, + LastUpdatedUserName = @p5, + LastUpdatedDate = GETDATE() +WHEN NOT MATCHED THEN + INSERT ( + ItemTypeCode, ItemCode, CountryCode, SeasonCode, BasePriceCode, + PriceDate, CurrencyCode, Price, CreatedUserName, CreatedDate, LastUpdatedUserName, LastUpdatedDate + ) + VALUES ( + S.ItemTypeCode, S.ItemCode, S.CountryCode, S.SeasonCode, S.BasePriceCode, + S.PriceDate, S.CurrencyCode, @p4, @p5, GETDATE(), @p5, GETDATE() + ); +` + + _, err := mssqlDB.ExecContext(ctx, sqlText, 1, itemCode, priceDate, priceUSD, user) + return err +} + func GetProductAnaAltGrupByUrunKodu(ctx context.Context, mssqlDB *sql.DB, urunKodu string) (urunAnaGrubu string, urunAltGrubu string, err error) { urunKodu = strings.TrimSpace(urunKodu) if mssqlDB == nil || urunKodu == "" { @@ -449,7 +709,7 @@ SELECT END AS UretimSekli, RTRIM(CONVERT(VARCHAR(32), ISNULL(SonIsEmri.nUrtSiparisNo, 0))) AS nUrtSiparisNo, - CONVERT(VARCHAR(10), SonIsEmri.dteIslemTarihi, 23) AS dteIslemTarihi, + ISNULL(CONVERT(VARCHAR(10), SonIsEmri.dteIslemTarihi, 23), '') AS dteIslemTarihi, ISNULL(SonIsEmri.FirmaKodu, '') AS FirmaKodu, ISNULL(SonIsEmri.FirmaAdi, '') AS FirmaAdi, ISNULL(SonIsEmri.sVeren, '') AS SonIsEmriVeren, @@ -679,6 +939,8 @@ func GetProductionHasCostDetailHeaderByOnMLNo( SELECT TOP 1 ISNULL(UF.UretimiYapanFirma, '') AS UretimiYapanFirma, ISNULL(UF.SonIsEmriVeren, '') AS SonIsEmriVeren, + ISNULL(UF.FirmaKodu, '') AS FirmaKodu, + ISNULL(UF.nFirmaID, 0) AS nFirmaID, RTRIM(CONVERT(VARCHAR(32), ISNULL(M.nOnMLNo, 0))) AS nOnMLNo, LTRIM(RTRIM(ISNULL(M.UrunKodu, ''))) AS UrunKodu, ISNULL(M.UrunAdi, '') AS UrunAdi, @@ -701,6 +963,8 @@ LEFT JOIN dbo.mk_uretim_sekli US OUTER APPLY ( SELECT TOP 1 ISNULL(F.sAciklama, '') AS UretimiYapanFirma, + ISNULL(F.sKodu, '') AS FirmaKodu, + ISNULL(F.nFirmaID, 0) AS nFirmaID, ISNULL(SM.sVeren, '') AS SonIsEmriVeren FROM dbo.spUrtSiparisDet SD INNER JOIN dbo.spUrtSiparis SM @@ -757,6 +1021,8 @@ WITH RecipeMatch AS ( SELECT TOP 1 ISNULL(SonIsEmri.FirmaAdi, '') AS UretimiYapanFirma, ISNULL(SonIsEmri.SonIsEmriVeren, '') AS SonIsEmriVeren, + ISNULL(SonIsEmri.FirmaKodu, '') AS FirmaKodu, + ISNULL(SonIsEmri.nFirmaID, 0) AS nFirmaID, '' AS nOnMLNo, RM.UrunKodu, RM.UrunAdi, @@ -777,6 +1043,8 @@ FROM RecipeMatch RM OUTER APPLY ( SELECT TOP 1 ISNULL(F.sAciklama, '') AS FirmaAdi, + ISNULL(F.sKodu, '') AS FirmaKodu, + ISNULL(F.nFirmaID, 0) AS nFirmaID, ISNULL(SM.sVeren, '') AS SonIsEmriVeren, CONVERT(VARCHAR(16), SD.dteIslemTarihi, 120) AS dteIslemTarihi FROM dbo.spUrtSiparisDet SD @@ -960,6 +1228,221 @@ ORDER BY return uretimDB.QueryContext(ctx, sqlText, search, limit, searchLike) } +// ============================================================ +// Default quantities (URETIM): mk_MaliyetParcaEslestirme_vmiktarlar +// ============================================================ + +func ListProductionProductCostingDefaultQtyRows(ctx context.Context, uretimDB *sql.DB, search string, limit int) (*sql.Rows, error) { + search = strings.TrimSpace(search) + if limit <= 0 { + limit = 500 + } + if limit > 5000 { + limit = 5000 + } + searchLike := "%" + search + "%" + + sqlText := ` +SELECT TOP (@p3) + ISNULL(V.nHammaddeTuruNo, 0) AS nHammaddeTuruNo, + ISNULL(H.sAciklama, '') AS sAciklama, + ISNULL(V.lDefaultMiktar, 0) AS lDefaultMiktar, + CONVERT(VARCHAR(16), V.dteCalcTarihi, 120) AS dteCalcTarihi, + CAST(CASE WHEN ISNULL(V.bAktif, 0) = 1 THEN 1 ELSE 0 END AS bit) AS bAktif +FROM dbo.mk_MaliyetParcaEslestirme_vmiktarlar V WITH (NOLOCK) +LEFT JOIN dbo.spUrtOnMLHammaddeTuru H WITH (NOLOCK) + ON H.nHammaddeTuruNo = V.nHammaddeTuruNo +WHERE + (@p1 = '' OR CONVERT(VARCHAR(32), ISNULL(V.nHammaddeTuruNo, 0)) LIKE @p2) + AND ISNULL(H.bAktif, 0) = 1 +ORDER BY + V.nHammaddeTuruNo ASC +` + + return uretimDB.QueryContext(ctx, sqlText, search, searchLike, limit) +} + +func UpsertProductionProductCostingDefaultQtyRow(ctx context.Context, uretimDB *sql.DB, nHammaddeTuruNo int, lDefaultMiktar float64, bAktif *bool) error { + // NOTE: Legacy helper kept for backward-compat but now behaves as UPDATE-only. + activeVal := -1 + if bAktif != nil { + if *bAktif { + activeVal = 1 + } else { + activeVal = 0 + } + } + + sqlText := ` +UPDATE dbo.mk_MaliyetParcaEslestirme_vmiktarlar +SET + lDefaultMiktar = @p2, + dteCalcTarihi = GETDATE(), + bAktif = CASE WHEN @p3 < 0 THEN ISNULL(bAktif, 1) ELSE @p3 END +WHERE nHammaddeTuruNo = @p1; +SELECT @@ROWCOUNT; +` + var affected int + if err := uretimDB.QueryRowContext(ctx, sqlText, nHammaddeTuruNo, lDefaultMiktar, activeVal).Scan(&affected); err != nil { + return err + } + if affected == 0 { + return fmt.Errorf("default qty row not found: nHammaddeTuruNo=%d", nHammaddeTuruNo) + } + return nil +} + +func RefreshProductionProductCostingDefaultQty(ctx context.Context, uretimDB *sql.DB, topN int) error { + if topN <= 0 { + topN = 10 + } + if topN > 50 { + topN = 50 + } + sqlText := ` +;WITH ranked AS ( + SELECT + D.nHammaddeTuruNo, + D.lMiktar, + ROW_NUMBER() OVER ( + PARTITION BY D.nHammaddeTuruNo + ORDER BY + ISNULL(M.Tarihi, '19000101') DESC, + ISNULL(M.dteGuncellemeTarihi, '19000101') DESC, + D.nOnMLNo DESC, + D.dteIslemTarihi DESC, + D.nOnMLDetNo DESC + ) AS rn + FROM dbo.spUrtOnMLMasDet D WITH (NOLOCK) + INNER JOIN dbo.spUrtOnMLMas M WITH (NOLOCK) + ON M.nOnMLNo = D.nOnMLNo + WHERE ISNULL(D.lMiktar, 0) > 0 + AND ISNULL(M.bIptal, 0) = 0 +), +agg AS ( + SELECT + nHammaddeTuruNo, + CAST(AVG(CAST(lMiktar AS float)) AS numeric(14,4)) AS lDefaultMiktar + FROM ranked + WHERE rn <= @p1 + GROUP BY nHammaddeTuruNo +) +MERGE dbo.mk_MaliyetParcaEslestirme_vmiktarlar AS T +USING agg AS S + ON T.nHammaddeTuruNo = S.nHammaddeTuruNo +WHEN MATCHED THEN + UPDATE SET + T.lDefaultMiktar = S.lDefaultMiktar, + T.dteCalcTarihi = GETDATE(), + T.bAktif = 1 +WHEN NOT MATCHED THEN + INSERT (nHammaddeTuruNo, lDefaultMiktar, dteCalcTarihi, bAktif) + VALUES (S.nHammaddeTuruNo, S.lDefaultMiktar, GETDATE(), 1); +` + _, err := uretimDB.ExecContext(ctx, sqlText, topN) + return err +} + +func CalcProductionProductCostingDefaultQtyFromLastOnML(ctx context.Context, uretimDB *sql.DB, nHammaddeTuruNo int, topN int) (float64, int, error) { + if nHammaddeTuruNo <= 0 { + return 0, 0, fmt.Errorf("nHammaddeTuruNo required") + } + if topN <= 0 { + topN = 10 + } + if topN > 50 { + topN = 50 + } + sqlText := ` +;WITH ranked AS ( + SELECT TOP (@p2) + ISNULL(D.lMiktar, 0) AS lMiktar + FROM dbo.spUrtOnMLMasDet D WITH (NOLOCK) + INNER JOIN dbo.spUrtOnMLMas M WITH (NOLOCK) + ON M.nOnMLNo = D.nOnMLNo + WHERE D.nHammaddeTuruNo = @p1 + AND ISNULL(D.lMiktar, 0) > 0 + ORDER BY ISNULL(M.Tarihi, M.dteKayitTarihi) DESC, D.nOnMLNo DESC, D.nOnMLDetNo DESC +) +SELECT + CAST(ISNULL(AVG(CAST(lMiktar AS DECIMAL(18,4))), 0) AS FLOAT) AS avgQty, + COUNT(1) AS sampleCount +FROM ranked; +` + var avg float64 + var cnt int + if err := uretimDB.QueryRowContext(ctx, sqlText, nHammaddeTuruNo, topN).Scan(&avg, &cnt); err != nil { + return 0, 0, err + } + return avg, cnt, nil +} + +func LookupProductionProductCostingDefaultQtyByNos(ctx context.Context, uretimDB *sql.DB, nos []int) (map[int]float64, error) { + clean := make([]int, 0, len(nos)) + seen := make(map[int]struct{}, len(nos)) + for _, n := range nos { + if n <= 0 { + continue + } + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + clean = append(clean, n) + } + if len(clean) == 0 { + return map[int]float64{}, nil + } + if len(clean) > 1000 { + return nil, fmt.Errorf("too many hammadde nos") + } + + valueRows := make([]string, 0, len(clean)) + args := make([]any, 0, len(clean)) + for i, n := range clean { + valueRows = append(valueRows, "(@p"+strconv.Itoa(i+1)+")") + args = append(args, n) + } + + sqlText := ` +WITH req(nHammaddeTuruNo) AS ( + SELECT DISTINCT v.nHammaddeTuruNo + FROM (VALUES ` + strings.Join(valueRows, ",") + `) v(nHammaddeTuruNo) +) +SELECT + ISNULL(V.nHammaddeTuruNo, 0) AS nHammaddeTuruNo, + CAST(ISNULL(V.lDefaultMiktar, 0) AS FLOAT) AS lDefaultMiktar +FROM req R +INNER JOIN dbo.mk_MaliyetParcaEslestirme_vmiktarlar V WITH (NOLOCK) + ON V.nHammaddeTuruNo = R.nHammaddeTuruNo +INNER JOIN dbo.spUrtOnMLHammaddeTuru H WITH (NOLOCK) + ON H.nHammaddeTuruNo = V.nHammaddeTuruNo +WHERE ISNULL(H.bAktif, 0) = 1; +` + + rows, err := uretimDB.QueryContext(ctx, sqlText, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make(map[int]float64, len(clean)) + for rows.Next() { + var no int + var qty float64 + if err := rows.Scan(&no, &qty); err != nil { + return nil, err + } + if no > 0 && qty > 0 { + out[no] = qty + } + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + func buildSQLServerFullTextPrefixQuery(search string) string { terms := strings.Fields(strings.TrimSpace(search)) parts := make([]string, 0, len(terms)) diff --git a/svc/routes/production_product_costing.go b/svc/routes/production_product_costing.go index 8e7b068..fcacab0 100644 --- a/svc/routes/production_product_costing.go +++ b/svc/routes/production_product_costing.go @@ -566,6 +566,8 @@ func GetProductionHasCostDetailHeaderHandler(w http.ResponseWriter, r *http.Requ if err := row.Scan( &item.UretimiYapanFirma, &item.SonIsEmriVeren, + &item.FirmaKodu, + &item.NFirmaID, &item.NOnMLNo, &item.UrunKodu, &item.UrunAdi, @@ -631,6 +633,8 @@ func GetProductionHasCostDetailHeaderHandler(w http.ResponseWriter, r *http.Requ if err := row.Scan( &item.UretimiYapanFirma, &item.SonIsEmriVeren, + &item.FirmaKodu, + &item.NFirmaID, &item.NOnMLNo, &item.UrunKodu, &item.UrunAdi, @@ -881,6 +885,827 @@ func GetProductionHasCostDetailEditorOptionsHandler(w http.ResponseWriter, r *ht http.Error(w, "kind hammadde, item veya color olmali", http.StatusBadRequest) } +// GET /api/pricing/production-product-costing/default-quantities +func GetProductionProductCostingDefaultQuantitiesHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + uretimDB := db.GetUretimDB() + if uretimDB == nil { + http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable) + return + } + + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + search := strings.TrimSpace(r.URL.Query().Get("search")) + limit := parsePositiveIntOrDefault(r.URL.Query().Get("limit"), 500) + + // Always list only active hammadde turleri. + rows, err := queries.ListProductionProductCostingDefaultQtyRows(ctx, uretimDB, search, limit) + if err != nil { + http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) + return + } + defer rows.Close() + + out := make([]models.ProductionProductCostingDefaultQtyRow, 0, 256) + for rows.Next() { + var item models.ProductionProductCostingDefaultQtyRow + if err := rows.Scan(&item.NHammaddeTuruNo, &item.SAciklama, &item.LDefaultMiktar, &item.DteCalcTarihi, &item.BAktif); err != nil { + http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError) + return + } + out = append(out, item) + } + if err := rows.Err(); err != nil { + http.Error(w, "Veritabani satir hatasi", http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(out) +} + +// POST /api/pricing/production-product-costing/default-quantities/upsert +func PostProductionProductCostingDefaultQuantitiesUpsertHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + uretimDB := db.GetUretimDB() + if uretimDB == nil { + http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable) + return + } + + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + + var req models.ProductionProductCostingDefaultQtyUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Gecersiz JSON", http.StatusBadRequest) + return + } + if req.NHammaddeTuruNo <= 0 { + http.Error(w, "nHammaddeTuruNo zorunlu", http.StatusBadRequest) + return + } + if req.LDefaultMiktar <= 0 { + http.Error(w, "lDefaultMiktar pozitif olmali", http.StatusBadRequest) + return + } + + if err := queries.UpsertProductionProductCostingDefaultQtyRow(ctx, uretimDB, req.NHammaddeTuruNo, req.LDefaultMiktar, req.BAktif); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) +} + +// POST /api/pricing/production-product-costing/default-quantities/update-bulk +func PostProductionProductCostingDefaultQuantitiesBulkUpdateHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + uretimDB := db.GetUretimDB() + if uretimDB == nil { + http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable) + return + } + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + + var req models.ProductionProductCostingDefaultQtyBulkUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Gecersiz JSON", http.StatusBadRequest) + return + } + if len(req.Items) == 0 { + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "updated": 0}) + return + } + if len(req.Items) > 5000 { + http.Error(w, "cok fazla satir", http.StatusBadRequest) + return + } + + tx, err := uretimDB.BeginTx(ctx, nil) + if err != nil { + http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) + return + } + defer func() { _ = tx.Rollback() }() + + updated := 0 + for _, item := range req.Items { + if item.NHammaddeTuruNo <= 0 { + http.Error(w, "nHammaddeTuruNo zorunlu", http.StatusBadRequest) + return + } + if item.LDefaultMiktar <= 0 { + http.Error(w, "lDefaultMiktar pozitif olmali", http.StatusBadRequest) + return + } + activeVal := -1 + if item.BAktif != nil { + if *item.BAktif { + activeVal = 1 + } else { + activeVal = 0 + } + } + sqlText := ` +UPDATE dbo.mk_MaliyetParcaEslestirme_vmiktarlar +SET + lDefaultMiktar = @p2, + dteCalcTarihi = GETDATE(), + bAktif = CASE WHEN @p3 < 0 THEN ISNULL(bAktif, 1) ELSE @p3 END +WHERE nHammaddeTuruNo = @p1; +SELECT @@ROWCOUNT; +` + var affected int + if err := tx.QueryRowContext(ctx, sqlText, item.NHammaddeTuruNo, item.LDefaultMiktar, activeVal).Scan(&affected); err != nil { + http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) + return + } + if affected == 0 { + http.Error(w, "satir bulunamadi: "+strconv.Itoa(item.NHammaddeTuruNo), http.StatusBadRequest) + return + } + updated++ + } + + if err := tx.Commit(); err != nil { + http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "updated": updated}) +} + +// POST /api/pricing/production-product-costing/default-quantities/calc-avg +func PostProductionProductCostingDefaultQuantitiesCalcAvgHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + uretimDB := db.GetUretimDB() + if uretimDB == nil { + http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable) + return + } + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + + var req models.ProductionProductCostingDefaultQtyCalcRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Gecersiz JSON", http.StatusBadRequest) + return + } + if req.NHammaddeTuruNo <= 0 { + http.Error(w, "nHammaddeTuruNo zorunlu", http.StatusBadRequest) + return + } + if req.TopN <= 0 { + req.TopN = 10 + } + + avg, cnt, err := queries.CalcProductionProductCostingDefaultQtyFromLastOnML(ctx, uretimDB, req.NHammaddeTuruNo, req.TopN) + if err != nil { + http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(models.ProductionProductCostingDefaultQtyCalcResponse{ + NHammaddeTuruNo: req.NHammaddeTuruNo, + LDefaultMiktar: avg, + NSampleCount: cnt, + }) +} + +// POST /api/pricing/production-product-costing/default-quantities/lookup +func PostProductionProductCostingDefaultQuantitiesLookupHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + uretimDB := db.GetUretimDB() + if uretimDB == nil { + http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable) + return + } + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + + var req models.ProductionProductCostingDefaultQtyLookupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Gecersiz JSON", http.StatusBadRequest) + return + } + + m, err := queries.LookupProductionProductCostingDefaultQtyByNos(ctx, uretimDB, req.NHammaddeTuruNos) + if err != nil { + http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) + return + } + out := make([]models.ProductionProductCostingDefaultQtyLookupItem, 0, len(m)) + for no, qty := range m { + out = append(out, models.ProductionProductCostingDefaultQtyLookupItem{ + NHammaddeTuruNo: no, + LDefaultMiktar: qty, + }) + } + _ = json.NewEncoder(w).Encode(out) +} + +// POST /api/pricing/production-product-costing/default-quantities/refresh +func PostProductionProductCostingDefaultQuantitiesRefreshHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + uretimDB := db.GetUretimDB() + if uretimDB == nil { + http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable) + return + } + + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + + topN := parsePositiveIntOrDefault(r.URL.Query().Get("top_n"), 10) + if err := queries.RefreshProductionProductCostingDefaultQty(ctx, uretimDB, topN); err != nil { + http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "top_n": topN}) +} + +// POST /api/pricing/production-product-costing/onml/save +func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + uretimDB := db.GetUretimDB() + if uretimDB == nil { + http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable) + return + } + mssqlDB := db.GetDB() + + claims, _ := auth.GetClaimsFromContext(r.Context()) + user := "" + if claims != nil { + user = strings.TrimSpace(claims.Username) + } + + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.onml.save") + + var req models.ProductionProductCostingOnMLSaveRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + logger.Warn("invalid json", "err", err) + http.Error(w, "Gecersiz JSON", http.StatusBadRequest) + return + } + + req.DetailSource = strings.ToLower(strings.TrimSpace(req.DetailSource)) + req.Header.UrunKodu = strings.TrimSpace(req.Header.UrunKodu) + req.Header.UrunAdi = strings.TrimSpace(req.Header.UrunAdi) + req.Header.MaliyetTarihi = strings.TrimSpace(req.Header.MaliyetTarihi) + req.Header.SAciklama = strings.TrimSpace(req.Header.SAciklama) + req.Header.FirmaKodu = strings.TrimSpace(req.Header.FirmaKodu) + + if req.Header.UrunKodu == "" || req.Header.MaliyetTarihi == "" { + http.Error(w, "urun_kodu ve maliyet_tarihi zorunlu", http.StatusBadRequest) + return + } + + // Resolve exchange rates (for price conversions). + usdRate := 0.0 + eurRate := 0.0 + gbpRate := 0.0 + if mssqlDB != nil { + row, err := queries.GetProductionHasCostDetailExchangeRatesByDate(ctx, mssqlDB, req.Header.MaliyetTarihi) + if err != nil { + logger.Error("exchange rate query error", "err", err) + http.Error(w, "Kur bilgisi alinamadi", http.StatusInternalServerError) + return + } + var rateDate string + if err := row.Scan(&rateDate, &usdRate, &eurRate, &gbpRate); err != nil { + logger.Error("exchange rate scan error", "err", err) + http.Error(w, "Kur bilgisi alinamadi", http.StatusInternalServerError) + return + } + } + if usdRate <= 0 { + usdRate = 1 + } + if eurRate <= 0 { + eurRate = 1 + } + if gbpRate <= 0 { + gbpRate = 1 + } + + // Resolve firma id + firmaID := req.Header.NFirmaID + if firmaID <= 0 && req.Header.FirmaKodu != "" { + id, err := queries.LookupFirmaIDByKodu(ctx, uretimDB, req.Header.FirmaKodu) + if err != nil { + logger.Error("firma lookup error", "err", err) + http.Error(w, "Firma bulunamadi", http.StatusBadRequest) + return + } + firmaID = id + } + if firmaID <= 0 { + http.Error(w, "Firma secilmeden kaydedilemez (n_firma_id / firma_kodu)", http.StatusBadRequest) + return + } + + // Resolve or generate nOnMLNo + nOnMLNo := req.Header.NOnMLNo + if nOnMLNo <= 0 { + next, err := queries.GetNextOnMLNoFrom100k(ctx, uretimDB) + if err != nil { + logger.Error("next onmlno error", "err", err) + http.Error(w, "Yeni nOnMLNo uretilemedi", http.StatusInternalServerError) + return + } + nOnMLNo = next + } + + // Resolve / create nMamulTuruNo from product groups (UrunAna + UrunAlt) + nMamulTuruNo := 1 + + // Compute totals from incoming rows (TRY/USD/EUR) + totalTRY := 0.0 + totalUSD := 0.0 + totalEUR := 0.0 + for _, r := range req.Detail.Upserts { + qty := r.LMiktar + if qty < 0 { + qty = 0 + } + // Convert input price to TRY (unit) + cur := strings.ToUpper(strings.TrimSpace(r.FiyatDoviz)) + in := r.FiyatGirilen + 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 := unitTRY / usdRate + unitEUR := unitTRY / eurRate + totalTRY += unitTRY * qty + totalUSD += unitUSD * qty + totalEUR += unitEUR * qty + } + + // Parse Tarihi + tarihi, err := time.Parse("2006-01-02", req.Header.MaliyetTarihi) + if err != nil { + http.Error(w, "maliyet_tarihi YYYY-MM-DD formatinda olmali", http.StatusBadRequest) + return + } + + tx, err := uretimDB.BeginTx(ctx, nil) + if err != nil { + logger.Error("tx begin error", "err", err) + http.Error(w, "Islem baslatilamadi", http.StatusInternalServerError) + return + } + defer func() { _ = tx.Rollback() }() + + // Determine mamul turu inside same tx (to keep create atomic) + mamulLabel := "" + if mssqlDB != nil { + _, ana, alt, err := queries.GetProductIlkAnaAltGrupByUrunKodu(ctx, mssqlDB, req.Header.UrunKodu) + if err == nil { + ana = strings.TrimSpace(ana) + alt = strings.TrimSpace(alt) + if alt == "" || alt == "-" { + mamulLabel = ana + } else { + // matches inserted convention: "ANA-ALT" (no spaces) + mamulLabel = strings.TrimSpace(ana + "-" + alt) + } + } + } + if strings.TrimSpace(mamulLabel) != "" && mamulLabel != "-" { + mt, err := queries.GetOnMLMamulTuruNoByAciklama(ctx, tx, mamulLabel) + if err != nil { + logger.Error("mamul turu resolve error", "err", err) + http.Error(w, "Mamul turu olusturulamadi", http.StatusInternalServerError) + return + } + if mt <= 0 { + http.Error(w, "Mamul turu bulunamadi: "+mamulLabel, http.StatusBadRequest) + return + } + nMamulTuruNo = mt + } else { + nMamulTuruNo = 1 + } + + var receteID sql.NullInt64 + if req.Header.NUrtReceteID > 0 { + receteID = sql.NullInt64{Int64: int64(req.Header.NUrtReceteID), Valid: true} + } + var uretimSekliID sql.NullInt64 + if req.Header.UretimSekliID > 0 { + uretimSekliID = sql.NullInt64{Int64: int64(req.Header.UretimSekliID), Valid: true} + } + var sAciklama sql.NullString + if req.Header.SAciklama != "" { + sAciklama = sql.NullString{String: req.Header.SAciklama, Valid: true} + } + + if err := queries.UpsertOnMLHeader(tx, ctx, queries.OnMLHeaderUpsertArgs{ + NOnMLNo: nOnMLNo, + UrunKodu: req.Header.UrunKodu, + UrunAdi: req.Header.UrunAdi, + Tarihi: tarihi, + NMamulTuruNo: nMamulTuruNo, + NUrtReceteID: receteID, + UretimSekliID: uretimSekliID, + SAciklama: sAciklama, + NFirmaID: firmaID, + SUser: user, + LTutarTL: totalTRY, + LTutarUSD: totalUSD, + LTutarEURO: totalEUR, + SDovizCinsi: "USD", + LTutarDoviz: totalUSD, + }); err != nil { + logger.Error("header upsert error", "err", err) + http.Error(w, "Header kaydedilemedi", http.StatusInternalServerError) + return + } + + // Deletes + for _, d := range req.Detail.Deletes { + if d.NOnMLDetNo <= 0 { + continue + } + if _, err := tx.ExecContext(ctx, `DELETE FROM dbo.spUrtOnMLMasDet WHERE nOnMLNo=@p1 AND nOnMLDetNo=@p2`, nOnMLNo, d.NOnMLDetNo); err != nil { + logger.Error("detail delete error", "err", err) + http.Error(w, "Detay silinemedi", http.StatusInternalServerError) + return + } + } + + // Upserts + for _, row := range req.Detail.Upserts { + if row.NOnMLDetNo <= 0 { + continue + } + if row.NHammaddeTuruNo <= 0 || strings.TrimSpace(row.SKodu) == "" { + http.Error(w, "Detay satirinda n_hammadde_turu_no ve s_kodu zorunlu", http.StatusBadRequest) + return + } + qty := row.LMiktar + if qty < 0 { + qty = 0 + } + cur := strings.ToUpper(strings.TrimSpace(row.FiyatDoviz)) + in := row.FiyatGirilen + 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 := unitTRY / usdRate + + lTutar := unitTRY * qty + lDovizTutari := unitUSD * qty + + // 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) + var nStokTipiID int + err := tx.QueryRowContext(ctx, ` +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(@p1, ' ', '') + OR LTRIM(RTRIM(ISNULL(S.sModel,''))) = @p1 + OR @p1 LIKE LTRIM(RTRIM(ISNULL(S.sModel,''))) + '%' + ) +ORDER BY + CASE + WHEN REPLACE(LTRIM(RTRIM(ISNULL(S.sKodu,''))), ' ', '') = REPLACE(@p1, ' ', '') THEN 0 + WHEN LTRIM(RTRIM(ISNULL(S.sModel,''))) = @p1 THEN 1 + ELSE 2 + END, + S.dteKayitTarihi DESC, + S.nStokID DESC +`, rawSKodu).Scan(&nStokTipiID) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Stok tipi bulunamadi (s_kodu="+rawSKodu+")", http.StatusBadRequest) + return + } + logger.Error("stok tipi lookup error", "err", err) + http.Error(w, "Stok tipi bulunamadi (tbStok sorgu hatasi)", http.StatusInternalServerError) + return + } + if nStokTipiID <= 0 { + http.Error(w, "Stok tipi bulunamadi (s_kodu="+rawSKodu+")", http.StatusBadRequest) + return + } + + // Dummy/system-mapped required fields: + const bOtoFiyat = 0 + const lFireOrani = 0 + const nTutarGirisTipiNo = 1 + const sFiyatTipi = "ML" + const nKurTipiNo = 6 + const lEkMasraf = 0 + const lMiktar2 = 0 + const lEkFiyat = 0 + + // MERGE by (nOnMLNo, nOnMLDetNo) + mergeSQL := ` +MERGE dbo.spUrtOnMLMasDet AS T +USING (SELECT @p1 AS nOnMLNo, @p2 AS nOnMLDetNo) AS S +ON (T.nOnMLNo = S.nOnMLNo AND T.nOnMLDetNo = S.nOnMLDetNo) +WHEN MATCHED THEN + UPDATE SET + nHammaddeTuruNo = @p3, + sKodu = @p4, + sAciklama = @p5, + sRenk = @p6, + sBeden = NULLIF(@p7,''), + sAciklama2 = NULLIF(@p8,''), + lMiktar = @p9, + lFiyat = @p10, + lTutar = @p11, + bOtoFiyat = @p12, + lFireOrani = @p13, + nTutarGirisTipiNo = @p14, + sFiyatTipi = @p15, + sDovizCinsi = @p16, + nKurTipiNo = @p17, + lDovizKuru = @p18, + lDovizFiyati = @p19, + sBirim = @p20, + lDovizTutari = @p21, + lEkMasraf = @p22, + lMiktar2 = @p23, + nStokTipiID = @p24, + lEkFiyat = @p25, + nUrtMTBolumID = @p26, + fiyat_girilen = NULLIF(@p27, 0), + fiyat_doviz = NULLIF(@p28,''), + Maliyete_dahil = @p29, + cm_price_type_id = @p30, + sKullaniciAdiDeg = @p31, + dteIslemTarihiDeg = GETDATE() +WHEN NOT MATCHED THEN + INSERT ( + nOnMLNo,nOnMLDetNo,nHammaddeTuruNo,sKodu,sAciklama,sRenk,lMiktar,lFiyat,lTutar, + bOtoFiyat,lFireOrani,nTutarGirisTipiNo,sFiyatTipi,sDovizCinsi,nKurTipiNo,lDovizKuru,lDovizFiyati, + sBirim,sAciklama2,lDovizTutari,lEkMasraf,sKullaniciAdi,dteIslemTarihi,sBeden,sAciklama3,lMiktar2,nStokTipiID, + lEkFiyat,nUrtMTBolumID,fiyat_girilen,fiyat_doviz,Maliyete_dahil,cm_price_type_id + ) + VALUES ( + @p1,@p2,@p3,@p4,@p5,@p6,@p9,@p10,@p11, + @p12,@p13,@p14,@p15,@p16,@p17,@p18,@p19, + @p20,NULLIF(@p8,''),@p21,@p22,@p31,GETDATE(),NULLIF(@p7,''),NULL,@p23,@p24, + @p25,@p26,NULLIF(@p27,0),NULLIF(@p28,''),@p29,@p30 + ); +` + + if _, err := tx.ExecContext( + ctx, + mergeSQL, + nOnMLNo, + row.NOnMLDetNo, + row.NHammaddeTuruNo, + strings.TrimSpace(row.SKodu), + strings.TrimSpace(row.SAciklama), + strings.TrimSpace(row.SRenk), + strings.TrimSpace(row.SBeden), + strings.TrimSpace(row.SAciklama2), + qty, + unitTRY, + lTutar, + bOtoFiyat, + lFireOrani, + nTutarGirisTipiNo, + sFiyatTipi, + "USD", + nKurTipiNo, + usdRate, + unitUSD, + strings.TrimSpace(row.SBirim), + lDovizTutari, + lEkMasraf, + lMiktar2, + nStokTipiID, + lEkFiyat, + row.NUrtMTBolumID, + row.FiyatGirilen, + strings.TrimSpace(row.FiyatDoviz), + row.MaliyeteDahil, + row.CMPriceTypeID, + user, + ); err != nil { + logger.Error("detail merge error", "err", err) + http.Error(w, "Detay kaydedilemedi", http.StatusInternalServerError) + return + } + } + + // ============================================================ + // Recipe sync (URETIM): ensure recipe contains all OnML hammadde rows + // so future no-cost loads don't keep showing them as missing. + // Table observed in queries: dbo.spUrtRecMBolumMik (nUrtMBolumID stores nHammaddeTuruNo). + // ============================================================ + if req.Header.NUrtReceteID > 0 { + receteID := req.Header.NUrtReceteID + + // Determine next available recipe detail id (nUrtRecMBolumID) + nextRecDetID := 0 + _ = tx.QueryRowContext(ctx, ` +SELECT ISNULL(MAX(RMik.nUrtRecMBolumID), 0) + 1 +FROM dbo.spUrtRecMBolumMik RMik WITH (UPDLOCK, HOLDLOCK) +WHERE RMik.nUrtReceteID = @p1 +`, receteID).Scan(&nextRecDetID) + if nextRecDetID <= 0 { + nextRecDetID = 1 + } + + for _, row := range req.Detail.Upserts { + hNo := row.NHammaddeTuruNo + if hNo <= 0 { + continue + } + + // CM1/CM2 rows are cost-only and must NOT be written back into recipe tables. + // Source of truth: spUrtOnMLHammaddeTuru.sAciklama3 (e.g. 'CM2'). + var grp sql.NullString + if err := tx.QueryRowContext(ctx, ` +SELECT TOP 1 LTRIM(RTRIM(ISNULL(H.sAciklama3, ''))) +FROM dbo.spUrtOnMLHammaddeTuru H WITH (NOLOCK) +WHERE H.nHammaddeTuruNo = @p1 +`, hNo).Scan(&grp); err == nil { + g := strings.ToUpper(strings.TrimSpace(grp.String)) + if g == "CM1" || g == "CM2" { + continue + } + } + + // Resolve nHStokID from tbStok using sKodu (exact), then sModel fallback. + // IMPORTANT: nHStokID must be resolved; otherwise we'd update/insert too broadly. + rawSKodu := strings.TrimSpace(row.SKodu) + var nHStokID int + err := tx.QueryRowContext(ctx, ` +SELECT TOP 1 ISNULL(S.nStokID, 0) +FROM dbo.tbStok S WITH (NOLOCK) +WHERE ISNULL(S.IsBlocked, 0) = 0 + AND ( + REPLACE(LTRIM(RTRIM(ISNULL(S.sKodu,''))), ' ', '') = REPLACE(@p1, ' ', '') + OR LTRIM(RTRIM(ISNULL(S.sModel,''))) = @p1 + OR @p1 LIKE LTRIM(RTRIM(ISNULL(S.sModel,''))) + '%' + ) +ORDER BY + CASE + WHEN REPLACE(LTRIM(RTRIM(ISNULL(S.sKodu,''))), ' ', '') = REPLACE(@p1, ' ', '') THEN 0 + WHEN LTRIM(RTRIM(ISNULL(S.sModel,''))) = @p1 THEN 1 + ELSE 2 + END, + S.dteKayitTarihi DESC, + S.nStokID DESC +`, rawSKodu).Scan(&nHStokID) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Recete sync icin stok bulunamadi (s_kodu="+rawSKodu+")", http.StatusBadRequest) + return + } + logger.Error("recipe stok lookup error", "err", err) + http.Error(w, "Recete sync stok bulunamadi", http.StatusInternalServerError) + return + } + if nHStokID == 0 { + http.Error(w, "Recete sync icin stok bulunamadi (s_kodu="+rawSKodu+")", http.StatusBadRequest) + return + } + + // If we cannot resolve stock, we still try to insert by hammadde no only. + // Some recipes may omit nHStokID; but we prefer filling it when possible. + + // Update quantity if row already exists for (recete, hammadde, stok) + var exists int + if err := tx.QueryRowContext(ctx, ` +SELECT COUNT(1) +FROM dbo.spUrtRecMBolumMik RMik WITH (NOLOCK) +WHERE RMik.nUrtReceteID = @p1 + AND RMik.nUrtMBolumID = @p2 + AND RMik.nHStokID = @p3 +`, receteID, hNo, nHStokID).Scan(&exists); err == nil && exists > 0 { + _, _ = tx.ExecContext(ctx, ` +UPDATE dbo.spUrtRecMBolumMik +SET lHMiktar = @p4, + sKullaniciAdiDeg = @p5, + dteIslemTarihiDeg = GETDATE() +WHERE nUrtReceteID = @p1 + AND nUrtMBolumID = @p2 + AND nHStokID = @p3 +`, receteID, hNo, nHStokID, row.LMiktar, user) + continue + } + + // Insert missing: best-effort with minimal columns. + // NOTE: This assumes nUrtRecMBolumID can be a sequential int and that other columns are nullable/have defaults. + _, insertErr := tx.ExecContext(ctx, ` +INSERT INTO dbo.spUrtRecMBolumMik ( + nUrtReceteID, + nUrtUBolumID, + nUrtRecMBolumID, + nStokID, + nHStokID, + lHMiktar, + lHFire, + nMaliyetTipiID, + lHMaliyet, + lMiktar, + sIslemKodu, + nUrtMBolumID, + nUrtMTBolumID, + lHCarpan, + bIslem, + nSure, + sAciklama, + dteDovizTarihi, + sDovizCinsi, + lDovizOran, + lDovizFiyat, + sKullaniciAdi, + dteIslemTarihi, + nMBolumSarfTipiNo +) +VALUES ( + @p1, + 0, + @p2, + 0, + @p3, + @p4, + 0, + 6, + 0, + 1, + '', + @p5, + @p6, + 1, + 0, + 0, + NULL, + NULL, + NULL, + NULL, + NULL, + @p7, + GETDATE(), + 1 +) + `, receteID, nextRecDetID, nHStokID, row.LMiktar, hNo, row.NUrtMTBolumID, user) + if insertErr == nil { + nextRecDetID += 1 + } + } + } + + if err := tx.Commit(); err != nil { + logger.Error("tx commit error", "err", err) + http.Error(w, "Kaydetme tamamlanamadi", http.StatusInternalServerError) + return + } + + // 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 { + if err := queries.UpsertV3ItemBasePriceUSD(ctx, mssqlDB, req.Header.UrunKodu, req.Header.MaliyetTarihi, totalUSD, user); err != nil { + logger.Error("v3 base price upsert error", "err", err) + http.Error(w, "URETIM kaydedildi ama V3 maliyet guncellenemedi", http.StatusInternalServerError) + return + } + } + + _ = json.NewEncoder(w).Encode(models.ProductionProductCostingOnMLSaveResponse{NOnMLNo: nOnMLNo}) +} + // GET /api/pricing/production-product-costing/has-cost-detail-exchange-rates func GetProductionHasCostDetailExchangeRatesHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") diff --git a/ui/.quasar/prod-spa/app.js b/ui/.quasar/prod-spa/app.js new file mode 100644 index 0000000..caeaac1 --- /dev/null +++ b/ui/.quasar/prod-spa/app.js @@ -0,0 +1,75 @@ +/* eslint-disable */ +/** + * THIS FILE IS GENERATED AUTOMATICALLY. + * DO NOT EDIT. + * + * You are probably looking on adding startup/initialization code. + * Use "quasar new boot " and add it there. + * One boot file per concern. Then reference the file(s) in quasar.config file > boot: + * boot: ['file', ...] // do not add ".js" extension to it. + * + * Boot files are your "main.js" + **/ + + + + + +import { Quasar } from 'quasar' +import { markRaw } from 'vue' +import RootComponent from 'app/src/App.vue' + +import createStore from 'app/src/stores/index' +import createRouter from 'app/src/router/index' + + + + + +export default async function (createAppFn, quasarUserOptions) { + + + // Create the app instance. + // Here we inject into it the Quasar UI, the router & possibly the store. + const app = createAppFn(RootComponent) + + + + app.use(Quasar, quasarUserOptions) + + + + + const store = typeof createStore === 'function' + ? await createStore({}) + : createStore + + + app.use(store) + + + + + + const router = markRaw( + typeof createRouter === 'function' + ? await createRouter({store}) + : createRouter + ) + + + // make router instance available in store + + store.use(({ store }) => { store.router = router }) + + + + // Expose the app, the router and the store. + // Note that we are not mounting the app here, since bootstrapping will be + // different depending on whether we are in a browser or on the server. + return { + app, + store, + router + } +} diff --git a/ui/.quasar/prod-spa/client-entry.js b/ui/.quasar/prod-spa/client-entry.js new file mode 100644 index 0000000..5223e2b --- /dev/null +++ b/ui/.quasar/prod-spa/client-entry.js @@ -0,0 +1,158 @@ +/* eslint-disable */ +/** + * THIS FILE IS GENERATED AUTOMATICALLY. + * DO NOT EDIT. + * + * You are probably looking on adding startup/initialization code. + * Use "quasar new boot " and add it there. + * One boot file per concern. Then reference the file(s) in quasar.config file > boot: + * boot: ['file', ...] // do not add ".js" extension to it. + * + * Boot files are your "main.js" + **/ + + +import { createApp } from 'vue' + + + + + + + +import '@quasar/extras/roboto-font/roboto-font.css' + +import '@quasar/extras/material-icons/material-icons.css' + + + + +// We load Quasar stylesheet file +import 'quasar/dist/quasar.sass' + + + + +import 'src/css/app.css' + + +import createQuasarApp from './app.js' +import quasarUserOptions from './quasar-user-options.js' + + + + + + + + +const publicPath = `/` + + +async function start ({ + app, + router + , store +}, bootFiles) { + + let hasRedirected = false + const getRedirectUrl = url => { + try { return router.resolve(url).href } + catch (err) {} + + return Object(url) === url + ? null + : url + } + const redirect = url => { + hasRedirected = true + + if (typeof url === 'string' && /^https?:\/\//.test(url)) { + window.location.href = url + return + } + + const href = getRedirectUrl(url) + + // continue if we didn't fail to resolve the url + if (href !== null) { + window.location.href = href + window.location.reload() + } + } + + const urlPath = window.location.href.replace(window.location.origin, '') + + for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) { + try { + await bootFiles[i]({ + app, + router, + store, + ssrContext: null, + redirect, + urlPath, + publicPath + }) + } + catch (err) { + if (err && err.url) { + redirect(err.url) + return + } + + console.error('[Quasar] boot error:', err) + return + } + } + + if (hasRedirected === true) return + + + app.use(router) + + + + + + + app.mount('#q-app') + + + +} + +createQuasarApp(createApp, quasarUserOptions) + + .then(app => { + // eventually remove this when Cordova/Capacitor/Electron support becomes old + const [ method, mapFn ] = Promise.allSettled !== void 0 + ? [ + 'allSettled', + bootFiles => bootFiles.map(result => { + if (result.status === 'rejected') { + console.error('[Quasar] boot error:', result.reason) + return + } + return result.value.default + }) + ] + : [ + 'all', + bootFiles => bootFiles.map(entry => entry.default) + ] + + return Promise[ method ]([ + + import(/* webpackMode: "eager" */ 'boot/dayjs'), + + import(/* webpackMode: "eager" */ 'boot/locale'), + + import(/* webpackMode: "eager" */ 'boot/resizeObserverGuard') + + ]).then(bootFiles => { + const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function') + start(app, boot) + }) + }) + diff --git a/ui/.quasar/prod-spa/client-prefetch.js b/ui/.quasar/prod-spa/client-prefetch.js new file mode 100644 index 0000000..9bbe3c5 --- /dev/null +++ b/ui/.quasar/prod-spa/client-prefetch.js @@ -0,0 +1,116 @@ +/* eslint-disable */ +/** + * THIS FILE IS GENERATED AUTOMATICALLY. + * DO NOT EDIT. + * + * You are probably looking on adding startup/initialization code. + * Use "quasar new boot " and add it there. + * One boot file per concern. Then reference the file(s) in quasar.config file > boot: + * boot: ['file', ...] // do not add ".js" extension to it. + * + * Boot files are your "main.js" + **/ + + + +import App from 'app/src/App.vue' +let appPrefetch = typeof App.preFetch === 'function' + ? App.preFetch + : ( + // Class components return the component options (and the preFetch hook) inside __c property + App.__c !== void 0 && typeof App.__c.preFetch === 'function' + ? App.__c.preFetch + : false + ) + + +function getMatchedComponents (to, router) { + const route = to + ? (to.matched ? to : router.resolve(to).route) + : router.currentRoute.value + + if (!route) { return [] } + + const matched = route.matched.filter(m => m.components !== void 0) + + if (matched.length === 0) { return [] } + + return Array.prototype.concat.apply([], matched.map(m => { + return Object.keys(m.components).map(key => { + const comp = m.components[key] + return { + path: m.path, + c: comp + } + }) + })) +} + +export function addPreFetchHooks ({ router, store, publicPath }) { + // Add router hook for handling preFetch. + // Doing it after initial route is resolved so that we don't double-fetch + // the data that we already have. Using router.beforeResolve() so that all + // async components are resolved. + router.beforeResolve((to, from, next) => { + const + urlPath = window.location.href.replace(window.location.origin, ''), + matched = getMatchedComponents(to, router), + prevMatched = getMatchedComponents(from, router) + + let diffed = false + const preFetchList = matched + .filter((m, i) => { + return diffed || (diffed = ( + !prevMatched[i] || + prevMatched[i].c !== m.c || + m.path.indexOf('/:') > -1 // does it has params? + )) + }) + .filter(m => m.c !== void 0 && ( + typeof m.c.preFetch === 'function' + // Class components return the component options (and the preFetch hook) inside __c property + || (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function') + )) + .map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch) + + + if (appPrefetch !== false) { + preFetchList.unshift(appPrefetch) + appPrefetch = false + } + + + if (preFetchList.length === 0) { + return next() + } + + let hasRedirected = false + const redirect = url => { + hasRedirected = true + next(url) + } + const proceed = () => { + + if (hasRedirected === false) { next() } + } + + + + preFetchList.reduce( + (promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({ + store, + currentRoute: to, + previousRoute: from, + redirect, + urlPath, + publicPath + })), + Promise.resolve() + ) + .then(proceed) + .catch(e => { + console.error(e) + proceed() + }) + }) +} diff --git a/ui/.quasar/prod-spa/quasar-user-options.js b/ui/.quasar/prod-spa/quasar-user-options.js new file mode 100644 index 0000000..ac1dae3 --- /dev/null +++ b/ui/.quasar/prod-spa/quasar-user-options.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +/** + * THIS FILE IS GENERATED AUTOMATICALLY. + * DO NOT EDIT. + * + * You are probably looking on adding startup/initialization code. + * Use "quasar new boot " and add it there. + * One boot file per concern. Then reference the file(s) in quasar.config file > boot: + * boot: ['file', ...] // do not add ".js" extension to it. + * + * Boot files are your "main.js" + **/ + +import lang from 'quasar/lang/tr.js' + + + +import {Loading,Dialog,Notify} from 'quasar' + + + +export default { config: {"notify":{"position":"top","timeout":2500}},lang,plugins: {Loading,Dialog,Notify} } + diff --git a/ui/quasar.config.js.temporary.compiled.1778713117975.mjs b/ui/quasar.config.js.temporary.compiled.1778776531494.mjs similarity index 100% rename from ui/quasar.config.js.temporary.compiled.1778713117975.mjs rename to ui/quasar.config.js.temporary.compiled.1778776531494.mjs diff --git a/ui/src/layouts/MainLayout.vue b/ui/src/layouts/MainLayout.vue index 992b259..145ae99 100644 --- a/ui/src/layouts/MainLayout.vue +++ b/ui/src/layouts/MainLayout.vue @@ -344,6 +344,11 @@ const menuItems = [ label: 'Maliyet Parça Eşleştirme', to: '/app/pricing/production-product-costing/maliyet-parca-eslestirme', permission: 'order:view' + }, + { + label: 'Maliyet Varsayilan Miktarlar', + to: '/app/pricing/production-product-costing/default-quantities', + permission: 'order:view' } ] }, diff --git a/ui/src/pages/ProductionProductCostingDefaultQuantities.vue b/ui/src/pages/ProductionProductCostingDefaultQuantities.vue new file mode 100644 index 0000000..e1dda3e --- /dev/null +++ b/ui/src/pages/ProductionProductCostingDefaultQuantities.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/ui/src/pages/ProductionProductCostingHasCostDetail.vue b/ui/src/pages/ProductionProductCostingHasCostDetail.vue index 1bf090c..e777925 100644 --- a/ui/src/pages/ProductionProductCostingHasCostDetail.vue +++ b/ui/src/pages/ProductionProductCostingHasCostDetail.vue @@ -2818,6 +2818,39 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) { // for multiple parts (Ceket/Pantolon/Yelek...), so de-duping only by hNo is incorrect. const processedRequiredKeys = new Set() + // Prefetch default quantities for all required hammadde types (1 call). + const allRequiredNos = [] + for (const mapping of list) { + const hList = Array.isArray(mapping?.nHammaddeTurleri) ? mapping.nHammaddeTurleri : [] + for (const hNoRaw of hList) { + const hNo = parseInt(String(hNoRaw || '').trim(), 10) + if (hNo > 0) allRequiredNos.push(hNo) + } + } + let defaultQtyByNo = {} + try { + const defaults = await post('/pricing/production-product-costing/default-quantities/lookup', { + nHammaddeTuruNos: allRequiredNos + }) + const arr = Array.isArray(defaults) ? defaults : [] + arr.forEach(it => { + const no = parseInt(String(it?.nHammaddeTuruNo ?? '0'), 10) || 0 + const qty = Number(it?.lDefaultMiktar || 0) + if (no > 0 && qty > 0) defaultQtyByNo[no] = qty + }) + } catch (err) { + defaultQtyByNo = {} + slog.error('production-product-costing.detail', 'default-qty:lookup:error', { + trace_id: traceId.value, + error: String(err?.message || err) + }) + $q.notify({ + type: 'warning', + message: 'Varsayilan miktarlar alinmadi (lookup hatasi). Miktarlar 1 olarak geldi.', + position: 'top-right' + }) + } + // Add missing placeholder rows (qty=1, price=0) to remind user for (const mapping of list) { // Parca adi (CEKET/PANTOLON/YELEK...) comes from the MT bolum description (joined in backend as parcaBolumAdi). @@ -2899,8 +2932,8 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) { sRenk: '', ColorCode: '', ColorDescription: '', - lMiktar: 1, - miktarInput: '1', + lMiktar: (Number(defaultQtyByNo[parseInt(hNo, 10)] || 0) > 0 ? Number(defaultQtyByNo[parseInt(hNo, 10)]) : 1), + miktarInput: (Number(defaultQtyByNo[parseInt(hNo, 10)] || 0) > 0 ? String(defaultQtyByNo[parseInt(hNo, 10)]) : '1'), inputPrice: '0', inputPricePrBr: 'USD', fiyat_girilen: 0, @@ -3086,6 +3119,83 @@ function saveRowEditor () { rowEditorDialogOpen.value = false } +function round1 (n) { + const x = Number(n || 0) + if (!Number.isFinite(x)) return 0 + return Math.round(x * 10) / 10 +} + +function round4 (n) { + const x = Number(n || 0) + if (!Number.isFinite(x)) return 0 + return Math.round(x * 10000) / 10000 +} + +async function confirmDefaultQtyDeviationIfNeeded () { + // Compare entered qty vs default qty (mk_MaliyetParcaEslestirme_vmiktarlar) per hammadde type. + // Rule: if deviation > 10% (abs), require user confirmation. + const qtyByNo = {} + flatDetailRows.value.forEach(r => { + const no = parseInt(String(r?.nHammaddeTuruNo || '').trim() || '0', 10) || 0 + const qty = Number(r?.lMiktar || 0) + if (!(no > 0) || !(qty > 0)) return + qtyByNo[no] = (qtyByNo[no] || 0) + qty + }) + const nos = Object.keys(qtyByNo).map(k => parseInt(k, 10)).filter(n => n > 0) + if (nos.length === 0) return true + + let defaults = [] + try { + defaults = await post('/pricing/production-product-costing/default-quantities/lookup', { + nHammaddeTuruNos: nos + }) + } catch { + // If lookup fails, don't block save. + return true + } + const defMap = {} + ;(Array.isArray(defaults) ? defaults : []).forEach(it => { + const no = parseInt(String(it?.nHammaddeTuruNo || '0'), 10) || 0 + const d = Number(it?.lDefaultMiktar || 0) + if (no > 0 && d > 0) defMap[no] = d + }) + + const outliers = [] + Object.keys(defMap).forEach(k => { + const no = parseInt(k, 10) || 0 + const defQty = Number(defMap[no] || 0) + const enteredQty = Number(qtyByNo[no] || 0) + if (!(defQty > 0) || !(enteredQty > 0)) return + const pct = ((enteredQty - defQty) / defQty) * 100 + if (Math.abs(pct) > 10) { + outliers.push({ no, defQty, enteredQty, pct }) + } + }) + if (outliers.length === 0) return true + + outliers.sort((a, b) => Math.abs(b.pct) - Math.abs(a.pct)) + + const maxRows = 30 + const lines = outliers.slice(0, maxRows).map(x => { + const sign = x.pct >= 0 ? '+' : '' + return `${x.no}: varsayilan ${round4(x.defQty)} | girilen ${round4(x.enteredQty)} | fark ${sign}${round1(x.pct)}%` + }) + const truncated = outliers.length > maxRows + ? `\n... (Toplam ${outliers.length} satir. Ilk ${maxRows} gosterildi.)` + : '' + + const ok = await new Promise(resolve => { + $q.dialog({ + title: 'Varsayilan Miktar Kontrolu', + message: `Bazi hammadde turlerinde varsayilan miktardan %10'dan fazla sapma var.\n\n${lines.join('\n')}${truncated}\n\nOnayliyorsaniz Kaydet'e basın. Duzenlemek icin Geri Don.`, + cancel: { label: 'Geri Don' }, + ok: { label: 'Onayla ve Kaydet', color: 'primary' }, + persistent: true + }).onOk(() => resolve(true)).onCancel(() => resolve(false)) + }) + return ok +} + async function saveChanges () { saveLoading.value = true try { @@ -3117,11 +3227,68 @@ async function saveChanges () { } } - $q.notify({ - type: 'warning', - message: 'Kaydetme endpointi henuz eklenmedi. Buton hazir, backend baglantisi bir sonraki adimda eklenebilir.', - position: 'top-right' - }) + const okDefaultQty = await confirmDefaultQtyDeviationIfNeeded() + if (!okDefaultQty) return + + if (!detailHeader.value) { + $q.notify({ type: 'negative', message: 'Header bulunamadi.', position: 'top-right' }) + return + } + + const header = detailHeader.value + const upserts = flatDetailRows.value.map(r => ({ + n_onml_det_no: parseInt(String(r?.nOnMLDetNo || '').trim() || '0', 10) || 0, + n_hammadde_turu_no: parseInt(String(r?.nHammaddeTuruNo || '').trim() || '0', 10) || 0, + n_urt_mt_bolum_id: parseInt(String(r?.nUrtMTBolumID || '0').trim() || '0', 10) || 0, + s_kodu: String(r?.sKodu || '').trim(), + s_aciklama: String(r?.sAciklama || '').trim(), + s_renk: String(r?.sRenk || '').trim(), + s_beden: String(r?.sBeden || '').trim(), + s_aciklama2: String(r?.sAciklama2 || '').trim(), + s_birim: String(r?.sBirim || '').trim(), + l_miktar: Number(r?.lMiktar || 0), + fiyat_girilen: Number(resolveNumericRowInputPrice(r) || 0), + fiyat_doviz: String(resolveInputCurrency(r) || '').trim(), + maliyete_dahil: (r?.maliyeteDahil || r?.maliyete_dahil) ? 1 : 0, + cm_price_type_id: r?.cmPriceTypeId ?? r?.cm_price_type_id ?? null + })) + + const deletes = (Array.isArray(deletedDetailRows.value) ? deletedDetailRows.value : []).map(d => ({ + n_onml_det_no: parseInt(String(d?.nOnMLDetNo || '').trim() || '0', 10) || 0 + })).filter(x => x.n_onml_det_no > 0) + + const payload = { + detail_source: isNoCostDetail.value ? 'no-cost' : 'has-cost', + header: { + n_onml_no: parseInt(String(header?.nOnMLNo || onMLNo.value || '0').trim() || '0', 10) || 0, + urun_kodu: String(header?.UrunKodu || productCode.value || '').trim(), + urun_adi: String(header?.UrunAdi || '').trim(), + maliyet_tarihi: normalizeDateInput(costDate.value), + n_urt_recete_id: parseInt(String(header?.nUrtReceteID || recipeCode.value || '0').trim() || '0', 10) || 0, + uretim_sekli_id: parseInt(String(header?.UretimSekliID || '0').trim() || '0', 10) || 0, + s_aciklama: String(header?.sAciklama || header?.SAciklama || '').trim(), + firma_kodu: String(header?.FirmaKodu || '').trim(), + n_firma_id: parseInt(String(header?.nFirmaID || header?.NFirmaID || '0').trim() || '0', 10) || 0 + }, + detail: { upserts, deletes } + } + + const response = await post('/pricing/production-product-costing/onml/save', payload) + const newOnMLNo = parseInt(String(response?.n_onml_no || '0'), 10) || 0 + + $q.notify({ type: 'positive', message: 'Kaydedildi.', position: 'top-right' }) + + // If we created a new OnML (no-cost), switch to has-cost detail mode. + if (isNoCostDetail.value && newOnMLNo > 0) { + router.replace({ + name: 'production-product-costing-has-cost-detail', + query: { + n_onml_no: String(newOnMLNo), + urun_kodu: String(header?.UrunKodu || productCode.value || '').trim() + } + }) + return + } } finally { saveLoading.value = false } @@ -3158,6 +3325,8 @@ onBeforeUnmount(() => { window.removeEventListener('resize', updateStickyTop) ensureBeforeUnloadGuard(false) if (localDraftTimer.value) window.clearTimeout(localDraftTimer.value) + // Defensive: if component unmounts without router guard (rare), drop the draft. + clearLocalDraft() }) watch( @@ -3169,6 +3338,8 @@ watch( onBeforeRouteLeave((to, from, next) => { if (!hasUnsavedChanges.value) { + // Always clear local draft on page exit; backend is source of truth. + clearLocalDraft() next() return } @@ -3177,20 +3348,13 @@ onBeforeRouteLeave((to, from, next) => { title: 'Sayfadan ayriliyorsunuz', message: pageMode.value === 'edit' ? 'Yaptiginiz degisiklikler kaybolacak. Devam edilsin mi?' - : 'Taslak korunacak. Sayfadan cikmak istiyor musunuz?', + : 'Yaptiginiz degisiklikler kaybolacak. Devam edilsin mi?', ok: { label: 'Evet', color: 'negative' }, cancel: { label: 'Hayir' }, persistent: true }) .onOk(() => { - // NEW (no-cost): always persist draft so it can be resumed - if (pageMode.value === 'new') { - persistLocalDraftNow() - next() - return - } - - // EDIT (has-cost): allow exit, keep draft for now (later we can clear it after successful save) + clearLocalDraft() next() }) .onCancel(() => next(false)) diff --git a/ui/src/router/routes.js b/ui/src/router/routes.js index a341390..6fd2381 100644 --- a/ui/src/router/routes.js +++ b/ui/src/router/routes.js @@ -371,6 +371,12 @@ const routes = [ component: () => import('pages/ProductionProductCostingMTBolumMapping.vue'), meta: { permission: 'order:view' } }, + { + path: 'pricing/production-product-costing/default-quantities', + name: 'production-product-costing-default-quantities', + component: () => import('pages/ProductionProductCostingDefaultQuantities.vue'), + meta: { permission: 'order:view' } + }, /* ================= PASSWORD ================= */ diff --git a/ui/src/stores/productionProductCostingDefaultQtyStore.js b/ui/src/stores/productionProductCostingDefaultQtyStore.js new file mode 100644 index 0000000..c0d7e9c --- /dev/null +++ b/ui/src/stores/productionProductCostingDefaultQtyStore.js @@ -0,0 +1,171 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import { get, post, extractApiErrorDetail } from 'src/services/api' + +const DRAFT_KEY = 'bssapp:production-product-costing:default-quantities:draft:v1' + +function safeJsonParse (raw) { + try { + return JSON.parse(raw) + } catch { + return null + } +} + +export const useProductionProductCostingDefaultQtyStore = defineStore('production_product_costing_default_qty', () => { + const rows = ref([]) + const loading = ref(false) + const saving = ref(false) + const error = ref('') + + // draftByNo: { [nHammaddeTuruNo]: { lDefaultMiktar, bAktif } } + const draftByNo = ref({}) + const persistTimer = ref(0) + + const dirtyNos = computed(() => Object.keys(draftByNo.value || {}).map(k => Number(k)).filter(n => n > 0)) + const hasUnsavedChanges = computed(() => dirtyNos.value.length > 0) + + function schedulePersistDraft () { + try { + if (persistTimer.value) window.clearTimeout(persistTimer.value) + persistTimer.value = window.setTimeout(() => { + persistTimer.value = 0 + persistDraftNow() + }, 350) + } catch { + persistDraftNow() + } + } + + function persistDraftNow () { + try { + const payload = { + version: 1, + savedAt: new Date().toISOString(), + draftByNo: draftByNo.value || {} + } + localStorage.setItem(DRAFT_KEY, JSON.stringify(payload)) + } catch { + // ignore + } + } + + function hydrateDraft () { + try { + const raw = localStorage.getItem(DRAFT_KEY) + if (!raw) return + const payload = safeJsonParse(raw) + if (!payload || typeof payload !== 'object') return + if (!payload.draftByNo || typeof payload.draftByNo !== 'object') return + draftByNo.value = payload.draftByNo + } catch { + // ignore + } + } + + function clearDraft () { + draftByNo.value = {} + try { + localStorage.removeItem(DRAFT_KEY) + } catch { + // ignore + } + } + + function setDraft (nHammaddeTuruNo, patch) { + const no = Number(nHammaddeTuruNo || 0) + if (!(no > 0)) return + const prev = draftByNo.value?.[String(no)] || {} + draftByNo.value = { + ...(draftByNo.value || {}), + [String(no)]: { + ...prev, + ...(patch || {}) + } + } + schedulePersistDraft() + } + + function getEffectiveRow (row) { + const no = Number(row?.nHammaddeTuruNo || 0) + if (!(no > 0)) return row + const draft = draftByNo.value?.[String(no)] + if (!draft) return row + return { + ...row, + lDefaultMiktar: typeof draft.lDefaultMiktar === 'number' ? draft.lDefaultMiktar : row?.lDefaultMiktar, + bAktif: typeof draft.bAktif === 'boolean' ? draft.bAktif : row?.bAktif, + __dirty: true + } + } + + async function fetch ({ search = '', onlyActive = true, limit = 2000 } = {}) { + loading.value = true + error.value = '' + try { + const data = await get('/pricing/production-product-costing/default-quantities', { + search: String(search || '').trim(), + limit + }) + const base = Array.isArray(data) ? data : [] + rows.value = base.map(getEffectiveRow) + } catch (e) { + error.value = extractApiErrorDetail(e) || String(e?.message || e || 'Hata') + throw e + } finally { + loading.value = false + } + } + + async function calcAvgForRow ({ nHammaddeTuruNo, topN = 10 } = {}) { + const no = Number(nHammaddeTuruNo || 0) + if (!(no > 0)) return null + const resp = await post('/pricing/production-product-costing/default-quantities/calc-avg', { + nHammaddeTuruNo: no, + topN: Number(topN || 10) + }) + return resp + } + + async function saveAll () { + if (!hasUnsavedChanges.value) return { updated: 0 } + saving.value = true + error.value = '' + try { + const items = dirtyNos.value.map(no => { + const patch = draftByNo.value?.[String(no)] || {} + return { + nHammaddeTuruNo: Number(no), + lDefaultMiktar: Number(patch.lDefaultMiktar || 0), + bAktif: typeof patch.bAktif === 'boolean' ? patch.bAktif : undefined + } + }).filter(it => it.nHammaddeTuruNo > 0 && it.lDefaultMiktar > 0) + + const resp = await post('/pricing/production-product-costing/default-quantities/update-bulk', { items }) + clearDraft() + return resp + } catch (e) { + error.value = extractApiErrorDetail(e) || String(e?.message || e || 'Hata') + throw e + } finally { + saving.value = false + } + } + + hydrateDraft() + + return { + rows, + loading, + saving, + error, + draftByNo, + dirtyNos, + hasUnsavedChanges, + fetch, + setDraft, + calcAvgForRow, + saveAll, + clearDraft + } +})