Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-05-15 14:33:35 +03:00
parent 562d397480
commit dacd3aefa9
14 changed files with 2409 additions and 17 deletions

View File

@@ -0,0 +1,235 @@
<template>
<q-page class="q-pa-md">
<div class="sticky-stack">
<div class="save-toolbar">
<div class="row items-center justify-between q-col-gutter-sm">
<div class="col-auto text-weight-bold">Maliyet Varsayilan Miktarlar</div>
<div class="col-auto row items-center q-gutter-sm">
<q-btn
dense
outline
color="grey-8"
icon="undo"
label="Taslagi Temizle"
:disable="!hasUnsavedChanges"
@click="onClearDraft"
/>
<q-btn
dense
color="primary"
icon="save"
label="Degisiklikleri Kaydet"
:loading="saving"
:disable="!hasUnsavedChanges"
@click="onSaveAll"
/>
<q-btn
dense
outline
color="grey-8"
icon="refresh"
label="Yenile"
:loading="loading || saving"
@click="fetchRows"
/>
</div>
</div>
</div>
<div class="filter-bar q-mb-md">
<div class="row q-col-gutter-sm items-center">
<div class="col-12 col-md-4">
<q-input v-model="search" dense filled clearable label="Ara (Hammadde No)" @update:model-value="debouncedFetch" />
</div>
<div class="col-12 col-md-auto text-grey-7">
Kayit: {{ Array.isArray(rows) ? rows.length : 0 }} | Degisen: {{ dirtyNos.length }}
</div>
</div>
</div>
</div>
<q-banner v-if="error" class="bg-red text-white q-mb-md">
{{ error }}
</q-banner>
<q-table
flat
bordered
dense
row-key="nHammaddeTuruNo"
:rows="rows"
:columns="columns"
:loading="loading"
:rows-per-page-options="[0]"
hide-bottom
>
<template #body-cell-actions="props">
<q-td :props="props">
<q-btn
dense
flat
icon="calculate"
color="primary"
@click="onCalcAvg(props.row)"
>
<q-tooltip>Son 10 Ortalama</q-tooltip>
</q-btn>
</q-td>
</template>
<template #body-cell-lDefaultMiktar="props">
<q-td :props="props">
<q-input
:model-value="props.row.lDefaultMiktar"
dense
filled
type="number"
step="0.0001"
@update:model-value="val => onEditQty(props.row, val)"
style="max-width: 140px"
/>
</q-td>
</template>
<template #body-cell-bAktif="props">
<q-td :props="props">
<q-toggle
:model-value="Boolean(props.row.bAktif)"
@update:model-value="val => onEditActive(props.row, val)"
/>
</q-td>
</template>
</q-table>
</q-page>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useQuasar } from 'quasar'
import { onBeforeRouteLeave } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useProductionProductCostingDefaultQtyStore } from 'src/stores/productionProductCostingDefaultQtyStore'
const $q = useQuasar()
const store = useProductionProductCostingDefaultQtyStore()
const { rows, loading, saving, error, dirtyNos, hasUnsavedChanges } = storeToRefs(store)
const search = ref('')
const columns = [
{ name: 'nHammaddeTuruNo', label: 'HammaddeTuruNo', field: 'nHammaddeTuruNo', align: 'left', sortable: true },
{ name: 'sAciklama', label: 'Aciklama', field: 'sAciklama', align: 'left', sortable: true },
{ name: 'lDefaultMiktar', label: 'Varsayilan Miktar', field: 'lDefaultMiktar', align: 'right', sortable: true },
{ name: 'dteCalcTarihi', label: 'Hesap Tarihi', field: 'dteCalcTarihi', align: 'left', sortable: true },
{ name: 'bAktif', label: 'Aktif', field: 'bAktif', align: 'center', sortable: true },
{ name: 'actions', label: '', field: '__actions', align: 'right', sortable: false }
]
let debounceTimer = null
function debouncedFetch () {
if (debounceTimer) window.clearTimeout(debounceTimer)
debounceTimer = window.setTimeout(() => fetchRows(), 250)
}
async function fetchRows () {
await store.fetch({
search: String(search.value || '').trim()
})
}
function onEditQty (row, val) {
const no = Number(row?.nHammaddeTuruNo || 0)
const qty = Number(val || 0)
if (!(no > 0)) return
store.setDraft(no, { lDefaultMiktar: qty })
// keep table row in sync visually
row.lDefaultMiktar = qty
}
function onEditActive (row, val) {
const no = Number(row?.nHammaddeTuruNo || 0)
if (!(no > 0)) return
store.setDraft(no, { bAktif: Boolean(val) })
row.bAktif = Boolean(val)
}
async function onCalcAvg (row) {
const no = Number(row?.nHammaddeTuruNo || 0)
if (!(no > 0)) return
try {
const resp = await store.calcAvgForRow({ nHammaddeTuruNo: no, topN: 10 })
const qty = Number(resp?.lDefaultMiktar || 0)
if (qty > 0) {
store.setDraft(no, { lDefaultMiktar: qty })
row.lDefaultMiktar = qty
$q.notify({ type: 'positive', message: `Ortalama yazildi (n=${Number(resp?.nSampleCount || 0)})`, position: 'top-right' })
} else {
$q.notify({ type: 'warning', message: 'Ortalama bulunamadi', position: 'top-right' })
}
} catch (e) {
$q.notify({ type: 'negative', message: String(e?.message || e || 'Hata'), position: 'top-right' })
}
}
async function onSaveAll () {
try {
const resp = await store.saveAll()
await fetchRows()
$q.notify({ type: 'positive', message: `Kaydedildi (${Number(resp?.updated || 0)})`, position: 'top-right' })
} catch (e) {
$q.notify({ type: 'negative', message: String(e?.message || e || 'Kaydedilemedi'), position: 'top-right' })
}
}
function onClearDraft () {
$q.dialog({
title: 'Taslak Temizlensin mi?',
message: 'Kaydedilmemis degisiklikler silinecek.',
cancel: true,
persistent: true
}).onOk(() => {
store.clearDraft()
fetchRows()
})
}
function ensureBeforeUnloadGuard (enabled) {
if (!enabled) {
window.onbeforeunload = null
return
}
window.onbeforeunload = (e) => {
e.preventDefault()
e.returnValue = ''
return ''
}
}
watch(hasUnsavedChanges, (v) => ensureBeforeUnloadGuard(Boolean(v)))
onBeforeRouteLeave((to, from, next) => {
if (!hasUnsavedChanges.value) return next()
$q.dialog({
title: 'Kaydedilmemis Degisiklikler Var',
message: 'Sayfadan cikmak istiyor musunuz?',
cancel: true,
persistent: true
}).onOk(() => next()).onCancel(() => next(false))
})
onMounted(() => {
fetchRows()
})
onUnmounted(() => {
window.onbeforeunload = null
})
</script>
<style scoped>
.sticky-stack {
position: sticky;
top: var(--header-h);
z-index: 100;
background: #fff;
padding-top: 8px;
}
</style>

View File

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