Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-05-20 20:20:10 +03:00
parent 9b4b82dd52
commit c1c1ed99c7
6 changed files with 1123 additions and 261 deletions

View File

@@ -84,6 +84,16 @@
:disable="!detailHeader || detailLoading || saveLoading || bulkPriceLoading"
@click="saveChanges"
/>
<q-btn
label="PDF"
icon="picture_as_pdf"
dense
color="grey-9"
outline
class="pcd-toolbar-btn"
:disable="!detailHeader || detailLoading || saveLoading"
@click="exportCostingPDF"
/>
<q-btn
label="Kaydi Sil"
icon="delete"
@@ -244,6 +254,9 @@
{{ grp.sAciklama3 || 'TANIMSIZ' }}
</div>
<div class="sub-right pcd-sub-right-clickable" @click="toggleGroup(grp, gi)">
<span v-if="normalizeGroupName(grp.sAciklama3) === 'FABRIC'" class="q-mr-sm">
Toplam Miktar: {{ formatBarQuantity(resolveGroupQuantity(grp)) }} MT |
</span>
Grup Toplami TRY: {{ formatBarMoney(resolveGroupTRYTutar(grp)) }} | USD: {{ formatBarMoney(resolveGroupUSDTutar(grp)) }}
<q-icon
:name="isGroupOpen(grp, gi) ? 'expand_less' : 'expand_more'"
@@ -529,6 +542,16 @@
@filter="filterRowEditorItemOptions"
@update:model-value="onRowEditorItemChange"
/>
<div v-if="showFabricCopyBtn" class="q-mt-xs row items-center q-gutter-xs">
<q-btn
dense
outline
color="primary"
icon="content_copy"
label="Kumastan Kopyala"
@click="openFabricCopyDialog"
/>
</div>
</div>
<div class="col-12 col-md-6">
<q-select
@@ -599,6 +622,41 @@
</q-card>
</q-dialog>
<!-- FABRIC copy helper: pick another fabric row and copy Code + Price + Pr.Br into editor -->
<q-dialog v-model="fabricCopyDialogOpen">
<q-card style="min-width: min(720px, 92vw);">
<q-card-section class="row items-center justify-between q-pb-sm">
<div class="text-subtitle1 text-weight-bold">Kumastan Kopyala</div>
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-separator />
<q-card-section class="q-pa-md">
<q-select
v-model="fabricCopySelectedKey"
dense
filled
label="Kaynak Kumaş"
:options="fabricCopyOptions"
option-value="value"
option-label="label"
emit-value
map-options
/>
</q-card-section>
<q-separator />
<q-card-actions align="right" class="q-pa-sm">
<q-btn flat label="Iptal" color="grey-7" v-close-popup />
<q-btn
unelevated
color="primary"
label="Kopyala"
:disable="!fabricCopySelectedKey"
@click="applyFabricCopySelection"
/>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="lineHistoryDialogOpen" maximized>
<q-card class="pcd-history-dialog">
<q-card-section class="row items-center justify-between q-gutter-sm">
@@ -744,7 +802,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useQuasar } from 'quasar'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { usePermission } from 'src/composables/usePermission'
import { get, post, extractApiErrorDetail } from 'src/services/api'
import { get, post, download, extractApiErrorDetail } from 'src/services/api'
import { createTraceId, slog } from 'src/utils/slog'
const route = useRoute()
@@ -773,6 +831,8 @@ const rowEditorDialogOpen = ref(false)
const rowEditorMode = ref('new')
const rowEditorTargetRowKey = ref('')
const rowEditorForm = ref(createRowEditorForm())
const fabricCopyDialogOpen = ref(false)
const fabricCopySelectedKey = ref('')
// Draggable "Satir Duzenle" dialog (mouse drag by header).
const rowEditorDialogPos = ref({ x: 0, y: 0 })
@@ -872,10 +932,53 @@ const priceCurrencyOptions = [
]
const flatDetailRows = computed(() => detailGroups.value.flatMap(grp => Array.isArray(grp?.items) ? grp.items : []))
const showFabricCopyBtn = computed(() => normalizeGroupName(rowEditorForm.value?.sAciklama3 || '') === 'FABRIC')
const fabricCopySourceRows = computed(() => {
const currentKey = String(rowEditorTargetRowKey.value || '').trim()
return (Array.isArray(flatDetailRows.value) ? flatDetailRows.value : [])
.filter(r => normalizeGroupName(r?.sAciklama3 || '') === 'FABRIC')
.filter(r => String(r?.__rowKey || '').trim() && String(r?.__rowKey || '').trim() !== currentKey)
})
const fabricCopyOptions = computed(() => {
// Deduplicate for UX: group by (code + color + hammaddeNo). Keep most recently changed first.
const list = (Array.isArray(fabricCopySourceRows.value) ? fabricCopySourceRows.value : [])
.slice()
.sort((a, b) => {
const da = new Date(a?.dteIslemTarihiDeg || a?.dteIslemTarihi || 0).getTime()
const db = new Date(b?.dteIslemTarihiDeg || b?.dteIslemTarihi || 0).getTime()
return db - da
})
const seen = new Set()
const out = []
for (const r of list) {
const code = String(r?.sKodu || '').trim()
if (!code) continue
const color = String(resolveRowColorCode(r) || '').trim()
const hNo = String(r?.nHammaddeTuruNo || '').trim()
const hName = String(r?.sHammaddeTuruAdi || '').trim()
const key = `${hNo}|${code}|${color}`
if (seen.has(key)) continue
seen.add(key)
const price = resolveNumericRowInputPrice(r)
const cur = resolveInputCurrency(r)
const desc = String(r?.sAciklama || '').trim()
const hLabel = `${hNo}${hName ? ` - ${hName}` : ''}`
const label = `${hLabel} | ${code}${desc ? ` - ${desc}` : ''}${color ? ` | ${color}` : ''}${price > 0 ? ` | ${price} ${cur}` : ''}`
out.push({ value: key, label })
}
return out
})
// no-cost: required parca slots (from Maliyet Parca Eslestirme)
const requiredParcaMappings = ref([])
const requiredAttentionRowKeys = ref({})
const requiredHammaddeMetaCache = ref({}) // hammaddeNo -> { groupName, parcaAdi }
// When user explicitly deletes a requiredPlaceholder row, we should not keep warning/recreating it
// for this draft/session. Key format: `${mtBolumID}:${hNo}`.
const suppressedRequiredKeys = ref({})
// Bump this when draft payload semantics change, to avoid hydrating stale rows after backend logic updates.
const DRAFT_STORAGE_VERSION = 'v2'
@@ -929,6 +1032,7 @@ function persistLocalDraftNow () {
UretimSekli: String(detailHeader.value?.UretimSekli || '').trim()
} : null,
deletedDetailRows: Array.isArray(deletedDetailRows.value) ? deletedDetailRows.value : [],
suppressedRequiredKeys: suppressedRequiredKeys.value || {},
detailGroups: Array.isArray(detailGroups.value) ? detailGroups.value : []
}
localStorage.setItem(key, JSON.stringify(payload))
@@ -972,6 +1076,7 @@ function tryHydrateFromLocalDraft () {
const payload = JSON.parse(raw)
if (!payload || typeof payload !== 'object') return false
if (Array.isArray(payload.deletedDetailRows)) deletedDetailRows.value = payload.deletedDetailRows
if (payload.suppressedRequiredKeys && typeof payload.suppressedRequiredKeys === 'object') suppressedRequiredKeys.value = payload.suppressedRequiredKeys
if (Array.isArray(payload.detailGroups)) detailGroups.value = normalizeDetailGroups(payload.detailGroups)
if (payload.header && detailHeader.value) {
detailHeader.value.UretimSekliID = String(payload.header.UretimSekliID || '').trim()
@@ -1189,6 +1294,11 @@ function formatBarMoney (value) {
return formatMoney(roundedValue)
}
function formatBarQuantity (value) {
const roundedValue = Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000
return formatQuantity(roundedValue)
}
function normalizePriceCurrency (value) {
const normalizedValue = String(value || '').trim().toUpperCase()
return ['USD', 'TRY', 'EUR', 'GBP'].includes(normalizedValue) ? normalizedValue : ''
@@ -2091,6 +2201,11 @@ function resolveGroupUSDTutar (grp) {
return items.reduce((acc, row) => acc + (shouldIncludeRowInGroupTotal(grp, row) ? resolveRowUSDTutar(row) : 0), 0)
}
function resolveGroupQuantity (grp) {
const items = Array.isArray(grp?.items) ? grp.items : []
return items.reduce((acc, row) => acc + (Number(row?.lMiktar || 0) || 0), 0)
}
function groupKey (grp, gi) {
return `${String(grp?.sAciklama3 || 'TANIMSIZ')}-${gi}`
}
@@ -2264,15 +2379,20 @@ async function fetchDetail (options = {}) {
if (hydrateDraft) {
tryHydrateFromLocalDraft()
}
// ensure required placeholder rows exist (based on mapping screen)
try {
const mappings = await fetchRequiredParcaMappings()
await ensureNoCostRequiredRowsFromMappings(mappings)
} catch (err) {
slog.error('production-product-costing.detail', 'required-mapping:error', {
trace_id: traceId.value,
detail: await extractApiErrorDetail(err)
})
// Ensure required placeholder rows exist (based on mapping screen) only for the "new/no-cost" flow.
// For has-cost records (existing), we do not inject placeholders after load.
// Placeholders should be created only when detail_source=no-cost (route query),
// so saved records always render exactly what backend has.
if (isNoCostDetail.value) {
try {
const mappings = await fetchRequiredParcaMappings()
await ensureNoCostRequiredRowsFromMappings(mappings)
} catch (err) {
slog.error('production-product-costing.detail', 'required-mapping:error', {
trace_id: traceId.value,
detail: await extractApiErrorDetail(err)
})
}
}
const initialOpen = {}
detailGroups.value.forEach((grp, gi) => {
@@ -2821,6 +2941,44 @@ function onRowEditorHammaddeChange (value) {
}
}
function openFabricCopyDialog () {
fabricCopySelectedKey.value = ''
fabricCopyDialogOpen.value = true
}
async function applyFabricCopySelection () {
const key = String(fabricCopySelectedKey.value || '').trim()
if (!key) return
const match = fabricCopySourceRows.value.find(r => {
const code = String(r?.sKodu || '').trim()
const color = String(resolveRowColorCode(r) || '').trim()
const hNo = String(r?.nHammaddeTuruNo || '').trim()
return `${hNo}|${code}|${color}` === key
})
if (!match) {
fabricCopyDialogOpen.value = false
return
}
// Copy: Code + Price + Currency. Leave color as-is (user may be copying price only).
rowEditorForm.value.sKodu = String(match.sKodu || '').trim()
// Ensure select can render the chosen value even if it isn't in the current options list.
upsertEditorOption(rowEditorItemAllOptions, buildRowEditorItemOption({ ...match, value: String(match.sKodu || '').trim() }))
upsertEditorOption(rowEditorItemOptions, buildRowEditorItemOption({ ...match, value: String(match.sKodu || '').trim() }))
try {
await onRowEditorItemChange(String(match.sKodu || '').trim())
} catch {}
const price = resolveNumericRowInputPrice(match)
const cur = resolveInputCurrency(match) || 'USD'
rowEditorForm.value.inputPrice = normalizeInputPrice(price)
rowEditorForm.value.fiyat_girilen = parseMoneyInput(price)
rowEditorForm.value.inputPricePrBr = normalizePriceCurrency(cur) || 'USD'
rowEditorForm.value.fiyat_doviz = normalizePriceCurrency(cur) || 'USD'
fabricCopyDialogOpen.value = false
}
async function onRowEditorItemChange (value) {
const selected = rowEditorItemOptions.value.find(opt => String(opt?.value || '') === String(value || ''))
rowEditorForm.value.sKodu = String(value || '').trim()
@@ -2980,8 +3138,9 @@ async function fetchRequiredParcaMappings () {
async function resolveHammaddeMetaForRequired (hNo) {
const key = String(hNo || '').trim()
if (!key) return null
// Always attempt a fresh resolve; backend rules for active/inactive hammadde types can change.
// We still store into cache as a last-known-good for the current page load.
const cached = requiredHammaddeMetaCache.value?.[key]
if (cached) return cached
try {
const rows = await get('/pricing/production-product-costing/detail-editor-options', {
@@ -3009,7 +3168,7 @@ async function resolveHammaddeMetaForRequired (hNo) {
return meta
} catch {
requiredHammaddeMetaCache.value = { ...(requiredHammaddeMetaCache.value || {}), [key]: { groupName: '', hammaddeAdi: '', mtBolumID: 0, parcaAdi: '' } }
return null
return cached || null
}
}
@@ -3067,6 +3226,7 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) {
const hNo = normalizeHammaddeNo(hNoRaw)
if (!hNo) continue
const reqKey = `${mappingMtBolumID}:${hNo}`
if (suppressedRequiredKeys.value?.[reqKey]) continue
if (processedRequiredKeys.has(reqKey)) continue
processedRequiredKeys.add(reqKey)
@@ -3093,12 +3253,19 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) {
if (anyMatch) {
// If we previously created a placeholder under a wrong group name, move it instead of duplicating.
if (anyMatch?.requiredPlaceholder) {
const ensuredDetNo = String(anyMatch?.nOnMLDetNo || '').trim() || getNextDetailNo()
const currentGroup = normalizeGroupName(anyMatch?.sAciklama3)
const currentParcaAdi = normalizeGroupName(anyMatch?.sParcaAdi || '')
const currentHammaddeAdi = String(anyMatch?.sHammaddeTuruAdi || '').trim()
if (currentGroup !== effectiveGroupName || currentParcaAdi !== desiredParcaAdi || (hammaddeAdi && currentHammaddeAdi !== hammaddeAdi)) {
if (
String(anyMatch?.nOnMLDetNo || '').trim() !== ensuredDetNo ||
currentGroup !== effectiveGroupName ||
currentParcaAdi !== desiredParcaAdi ||
(hammaddeAdi && currentHammaddeAdi !== hammaddeAdi)
) {
const moved = recalculateDetailRow({
...anyMatch,
nOnMLDetNo: ensuredDetNo,
sAciklama3: effectiveGroupName,
sParcaAdi: desiredParcaAdi,
nUrtMTBolumID: mtBolumID || anyMatch?.nUrtMTBolumID || anyMatch?.NUrtMTBolumID || 0,
@@ -3124,7 +3291,8 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) {
isNew: true,
nUrtMTBolumID: mtBolumID,
nOnMLNo: detailHeader.value?.nOnMLNo || onMLNo.value || '',
nOnMLDetNo: '',
// IMPORTANT: Backend skips upserts where nOnMLDetNo <= 0. Required placeholders must have a real number.
nOnMLDetNo: getNextDetailNo(),
// Group header key
sAciklama3: effectiveGroupName,
// Parca adi column value (CEKET/PANTOLON/YELEK...)
@@ -3162,6 +3330,142 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) {
}
}
// CM2 auto-required: include all active CM2 hammadde types (bAktif=1) as required placeholders too.
// This is independent of the mapping screen, because CM2 labor lines are managed in OnML detail.
try {
// Filter CM2 types by the parts that exist for this product (MTBolumIDs coming from mapping list).
// This prevents irrelevant CM2 types (e.g. GML/KBN/MNT) from showing up for products that don't have those parts.
const allowedMtBolumIDs = new Set()
for (const m of list) {
const mt = parseInt(String(m?.nUrtMTBolumID ?? m?.NUrtMTBolumID ?? '0'), 10) || 0
if (mt > 0) allowedMtBolumIDs.add(mt)
}
if (allowedMtBolumIDs.size === 0) {
// No parts to scope CM2 by -> don't add global CM2 placeholders.
return
}
const cm2Types = await get('/pricing/production-product-costing/detail-editor-options', {
kind: 'hammadde',
group: 'CM2',
only_active: 1,
search: '',
limit: 200,
trace_id: traceId.value
})
const cm2List = Array.isArray(cm2Types) ? cm2Types : []
for (const it of cm2List) {
const hNo = normalizeHammaddeNo(it?.nHammaddeTuruNo ?? it?.value)
if (!hNo) continue
const meta = {
groupName: String(it?.sAciklama3 || it?.sAciklama2 || 'CM2').trim(),
hammaddeAdi: String(it?.sHammaddeTuruAdi || it?.sAciklama || '').trim(),
mtBolumID: parseInt(String(it?.mtUrtMTBolumID ?? it?.MTUrtMTBolumID ?? it?.nUrtMTBolumID ?? it?.NUrtMTBolumID ?? '0'), 10) || 0,
parcaAdi: String(it?.sParcaAdi || '').trim()
}
if (!(meta.mtBolumID > 0) || !allowedMtBolumIDs.has(meta.mtBolumID)) continue
const effectiveGroupName = normalizeGroupName(meta.groupName || 'CM2') || 'CM2'
const mtBolumID = meta.mtBolumID || 0
const desiredParcaAdi = normalizeGroupName(meta.parcaAdi || '') || 'CM2'
const reqKey = `${mtBolumID}:${hNo}`
if (mtBolumID > 0 && suppressedRequiredKeys.value?.[reqKey]) continue
const anyMatch = flatDetailRows.value.find(r => {
if (normalizeHammaddeNo(r?.nHammaddeTuruNo) !== hNo) return false
const rowMtBolumID = parseInt(String(r?.nUrtMTBolumID ?? r?.NUrtMTBolumID ?? '0'), 10) || 0
if (rowMtBolumID > 0 && mtBolumID > 0) return rowMtBolumID === mtBolumID
return normalizeGroupName(r?.sParcaAdi) === desiredParcaAdi
})
if (anyMatch) {
// Ensure existing required CM2 placeholder has a valid detNo, otherwise backend will skip it on save.
if (anyMatch?.requiredPlaceholder && String(anyMatch?.nOnMLDetNo || '').trim() === '') {
const patched = recalculateDetailRow({
...anyMatch,
nOnMLDetNo: getNextDetailNo(),
draftChanged: true
}, {
preserveInputs: true,
priceType: 'REQ',
updateState: 'required-cm2',
markChanged: true
})
applyEditorRowToGroups(patched)
}
continue
}
newRowSequence.value += 1
const rowKey = `req-auto-cm2-row-${newRowSequence.value}`
const placeholder = recalculateDetailRow({
__rowKey: rowKey,
isNew: true,
nUrtMTBolumID: mtBolumID,
nOnMLNo: detailHeader.value?.nOnMLNo || onMLNo.value || '',
// IMPORTANT: Backend skips upserts where nOnMLDetNo <= 0. Required placeholders must have a real number.
nOnMLDetNo: getNextDetailNo(),
sAciklama3: effectiveGroupName,
sParcaAdi: desiredParcaAdi,
nHammaddeTuruNo: hNo,
sHammaddeTuruAdi: meta.hammaddeAdi,
sKodu: '',
sAciklama: '',
sRenk: '',
ColorCode: '',
ColorDescription: '',
lMiktar: 1,
miktarInput: '1',
inputPrice: '0',
inputPricePrBr: 'USD',
fiyat_girilen: 0,
fiyat_doviz: 'USD',
maliyeteDahil: true,
maliyete_dahil: 1,
Maliyete_dahil: 1,
cmPriceTypeId: normalizeCMPriceTypeId(1, effectiveGroupName),
cm_price_type_id: normalizeCMPriceTypeId(1, effectiveGroupName),
sBirim: 'AD',
draftChanged: true,
requiredPlaceholder: true
}, {
preserveInputs: true,
priceType: 'REQ',
updateState: 'required-cm2',
markChanged: true
})
applyEditorRowToGroups(placeholder)
}
} catch (e) {
slog.error('production-product-costing.detail', 'cm2:required:error', {
trace_id: traceId.value,
error: String(e?.message || e)
})
}
// Debug: required placeholders must always have a valid detNo.
try {
const flatNow = detailGroups.value.flatMap(grp => Array.isArray(grp?.items) ? grp.items : [])
const reqRows = flatNow.filter(r => Boolean(r?.requiredPlaceholder))
const missingDetNo = reqRows.filter(r => (parseInt(String(r?.nOnMLDetNo || '').trim() || '0', 10) || 0) <= 0)
slog.info('production-product-costing.detail', 'required:placeholders:ensured', {
trace_id: traceId.value,
required_count: reqRows.length,
required_missing_detno: missingDetNo.length,
required_cm2_count: reqRows.filter(r => normalizeGroupName(r?.sAciklama3) === 'CM2').length
})
if (missingDetNo.length > 0) {
slog.warn('production-product-costing.detail', 'required:placeholders:missing-detno', {
trace_id: traceId.value,
sample: missingDetNo.slice(0, 10).map(r => ({
detNo: String(r?.nOnMLDetNo || ''),
hNo: String(r?.nHammaddeTuruNo || ''),
mtBolumID: String(r?.nUrtMTBolumID || ''),
group: String(r?.sAciklama3 || ''),
rowKey: String(r?.__rowKey || '')
}))
})
}
} catch {}
// CM2 special case:
// Bulk autofill is disabled for CM2 (and FABRIC) to prevent wrong price pulls,
// but we still want the I.* code templates (code + description) to appear on page open.
@@ -3243,6 +3547,9 @@ function computeMissingRequiredSlots () {
const missing = []
if (list.length === 0) return missing
// Expand suppression keys: support both mappingMtBolumID and meta-derived mtBolumID.
const suppressed = suppressedRequiredKeys.value || {}
list.forEach(mapping => {
// Required slots are defined per (ParcaBolum / MTBolumID) + (HammaddeTuruNo).
// Do NOT match by row.sAciklama3, because that field is the "group header" (DT/TP/CM2/FABRIC), not the part.
@@ -3252,6 +3559,33 @@ function computeMissingRequiredSlots () {
hList.forEach(hNoRaw => {
const hNo = normalizeHammaddeNo(hNoRaw)
if (!hNo) return
const reqKeyByMapping = `${mappingMtBolumID}:${hNo}`
// Also allow suppression by the mtBolumID that the placeholder row itself uses (meta override).
const reqKeyByRowMt = (() => {
const rowLike = flatDetailRows.value.find(r => normalizeHammaddeNo(r?.nHammaddeTuruNo) === hNo && Boolean(r?.requiredPlaceholder))
const rowMt = parseInt(String(rowLike?.nUrtMTBolumID ?? rowLike?.NUrtMTBolumID ?? '0'), 10) || 0
return rowMt > 0 ? `${rowMt}:${hNo}` : ''
})()
// User explicitly removed this required placeholder in the UI; don't keep warning about it.
if (suppressed?.[reqKeyByMapping] || (reqKeyByRowMt && suppressed?.[reqKeyByRowMt])) {
// Auto-clear suppression if a real row exists again.
const existing = flatDetailRows.value.find(r => {
if (normalizeHammaddeNo(r?.nHammaddeTuruNo) !== hNo) return false
const rowMtBolumID = parseInt(String(r?.nUrtMTBolumID ?? r?.NUrtMTBolumID ?? '0'), 10) || 0
if (mappingMtBolumID > 0 && rowMtBolumID > 0) return mappingMtBolumID === rowMtBolumID
return normalizeGroupName(r?.sParcaAdi) === mappingParcaAdi
})
if (existing) {
const next = { ...(suppressedRequiredKeys.value || {}) }
delete next[reqKeyByMapping]
if (reqKeyByRowMt) delete next[reqKeyByRowMt]
suppressedRequiredKeys.value = next
schedulePersistLocalDraft()
} else {
return
}
}
const match = flatDetailRows.value.find(r => {
if (normalizeHammaddeNo(r?.nHammaddeTuruNo) !== hNo) return false
@@ -3294,6 +3628,16 @@ function removeDetailRowByKey (rowKey) {
detailGroups.value = nextGroups
syncAllGroupsOpen()
// If user deletes an auto-created required placeholder, suppress the corresponding required key
// so we don't keep recreating/warning about it for this draft.
if (existingRow?.requiredPlaceholder) {
const hNo = normalizeHammaddeNo(existingRow?.nHammaddeTuruNo)
const mtBolumID = parseInt(String(existingRow?.nUrtMTBolumID ?? existingRow?.NUrtMTBolumID ?? '0'), 10) || 0
if (hNo && mtBolumID > 0) {
const reqKey = `${mtBolumID}:${hNo}`
suppressedRequiredKeys.value = { ...(suppressedRequiredKeys.value || {}), [reqKey]: true }
}
}
if (existingRow && !existingRow?.isNew) {
deletedDetailRows.value = [
...(Array.isArray(deletedDetailRows.value) ? deletedDetailRows.value : []),
@@ -3588,17 +3932,79 @@ async function confirmRefresh () {
await fetchDetail({ clearDraft: true, hydrateDraft: false })
}
function scrollToFirstRequiredAttentionRow () {
// Best-effort DOM scroll: q-table renders rowClass on the <tr>.
// This avoids users having to hunt for missing rows after we block save.
try {
const el = document.querySelector('.pcd-detail-row-required')
if (el && typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
} catch {}
}
async function saveChanges () {
saveLoading.value = true
try {
requiredAttentionRowKeys.value = {}
if (isNoCostDetail.value) {
const missing = computeMissingRequiredSlots()
if (missing.length > 0) {
slog.info('production-product-costing.detail', 'required:missing:computed', {
trace_id: traceId.value,
missing_count: missing.length,
sample: missing.slice(0, 20).map(m => ({
mtBolumID: m?.mtBolumID,
parcaAdi: String(m?.parcaAdi || '').trim(),
hNo: String(m?.nHammaddeTuruNo || '').trim(),
rowKey: String(m?.rowKey || '').trim()
}))
})
if (missing.length > 0) {
const rowsHtml = missing.slice(0, 40).map((m, i) => {
const key = String(m?.rowKey || '').trim()
const row = key ? flatDetailRows.value.find(r => String(r?.__rowKey || '').trim() === key) : null
const code = String(row?.sKodu || '').trim()
const desc = String(row?.sAciklama || '').trim()
return `
<tr>
<td style="padding:4px 6px; border-bottom:1px solid #eee;">${i + 1}</td>
<td style="padding:4px 6px; border-bottom:1px solid #eee;">${String(m?.parcaAdi || '').trim()}</td>
<td style="padding:4px 6px; border-bottom:1px solid #eee;">${String(m?.nHammaddeTuruNo || '').trim()}</td>
<td style="padding:4px 6px; border-bottom:1px solid #eee; color:#b71c1c;">${code || '-'}</td>
<td style="padding:4px 6px; border-bottom:1px solid #eee; color:#b71c1c;">${desc || '-'}</td>
</tr>
`
}).join('')
const truncatedNote = missing.length > 40
? `<div style="margin-top:8px; color:#777;">Ilk 40 satir gosterildi. Toplam: ${missing.length}</div>`
: ''
const ok = await new Promise(resolve => {
$q.dialog({
title: 'Eksik Maliyet Parcalari',
message: `Eslestirilen parcalarda fiyat girilmemis satirlar var. Devam etmek istiyor musunuz? (Eksik: ${missing.length})`,
html: true,
message: `
<div>
Eslestirilen parcalarda fiyat girilmemis satirlar var. Devam etmek istiyor musunuz?
<div style="margin-top:6px; font-weight:600;">Eksik: ${missing.length}</div>
<div style="margin-top:8px; max-height:280px; overflow:auto; border:1px solid #eee;">
<table style="width:100%; border-collapse:collapse; font-size:12px;">
<thead>
<tr style="text-align:left; background:#fafafa;">
<th style="padding:4px 6px; border-bottom:1px solid #eee;">No</th>
<th style="padding:4px 6px; border-bottom:1px solid #eee;">Parca</th>
<th style="padding:4px 6px; border-bottom:1px solid #eee;">Hammadde</th>
<th style="padding:4px 6px; border-bottom:1px solid #eee;">Kod</th>
<th style="padding:4px 6px; border-bottom:1px solid #eee;">Aciklama</th>
</tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
</table>
</div>
${truncatedNote}
</div>
`,
cancel: true,
persistent: true
}).onOk(() => resolve(true)).onCancel(() => resolve(false))
@@ -3614,6 +4020,8 @@ async function saveChanges () {
message: 'Eksik parcalar isaretlendi. Fiyat girip tekrar deneyin.',
position: 'top-right'
})
// Scroll to first highlighted row.
nextTick(() => scrollToFirstRequiredAttentionRow())
return
}
}
@@ -3627,6 +4035,17 @@ async function saveChanges () {
return
}
// Validate required header fields
const selectedUretimSekliID = parseInt(String(detailHeader.value?.UretimSekliID || '0').trim() || '0', 10) || 0
if (!(selectedUretimSekliID > 0)) {
$q.notify({
type: 'warning',
message: 'Uretim Sekli secilmeden kayit yapilamaz.',
position: 'top-right'
})
return
}
// Validate: "Kod" (sKodu) is mandatory for any row that has a hammadde type.
// We block saving if there are rows with empty/whitespace code, to avoid sending blank rows to backend.
const blankCodeRows = (Array.isArray(flatDetailRows.value) ? flatDetailRows.value : [])
@@ -3651,6 +4070,24 @@ async function saveChanges () {
}
const header = detailHeader.value
// Debug: backend skips rows where n_onml_det_no <= 0. Log this before sending.
try {
const rows = Array.isArray(flatDetailRows.value) ? flatDetailRows.value : []
const detNoZero = rows.filter(r => (parseInt(String(r?.nOnMLDetNo || '').trim() || '0', 10) || 0) <= 0)
slog.info('production-product-costing.detail', 'save:payload:summary', {
trace_id: traceId.value,
row_count: rows.length,
det_no_zero_count: detNoZero.length,
det_no_zero_sample: detNoZero.slice(0, 10).map(r => ({
rowKey: String(r?.__rowKey || ''),
detNo: String(r?.nOnMLDetNo || ''),
hNo: String(r?.nHammaddeTuruNo || ''),
group: String(r?.sAciklama3 || ''),
code: String(r?.sKodu || '').trim()
}))
})
} catch {}
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,
@@ -3734,6 +4171,27 @@ async function saveChanges () {
}
}
async function exportCostingPDF () {
try {
const n = parseInt(String(onMLNo.value || detailHeader.value?.nOnMLNo || detailHeader.value?.NOnMLNo || '0'), 10) || 0
if (!(n > 0)) {
$q.notify({ type: 'warning', message: 'OnML No bulunamadi.', position: 'top-right' })
return
}
const blob = await download('/pricing/production-product-costing/onml/pdf', {
n_onml_no: n,
trace_id: traceId.value
})
const url = URL.createObjectURL(blob)
window.open(url, '_blank')
window.setTimeout(() => {
try { URL.revokeObjectURL(url) } catch {}
}, 60_000)
} catch (e) {
$q.notify({ type: 'negative', message: await extractApiErrorDetail(e), position: 'top-right' })
}
}
watch(
() => [onMLNo.value, productCode.value, recipeCode.value, detailSource.value],
() => {
@@ -4062,6 +4520,22 @@ watch(
overflow: visible !important;
}
.pcd-detail-table :deep(.q-table) {
table-layout: fixed;
width: 100%;
}
.pcd-detail-table :deep(.q-table th),
.pcd-detail-table :deep(.q-table td) {
overflow: hidden;
text-overflow: ellipsis;
}
.pcd-detail-table :deep(.q-table tbody td) {
white-space: normal;
word-break: break-word;
}
.pcd-detail-table :deep(.q-table thead) {
display: table-header-group !important;
}