Files
bssapp/ui/src/pages/ProductionProductCostingHasCostDetail.vue
T

5856 lines
209 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<q-page v-if="canReadOrder" class="pcd-page" :style="pageVars">
<div ref="stickyStackRef" class="sticky-stack pcd-sticky-stack">
<div ref="saveToolbarRef" class="save-toolbar pcd-save-toolbar q-px-md">
<div class="pcd-toolbar-row">
<div class="pcd-toolbar-left">
<div class="pcd-toolbar-title">Maliyet Detay Sayfasi</div>
<div v-if="detailHeader && !detailLoading" class="pcd-toolbar-summary">
<!-- tbStok kontrolu (exists-bulk) manuel giris kapandigi icin devre disi -->
<div class="pcd-toolbar-summary-row">
<div class="pcd-toolbar-pill pcd-toolbar-pill-emphasis">
<span class="pcd-toolbar-pill-label">USD</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(toolbarSummary.usdTotal) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-emphasis">
<span class="pcd-toolbar-pill-label">TRY</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(toolbarSummary.tryTotal) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-emphasis">
<span class="pcd-toolbar-pill-label">EUR</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(toolbarSummary.eurTotal) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-emphasis">
<span class="pcd-toolbar-pill-label">GBP</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(toolbarSummary.gbpTotal) }}</span>
</div>
</div>
<div class="pcd-toolbar-summary-row">
<div class="pcd-toolbar-pill pcd-toolbar-pill-neutral">
<span class="pcd-toolbar-pill-label">USD Kur</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(exchangeRates.usdRate) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-neutral">
<span class="pcd-toolbar-pill-label">EUR Kur</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(exchangeRates.eurRate) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-neutral">
<span class="pcd-toolbar-pill-label">GBP Kur</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(exchangeRates.gbpRate) }}</span>
</div>
</div>
</div>
</div>
<div class="pcd-toolbar-actions">
<div
v-if="last10WarningCount > 0"
class="pcd-toolbar-pill pcd-toolbar-pill-warn"
style="cursor:pointer;"
title="Son 10 ort. fiyata gore %10+ sapma var"
@click="last10WarningDialogOpen = true"
>
<span class="pcd-toolbar-pill-label">Fiyat Uyari</span>
<span class="pcd-toolbar-pill-value">{{ last10WarningCount }}</span>
</div>
<q-btn
flat
dense
color="grey-7"
class="pcd-toolbar-btn"
:label="summaryOpen ? 'OZET GIZLE' : 'OZET GOSTER'"
:icon="summaryOpen ? 'visibility_off' : 'visibility'"
@click="summaryOpen = !summaryOpen"
/>
<q-btn
flat
dense
color="grey-7"
class="pcd-toolbar-btn"
:label="headerInfoCollapsed ? 'HEADER GOSTER' : 'HEADER DARALT'"
:icon="headerInfoCollapsed ? 'expand_more' : 'expand_less'"
@click="toggleHeaderInfo"
/>
<q-btn icon="arrow_back" label="Geri" dense flat color="grey-8" class="pcd-toolbar-btn" @click="goBack" />
<q-btn
label="Yenile"
icon="refresh"
dense
color="primary"
class="pcd-toolbar-btn"
:loading="detailLoading"
@click="confirmRefresh"
/>
<q-btn
label="Toplu Fiyat Cagir"
icon="playlist_add_check"
dense
color="secondary"
outline
class="pcd-toolbar-btn"
:loading="bulkPriceLoading"
:disable="!detailHeader || detailLoading || saveLoading || bulkPriceLoading"
@click="fetchBulkItemPrices"
/>
<q-btn
label="SATIR EKLE"
dense
color="secondary"
icon="add"
class="pcd-toolbar-btn"
:disable="!detailHeader || detailLoading || saveLoading || bulkPriceLoading"
@click="openNewRowDialog"
/>
<q-btn
label="Kaydet"
icon="save"
dense
color="primary"
outline
class="pcd-toolbar-btn"
:loading="saveLoading"
: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"
dense
color="negative"
outline
class="pcd-toolbar-btn"
:disable="!canDeleteCosting || detailLoading || saveLoading || bulkPriceLoading"
@click="deleteCosting"
>
<q-tooltip v-if="!canDeleteCosting">Once maliyet olusturulunca aktif olur</q-tooltip>
</q-btn>
</div>
</div>
</div>
<q-banner v-if="detailError" class="bg-red text-white q-mb-md">
Hata: {{ detailError }}
</q-banner>
<q-expansion-item
v-if="detailHeader && !detailLoading"
v-model="summaryOpen"
dense
expand-separator
class="pcd-summary-expansion q-mx-md q-mb-md"
icon="summarize"
label="Ozet"
>
<div class="row q-col-gutter-md q-pa-sm">
<div class="col-12 col-lg-6">
<div class="pcd-summary-title">Maliyetlere Islenen Toplam Tutar</div>
<q-markup-table dense flat bordered class="pcd-summary-table">
<tbody>
<tr>
<td class="pcd-summary-k">USD</td>
<td class="pcd-summary-v">{{ formatMoney(mailSummary.headerTotals.usd) }}</td>
<td class="pcd-summary-k">TRY</td>
<td class="pcd-summary-v">{{ formatMoney(mailSummary.headerTotals.try) }}</td>
</tr>
<tr>
<td class="pcd-summary-k">EUR</td>
<td class="pcd-summary-v">{{ formatMoney(mailSummary.headerTotals.eur) }}</td>
<td class="pcd-summary-k">GBP</td>
<td class="pcd-summary-v">{{ formatMoney(mailSummary.headerTotals.gbp) }}</td>
</tr>
</tbody>
</q-markup-table>
</div>
<div class="col-12 col-lg-6">
<div class="pcd-summary-title">Iscilik Fiyatlari (CM2)</div>
<q-markup-table dense flat bordered class="pcd-summary-table">
<thead>
<tr>
<th class="text-left">Parca</th>
<th class="text-right">Giris</th>
<th class="text-left">Pr.Br.</th>
<th class="text-right">USD Tutar</th>
<th class="text-right">TRY Tutar</th>
<th class="text-center">CMT/Malzemeli</th>
</tr>
</thead>
<tbody>
<tr v-for="r in mailSummary.laborRows" :key="'labor-'+r.part">
<td class="text-left">{{ r.part }}</td>
<td class="text-right">{{ r.inputAmountLabel }}</td>
<td class="text-left">{{ r.inputCurrencyLabel }}</td>
<td class="text-right">{{ formatMoney(r.usd) }}</td>
<td class="text-right">{{ formatMoney(r.try) }}</td>
<td class="text-center">{{ r.hasCmtOrMalzemeli ? '✓' : '' }}</td>
</tr>
<tr class="pcd-summary-total-row">
<td class="text-left text-weight-bold">TOPLAM</td>
<td></td>
<td></td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.laborRows.reduce((a, x) => a + (x.usd || 0), 0)) }}</td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.laborRows.reduce((a, x) => a + (x.try || 0), 0)) }}</td>
<td></td>
</tr>
</tbody>
</q-markup-table>
</div>
<div class="col-12 col-lg-6">
<div class="pcd-summary-title">Malzeme Fiyatlari (DT/TP, maliyete dahil)</div>
<q-markup-table dense flat bordered class="pcd-summary-table">
<thead>
<tr>
<th class="text-left">Parca</th>
<th class="text-right">USD Tutar</th>
<th class="text-right">TRY Tutar</th>
</tr>
</thead>
<tbody>
<tr v-for="r in mailSummary.materialRows" :key="'mat-'+r.part">
<td class="text-left">{{ r.part }}</td>
<td class="text-right">{{ formatMoney(r.usd) }}</td>
<td class="text-right">{{ formatMoney(r.try) }}</td>
</tr>
<tr class="pcd-summary-total-row">
<td class="text-left text-weight-bold">TOPLAM</td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.materialRows.reduce((a, x) => a + (x.usd || 0), 0)) }}</td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.materialRows.reduce((a, x) => a + (x.try || 0), 0)) }}</td>
</tr>
</tbody>
</q-markup-table>
</div>
<div class="col-12 col-lg-6">
<div class="pcd-summary-title">Kumas Fiyatlari (FABRIC, maliyete dahil)</div>
<q-markup-table dense flat bordered class="pcd-summary-table">
<thead>
<tr>
<th class="text-left">Parca</th>
<th class="text-right">Metraj</th>
<th class="text-right">MT Giris Fiyat</th>
<th class="text-left">Pr.Br.</th>
<th class="text-right">USD Tutar</th>
<th class="text-right">TRY Tutar</th>
</tr>
</thead>
<tbody>
<tr v-for="r in mailSummary.fabricRows" :key="'fab-'+r.part">
<td class="text-left">{{ r.part }}</td>
<td class="text-right">{{ r.meterLabel }}</td>
<td class="text-right">{{ r.inputUnitLabel }}</td>
<td class="text-left">{{ r.inputCurrencyLabel }}</td>
<td class="text-right">{{ formatMoney(r.usd) }}</td>
<td class="text-right">{{ formatMoney(r.try) }}</td>
</tr>
<tr class="pcd-summary-total-row">
<td class="text-left text-weight-bold">TOPLAM</td>
<td class="text-right text-weight-bold">
{{
(() => {
const total = mailSummary.fabricRows.reduce((a, x) => a + (Number(x.meterQty || 0) || 0), 0)
return total > 0 ? `${formatBarQuantity(total)} MT` : '-'
})()
}}
</td>
<td></td>
<td></td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.fabricRows.reduce((a, x) => a + (x.usd || 0), 0)) }}</td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.fabricRows.reduce((a, x) => a + (x.try || 0), 0)) }}</td>
</tr>
</tbody>
</q-markup-table>
</div>
</div>
</q-expansion-item>
<q-dialog v-model="last10WarningDialogOpen" persistent>
<q-card style="min-width: 860px; max-width: 92vw;">
<q-card-section class="row items-center">
<div class="text-h6">Son 10 Ort. Fiyat Sapmalari</div>
<q-space />
<q-btn
dense
outline
color="secondary"
icon="download"
label="Excel"
class="q-mr-sm"
:disable="!last10Warnings || last10Warnings.length === 0"
@click="downloadLast10WarningsExcel"
/>
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-separator />
<q-card-section style="max-height: 70vh; overflow: auto;">
<q-markup-table dense flat bordered>
<thead>
<tr>
<th class="text-left">Kod</th>
<th class="text-left">Aciklama</th>
<th class="text-left">Doviz</th>
<th class="text-right">Giris</th>
<th class="text-right">Ort10</th>
<th class="text-right">Sapma %</th>
<th class="text-right">Sample</th>
<th class="text-left">Tarih Araligi</th>
</tr>
</thead>
<tbody>
<tr v-for="w in last10Warnings" :key="w.item_code + '|' + w.currency_code">
<td class="text-left">{{ w.item_code }}</td>
<td class="text-left">{{ w.item_description || '' }}</td>
<td class="text-left">{{ w.currency_code }}</td>
<td class="text-right">{{ formatMoney(w.input_price) }}</td>
<td class="text-right">{{ formatMoney(w.avg_doc_price) }}</td>
<td class="text-right">{{ formatPercent(w.diff_ratio) }}</td>
<td class="text-right">{{ w.sample_count }}</td>
<td class="text-left">{{ (w.min_invoice_date || '-') + ' / ' + (w.max_invoice_date || '-') }}</td>
</tr>
<tr v-if="last10Warnings.length === 0">
<td colspan="8" class="text-center text-grey-7">Kayit yok</td>
</tr>
</tbody>
</q-markup-table>
</q-card-section>
</q-card>
</q-dialog>
<div v-if="detailHeader && !detailLoading && !headerInfoCollapsed" class="filter-bar pcd-detail-header-bar q-mx-md q-mb-md">
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-3">
<q-input
dense
filled
readonly
label="Maliyet Tarihi"
:model-value="formatDateTR(costDate)"
class="pcd-emphasis-field-alt"
>
<template #append>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date v-model="costDate" mask="YYYY-MM-DD" locale="tr-TR" />
</q-popup-proxy>
</q-icon>
</template>
</q-input>
</div>
<div class="col-12 col-md-3">
<q-select
v-model="detailHeader.UretimSekliID"
:options="productionTypes"
option-value="id"
option-label="aciklama"
emit-value
map-options
dense
filled
label="Uretim Sekli"
class="pcd-emphasis-field-alt"
@update:model-value="onUretimSekliChange"
/>
</div>
<div class="col-12 col-md-4">
<q-input dense filled readonly label="Uretimi Yapan Firma" :model-value="detailHeader.UretimiYapanFirma || '-'" />
</div>
<div class="col-12 col-md-4">
<q-input dense filled readonly label="2.Firma" :model-value="detailHeader.SonIsEmriVeren || '-'" />
</div>
<div class="col-6 col-md-2">
<q-input dense filled readonly label="nOnMLNo" :model-value="detailHeader.nOnMLNo || '-'" />
</div>
<div class="col-6 col-md-2">
<q-input dense filled readonly label="UrunKodu" :model-value="detailHeader.UrunKodu || '-'" />
</div>
<div class="col-12 col-md-3">
<q-input dense filled readonly label="UrunAdi" :model-value="detailHeader.UrunAdi || '-'" />
</div>
<div class="col-12 col-md-3">
<q-input dense filled readonly label="Urun Ilk Grubu" :model-value="detailHeader.UrunIlkGrubu || '-'" />
</div>
<div class="col-12 col-md-3">
<q-input dense filled readonly label="Urun Ana Grubu" :model-value="detailHeader.UrunAnaGrubu || '-'" />
</div>
<div class="col-12 col-md-3">
<q-input dense filled readonly label="Urun Alt Grubu" :model-value="detailHeader.UrunAltGrubu || '-'" />
</div>
<div class="col-6 col-md-2">
<q-input dense filled readonly label="sKullaniciAdi" :model-value="detailHeader.sKullaniciAdi || '-'" />
</div>
<div class="col-12 col-md-3">
<q-input dense filled readonly label="Son Guncelleme Tarihi" :model-value="formatDateTR(detailHeader.dteGuncellemeTarihi)" />
</div>
<div class="col-12 col-md-2">
<q-input dense filled readonly label="sGuncellemeKullaniciAdi" :model-value="detailHeader.sGuncellemeKullaniciAdi || '-'" />
</div>
<div class="col-6 col-md-2">
<q-input dense filled readonly label="nUrtReceteID" :model-value="detailHeader.nUrtReceteID || '-'" />
</div>
<div v-if="false && partSummary && partSummary.length > 0" class="col-12">
<div class="pcd-part-summary-card">
<div class="pcd-part-summary-title">Parça Bazlı Maliyet Özellikleri</div>
<q-markup-table dense flat bordered separator="cell" class="pcd-part-summary-table">
<thead>
<tr>
<th class="text-left">Parça</th>
<th class="text-right">TRY</th>
<th class="text-right">USD</th>
<th class="text-right">EUR</th>
</tr>
</thead>
<tbody>
<tr v-for="ps in partSummary" :key="ps.name">
<td class="text-left text-weight-bold">{{ ps.name }}</td>
<td class="text-right">{{ formatMoney(ps.try) }}</td>
<td class="text-right">{{ formatMoney(ps.usd) }}</td>
<td class="text-right">{{ formatMoney(ps.eur) }}</td>
</tr>
</tbody>
</q-markup-table>
</div>
</div>
</div>
</div>
<div v-if="detailHeader && !detailLoading && headerInfoCollapsed" class="filter-bar pcd-detail-header-bar q-mx-md q-mb-md">
<div class="row q-col-gutter-sm">
<div class="col-6 col-md-2">
<q-input dense filled readonly label="nOnMLNo" :model-value="detailHeader.nOnMLNo || '-'" />
</div>
<div class="col-12 col-md-3">
<q-input dense filled readonly label="Uretimi Yapan Firma" :model-value="detailHeader.UretimiYapanFirma || '-'" />
</div>
<div class="col-12 col-md-3">
<q-input dense filled readonly label="2.Firma" :model-value="detailHeader.SonIsEmriVeren || '-'" />
</div>
<div class="col-6 col-md-2">
<q-input dense filled readonly label="UrunKodu" :model-value="detailHeader.UrunKodu || '-'" />
</div>
<div class="col-12 col-md-2">
<q-input dense filled readonly label="Uretim Sekli" :model-value="formatUretimSekli(detailHeader)" />
</div>
</div>
</div>
</div>
<div v-if="detailLoading" class="row justify-center q-pa-lg">
<q-spinner color="primary" size="36px" />
</div>
<div v-else-if="detailGroups.length === 0" class="text-grey-7 q-pa-md">
Kayit bulunamadi.
</div>
<div v-else class="column q-gutter-md pcd-content-body">
<div
v-for="(grp, gi) in detailGroups"
:key="groupKey(grp, gi)"
class="pcd-group-card"
>
<div class="order-sub-header pcd-sub-header">
<div class="sub-left">
<span class="pcd-sub-title">{{ grp.sAciklama3 || 'TANIMSIZ' }}</span>
<span v-if="normalizeGroupName(grp.sAciklama3) === 'FABRIC'" class="pcd-sub-mt-qty">
Toplam Miktar: {{ formatBarQuantity(resolveGroupQuantity(grp)) }} MT
</span>
</div>
<div class="sub-right pcd-sub-right-clickable" @click="toggleGroup(grp, gi)">
Grup Toplami TRY: {{ formatBarMoney(resolveGroupTRYTutar(grp)) }} | USD: {{ formatBarMoney(resolveGroupUSDTutar(grp)) }}
<q-icon
:name="isGroupOpen(grp, gi) ? 'expand_less' : 'expand_more'"
size="18px"
class="q-ml-sm"
/>
</div>
</div>
<q-table
v-if="isGroupOpen(grp, gi)"
class="pcd-detail-table"
dense
flat
bordered
separator="cell"
row-key="__rowKey"
:rows="grp.items"
:columns="detailColumns"
:table-row-class-fn="resolveDetailRowClass"
@row-click="onDetailRowClick"
hide-bottom
:rows-per-page-options="[0]"
>
<template #header-cell-lMiktar="props">
<q-th :props="props" class="pcd-entry-header">
{{ props.col.label }}
</q-th>
</template>
<template #header-cell-inputPrice="props">
<q-th :props="props" class="pcd-entry-header">
{{ props.col.label }}
</q-th>
</template>
<template #header-cell-inputPricePrBr="props">
<q-th :props="props" class="pcd-entry-header">
{{ props.col.label }}
</q-th>
</template>
<template #header-cell-maliyeteDahil="props">
<q-th :props="props" class="pcd-secondary-header">
{{ props.col.label }}
</q-th>
</template>
<template #header-cell-cmPriceType="props">
<q-th :props="props" class="pcd-secondary-header">
{{ props.col.label }}
</q-th>
</template>
<template #body-cell-actions="props">
<q-td :props="props" class="q-gutter-xs no-wrap">
<q-btn
flat
round
dense
size="sm"
color="primary"
icon="history"
@click.stop="openLineHistory(props.row)"
>
<q-tooltip>Eski Fiyatlar / Geçmiş</q-tooltip>
</q-btn>
<q-icon
name="edit"
size="xs"
color="grey-7"
class="cursor-pointer"
@click.stop="openRowEditorForEdit(props.row)"
>
<q-tooltip>Satırı Düzenle</q-tooltip>
</q-icon>
</q-td>
</template>
<template #body-cell-maliyeteDahil="props">
<q-td :props="props" class="text-center pcd-secondary-cell">
<q-checkbox
v-model="props.row.maliyeteDahil"
color="primary"
keep-color
dense
@update:model-value="value => onRowMaliyeteDahilChange(props.row, value)"
/>
</q-td>
</template>
<template #body-cell-cmPriceType="props">
<q-td :props="props" class="text-center pcd-secondary-cell">
<q-checkbox
v-if="isCMGroupName(props.row.sAciklama3)"
:model-value="resolveCMPriceTypeChecked(props.row)"
color="secondary"
keep-color
dense
@update:model-value="value => onRowCMPriceTypeChange(props.row, value)"
/>
<span v-else class="text-grey-6">-</span>
</q-td>
</template>
<template #body-cell-nOnMLDetNo="props">
<q-td :props="props">
<div v-if="props.row.isNew" class="text-primary text-weight-bold">YENI</div>
<div>{{ props.value }}</div>
</q-td>
</template>
<template #body-cell-sParcaAdi="props">
<q-td :props="props">
{{ props.value || '-' }}
</q-td>
</template>
<template #body-cell-nHammaddeTuruNo="props">
<q-td :props="props">
<div class="text-weight-medium" style="font-size: 1.1em;">
{{ props.value }}<span v-if="props.row.sHammaddeTuruAdi"> - {{ props.row.sHammaddeTuruAdi }}</span>
</div>
</q-td>
</template>
<template #body-cell-sKodu="props">
<q-td
:props="props"
:class="[resolveAutoOrICodeHighlightClass(props.row), resolveMissingStockCodeClass(props.row)]"
>
<span>{{ props.value }}</span>
</q-td>
</template>
<template #body-cell-sAciklama="props">
<q-td
:props="props"
:class="[resolveAutoOrICodeHighlightClass(props.row), resolveMissingStockCodeClass(props.row)]"
>
<span>{{ props.value }}</span>
</q-td>
</template>
<template #body-cell-sRenk="props">
<q-td :props="props">
<span>{{ props.value }}</span>
</q-td>
</template>
<template #body-cell-lMiktar="props">
<q-td :props="props">
<q-input
v-model="props.row.miktarInput"
class="pcd-inline-input pcd-entry-input"
color="primary"
dense
filled
@update:model-value="value => onRowQuantityInput(props.row, value)"
@blur="normalizeRowQuantityDisplay(props.row)"
input-class="text-right"
inputmode="decimal"
placeholder="0,0000"
/>
</q-td>
</template>
<template #body-cell-inputPrice="props">
<q-td :props="props">
<q-input
v-model="props.row.inputPrice"
class="pcd-inline-input pcd-entry-input"
color="primary"
dense
filled
@update:model-value="value => onRowInputPriceChange(props.row, value)"
@blur="normalizeRowPriceDisplay(props.row)"
input-class="text-right"
inputmode="decimal"
placeholder="0,00"
/>
</q-td>
</template>
<template #body-cell-inputPricePrBr="props">
<q-td :props="props">
<q-select
v-model="props.row.inputPricePrBr"
class="pcd-inline-input pcd-entry-input"
:options="priceCurrencyOptions"
color="primary"
dense
filled
@update:model-value="value => onRowInputPriceCurrencyChange(props.row, value)"
emit-value
map-options
options-dense
/>
</q-td>
</template>
<template #body-cell-sBirim="props">
<q-td :props="props">
<span>{{ props.value }}</span>
</q-td>
</template>
</q-table>
</div>
</div>
</q-page>
<q-page v-else class="q-pa-md flex flex-center">
<div class="text-negative text-subtitle1">
Bu module erisim yetkiniz yok.
</div>
</q-page>
<q-dialog v-model="rowEditorDialogOpen" persistent @show="resetRowEditorDialogPosition" @hide="stopRowEditorDialogDrag">
<q-card class="pcd-row-editor-dialog" :style="rowEditorDialogStyle">
<q-card-section
class="row items-center justify-between q-pb-sm pcd-row-editor-drag-handle"
@mousedown.prevent.stop="startRowEditorDialogDrag"
>
<div class="text-subtitle1 text-weight-bold">
{{ rowEditorMode === 'edit' ? 'Satir Duzenle' : 'Yeni Satir Ekle' }}
</div>
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-separator />
<q-card-section class="q-pa-md">
<div class="row q-col-gutter-md">
<div class="col-12 col-md-2">
<q-input dense filled readonly label="No" v-model="rowEditorForm.nOnMLDetNo" />
</div>
<div class="col-12 col-md-3">
<q-input dense filled readonly label="Parca Adi" v-model="rowEditorForm.sParcaAdi" />
</div>
<div class="col-12 col-md-3">
<q-input dense filled readonly label="Parca Grubu" v-model="rowEditorForm.sAciklama3" />
</div>
<div class="col-12 col-md-4">
<q-select
v-model="rowEditorForm.nHammaddeTuruNo"
class="pcd-row-editor-entry"
:options="rowEditorHammaddeOptions"
:loading="rowEditorHammaddeLoading"
option-value="value"
option-label="label"
emit-value
map-options
use-input
fill-input
hide-selected
input-debounce="250"
dense
filled
label="Hammadde Turu"
placeholder="Aramak icin yazin"
@filter="filterRowEditorHammaddeOptions"
@update:model-value="onRowEditorHammaddeChange"
/>
</div>
<div class="col-12 col-md-6">
<q-select
v-model="rowEditorForm.sKodu"
class="pcd-row-editor-entry"
:options="rowEditorItemOptions"
:loading="rowEditorItemLoading"
option-value="value"
option-label="label"
emit-value
map-options
use-input
fill-input
hide-selected
input-debounce="350"
dense
filled
label="Kod / Aciklama"
placeholder="En az 2 karakter yazin"
@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
v-model="rowEditorForm.ColorCode"
class="pcd-row-editor-entry"
:options="rowEditorColorOptions"
:loading="rowEditorColorLoading"
option-value="value"
option-label="label"
emit-value
map-options
use-input
fill-input
hide-selected
input-debounce="250"
dense
filled
label="Renk"
placeholder="Aramak icin yazin"
@filter="filterRowEditorColorOptions"
@update:model-value="onRowEditorColorChange"
/>
</div>
<div class="col-12 col-md-2">
<q-input v-model="rowEditorForm.miktarInput" class="pcd-row-editor-entry" dense filled label="Miktar" input-class="text-right" inputmode="decimal" />
</div>
<div class="col-12 col-md-2">
<q-input v-model="rowEditorForm.inputPrice" class="pcd-row-editor-entry" dense filled label="Fiyat Giris" input-class="text-right" inputmode="decimal" />
</div>
<div class="col-12 col-md-2">
<q-select
v-model="rowEditorForm.inputPricePrBr"
class="pcd-row-editor-entry"
:options="priceCurrencyOptions"
emit-value
map-options
options-dense
dense
filled
label="Pr.Br."
/>
</div>
<div class="col-12 col-md-3">
<q-input dense filled readonly label="Birim" v-model="rowEditorForm.sBirim" />
</div>
<div class="col-12 col-md-2 pcd-row-editor-flag" style="display:flex;align-items:center;">
<q-checkbox v-model="rowEditorForm.maliyeteDahil" label="Maliyete Dahil" color="primary" keep-color />
</div>
<div v-if="isCMGroupName(rowEditorForm.sAciklama3)" class="col-12 col-md-3 pcd-row-editor-flag" style="display:flex;align-items:center;">
<q-checkbox v-model="rowEditorForm.cmPriceTypeChecked" label="CMT Malzeme Dahil" color="secondary" keep-color />
</div>
</div>
</q-card-section>
<q-card-actions align="right" class="q-pa-md">
<q-btn
v-if="rowEditorMode === 'edit'"
flat
color="negative"
icon="delete"
label="Satiri Sil"
class="q-mr-auto"
@click="deleteRowEditor"
/>
<q-btn flat label="Vazgec" color="grey-7" v-close-popup />
<q-btn color="secondary" icon="save" label="Satira Uygula" @click="saveRowEditor" />
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="priceDeviationDialogOpen" persistent>
<q-card class="pcd-price-deviation-dialog">
<q-card-section class="row items-center justify-between q-pb-sm">
<div>
<div class="text-subtitle1 text-weight-bold">Fiyat Kontrolu (Satinalma Ortalama)</div>
<div class="text-caption text-grey-7">
NEBIM_V3 satinalma gecmisindeki son 10 USD ortalamasindan %10'dan fazla sapan tum satirlar.
</div>
</div>
<q-badge color="primary" outline>{{ priceDeviationRows.length }} satir</q-badge>
</q-card-section>
<q-separator />
<q-card-section class="q-pa-sm">
<div class="row items-center q-gutter-sm q-mb-sm">
<q-checkbox
:model-value="priceDeviationAllSelected"
color="primary"
label="Tumunu sec"
@update:model-value="setAllPriceDeviationRowsSelected"
/>
<q-btn
dense
outline
color="primary"
icon="price_change"
label="Secilileri Ortalama ile Degistir"
:disable="!priceDeviationRows.some(r => r.selected)"
@click="applySelectedPriceDeviationAverages"
/>
</div>
<div class="pcd-price-deviation-table-wrap">
<table class="pcd-price-deviation-table">
<thead>
<tr>
<th>Sec</th>
<th>Kod</th>
<th>Ort USD</th>
<th>Girilen USD</th>
<th>Fark %</th>
<th>Yeni Fiyat</th>
<th>Pr.Br.</th>
</tr>
</thead>
<tbody>
<tr v-for="row in priceDeviationRows" :key="row.key">
<td class="text-center">
<q-checkbox v-model="row.selected" dense color="primary" />
</td>
<td class="text-weight-bold text-no-wrap">{{ row.code }}</td>
<td class="text-right">{{ formatMoney(row.avgUSD) }}</td>
<td class="text-right">{{ formatMoney(row.enteredUSD) }}</td>
<td class="text-right text-weight-bold" :class="row.pct >= 0 ? 'text-negative' : 'text-positive'">
{{ formatSignedPercent(row.pct) }}
</td>
<td>
<q-input
v-model="row.newPrice"
dense
filled
input-class="text-right"
inputmode="decimal"
:disable="!row.selected"
/>
</td>
<td>
<q-select
v-model="row.newCurrency"
dense
filled
emit-value
map-options
options-dense
:options="priceCurrencyOptions"
:disable="!row.selected"
/>
</td>
</tr>
</tbody>
</table>
</div>
</q-card-section>
<q-separator />
<q-card-actions align="right" class="q-pa-md">
<q-btn flat color="grey-7" label="Geri Don" @click="cancelPriceDeviationDialog" />
<q-btn color="primary" icon="save" label="Onayla ve Kaydet" @click="confirmPriceDeviationDialog" />
</q-card-actions>
</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">
<div>
<div class="text-subtitle1 text-weight-bold">Eski Satinalma ve Recete Fiyatlari</div>
<div class="text-caption text-grey-7">
{{ lineHistoryTargetSummary }}
</div>
</div>
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-separator />
<q-card-section class="q-pa-md">
<q-banner v-if="lineHistoryError" class="bg-red text-white q-mb-md">
Hata: {{ lineHistoryError }}
</q-banner>
<q-banner v-if="lineHistoryShowingFallback" class="bg-amber-1 text-amber-9 q-mb-md rounded-borders border-amber">
<template v-slot:avatar>
<q-icon name="info" color="amber-9" />
</template>
Tam eşleşme bulunamadı. Benzer kodlara (prefix) ait geçmiş fiyatlar gösteriliyor.
</q-banner>
<div v-if="lineHistoryCanFetchSimilar || lineHistoryCanFetchAlternative" class="row q-col-gutter-sm q-mb-md">
<div v-if="lineHistorySearchMode !== 'exact'" class="col-auto">
<q-btn
label="Geri (Tam Eslesme)"
icon="arrow_back"
color="grey-7"
outline
no-caps
@click="fetchSimilarItemHistory('exact')"
/>
</div>
<div v-if="lineHistoryCanFetchSimilar" class="col-auto">
<q-btn
label="Benzer Kodları Göster"
icon="travel_explore"
color="secondary"
outline
no-caps
:loading="lineHistoryLoading && lineHistorySearchMode === 'prefix'"
@click="fetchSimilarItemHistory('prefix')"
/>
</div>
<div v-if="lineHistoryCanFetchAlternative" class="col-auto">
<q-btn
label="Diğer Alternatifleri Göster"
icon="alt_route"
color="primary"
outline
no-caps
:loading="lineHistoryLoading && lineHistorySearchMode === 'alternative'"
@click="fetchSimilarItemHistory('alternative')"
/>
</div>
</div>
<div v-if="lineHistoryLoading" class="row justify-center q-pa-lg">
<q-spinner color="primary" size="32px" />
</div>
<div v-else-if="lineHistoryRows.length === 0" class="column items-center q-pa-xl">
<q-icon name="history" size="64px" color="grey-4" />
<div class="text-grey-7 q-mt-md text-center">
Bu kalem için geçmiş hareket bulunamadı.
<div v-if="lineHistoryTargetHammaddeTuruNo" class="q-mt-sm">
<q-btn
label="Hammadde Türü Bazında Benzer Ürünleri Göster"
icon="travel_explore"
color="secondary"
outline
no-caps
:loading="lineHistoryLoading"
@click="fetchSimilarItemHistory('prefix')"
/>
</div>
</div>
</div>
<div v-else>
<q-table
class="pcd-history-table"
dense
flat
bordered
row-key="__historyKey"
:rows="lineHistoryRows"
:columns="lineHistoryColumns"
:table-row-class-fn="resolveLineHistoryRowClass"
:rows-per-page-options="[0]"
hide-bottom
>
<template v-slot:body-cell-sourceLabel="props">
<q-td :props="props">
<span class="pcd-history-source-chip" :class="`pcd-history-source-chip--${String(props.row.sourceType || '').toLowerCase()}`">
{{ props.row.sourceLabel }}
</span>
</q-td>
</template>
<template v-slot:body-cell-price="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.price) }}
</q-td>
</template>
<template v-slot:body-cell-quantity="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.quantity) }}
</q-td>
</template>
<template v-slot:body-cell-amount="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.amount) }}
</q-td>
</template>
<template v-slot:body-cell-select="props">
<q-td :props="props" class="text-right">
<q-btn
label="Sec"
color="primary"
dense
unelevated
@click="applyLineHistorySelection(props.row)"
/>
</q-td>
</template>
</q-table>
</div>
</q-card-section>
</q-card>
</q-dialog>
</template>
<script setup>
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, download, extractApiErrorDetail } from 'src/services/api'
import { createTraceId, slog } from 'src/utils/slog'
import { formatDataSourceLabel } from 'src/utils/formatters'
const route = useRoute()
const router = useRouter()
const $q = useQuasar()
const { canRead } = usePermission()
const canReadOrder = canRead('order')
const detailLoading = ref(false)
const detailError = ref('')
const detailGroups = ref([])
const detailHeader = ref(null)
const costDate = ref('')
const exchangeRates = ref(createEmptyExchangeRates())
const deletedDetailRows = ref([])
const localDraftTimer = ref(0)
const initialHeaderSnapshot = ref('')
const saveLoading = ref(false)
const bulkPriceLoading = ref(false)
const newRowSequence = ref(0)
const ROW_EDITOR_OPTIONS_LIMIT = 100
const ROW_EDITOR_ITEM_MIN_SEARCH_LENGTH = 2
const LINE_HISTORY_ROW_LIMIT = 500
const LINE_HISTORY_COMBINED_ROW_LIMIT = LINE_HISTORY_ROW_LIMIT * 2
const rowEditorDialogOpen = ref(false)
const rowEditorMode = ref('new')
const rowEditorTargetRowKey = ref('')
const rowEditorForm = ref(createRowEditorForm())
const priceDeviationDialogOpen = ref(false)
const priceDeviationRows = ref([])
const priceDeviationResolver = ref(null)
const fabricCopyDialogOpen = ref(false)
const fabricCopySelectedKey = ref('')
// Draggable "Satir Duzenle" dialog (mouse drag by header).
const rowEditorDialogPos = ref({ x: 0, y: 0 })
const rowEditorDialogPanBase = ref({ x: 0, y: 0 })
const rowEditorDialogDragging = ref(false)
const rowEditorDialogDragMouseStart = ref({ x: 0, y: 0 })
const rowEditorDialogDragPosStart = ref({ x: 0, y: 0 })
const rowEditorDialogStyle = computed(() => ({
transform: `translate(${Number(rowEditorDialogPos.value?.x || 0)}px, ${Number(rowEditorDialogPos.value?.y || 0)}px)`
}))
function resetRowEditorDialogPosition () {
rowEditorDialogPos.value = { x: 0, y: 0 }
rowEditorDialogPanBase.value = { x: 0, y: 0 }
}
function startRowEditorDialogDrag (e) {
if (!e) return
rowEditorDialogDragging.value = true
rowEditorDialogDragMouseStart.value = { x: Number(e.clientX || 0), y: Number(e.clientY || 0) }
rowEditorDialogDragPosStart.value = { x: Number(rowEditorDialogPos.value?.x || 0), y: Number(rowEditorDialogPos.value?.y || 0) }
window.addEventListener('mousemove', onRowEditorDialogDragMove, true)
window.addEventListener('mouseup', stopRowEditorDialogDrag, true)
}
function onRowEditorDialogDragMove (e) {
if (!rowEditorDialogDragging.value) return
const startMouse = rowEditorDialogDragMouseStart.value || { x: 0, y: 0 }
const startPos = rowEditorDialogDragPosStart.value || { x: 0, y: 0 }
const dx = Number(e?.clientX || 0) - Number(startMouse.x || 0)
const dy = Number(e?.clientY || 0) - Number(startMouse.y || 0)
rowEditorDialogPos.value = { x: Number(startPos.x || 0) + dx, y: Number(startPos.y || 0) + dy }
}
function stopRowEditorDialogDrag () {
rowEditorDialogDragging.value = false
try {
window.removeEventListener('mousemove', onRowEditorDialogDragMove, true)
window.removeEventListener('mouseup', stopRowEditorDialogDrag, true)
} catch {}
}
const rowEditorHammaddeOptions = ref([])
const rowEditorHammaddeAllOptions = ref([])
const rowEditorHammaddeLoading = ref(false)
const rowEditorItemOptions = ref([])
const rowEditorItemAllOptions = ref([])
const rowEditorItemLoading = ref(false)
const rowEditorLastValidItemValue = ref('')
const rowEditorColorOptions = ref([])
const rowEditorColorAllOptions = ref([])
const rowEditorColorLoading = ref(false)
const lineHistoryDialogOpen = ref(false)
const lineHistoryLoading = ref(false)
const lineHistoryError = ref('')
const lineHistoryRows = ref([])
const lineHistoryTargetRowKey = ref('')
const lineHistoryTargetHammaddeTuruNo = ref('')
const lineHistoryTargetItemCode = ref('')
const lineHistoryTargetSummary = ref('')
const lineHistorySearchMode = ref('exact')
const lineHistoryLastPurchaseMatchStage = ref('')
const lineHistoryLastRecipeMatchStage = ref('')
// tbStok validation (missing stock cards)
// Manuel giris kapandigi icin tbStok exists-bulk kontrolunu devre disi biraktik.
// Eski UI/CSS hook'lari kalsin diye state'i tutuyoruz ama request atmayacagiz.
const missingTbStokCodesMap = ref({})
const tbStokValidationLastError = ref('')
const tbStokMissingDialogShown = ref(false)
let tbStokValidationTimer = null
function normalizeStockCodeKey (code) {
return String(code || '').trim().toUpperCase()
}
function openMissingTbStokDialog () {
const missing = Object.keys(missingTbStokCodesMap.value || {})
if (missing.length === 0) return
$q.dialog({
title: 'tbStok Eksik Kodlar',
message: `Asagidaki kodlar tbStok'ta yok. Stok kartlarini duzeltmeden maliyet kaydetmeyin:\n\n${missing.slice(0, 80).join(', ')}${missing.length > 80 ? `\n(+${missing.length - 80} daha)` : ''}`,
ok: { label: 'Tamam' }
})
}
function isTbStokMissingCode (code) {
code = normalizeStockCodeKey(code)
if (!code) return false
return Boolean(missingTbStokCodesMap.value?.[code])
}
function resolveMissingStockCodeClass (row) {
const code = normalizeStockCodeKey(row?.sKodu)
if (!code) return ''
if (isTbStokMissingCode(code)) return 'pcd-missing-stock-code'
return ''
}
function collectUniqueCodesFromRows () {
const out = []
const seen = new Set()
for (const r of flatDetailRows.value || []) {
const code = normalizeStockCodeKey(r?.sKodu)
if (!code) continue
if (seen.has(code)) continue
seen.add(code)
out.push(code)
}
return out
}
async function refreshTbStokMissingCodes () {
tbStokValidationLastError.value = ''
missingTbStokCodesMap.value = {}
}
function formatPercent (ratio) {
const n = Number(ratio)
if (!Number.isFinite(n)) return '-'
return `${(n * 100).toFixed(0)}%`
}
function downloadLast10WarningsExcel () {
const rows = Array.isArray(last10Warnings.value) ? last10Warnings.value : []
if (rows.length === 0) return
// Excel-friendly CSV (UTF-8 with BOM).
const header = [
'Kod',
'Aciklama',
'Doviz',
'Giris',
'Ort10',
'SapmaOran',
'SapmaYuzde',
'Sample',
'MinTarih',
'MaxTarih'
]
const lines = [header.join(';')]
for (const w of rows) {
const code = String(w?.item_code || '').trim()
const desc = String(w?.item_description || '').replaceAll('\n', ' ').replaceAll('\r', ' ').trim()
const cur = String(w?.currency_code || '').trim()
const inP = Number(w?.input_price || 0)
const avgP = Number(w?.avg_doc_price || 0)
const diff = Number(w?.diff_ratio || 0)
const sample = Number(w?.sample_count || 0)
const minD = String(w?.min_invoice_date || '').trim()
const maxD = String(w?.max_invoice_date || '').trim()
const safe = (s) => {
s = String(s ?? '')
if (s.includes(';') || s.includes('\"')) {
s = '\"' + s.replaceAll('\"', '\"\"') + '\"'
}
return s
}
lines.push([
safe(code),
safe(desc),
safe(cur),
String(Number.isFinite(inP) ? inP : 0),
String(Number.isFinite(avgP) ? avgP : 0),
String(Number.isFinite(diff) ? diff : 0),
`${Number.isFinite(diff) ? (diff * 100).toFixed(0) : '0'}%`,
String(Number.isFinite(sample) ? sample : 0),
safe(minD),
safe(maxD)
].join(';'))
}
const bom = '\uFEFF'
const blob = new Blob([bom + lines.join('\n')], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const urun = String(detailHeader.value?.UrunKodu || productCode.value || '').trim()
const onml = String(onMLNo.value || '').trim()
const date = String(costDate.value || '').trim()
a.href = url
a.download = `fiyat_uyari_${urun || 'urun'}_${onml || 'onml'}_${date || 'tarih'}.csv`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
async function refreshLast10Warnings () {
if (!onMLNo.value) {
last10Warnings.value = []
return
}
try {
const resp = await get('/pricing/production-product-costing/last10-warnings', { n_onml_no: onMLNo.value })
const items = Array.isArray(resp?.items) ? resp.items : []
last10Warnings.value = items
} catch (err) {
// non-blocking
}
}
function scheduleTbStokValidation () {
// no-op (tbStok kontrolu devre disi)
if (tbStokValidationTimer) clearTimeout(tbStokValidationTimer)
tbStokValidationTimer = null
}
const purchaseAvgUSDCachedByCode = ref({})
const last10Warnings = ref([])
const last10WarningDialogOpen = ref(false)
const last10WarningCount = computed(() => (Array.isArray(last10Warnings.value) ? last10Warnings.value.length : 0))
const headerInfoCollapsed = ref(false)
const subHeaderTop = ref(140)
const stickyStackRef = ref(null)
const saveToolbarRef = ref(null)
const groupOpenState = ref({})
const productionTypes = ref([])
const onMLNo = computed(() => String(route.query?.n_onml_no || '').trim())
const productCode = computed(() => String(route.query?.urun_kodu || '').trim())
const recipeCode = computed(() => String(route.query?.recete_kodu || '').trim())
const detailSource = computed(() => String(route.query?.detail_source || '').trim().toLowerCase())
const isNoCostDetail = computed(() => detailSource.value === 'no-cost')
const generatedTraceId = ref(createTraceId('pcd-detail'))
const traceId = computed(() => String(route.query?.trace_id || generatedTraceId.value).trim())
const pageMode = computed(() => (isNoCostDetail.value ? 'new' : 'edit'))
const pageVars = computed(() => ({
'--pcd-subheader-top': `${subHeaderTop.value}px`
}))
const lineHistoryCanFetchSimilar = computed(() => (
Boolean(lineHistoryTargetItemCode.value) &&
lineHistorySearchMode.value !== 'prefix'
))
const lineHistoryCanFetchAlternative = computed(() => (
Boolean(lineHistoryTargetHammaddeTuruNo.value) &&
lineHistorySearchMode.value !== 'alternative'
))
const lineHistoryShowingFallback = computed(() => (
lineHistorySearchMode.value === 'exact' &&
lineHistoryRows.value.some(r => r.priceType === 'BNZ')
))
const priceCurrencyOptions = [
{ label: 'USD', value: 'USD' },
{ label: 'TRY', value: 'TRY' },
{ label: 'EUR', value: 'EUR' },
{ label: 'GBP', value: 'GBP' }
]
const flatDetailRows = computed(() => detailGroups.value.flatMap(grp => Array.isArray(grp?.items) ? grp.items : []))
const priceDeviationAllSelected = computed(() => {
const list = Array.isArray(priceDeviationRows.value) ? priceDeviationRows.value : []
return list.length > 0 && list.every((row) => row.selected)
})
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'
const draftStorageKey = computed(() => {
if (isNoCostDetail.value) {
if (!recipeCode.value) return ''
return `pcd-costing:${DRAFT_STORAGE_VERSION}:no-cost:${String(productCode.value || '').trim()}:${String(recipeCode.value || '').trim()}`
}
if (!onMLNo.value) return ''
return `pcd-costing:${DRAFT_STORAGE_VERSION}:has-cost:${String(onMLNo.value).trim()}`
})
const currentHeaderSnapshot = computed(() => JSON.stringify({
UretimSekliID: String(detailHeader.value?.UretimSekliID || '').trim(),
UretimSekli: String(detailHeader.value?.UretimSekli || '').trim()
}))
const headerHasUnsavedChanges = computed(() => {
if (!initialHeaderSnapshot.value) return false
return currentHeaderSnapshot.value !== initialHeaderSnapshot.value
})
const hasUnsavedChanges = computed(() => {
if (deletedDetailRows.value.length > 0) return true
if (headerHasUnsavedChanges.value) return true
return flatDetailRows.value.some(row => Boolean(row?.draftChanged))
})
const canDeleteCosting = computed(() => {
// Allow delete only for records created by this app's no-cost flow (we generate >= 100000).
// This avoids accidental deletion of legacy OnML records.
const n = parseInt(String(onMLNo.value || detailHeader.value?.nOnMLNo || detailHeader.value?.NOnMLNo || '0'), 10) || 0
return n >= 100000
})
function persistLocalDraftNow () {
const key = draftStorageKey.value
if (!key) return
try {
const payload = {
version: 1,
savedAt: new Date().toISOString(),
mode: pageMode.value,
detail_source: detailSource.value || 'has-cost',
n_onml_no: onMLNo.value,
urun_kodu: productCode.value,
recete_kodu: recipeCode.value,
header: detailHeader.value ? {
UretimSekliID: String(detailHeader.value?.UretimSekliID || '').trim(),
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))
} catch (err) {
slog.error('production-product-costing.detail', 'draft:persist:error', {
trace_id: traceId.value,
key: draftStorageKey.value,
error: String(err?.message || err)
})
}
}
function schedulePersistLocalDraft () {
try {
if (localDraftTimer.value) window.clearTimeout(localDraftTimer.value)
localDraftTimer.value = window.setTimeout(() => {
localDraftTimer.value = 0
persistLocalDraftNow()
}, 400)
} catch {
persistLocalDraftNow()
}
}
function clearLocalDraft () {
const key = draftStorageKey.value
if (!key) return
try {
localStorage.removeItem(key)
} catch {
// ignore
}
}
function tryHydrateFromLocalDraft () {
const key = draftStorageKey.value
if (!key) return false
try {
const raw = localStorage.getItem(key)
if (!raw) return false
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()
detailHeader.value.UretimSekli = String(payload.header.UretimSekli || '').trim()
}
return true
} catch {
return false
}
}
function ensureBeforeUnloadGuard (enabled) {
if (!enabled) {
window.onbeforeunload = null
return
}
window.onbeforeunload = (e) => {
e.preventDefault()
e.returnValue = ''
return ''
}
}
function shouldIncludeRowInCostingTotal (row) {
// The explicit "Maliyete Dahil" selection is authoritative for the top
// header and the amount persisted to spUrtOnMLMas, including CM rows.
return normalizeBooleanFlag(row?.maliyeteDahil ?? row?.maliyete_dahil ?? row?.Maliyete_dahil)
}
const toolbarSummary = computed(() => flatDetailRows.value.reduce((acc, row) => {
if (!shouldIncludeRowInCostingTotal(row)) return acc
acc.tryTotal += resolveRowTRYTutar(row)
acc.usdTotal += resolveRowUSDTutar(row)
acc.eurTotal += resolveRowEURTutar(row)
acc.gbpTotal += resolveRowGBPTutar(row)
return acc
}, {
tryTotal: 0,
usdTotal: 0,
eurTotal: 0,
gbpTotal: 0
}))
const summaryOpen = ref(false)
function makeEmptySummaryRow (part) {
return {
part,
inputAmountLabel: '-',
inputCurrencyLabel: '-',
inputUnitLabel: '-',
meterLabel: '-',
meterQty: 0,
meterUom: '',
usd: 0,
try: 0,
hasCmtOrMalzemeli: false
}
}
function accumulateInputAmount (bucket, row) {
const qty = resolveNumericRowQuantity(row)
const inputPrice = resolveNumericRowInputPrice(row)
const cur = resolveInputCurrency(row) || 'USD'
const amount = (Number.isFinite(inputPrice) ? inputPrice : 0) * (Number.isFinite(qty) ? qty : 0)
if (!Number.isFinite(amount) || amount === 0) return
if (!bucket.inputByCur) bucket.inputByCur = {}
bucket.inputByCur[cur] = (bucket.inputByCur[cur] || 0) + amount
}
function accumulateUnitPrice (bucket, row) {
// For fabric summary: show a representative unit input price (first non-zero).
const inputPrice = resolveNumericRowInputPrice(row)
const cur = resolveInputCurrency(row) || 'USD'
if (!Number.isFinite(inputPrice) || inputPrice <= 0) return
if (bucket.unitPrice === undefined || bucket.unitPrice === null) {
bucket.unitPrice = inputPrice
bucket.unitCur = cur
}
}
function finalizeInputLabel (bucket) {
if (Number(bucket.meterQty || 0) > 0) {
const uom = String(bucket.meterUom || '').trim()
bucket.meterLabel = `${formatBarQuantity(bucket.meterQty)}${uom ? ' ' + uom : ''}`
} else {
bucket.meterLabel = bucket.meterLabel || '-'
}
const byCur = bucket.inputByCur || {}
const currencies = Object.keys(byCur).filter(Boolean)
if (currencies.length === 0) {
bucket.inputAmountLabel = '-'
bucket.inputCurrencyLabel = '-'
bucket.inputUnitLabel = bucket.inputUnitLabel || '-'
return bucket
}
if (currencies.length === 1) {
const cur = currencies[0]
bucket.inputCurrencyLabel = cur
bucket.inputAmountLabel = formatMoney(byCur[cur] || 0)
if (bucket.unitPrice !== undefined && bucket.unitCur) {
bucket.inputUnitLabel = formatMoney(bucket.unitPrice)
bucket.inputCurrencyLabel = String(bucket.unitCur || cur).toUpperCase()
}
return bucket
}
// Multiple currencies mixed: keep labels short.
bucket.inputCurrencyLabel = 'MIX'
bucket.inputAmountLabel = formatMoney(currencies.reduce((acc, c) => acc + (byCur[c] || 0), 0))
return bucket
}
const mailSummary = computed(() => {
// Parts must be dynamic. Use sParcaAdi coming from backend (spUrtMTBolum.sAdi),
// and keep a stable order: common parts first, then the rest by first appearance.
const preferredParts = ['CEKET', 'PANTOLON', 'YELEK', 'AKSESUAR', 'YAKA']
const seenParts = new Set()
const dynamicParts = []
flatDetailRows.value.forEach((row) => {
const partRaw = normalizeGroupName(row?.sParcaAdi)
const part = String(partRaw || '').trim().toUpperCase()
if (!part) return
if (seenParts.has(part)) return
seenParts.add(part)
dynamicParts.push(part)
})
const parts = [
...preferredParts.filter(p => seenParts.has(p)),
...dynamicParts.filter(p => !preferredParts.includes(p))
]
const base = {
headerTotals: {
usd: toolbarSummary.value.usdTotal || 0,
try: toolbarSummary.value.tryTotal || 0,
eur: toolbarSummary.value.eurTotal || 0,
gbp: toolbarSummary.value.gbpTotal || 0
},
laborByPart: {},
materialByPart: {},
fabricByPart: {}
}
parts.forEach((p) => {
base.laborByPart[p] = makeEmptySummaryRow(p)
base.materialByPart[p] = makeEmptySummaryRow(p)
base.fabricByPart[p] = makeEmptySummaryRow(p)
})
flatDetailRows.value.forEach((row) => {
const partRaw = normalizeGroupName(row?.sParcaAdi)
const part = String(partRaw || '').trim().toUpperCase()
if (!part || !parts.includes(part)) return
const group = String(normalizeGroupName(row?.sAciklama3)).trim().toUpperCase()
const included = Boolean(row?.maliyeteDahil) || isCMGroupName(group)
if (!included) return
const usdAmount = resolveRowUSDTutar(row)
const tryAmount = resolveRowTRYTutar(row)
if (isCMGroupName(group)) {
const b = base.laborByPart[part]
b.usd += usdAmount
b.try += tryAmount
accumulateInputAmount(b, row)
// Tick should reflect actual checkbox state (only when user selected "malzemeli"/type=2).
if (resolveCMPriceTypeChecked(row)) b.hasCmtOrMalzemeli = true
return
}
if (group === 'DT' || group.includes(' DT') || group === 'TP' || group.includes(' TP')) {
const b = base.materialByPart[part]
b.usd += usdAmount
b.try += tryAmount
accumulateInputAmount(b, row)
return
}
if (group === 'FABRIC' || group.includes('FABRIC')) {
const b = base.fabricByPart[part]
b.usd += usdAmount
b.try += tryAmount
accumulateInputAmount(b, row)
accumulateUnitPrice(b, row)
const qty = resolveNumericRowQuantity(row)
if (Number.isFinite(qty) && qty > 0) {
b.meterQty += qty
if (!b.meterUom) b.meterUom = String(row?.sBirim || '').trim()
}
}
})
const laborRows = parts.map((p) => finalizeInputLabel(base.laborByPart[p]))
const materialRows = parts.map((p) => finalizeInputLabel(base.materialByPart[p]))
const fabricRows = parts.map((p) => finalizeInputLabel(base.fabricByPart[p]))
return {
headerTotals: base.headerTotals,
laborRows,
materialRows,
fabricRows
}
})
const partSummary = computed(() => {
const summary = {}
flatDetailRows.value.forEach(row => {
if (!row?.maliyeteDahil) return
const part = String(row?.sParcaAdi || 'TANIMSIZ').trim() || 'TANIMSIZ'
if (!summary[part]) {
summary[part] = { try: 0, usd: 0, eur: 0, gbp: 0 }
}
summary[part].try += resolveRowTRYTutar(row)
summary[part].usd += resolveRowUSDTutar(row)
summary[part].eur += resolveRowEURTutar(row)
summary[part].gbp += resolveRowGBPTutar(row)
})
return Object.entries(summary).map(([name, totals]) => ({ name, ...totals }))
})
const lineHistoryColumns = [
// Not: fixed percentage widths were overflowing at 100% zoom.
// Let the table layout decide widths so the "Sec" column stays visible.
{ name: 'sourceLabel', label: 'Kaynak', field: 'sourceLabel', align: 'left', sortable: false },
{ name: 'dateLabel', label: 'Tarih', field: 'dateLabel', align: 'left', sortable: true },
{ name: 'invoiceCode', label: 'Fatura/OnML', field: 'invoiceCode', align: 'left', sortable: true },
{ name: 'companyCode', label: 'Firma Kodu', field: 'companyCode', align: 'left', sortable: true },
{ name: 'companyDescription', label: 'Firma Aciklama', field: 'companyDescription', align: 'left', sortable: true },
{ name: 'itemCode', label: 'Masraf/sKodu', field: 'itemCode', align: 'left', sortable: true },
{ name: 'itemDescription', label: 'Masraf Detay', field: 'itemDescription', align: 'left', sortable: true },
{ name: 'colorCode', label: 'Renk', field: 'colorCode', align: 'left', sortable: true },
{ name: 'colorDescription', label: 'Renk Aciklama', field: 'colorDescription', align: 'left', sortable: true },
{ name: 'itemDim1Code', label: 'Dim1', field: 'itemDim1Code', align: 'left', sortable: true },
{ name: 'itemDim1Description', label: 'Dim1 Aciklama', field: 'itemDim1Description', align: 'left', sortable: true },
{ name: 'quantity', label: 'Miktar', field: 'quantity', align: 'right', sortable: true },
{ name: 'unit', label: 'Birim', field: 'unit', align: 'left', sortable: true },
{ name: 'price', label: 'Fiyat', field: 'price', align: 'right', sortable: true },
{ name: 'amount', label: 'Tutar', field: 'amount', align: 'right', sortable: true },
{ name: 'currency', label: 'Pr. Br.', field: 'currency', align: 'left', sortable: true },
{ name: 'select', label: '', field: 'select', align: 'right', sortable: false }
]
function resolveLineHistoryRowClass (row) {
if (row?.priceType === 'BNZ') return 'pcd-history-row-similar'
if (row?.sourceType === 'recipe') return 'pcd-history-row-recipe'
if (row?.sourceType === 'purchase') return 'pcd-history-row-purchase'
return ''
}
const detailColumns = [
{ name: 'actions', label: '', field: 'actions', align: 'left', style: 'width:60px', headerStyle: 'width:60px' },
{ name: 'nOnMLDetNo', label: 'No', field: 'nOnMLDetNo', align: 'left', sortable: true },
{ name: 'sParcaAdi', label: 'Parça Adı', field: 'sParcaAdi', align: 'left', sortable: true },
{ name: 'nHammaddeTuruNo', label: 'Hammadde Türü', field: 'nHammaddeTuruNo', align: 'left', sortable: true },
{ name: 'sKodu', label: 'Kod', field: 'sKodu', align: 'left', sortable: true },
{ name: 'sAciklama', label: 'Açıklama', field: 'sAciklama', align: 'left', sortable: true },
{ name: 'sRenk', label: 'Renk', field: 'sRenk', align: 'left', sortable: true },
{ name: 'lMiktar', label: 'Miktar', field: 'lMiktar', align: 'right', sortable: true, format: val => formatQuantity(val), style: 'width: 80px', headerStyle: 'width: 80px' },
{ name: 'inputPrice', label: 'Fiyat Giriş', field: 'inputPrice', align: 'right', sortable: false, style: 'width: 80px', headerStyle: 'width: 80px' },
{ name: 'inputPricePrBr', label: 'Fiyat Giriş Pr.Br.', field: 'inputPricePrBr', align: 'left', sortable: false, style: 'width: 92px', headerStyle: 'width: 92px' },
{ name: 'maliyeteDahil', label: 'Maliyete Dahil', field: 'maliyeteDahil', align: 'center', sortable: false },
{ name: 'cmPriceType', label: 'CMT', field: 'cm_price_type_id', align: 'center', sortable: false, style: 'width: 72px', headerStyle: 'width: 72px' },
{ name: 'lFiyat', label: 'lFiyat', field: 'lFiyat', align: 'right', sortable: true, format: val => formatMoney(val) },
{ name: 'lTutar', label: 'lTutar', field: 'lTutar', align: 'right', sortable: true, format: val => formatMoney(val) },
{ name: 'sFiyatTipi', label: 'sFiyatTipi', field: 'sFiyatTipi', align: 'left', sortable: true },
{ name: 'sDovizCinsi', label: 'Döviz', field: 'sDovizCinsi', align: 'left', sortable: true },
{ name: 'lDovizKuru', label: 'Kur', field: 'lDovizKuru', align: 'right', sortable: true, format: val => formatMoney(val) },
{ name: 'lDovizFiyati', label: 'Döviz Fiyatı', field: 'lDovizFiyati', align: 'right', sortable: true, format: val => formatMoney(val) },
{ name: 'priceUpdateState', label: 'Fiyat Tipi', field: 'priceUpdateState', align: 'left', sortable: true },
{
name: 'usdTutar',
label: 'USD TUTAR',
field: row => resolveRowUSDTutar(row),
align: 'right',
sortable: true,
format: val => formatMoney(val)
},
{ name: 'sBirim', label: 'Birim', field: 'sBirim', align: 'left', sortable: true }
]
function formatDateTR (value) {
const s = String(value || '').trim()
if (!s) return ''
const m = /^(\d{4})-(\d{2})-(\d{2})(?:\s+(\d{2}):(\d{2}))?/.exec(s)
if (!m) return s
const datePart = `${m[3]}.${m[2]}.${m[1]}`
if (m[4] && m[5]) return `${datePart} ${m[4]}:${m[5]}`
return datePart
}
function formatUretimSekli (header) {
const id = String(header?.UretimSekliID || '').trim()
const aciklama = String(header?.UretimSekli || '').trim()
if (id && aciklama) return `${id}-${aciklama}`
return id || aciklama || '-'
}
function normalizeDateInput (value) {
const s = String(value || '').trim()
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
if (!m) return ''
return `${m[1]}-${m[2]}-${m[3]}`
}
function isCMGroupName (value) {
const normalizedValue = String(value || '').trim().toUpperCase()
return normalizedValue.includes('CM1') || normalizedValue.includes('CM2')
}
function isKnownGroupName (value) {
const v = String(value || '').trim().toUpperCase()
if (!v) return false
return v === 'DT' || v === 'TP' || v === 'FABRIC' || v === 'CM1' || v === 'CM2' ||
v.includes(' DT') || v.includes(' TP') || v.includes('FABRIC') || v.includes('CM1') || v.includes('CM2')
}
function normalizeLegacyParcaAndGroup (seed = {}) {
const parca = String(seed?.sParcaAdi || '').trim()
const group = String(seed?.sAciklama3 || '').trim()
if (!parca && !group) return seed
// Some legacy rows were saved with parca/group swapped. Fix it at the UI layer:
// - Parca: CEKET/PANTOLON/YELEK...
// - Group: DT/TP/CM1/CM2/FABRIC...
if (isKnownGroupName(parca) && !isKnownGroupName(group)) {
return { ...seed, sParcaAdi: group, sAciklama3: parca }
}
return seed
}
function createEmptyExchangeRates () {
return {
rateDate: '',
tryRate: 1,
usdRate: 0,
eurRate: 0,
gbpRate: 0
}
}
function createRowEditorForm (seed = {}) {
seed = normalizeLegacyParcaAndGroup(seed)
const defaultCurrency = normalizePriceCurrency(seed?.inputPricePrBr || seed?.fiyat_doviz || detailHeader.value?.sDovizCinsi) || 'USD'
const cmPriceTypeId = normalizeCMPriceTypeId(seed?.cmPriceTypeId ?? seed?.cm_price_type_id, seed?.sAciklama3 ?? seed?.sParcaAdi)
return {
__rowKey: String(seed?.__rowKey || '').trim(),
isNew: Boolean(seed?.isNew),
nStokID: String(seed?.nStokID || '').trim(),
sModel: String(seed?.sModel || '').trim(),
nOnMLDetNo: String(seed?.nOnMLDetNo || '').trim(),
sParcaAdi: String(seed?.sParcaAdi || '').trim(),
nHammaddeTuruNo: String(seed?.nHammaddeTuruNo || '').trim(),
sHammaddeTuruAdi: String(seed?.sHammaddeTuruAdi || '').trim(),
sAciklama3: String(seed?.sAciklama3 || '').trim(),
sKodu: String(seed?.sKodu || '').trim(),
sAciklama: String(seed?.sAciklama || '').trim(),
sRenk: String(seed?.sRenk || seed?.ColorCode || '').trim(),
ColorCode: String(seed?.ColorCode || seed?.sRenk || '').trim(),
ColorDescription: String(seed?.ColorDescription || '').trim(),
miktarInput: normalizeQuantityInput(seed?.miktarInput ?? seed?.lMiktar ?? 1),
inputPrice: normalizeInputPrice(seed?.inputPrice ?? seed?.fiyat_girilen ?? 0),
inputPricePrBr: defaultCurrency,
maliyeteDahil: seed?.maliyeteDahil ?? normalizeBooleanFlag(seed?.maliyete_dahil ?? true),
cmPriceTypeChecked: cmPriceTypeId === 2,
sBirim: extractPrimaryUnitValue(seed?.sBirim || 'AD') || 'AD'
}
}
function formatMoney (value) {
return parseMoneyInput(value).toLocaleString('tr-TR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
function formatQuantity (value) {
return parseMoneyInput(value).toLocaleString('tr-TR', {
minimumFractionDigits: 4,
maximumFractionDigits: 4
})
}
function formatQuantity2 (value) {
return parseMoneyInput(value).toLocaleString('tr-TR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
function formatBarMoney (value) {
const roundedValue = Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100
return formatMoney(roundedValue)
}
function formatBarQuantity (value) {
// Bar/summary quantities: show 2 decimals (requested for MT totals).
const roundedValue = Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100
return formatQuantity2(roundedValue)
}
function convertPriceToUSD (price, currency) {
const p = Number(price || 0)
if (!Number.isFinite(p) || p <= 0) return 0
const cur = String(currency || '').trim().toUpperCase()
const usdRate = Number(exchangeRates.value?.usdRate || 0) || 0
const eurRate = Number(exchangeRates.value?.eurRate || 0) || 0
const gbpRate = Number(exchangeRates.value?.gbpRate || 0) || 0
// If we don't have rates, fall back to assuming USD.
if (!(usdRate > 0)) return p
switch (cur) {
case 'USD':
return p
case 'TRY':
case 'TL':
case '':
return p / usdRate
case 'EUR': {
const tryVal = (eurRate > 0 ? p * eurRate : p)
return tryVal / usdRate
}
case 'GBP': {
const tryVal = (gbpRate > 0 ? p * gbpRate : p)
return tryVal / usdRate
}
default:
return p
}
}
function normalizePriceCurrency (value) {
const normalizedValue = String(value || '').trim().toUpperCase()
return ['USD', 'TRY', 'EUR', 'GBP'].includes(normalizedValue) ? normalizedValue : ''
}
function normalizeCodeValue (value) {
return String(value ?? '').trim().toUpperCase()
}
function extractPrimaryUnitValue (value) {
const normalizedValue = String(value || '').trim()
if (!normalizedValue) return ''
return normalizedValue.split(/[\s,;|/]+/).filter(Boolean)[0] || normalizedValue
}
function normalizeInputPrice (value) {
const numericValue = parseMoneyInput(value)
if (value === null || value === undefined || value === '' || !Number.isFinite(numericValue)) {
return formatMoney(0)
}
return formatMoney(numericValue)
}
function normalizeQuantityInput (value) {
const numericValue = parseMoneyInput(value)
if (value === null || value === undefined || value === '' || !Number.isFinite(numericValue)) {
return formatQuantity(0)
}
return formatQuantity(numericValue)
}
function normalizeSingleSeparatorNumber (rawValue, separator) {
const parts = String(rawValue || '').split(separator)
if (parts.length <= 1) return String(rawValue || '')
if (parts.length === 2) {
return separator === ',' ? `${parts[0]}.${parts[1]}` : `${parts[0]}.${parts[1]}`
}
const headParts = parts.slice(0, -1)
const lastPart = parts[parts.length - 1]
const looksLikeGroupedInteger = lastPart.length === 3 && headParts.every((part, index) => {
if (index === 0) return part.length >= 1 && part.length <= 3
return part.length === 3
})
if (looksLikeGroupedInteger) {
return parts.join('')
}
return `${headParts.join('')}.${lastPart}`
}
function parseMoneyInput (value) {
const rawValue = String(value ?? '').trim()
if (!rawValue) return 0
const compactValue = rawValue.replace(/\s+/g, '')
const lastDotIndex = compactValue.lastIndexOf('.')
const lastCommaIndex = compactValue.lastIndexOf(',')
const hasDot = lastDotIndex >= 0
const hasComma = lastCommaIndex >= 0
let normalizedValue = compactValue
if (hasDot && hasComma) {
const decimalSeparator = lastDotIndex > lastCommaIndex ? '.' : ','
const thousandSeparator = decimalSeparator === '.' ? ',' : '.'
normalizedValue = compactValue.replace(new RegExp(`\\${thousandSeparator}`, 'g'), '')
if (decimalSeparator === ',') {
normalizedValue = normalizedValue.replace(',', '.')
}
} else if (hasComma) {
normalizedValue = normalizeSingleSeparatorNumber(compactValue, ',')
} else if (hasDot) {
normalizedValue = normalizeSingleSeparatorNumber(compactValue, '.')
}
const numericValue = Number(normalizedValue)
return Number.isFinite(numericValue) ? numericValue : 0
}
function normalizeBooleanFlag (value) {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value === 1
const normalizedValue = String(value ?? '').trim().toLowerCase()
return normalizedValue === '1' || normalizedValue === 'true' || normalizedValue === 'evet'
}
function normalizeCMPriceTypeId (value, groupName) {
if (!isCMGroupName(groupName)) return null
if (value === null || value === undefined || value === '') return 1
const numericValue = Number(value)
if (numericValue === 2) return 2
return 1
}
function resolveCMPriceTypeChecked (row) {
return normalizeCMPriceTypeId(row?.cmPriceTypeId ?? row?.cm_price_type_id, row?.sAciklama3) === 2
}
function firstDefinedValue (source, keys) {
for (const key of keys) {
const value = source?.[key]
if (value !== undefined && value !== null && String(value).trim() !== '') {
return value
}
}
return ''
}
function normalizeExchangeRatesPayload (payload, fallbackDate = '') {
return {
rateDate: normalizeDateInput(firstDefinedValue(payload, ['rateDate', 'RateDate', 'date', 'Date'])) || fallbackDate,
tryRate: 1,
usdRate: parseMoneyInput(firstDefinedValue(payload, ['usdRate', 'USDRate', 'usd', 'USD'])),
eurRate: parseMoneyInput(firstDefinedValue(payload, ['eurRate', 'EURRate', 'eur', 'EUR'])),
gbpRate: parseMoneyInput(firstDefinedValue(payload, ['gbpRate', 'GBPRate', 'gbp', 'GBP']))
}
}
function buildRowEditorHammaddeOption (source) {
const value = String(source?.value || source?.nHammaddeTuruNo || '').trim()
const name = String(source?.sHammaddeTuruAdi || '').trim()
const groupName = String(source?.sAciklama3 || '').trim()
return {
kind: 'hammadde',
value,
label: String(source?.label || `${value}${name ? ` - ${name}` : ''}`).trim(),
nHammaddeTuruNo: value,
sHammaddeTuruAdi: name,
sAciklama3: groupName,
sParcaAdi: String(source?.sParcaAdi || groupName).trim()
}
}
function buildRowEditorItemOption (source) {
const value = String(source?.value || source?.sKodu || '').trim()
const desc = String(source?.sAciklama || '').trim()
return {
kind: 'item',
value,
nStokID: String(source?.nStokID || '').trim(),
sModel: String(source?.sModel || '').trim(),
label: String(source?.label || `${value}${desc ? ` - ${desc}` : ''}`).trim(),
sKodu: value,
sAciklama: desc,
sBirim: extractPrimaryUnitValue(source?.sBirim)
}
}
function buildRowEditorColorOption (source) {
const value = String(source?.value || source?.colorCode || source?.ColorCode || source?.sRenk || '').trim()
const desc = String(source?.colorDescription || source?.ColorDescription || '').trim()
return {
kind: 'color',
value,
label: String(source?.label || `${value}${desc ? ` - ${desc}` : ''}`).trim(),
colorCode: value,
colorDescription: desc
}
}
function upsertEditorOption (optionsRef, option) {
if (!option?.value) return
const nextOptions = [...optionsRef.value]
const idx = nextOptions.findIndex(x => String(x?.value || '').trim() === option.value)
if (idx >= 0) {
nextOptions[idx] = { ...nextOptions[idx], ...option }
} else {
nextOptions.unshift(option)
}
optionsRef.value = nextOptions
}
async function fetchRowEditorOptions (kind, search = '', extraParams = {}) {
const response = await get('/pricing/production-product-costing/detail-editor-options', {
kind,
search,
limit: ROW_EDITOR_OPTIONS_LIMIT,
trace_id: traceId.value,
...extraParams
})
return Array.isArray(response) ? response : []
}
function setRowEditorOptionsByKind (kind, rows) {
if (kind === 'hammadde') {
rowEditorHammaddeAllOptions.value = rows
rowEditorHammaddeOptions.value = rows
return
}
if (kind === 'item') {
rowEditorItemAllOptions.value = rows
rowEditorItemOptions.value = rows
return
}
rowEditorColorAllOptions.value = rows
rowEditorColorOptions.value = rows
}
function setRowEditorLookupLoading (kind, isLoading) {
if (kind === 'hammadde') {
rowEditorHammaddeLoading.value = isLoading
return
}
if (kind === 'item') {
rowEditorItemLoading.value = isLoading
return
}
rowEditorColorLoading.value = isLoading
}
async function refreshRowEditorOptions (kind, search = '') {
const extraParams = kind === 'color'
? { model_code: String(rowEditorForm.value.sModel || '').trim() }
: {}
if (kind === 'color' && !extraParams.model_code) {
setRowEditorOptionsByKind(kind, [])
primeRowEditorOptionsFromForm()
return []
}
setRowEditorLookupLoading(kind, true)
try {
const rows = await fetchRowEditorOptions(kind, search, extraParams)
setRowEditorOptionsByKind(kind, rows)
primeRowEditorOptionsFromForm()
return rows
} finally {
setRowEditorLookupLoading(kind, false)
}
}
async function filterRowEditorOptions (kind, val, update, abort) {
const normalizedSearch = String(val || '').trim()
if (kind === 'item' && normalizedSearch.length < ROW_EDITOR_ITEM_MIN_SEARCH_LENGTH) {
update(() => {
rowEditorItemAllOptions.value = []
rowEditorItemOptions.value = []
primeRowEditorOptionsFromForm()
})
return
}
try {
const rows = await refreshRowEditorOptions(kind, normalizedSearch)
update(() => {
setRowEditorOptionsByKind(kind, rows)
primeRowEditorOptionsFromForm()
})
} catch (err) {
abort()
$q.notify({
type: 'negative',
message: `Lookup getirilemedi: ${await extractApiErrorDetail(err)}`,
position: 'top-right'
})
}
}
function filterRowEditorHammaddeOptions (val, update, abort) {
return filterRowEditorOptions('hammadde', val, update, abort)
}
function filterRowEditorItemOptions (val, update, abort) {
return filterRowEditorOptions('item', val, update, abort)
}
function filterRowEditorColorOptions (val, update, abort) {
const modelCode = String(rowEditorForm.value.sModel || '').trim()
if (!modelCode) {
update(() => {
rowEditorColorAllOptions.value = []
rowEditorColorOptions.value = []
})
return
}
return filterRowEditorOptions('color', val, update, abort)
}
async function loadRowEditorColorOptions () {
const modelCode = String(rowEditorForm.value.sModel || '').trim()
if (!modelCode) return []
try {
return await fetchRowEditorOptions('color', '', { model_code: modelCode })
} catch (err) {
$q.notify({
type: 'negative',
message: `Renk lookup getirilemedi: ${await extractApiErrorDetail(err)}`,
position: 'top-right'
})
return []
}
}
function primeRowEditorOptionsFromForm () {
upsertEditorOption(rowEditorHammaddeAllOptions, buildRowEditorHammaddeOption(rowEditorForm.value))
upsertEditorOption(rowEditorHammaddeOptions, buildRowEditorHammaddeOption(rowEditorForm.value))
// Do not inject invalid free-typed values (e.g. "GAMBOÇ") into the item select options.
// Item options must represent real tbStok model codes (usually like "M.X", "K.X", "I.X").
const itemCandidate = buildRowEditorItemOption(rowEditorForm.value)
const itemKey = String(itemCandidate?.value || '').trim()
const looksLikeModel = /^[A-Za-z0-9]\./.test(itemKey)
if (looksLikeModel) {
upsertEditorOption(rowEditorItemAllOptions, itemCandidate)
upsertEditorOption(rowEditorItemOptions, itemCandidate)
}
upsertEditorOption(rowEditorColorAllOptions, buildRowEditorColorOption(rowEditorForm.value))
upsertEditorOption(rowEditorColorOptions, buildRowEditorColorOption(rowEditorForm.value))
}
function getNextDetailNo () {
const maxNo = flatDetailRows.value.reduce((acc, row) => {
const rowNo = parseInt(String(row?.nOnMLDetNo || '').trim(), 10)
return Number.isFinite(rowNo) ? Math.max(acc, rowNo) : acc
}, 0)
return String(maxNo + 1)
}
function resolveRowColorCode (row) {
return normalizeCodeValue(firstDefinedValue(row, [
'ColorCode',
'colorCode',
'sRenk',
's_renk',
'renk'
]))
}
function resolveRowItemDim1Code (row) {
return normalizeCodeValue(firstDefinedValue(row, [
'ItemDim1Code',
'itemDim1Code',
'sBeden',
's_beden'
]))
}
function extractNamedArray (payload, keys) {
for (const key of keys) {
if (Array.isArray(payload?.[key])) return payload[key]
if (Array.isArray(payload?.data?.[key])) return payload.data[key]
}
return []
}
function parseDateSortValue (value) {
const rawValue = String(value || '').trim()
if (!rawValue) return 0
const normalizedValue = rawValue.replace(' ', 'T')
const timeValue = Date.parse(normalizedValue)
return Number.isFinite(timeValue) ? timeValue : 0
}
function sortHistoryRowsByDateDesc (rows) {
return [...rows].sort((a, b) => parseDateSortValue(b?.dateValue) - parseDateSortValue(a?.dateValue))
}
function resolveHistorySourceType (item, forcedType = '') {
const normalizedType = String(forcedType || firstDefinedValue(item, [
'sourceType',
'source',
'kaynak',
'historyType',
'tip'
])).trim().toLowerCase()
if (['recipe', 'recete', 'uretim'].includes(normalizedType)) return 'recipe'
if (['purchase', 'invoice', 'satinalma', 'baggi_v3', 'baggi-v3'].includes(normalizedType)) return 'purchase'
if (firstDefinedValue(item, ['MasrafKodu', 'FaturaKodu', 'EvrakFiyat', 'ItemDim1Code']) !== '') return 'purchase'
if (firstDefinedValue(item, ['nOnMLNo', 'dteIslemTarihi', 'lDovizFiyati', 'lDovizTutari']) !== '') return 'recipe'
return normalizedType || 'history'
}
function resolveHistorySourceLabel (sourceType) {
if (sourceType === 'purchase') return formatDataSourceLabel('BAGGI_V3')
if (sourceType === 'recipe') return formatDataSourceLabel('URETIM')
return 'HISTORY'
}
function normalizeHistoryRowFromItem (item, index, forcedType = '') {
const sourceType = resolveHistorySourceType(item, forcedType)
const price = parseMoneyInput(firstDefinedValue(item, [
'EvrakFiyat',
'evrakFiyat',
'fiyat_girilen',
'fiyatGirilen',
'price',
'fiyat',
'birimFiyat',
'unitPrice',
'lDovizFiyati',
'lFiyat'
]))
const quantity = parseMoneyInput(firstDefinedValue(item, [
'Miktar',
'miktar',
'quantity',
'qty',
'adet',
'lMiktar'
]))
const amountValue = firstDefinedValue(item, [
'EvrakTutar',
'evrakTutar',
'tutar',
'amount',
'toplamTutar',
'lTutar',
'lDovizTutari'
])
const currency = normalizePriceCurrency(firstDefinedValue(item, [
'EvrakDoviz',
'evrakDoviz',
'fiyat_doviz',
'fiyatDoviz',
'currency',
'priceCurrency',
'prBr',
'pb',
'sDovizCinsi',
'USD'
]))
const dateValue = String(firstDefinedValue(item, [
'Tarih',
'tarih',
'InvoiceDate',
'invoiceDate',
'dteIslemTarihi',
'islemTarihi',
'dteKayitTarihi',
'date'
])).trim()
const amount = amountValue === ''
? price * quantity
: parseMoneyInput(amountValue)
return {
__historyKey: String(firstDefinedValue(item, ['id', '__historyKey', 'historyKey'])).trim() || `${sourceType}-${index}`,
raw: item,
sourceType,
sourceLabel: resolveHistorySourceLabel(sourceType),
dateValue,
dateLabel: formatDateTR(dateValue),
invoiceCode: String(firstDefinedValue(item, [
'FaturaKodu',
'faturaKodu',
'InvoiceNumber',
'invoiceCode',
'FaturaNo',
'nOnMLNo',
'n_onml_no'
])).trim(),
companyCode: String(firstDefinedValue(item, [
'FirmaKodu',
'firmaKodu',
'CurrAccCode',
'currAccCode',
'accountCode',
'vendorCode'
])).trim(),
companyDescription: String(firstDefinedValue(item, [
'FirmaAciklama',
'firmaAciklama',
'CurrAccDescription',
'currAccDescription',
'companyDescription',
'vendorDescription'
])).trim(),
itemCode: String(firstDefinedValue(item, [
'MasrafKodu',
'masrafKodu',
'sKodu',
's_kodu',
'ItemCode',
'itemCode'
])).trim(),
itemDescription: String(firstDefinedValue(item, [
'MasrafDetay',
'masrafDetay',
'sAciklama',
's_aciklama',
'ItemDescription',
'itemDescription',
'description'
])).trim(),
colorCode: String(firstDefinedValue(item, [
'ColorCode',
'colorCode',
'sRenk',
's_renk'
])).trim(),
colorDescription: String(firstDefinedValue(item, [
'ColorDescription',
'colorDescription',
'sRenkAdi',
's_renk_adi'
])).trim(),
itemDim1Code: String(firstDefinedValue(item, [
'ItemDim1Code',
'itemDim1Code',
'sBeden',
's_beden'
])).trim(),
itemDim1Description: String(firstDefinedValue(item, [
'ItemDim1Description',
'itemDim1Description',
'sAciklama2',
's_aciklama2'
])).trim(),
priceType: String(item?.priceType || '').trim(),
quantity,
unit: String(firstDefinedValue(item, [
'BIRIM',
'birim',
'unit',
'Unit',
'sBirim',
's_birim'
])).trim(),
price,
amount,
currency: currency || 'USD',
recipeInfo: String(firstDefinedValue(item, [
'recipeInfo',
'receteBilgisi',
'recete',
'DUMMY',
'nUrtReceteID',
'sAciklama3',
'Description',
'Aciklama',
'Aciklama1',
'LineDescription',
'note'
])).trim()
}
}
function normalizeBulkPriceItems (payload) {
const list = Array.isArray(payload)
? payload
: extractNamedArray(payload, ['items', 'data', 'updates', 'matchedItems', 'rows'])
return list.map((item, index) => {
const price = parseMoneyInput(firstDefinedValue(item, [
'EvrakFiyat',
'evrakFiyat',
'fiyat_girilen',
'fiyatGirilen',
'price',
'fiyat',
'unitPrice',
'birimFiyat',
'lDovizFiyati',
'lFiyat'
]))
const currency = normalizePriceCurrency(firstDefinedValue(item, [
'EvrakDoviz',
'evrakDoviz',
'fiyat_doviz',
'fiyatDoviz',
'currency',
'priceCurrency',
'prBr',
'pb',
'sDovizCinsi'
]))
return {
__updateKey: `upd-${index}`,
__rowKey: String(firstDefinedValue(item, ['__rowKey', 'rowKey'])).trim(),
nOnMLDetNo: String(firstDefinedValue(item, ['nOnMLDetNo', 'n_onml_det_no'])).trim(),
nHammaddeTuruNo: String(firstDefinedValue(item, ['nHammaddeTuruNo', 'n_hammadde_turu_no'])).trim(),
sKodu: normalizeCodeValue(firstDefinedValue(item, ['sKodu', 's_kodu', 'stokKodu', 'MasrafKodu', 'masrafKodu', 'ItemCode', 'itemCode'])),
colorCode: normalizeCodeValue(firstDefinedValue(item, ['ColorCode', 'colorCode', 'sRenk', 's_renk', 'renk'])),
itemDim1Code: normalizeCodeValue(firstDefinedValue(item, ['ItemDim1Code', 'itemDim1Code', 'item_dim1_code', 'sBeden', 's_beden'])),
inputPrice: normalizeInputPrice(price),
fiyat_girilen: price,
inputPricePrBr: currency || 'USD',
fiyat_doviz: currency || 'USD'
}
})
}
function normalizeLineHistoryRows (payload) {
const purchaseRows = extractNamedArray(payload, [
'purchaseRows',
'purchase_items',
'purchaseItems',
'invoiceRows',
'invoice_items',
'invoiceItems',
'baggiV3Rows',
'baggi_v3_rows',
'baggiRows'
])
const recipeRows = extractNamedArray(payload, [
'recipeRows',
'recipe_items',
'recipeItems',
'uretimRows',
'uretim_rows',
'productionRows'
])
if (purchaseRows.length > 0 || recipeRows.length > 0) {
const normalizedPurchaseRows = sortHistoryRowsByDateDesc(
purchaseRows.map((item, index) => normalizeHistoryRowFromItem(item, index, 'purchase'))
)
const normalizedRecipeRows = sortHistoryRowsByDateDesc(
recipeRows.map((item, index) => normalizeHistoryRowFromItem(item, index, 'recipe'))
)
return sortHistoryRowsByDateDesc([
...normalizedPurchaseRows,
...normalizedRecipeRows
]).slice(0, LINE_HISTORY_COMBINED_ROW_LIMIT)
}
const flatList = Array.isArray(payload)
? payload
: extractNamedArray(payload, ['items', 'data', 'rows'])
const normalizedRows = flatList.map((item, index) => normalizeHistoryRowFromItem(item, index))
const purchaseHistoryRows = sortHistoryRowsByDateDesc(normalizedRows.filter(row => row.sourceType === 'purchase'))
const recipeHistoryRows = sortHistoryRowsByDateDesc(normalizedRows.filter(row => row.sourceType === 'recipe'))
const otherHistoryRows = sortHistoryRowsByDateDesc(normalizedRows.filter(row => !['purchase', 'recipe'].includes(row.sourceType)))
return sortHistoryRowsByDateDesc([
...purchaseHistoryRows,
...recipeHistoryRows,
...otherHistoryRows
]).slice(0, LINE_HISTORY_COMBINED_ROW_LIMIT)
}
function rowMatchesBulkUpdate (row, update) {
if (update.__rowKey && update.__rowKey === row.__rowKey) return true
if (update.nOnMLDetNo && String(row?.nOnMLDetNo || '').trim() === update.nOnMLDetNo) return true
const rowKodu = normalizeCodeValue(row?.sKodu)
const rowRenk = resolveRowColorCode(row)
const rowTur = String(row?.nHammaddeTuruNo || '').trim()
const rowItemDim1Code = resolveRowItemDim1Code(row)
if (update.nHammaddeTuruNo && update.sKodu) {
if (rowTur === update.nHammaddeTuruNo && rowKodu === update.sKodu) return true
}
if (update.sKodu && update.colorCode && update.itemDim1Code) {
if (rowKodu === update.sKodu && rowRenk === update.colorCode && rowItemDim1Code === update.itemDim1Code) return true
}
if (update.sKodu && update.colorCode) {
if (rowKodu === update.sKodu && rowRenk === update.colorCode) return true
}
if (update.sKodu && update.itemDim1Code) {
if (rowKodu === update.sKodu && rowItemDim1Code === update.itemDim1Code) return true
}
if (update.sKodu) {
return rowKodu === update.sKodu
}
return false
}
function resolveExchangeRateValue (currency) {
const normalizedCurrency = normalizePriceCurrency(currency)
if (normalizedCurrency === 'TRY') return 1
if (normalizedCurrency === 'USD') return parseMoneyInput(exchangeRates.value?.usdRate)
if (normalizedCurrency === 'EUR') return parseMoneyInput(exchangeRates.value?.eurRate)
if (normalizedCurrency === 'GBP') return parseMoneyInput(exchangeRates.value?.gbpRate)
return 0
}
function resolveInputCurrency (row) {
return normalizePriceCurrency(row?.inputPricePrBr || row?.fiyat_doviz) || 'USD'
}
function resolveAutoOrICodeHighlightClass (row) {
// Priority: auto-filled from previous costing (already firm-aware).
if (row?.__autoFilledFromPrev) {
return row?.__autoFilledFromPrevSameFirma ? 'text-positive text-weight-bold' : 'text-negative text-weight-bold'
}
// Manual selection for I.* codes: highlight same/other firm like the auto-fill convention.
const code = String(row?.sKodu || '').trim().toUpperCase()
if (code.startsWith('I.') && row?.__iCodeSelected) {
return row?.__iCodeSameFirma ? 'text-positive text-weight-bold' : 'text-negative text-weight-bold'
}
return ''
}
function resolveNumericRowQuantity (row) {
return parseMoneyInput(row?.miktarInput ?? row?.lMiktar)
}
function resolveNumericRowInputPrice (row) {
return parseMoneyInput(row?.inputPrice ?? row?.fiyat_girilen)
}
function resolveTRYUnitPriceByInput (inputPrice, inputCurrency, usdRate, eurRate, gbpRate) {
if (inputPrice <= 0) return 0
if (inputCurrency === 'TRY') return inputPrice
if (inputCurrency === 'USD') return usdRate > 0 ? inputPrice * usdRate : 0
if (inputCurrency === 'EUR') return eurRate > 0 ? inputPrice * eurRate : 0
if (inputCurrency === 'GBP') return gbpRate > 0 ? inputPrice * gbpRate : 0
return 0
}
function resolveUSDUnitPriceByInput (inputPrice, inputCurrency, usdRate, eurRate, gbpRate) {
if (inputPrice <= 0) return 0
if (inputCurrency === 'USD') return inputPrice
if (inputCurrency === 'TRY') return usdRate > 0 ? inputPrice / usdRate : 0
if (inputCurrency === 'EUR' || inputCurrency === 'GBP') {
const tryEquivalent = resolveTRYUnitPriceByInput(inputPrice, inputCurrency, usdRate, eurRate, gbpRate)
return usdRate > 0 ? tryEquivalent / usdRate : 0
}
return 0
}
function normalizeDetailRows (items, groupName = '') {
const list = Array.isArray(items) ? items : []
return list.map((x, i) => {
// Determine the original currency from backend fields.
const originalCurrency = normalizePriceCurrency(x?.fiyat_doviz || x?.sDovizCinsi || x?.inputPricePrBr) || 'USD'
return {
...x,
__rowKey: x?.__rowKey || `${x?.nOnMLNo || ''}-${x?.nOnMLDetNo || ''}-${i}`,
miktarInput: x?.miktarInput ?? normalizeQuantityInput(x?.lMiktar),
// If inputPrice is missing, it means we are loading fresh from backend.
// Use fiyat_girilen which is the unit price in original currency.
inputPrice: x?.inputPrice ?? normalizeInputPrice(x?.fiyat_girilen),
inputPricePrBr: originalCurrency,
maliyeteDahil: x?.maliyeteDahil ?? normalizeBooleanFlag(x?.maliyete_dahil ?? x?.Maliyete_dahil),
cmPriceTypeId: normalizeCMPriceTypeId(x?.cmPriceTypeId ?? x?.cm_price_type_id, groupName || x?.sAciklama3),
draftChanged: Boolean(x?.draftChanged),
priceUpdateState: String(x?.priceUpdateState || '').trim()
}
})
}
function normalizeDetailGroups (groups) {
const list = Array.isArray(groups) ? groups : []
const out = list.map(grp => {
const groupName = String(grp?.sAciklama3 || '').trim()
const items = normalizeDetailRows(grp?.items, groupName).map(row => ({
...row,
sAciklama3: String(grp?.sAciklama3 || row?.sAciklama3 || '').trim(),
cmPriceTypeId: normalizeCMPriceTypeId(row?.cmPriceTypeId ?? row?.cm_price_type_id, groupName || row?.sAciklama3)
}))
// USD TUTAR (DESC) sıralama
if (isNoCostDetail.value) {
items.sort((a, b) => {
const ha = parseInt(String(a?.nHammaddeTuruNo || '0'), 10) || 0
const hb = parseInt(String(b?.nHammaddeTuruNo || '0'), 10) || 0
if (ha !== hb) return ha - hb
const ka = String(a?.sKodu || '').trim()
const kb = String(b?.sKodu || '').trim()
return ka.localeCompare(kb, 'tr')
})
} else {
items.sort((a, b) => {
const valA = resolveRowUSDTutar(a)
const valB = resolveRowUSDTutar(b)
return valB - valA
})
}
return {
...grp,
items
}
})
return sortDetailGroups(out)
}
function groupOrderIndex (name) {
const v = String(name || '').trim().toUpperCase()
if (!v) return 999
if (v.includes('CM2')) return 0
if (v.includes('FABRIC')) return 1
if (v === 'TP' || v.includes(' TP')) return 2
if (v === 'DT' || v.includes(' DT')) return 3
if (v.includes('CM1')) return 4
return 999
}
function sortDetailGroups (groups) {
const list = Array.isArray(groups) ? [...groups] : []
list.sort((a, b) => {
const ga = String(a?.sAciklama3 || '').trim()
const gb = String(b?.sAciklama3 || '').trim()
const ia = groupOrderIndex(ga)
const ib = groupOrderIndex(gb)
if (ia !== ib) return ia - ib
return ga.localeCompare(gb, 'tr')
})
return list
}
function recalculateDetailRow (row, options = {}) {
const quantity = resolveNumericRowQuantity(row)
const inputPrice = resolveNumericRowInputPrice(row)
const inputCurrency = resolveInputCurrency(row)
const usdRate = resolveExchangeRateValue('USD')
const eurRate = resolveExchangeRateValue('EUR')
const gbpRate = resolveExchangeRateValue('GBP')
const unitTRY = resolveTRYUnitPriceByInput(inputPrice, inputCurrency, usdRate, eurRate, gbpRate)
const unitUSD = resolveUSDUnitPriceByInput(inputPrice, inputCurrency, usdRate, eurRate, gbpRate)
const unitEUR = eurRate > 0 ? unitTRY / eurRate : 0
const unitGBP = gbpRate > 0 ? unitTRY / gbpRate : 0
const maliyeteDahil = normalizeBooleanFlag(row?.maliyeteDahil ?? row?.maliyete_dahil ?? row?.Maliyete_dahil)
const cmPriceTypeId = normalizeCMPriceTypeId(row?.cmPriceTypeId ?? row?.cm_price_type_id, row?.sAciklama3)
row.lMiktar = quantity
row.fiyat_girilen = inputPrice
row.fiyat_doviz = inputCurrency
row.inputPricePrBr = inputCurrency
row.maliyeteDahil = maliyeteDahil
row.maliyete_dahil = maliyeteDahil ? 1 : 0
row.Maliyete_dahil = maliyeteDahil ? 1 : 0
row.cmPriceTypeId = cmPriceTypeId
row.cm_price_type_id = cmPriceTypeId
row.sDovizCinsi = 'USD'
row.lDovizKuru = usdRate
row.lDovizFiyati = unitUSD
row.lFiyat = unitTRY
row.lTutar = unitTRY * quantity
row.usdTutar = unitUSD * quantity
row.eurTutar = unitEUR * quantity
row.gbpTutar = unitGBP * quantity
row.lTutarUSD = row.usdTutar
row.lTutarEURO = row.eurTutar
row.lTutarGBP = row.gbpTutar
row.lTutarTL = row.lTutar
if (!options.preserveInputs) {
row.miktarInput = normalizeQuantityInput(quantity)
row.inputPrice = normalizeInputPrice(inputPrice)
}
if (options.markChanged) {
row.draftChanged = true
}
if (options.priceType !== undefined) {
row.sFiyatTipi = options.priceType
}
if (options.updateState !== undefined) {
row.priceUpdateState = options.updateState
}
return row
}
function recalculateAllDetailRows () {
detailGroups.value = detailGroups.value.map(grp => ({
...grp,
items: (Array.isArray(grp?.items) ? grp.items : []).map(row => recalculateDetailRow({ ...row }, { preserveInputs: true }))
}))
}
function resolveRowTRYTutar (row) {
const tryAmount = Number(row?.lTutar || 0)
return Number.isFinite(tryAmount) ? tryAmount : 0
}
function resolveRowEURTutar (row) {
const eurAmount = Number(row?.eurTutar || 0)
return Number.isFinite(eurAmount) ? eurAmount : 0
}
function resolveRowGBPTutar (row) {
const gbpAmount = Number(row?.gbpTutar || 0)
return Number.isFinite(gbpAmount) ? gbpAmount : 0
}
function resolveRowUSDTutar (row) {
const usdAmount = Number(row?.usdTutar || 0)
if (Number.isFinite(usdAmount)) return usdAmount
const miktar = resolveNumericRowQuantity(row)
const dovizFiyati = Number(row?.lDovizFiyati || 0)
const calc = miktar * dovizFiyati
return Number.isFinite(calc) ? calc : 0
}
function shouldIncludeRowInGroupTotal (_grp, row) {
// Sub-headers show the gross amount entered in that group. Inclusion in the
// actual costing is shown separately by the page header and checkbox.
return Boolean(row)
}
function resolveGroupTRYTutar (grp) {
const items = Array.isArray(grp?.items) ? grp.items : []
return items.reduce((acc, row) => acc + (shouldIncludeRowInGroupTotal(grp, row) ? resolveRowTRYTutar(row) : 0), 0)
}
function resolveGroupUSDTutar (grp) {
const items = Array.isArray(grp?.items) ? grp.items : []
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}`
}
function isGroupOpen (grp, gi) {
return groupOpenState.value[groupKey(grp, gi)] !== false
}
function toggleGroup (grp, gi) {
const key = groupKey(grp, gi)
groupOpenState.value = {
...groupOpenState.value,
[key]: !isGroupOpen(grp, gi)
}
}
function resolveElHeight (refVal) {
const el = refVal?.$el || refVal
return Number(el?.offsetHeight || 0)
}
function updateStickyTop () {
const stackH = resolveElHeight(stickyStackRef.value)
subHeaderTop.value = (stackH || 0) + 50
}
function toggleHeaderInfo () {
headerInfoCollapsed.value = !headerInfoCollapsed.value
nextTick(() => {
updateStickyTop()
})
}
function onUretimSekliChange (newId) {
if (!detailHeader.value) return
const found = productionTypes.value.find(t => String(t.id) === String(newId))
if (found) {
detailHeader.value.UretimSekliID = found.id
detailHeader.value.UretimSekli = found.aciklama
schedulePersistLocalDraft()
}
}
function buildDetailFetchParams () {
if (isNoCostDetail.value) {
return {
detail_source: 'no-cost',
urun_kodu: productCode.value,
recete_kodu: recipeCode.value,
trace_id: traceId.value
}
}
return {
n_onml_no: onMLNo.value,
urun_kodu: productCode.value,
trace_id: traceId.value
}
}
async function fetchExchangeRatesForCostDate (targetDate = costDate.value) {
const normalizedDate = normalizeDateInput(targetDate)
if (!normalizedDate) {
exchangeRates.value = createEmptyExchangeRates()
recalculateAllDetailRows()
return
}
try {
const response = await get('/pricing/production-product-costing/has-cost-detail-exchange-rates', {
maliyet_tarihi: normalizedDate,
trace_id: traceId.value
})
exchangeRates.value = normalizeExchangeRatesPayload(response, normalizedDate)
} catch (err) {
exchangeRates.value = {
...createEmptyExchangeRates(),
rateDate: normalizedDate
}
slog.error('production-product-costing.detail', 'exchange-rates:error', {
trace_id: traceId.value,
maliyet_tarihi: normalizedDate,
detail: await extractApiErrorDetail(err)
})
$q.notify({
type: 'negative',
message: await extractApiErrorDetail(err),
position: 'top-right'
})
} finally {
recalculateAllDetailRows()
}
}
async function fetchDetail (options = {}) {
const hydrateDraft = options.hydrateDraft !== false
const clearDraft = options.clearDraft === true
if (isNoCostDetail.value && !recipeCode.value) {
detailError.value = 'Recete kodu bulunamadi'
detailGroups.value = []
detailHeader.value = null
exchangeRates.value = createEmptyExchangeRates()
return
}
if (!isNoCostDetail.value && !onMLNo.value) {
detailError.value = 'nOnMLNo bulunamadi'
detailGroups.value = []
detailHeader.value = null
exchangeRates.value = createEmptyExchangeRates()
return
}
detailLoading.value = true
detailError.value = ''
detailGroups.value = []
detailHeader.value = null
costDate.value = ''
exchangeRates.value = createEmptyExchangeRates()
newRowSequence.value = 0
rowEditorDialogOpen.value = false
rowEditorMode.value = 'new'
rowEditorTargetRowKey.value = ''
rowEditorForm.value = createRowEditorForm()
rowEditorHammaddeAllOptions.value = []
rowEditorHammaddeOptions.value = []
rowEditorHammaddeLoading.value = false
rowEditorItemAllOptions.value = []
rowEditorItemOptions.value = []
rowEditorItemLoading.value = false
rowEditorColorAllOptions.value = []
rowEditorColorOptions.value = []
rowEditorColorLoading.value = false
lineHistoryDialogOpen.value = false
lineHistoryLoading.value = false
lineHistoryError.value = ''
lineHistoryRows.value = []
lineHistoryTargetRowKey.value = ''
lineHistoryTargetHammaddeTuruNo.value = ''
lineHistoryTargetItemCode.value = ''
lineHistoryTargetSummary.value = ''
lineHistorySearchMode.value = 'exact'
lineHistoryLastPurchaseMatchStage.value = ''
lineHistoryLastRecipeMatchStage.value = ''
try {
if (clearDraft) {
clearLocalDraft()
}
const detailParams = buildDetailFetchParams()
slog.info('production-product-costing.detail', 'fetch-detail:start', {
trace_id: traceId.value,
detail_source: detailSource.value || 'has-cost',
n_onml_no: onMLNo.value,
urun_kodu: productCode.value,
recete_kodu: recipeCode.value
})
const [headerData, groupsData, typesData] = await Promise.all([
get('/pricing/production-product-costing/has-cost-detail-header', detailParams),
get('/pricing/production-product-costing/has-cost-detail-groups', detailParams),
get('/pricing/production-product-costing/production-types', {
trace_id: traceId.value
})
])
detailHeader.value = headerData && typeof headerData === 'object' ? headerData : null
productionTypes.value = Array.isArray(typesData) ? typesData : []
// Prefer true costing date (spUrtOnMLMas.Tarihi) over record create/update timestamps.
costDate.value = normalizeDateInput(detailHeader.value?.maliyetTarihi || detailHeader.value?.dteKayitTarihi)
detailGroups.value = normalizeDetailGroups(groupsData)
initialHeaderSnapshot.value = currentHeaderSnapshot.value
// Optional: hydrate local draft after base data load.
if (hydrateDraft) {
tryHydrateFromLocalDraft()
}
// 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) => {
initialOpen[groupKey(grp, gi)] = true
})
groupOpenState.value = initialOpen
slog.info('production-product-costing.detail', 'fetch-detail:ok', {
trace_id: traceId.value,
detail_source: detailSource.value || 'has-cost',
group_count: detailGroups.value.length,
item_count: flatDetailRows.value.length,
urun_kodu: detailHeader.value?.UrunKodu || productCode.value,
n_urt_recete_id: detailHeader.value?.nUrtReceteID || ''
})
// tbStok exists-bulk kontrolu devre disi (manual giris kapali).
// Load last10 avg deviation warnings panel (non-blocking).
await refreshLast10Warnings()
// (eski tbStok missing dialog/notify kaldirildi)
} catch (err) {
detailError.value = await extractApiErrorDetail(err)
slog.error('production-product-costing.detail', 'fetch-detail:error', {
trace_id: traceId.value,
detail_source: detailSource.value || 'has-cost',
n_onml_no: onMLNo.value,
urun_kodu: productCode.value,
recete_kodu: recipeCode.value,
detail: detailError.value
})
} finally {
detailLoading.value = false
}
}
function goBack () {
if (isNoCostDetail.value) {
router.push({ name: 'production-product-costing-no-cost' })
return
}
const urunKodu = productCode.value
if (!urunKodu) {
router.push({ name: 'production-product-costing-has-cost' })
return
}
router.push({
name: 'production-product-costing-has-cost-history',
query: { urun_kodu: urunKodu }
})
}
function buildDetailItemRequestPayload (row) {
return {
__rowKey: row.__rowKey,
n_onml_no: String(row?.nOnMLNo || detailHeader.value?.nOnMLNo || onMLNo.value || '').trim(),
n_onml_det_no: String(row?.nOnMLDetNo || '').trim(),
n_hammadde_turu_no: String(row?.nHammaddeTuruNo || '').trim(),
s_kodu: String(row?.sKodu || '').trim(),
s_aciklama: String(row?.sAciklama || '').trim(),
s_renk: String(row?.sRenk || '').trim(),
color_code: String(firstDefinedValue(row, ['ColorCode', 'colorCode', 'sRenk', 's_renk'])).trim(),
color_description: String(firstDefinedValue(row, ['ColorDescription', 'colorDescription', 'sRenkAdi', 's_renk_adi'])).trim(),
item_dim1_code: String(firstDefinedValue(row, ['ItemDim1Code', 'itemDim1Code', 'sBeden', 's_beden'])).trim(),
item_dim1_description: String(firstDefinedValue(row, ['ItemDim1Description', 'itemDim1Description', 'sAciklama2', 's_aciklama2'])).trim(),
s_birim: String(row?.sBirim || '').trim(),
l_miktar: resolveNumericRowQuantity(row),
fiyat_girilen: resolveNumericRowInputPrice(row),
fiyat_doviz: resolveInputCurrency(row),
maliyete_dahil: row?.maliyeteDahil ? 1 : 0,
cm_price_type_id: normalizeCMPriceTypeId(row?.cmPriceTypeId ?? row?.cm_price_type_id, row?.sAciklama3)
}
}
function applyPriceSelectionToRow (targetRowKey, price, currency, priceType, itemCode, itemDescription, historyCompanyCode) {
const normalizedCurrency = normalizePriceCurrency(currency) || 'USD'
const normalizedPrice = parseMoneyInput(price)
const finalPriceType = priceType || 'SAF'
const normalizedCode = normalizeCodeValue(itemCode)
const normalizedDesc = String(itemDescription || '').trim()
const isICode = String(normalizedCode || '').toUpperCase().startsWith('I.')
const selectedFirma = String(detailHeader.value?.FirmaKodu || '').trim().toUpperCase()
const historyFirma = String(historyCompanyCode || '').trim().toUpperCase()
const isSameFirma = Boolean(selectedFirma && historyFirma && selectedFirma === historyFirma)
detailGroups.value = detailGroups.value.map(grp => ({
...grp,
items: (Array.isArray(grp?.items) ? grp.items : []).map(row => {
if (row.__rowKey !== targetRowKey) return row
return recalculateDetailRow({
...row,
...(normalizedCode ? { sKodu: normalizedCode } : {}),
...(normalizedDesc ? { sAciklama: normalizedDesc } : {}),
...(isICode ? { __iCodeSelected: true, __iCodeSameFirma: isSameFirma } : { __iCodeSelected: false, __iCodeSameFirma: false }),
inputPrice: normalizeInputPrice(normalizedPrice),
fiyat_girilen: normalizedPrice,
inputPricePrBr: normalizedCurrency,
fiyat_doviz: normalizedCurrency
}, {
priceType: finalPriceType,
updateState: 'selection',
markChanged: true
})
})
}))
}
async function fetchSimilarItemHistory (mode = 'prefix') {
if (mode === 'exact') {
const row = flatDetailRows.value.find(r => r.__rowKey === lineHistoryTargetRowKey.value)
if (row) {
await openLineHistory(row)
}
return
}
if (!lineHistoryTargetHammaddeTuruNo.value && !lineHistoryTargetItemCode.value) return
const normalizedMode = mode === 'alternative' ? 'alternative' : 'prefix'
lineHistorySearchMode.value = normalizedMode
lineHistoryLoading.value = true
lineHistoryError.value = ''
try {
slog.info('production-product-costing.detail', 'line-history-similar:start', {
trace_id: traceId.value,
search_mode: normalizedMode,
n_hammadde_turu_no: lineHistoryTargetHammaddeTuruNo.value,
s_kodu: lineHistoryTargetItemCode.value,
maliyet_tarihi: costDate.value,
limit: LINE_HISTORY_ROW_LIMIT
})
const response = await get('/pricing/production-product-costing/has-cost-detail-similar-history', {
n_hammadde_turu_no: lineHistoryTargetHammaddeTuruNo.value,
s_kodu: lineHistoryTargetItemCode.value,
maliyet_tarihi: costDate.value,
limit: LINE_HISTORY_ROW_LIMIT,
search_mode: normalizedMode,
trace_id: traceId.value
})
lineHistoryRows.value = normalizeLineHistoryRows(response)
lineHistoryLastPurchaseMatchStage.value = String(response?.purchase_match_stage || '').trim()
lineHistoryLastRecipeMatchStage.value = String(response?.recipe_match_stage || '').trim()
slog.info('production-product-costing.detail', 'line-history-similar:ok', {
trace_id: traceId.value,
search_mode: normalizedMode,
purchase_match_stage: response?.purchase_match_stage || '',
recipe_match_stage: response?.recipe_match_stage || '',
similar_code_prefix: response?.similar_code_prefix || '',
row_count: lineHistoryRows.value.length
})
if (lineHistoryRows.value.length === 0) {
$q.notify({
type: 'warning',
message: 'Bu hammadde türü için de benzer ürün kaydı bulunamadı.',
position: 'top-right'
})
}
} catch (err) {
lineHistoryError.value = await extractApiErrorDetail(err)
slog.error('production-product-costing.detail', 'line-history-similar:error', {
trace_id: traceId.value,
search_mode: normalizedMode,
n_hammadde_turu_no: lineHistoryTargetHammaddeTuruNo.value,
s_kodu: lineHistoryTargetItemCode.value,
error: lineHistoryError.value
})
} finally {
lineHistoryLoading.value = false
}
}
function onRowQuantityInput (row, value) {
row.miktarInput = value
recalculateDetailRow(row, {
preserveInputs: true,
markChanged: true
})
schedulePersistLocalDraft()
triggerUIUpdate()
}
function triggerUIUpdate () {
detailGroups.value = [...detailGroups.value]
}
function normalizeRowQuantityDisplay (row) {
row.miktarInput = normalizeQuantityInput(row?.miktarInput)
recalculateDetailRow(row, {
preserveInputs: true
})
schedulePersistLocalDraft()
triggerUIUpdate()
}
function onRowInputPriceChange (row, value) {
row.inputPrice = value
recalculateDetailRow(row, {
preserveInputs: true,
priceType: 'MAN',
updateState: 'manual',
markChanged: true
})
schedulePersistLocalDraft()
triggerUIUpdate()
}
function normalizeRowPriceDisplay (row) {
row.inputPrice = normalizeInputPrice(row?.inputPrice)
recalculateDetailRow(row, {
preserveInputs: true
})
schedulePersistLocalDraft()
triggerUIUpdate()
}
function onRowInputPriceCurrencyChange (row, value) {
row.inputPricePrBr = normalizePriceCurrency(value) || 'USD'
recalculateDetailRow(row, {
preserveInputs: true,
priceType: 'MAN',
updateState: 'manual',
markChanged: true
})
schedulePersistLocalDraft()
triggerUIUpdate()
}
function onRowMaliyeteDahilChange (row, value) {
row.maliyeteDahil = Boolean(value)
row.maliyete_dahil = value ? 1 : 0
row.Maliyete_dahil = value ? 1 : 0
row.draftChanged = true
schedulePersistLocalDraft()
triggerUIUpdate()
}
function onRowCMPriceTypeChange (row, value) {
row.cmPriceTypeId = isCMGroupName(row?.sAciklama3) ? (value ? 2 : 1) : null
row.cm_price_type_id = row.cmPriceTypeId
row.draftChanged = true
schedulePersistLocalDraft()
triggerUIUpdate()
}
async function fetchBulkItemPrices () {
const flatRows = detailGroups.value.flatMap(grp => Array.isArray(grp?.items) ? grp.items : [])
if (flatRows.length === 0) {
$q.notify({
type: 'warning',
message: 'Toplu fiyat cagrisi icin satir bulunamadi.',
position: 'top-right'
})
return
}
bulkPriceLoading.value = true
try {
const response = await post('/pricing/production-product-costing/has-cost-detail-bulk-prices', {
n_onml_no: onMLNo.value,
urun_kodu: detailHeader.value?.UrunKodu || productCode.value,
n_urt_recete_id: detailHeader.value?.nUrtReceteID || '',
maliyet_tarihi: costDate.value,
items: flatRows.map(buildDetailItemRequestPayload)
}, {
params: {
trace_id: traceId.value
}
})
const updates = normalizeBulkPriceItems(response)
if (updates.length === 0) {
$q.notify({
type: 'warning',
message: 'Toplu fiyat cagrisindan uygulanacak veri donmedi.',
position: 'top-right'
})
return
}
let appliedCount = 0
detailGroups.value = detailGroups.value.map(grp => ({
...grp,
items: (Array.isArray(grp?.items) ? grp.items : []).map(row => {
const match = updates.find(update => rowMatchesBulkUpdate(row, update))
if (!match) return row
appliedCount += 1
return recalculateDetailRow({
...row,
inputPrice: match.inputPrice,
fiyat_girilen: match.fiyat_girilen,
inputPricePrBr: match.inputPricePrBr,
fiyat_doviz: match.fiyat_doviz
}, {
priceType: match.priceType || 'SAF',
updateState: 'bulk',
markChanged: true
})
})
}))
$q.notify({
type: appliedCount > 0 ? 'positive' : 'warning',
message: appliedCount > 0
? `${appliedCount} kalemin fiyat ve pr. br. bilgisi guncellendi.`
: 'Donen veriler satirlarla eslestirilemedi.',
position: 'top-right'
})
// Autofill missing required codes from last OnML history (URETIM spUrtOnMLMasDet)
try {
const flatNow = detailGroups.value.flatMap(grp => Array.isArray(grp?.items) ? grp.items : [])
const targets = flatNow.filter(r =>
Boolean(r?.requiredPlaceholder) &&
// CM1/CM2 and FABRIC rows must be chosen by user; don't auto-fill from previous costing.
!isCMGroupName(r?.sAciklama3) &&
normalizeGroupName(r?.sAciklama3) !== 'FABRIC' &&
(String(r?.sKodu || '').trim() === '' || Number(resolveNumericRowInputPrice(r) || 0) <= 0)
)
const hNos = Array.from(new Set(targets
.map(r => parseInt(String(r?.nHammaddeTuruNo || '').trim() || '0', 10))
.filter(n => n > 0)
))
if (hNos.length > 0) {
const lastRows = await post('/pricing/production-product-costing/has-cost-detail/last-detail', {
nHammaddeTuruNos: hNos,
before_date: normalizeDateInput(costDate.value),
exclude_onml_no: parseInt(String(onMLNo.value || detailHeader.value?.nOnMLNo || detailHeader.value?.NOnMLNo || '0'), 10) || 0,
n_firma_id: parseInt(String(detailHeader.value?.nFirmaID || detailHeader.value?.NFirmaID || '0').trim() || '0', 10) || 0
}, { params: { trace_id: traceId.value } })
const list = Array.isArray(lastRows) ? lastRows : []
const byNo = {}
list.forEach(x => {
const no = parseInt(String(x?.nHammaddeTuruNo || '0'), 10) || 0
if (no > 0) byNo[no] = x
})
let filled = 0
detailGroups.value = detailGroups.value.map(grp => ({
...grp,
items: (Array.isArray(grp?.items) ? grp.items : []).map(row => {
if (!row?.requiredPlaceholder) return row
if (isCMGroupName(row?.sAciklama3) || normalizeGroupName(row?.sAciklama3) === 'FABRIC') return row
const no = parseInt(String(row?.nHammaddeTuruNo || '').trim() || '0', 10) || 0
const hit = byNo[no]
if (!hit) return row
const next = { ...row }
const hasCode = String(next.sKodu || '').trim() !== ''
const hasPrice = Number(resolveNumericRowInputPrice(next) || 0) > 0
if (!hasCode && String(hit?.sKodu || '').trim()) {
next.sKodu = String(hit.sKodu || '').trim()
next.sAciklama = String(hit?.sAciklama || '').trim()
}
if (!hasPrice && Number(hit?.fiyat_girilen || 0) > 0) {
next.inputPrice = String(hit.fiyat_girilen)
next.fiyat_girilen = Number(hit.fiyat_girilen)
const pr = String(hit?.fiyat_doviz || '').trim() || 'USD'
next.inputPricePrBr = pr
next.fiyat_doviz = pr
}
// Only mark if something changed
const changed = (next.sKodu !== row.sKodu) || (next.sAciklama !== row.sAciklama) || (next.inputPrice !== row.inputPrice) || (next.inputPricePrBr !== row.inputPricePrBr)
if (!changed) return row
filled += 1
return recalculateDetailRow({
...next,
__autoFilledFromPrev: true
,
__autoFilledFromPrevSameFirma: Boolean(hit?.is_same_firma || hit?.isSameFirma)
}, {
priceType: 'PREV',
updateState: 'autofill',
markChanged: true
})
})
}))
if (filled > 0) {
$q.notify({
type: 'info',
message: `${filled} satirda kod/aciklama ve fiyat bilgisi onceki maliyetten otomatik getirildi.`,
position: 'top-right'
})
}
}
} catch (e) {
// Non-blocking
slog.error('production-product-costing.detail', 'bulk:autofill-prev:error', {
trace_id: traceId.value,
error: String(e?.message || e || '')
})
}
} catch (err) {
$q.notify({
type: 'negative',
message: await extractApiErrorDetail(err),
position: 'top-right'
})
} finally {
bulkPriceLoading.value = false
}
}
async function openLineHistory (row) {
const rowCode = String(row?.sKodu || '').trim()
if (!rowCode) {
$q.notify({
type: 'warning',
message: 'History acmak icin satirda kod bilgisi olmali.',
position: 'top-right'
})
return
}
lineHistoryDialogOpen.value = true
lineHistoryLoading.value = true
lineHistoryError.value = ''
lineHistoryRows.value = []
lineHistoryTargetRowKey.value = row.__rowKey
lineHistoryTargetHammaddeTuruNo.value = String(row?.nHammaddeTuruNo || '').trim()
lineHistoryTargetItemCode.value = rowCode
lineHistoryTargetSummary.value = `${rowCode} | ${String(row?.sAciklama || '').trim() || 'TANIMSIZ'}`
lineHistorySearchMode.value = 'exact'
lineHistoryLastPurchaseMatchStage.value = ''
lineHistoryLastRecipeMatchStage.value = ''
try {
const response = await get('/pricing/production-product-costing/has-cost-detail-line-history', {
n_onml_no: onMLNo.value,
urun_kodu: detailHeader.value?.UrunKodu || productCode.value,
n_urt_recete_id: detailHeader.value?.nUrtReceteID || '',
n_onml_det_no: String(row?.nOnMLDetNo || '').trim(),
n_hammadde_turu_no: String(row?.nHammaddeTuruNo || '').trim(),
s_kodu: rowCode,
s_renk: String(row?.sRenk || '').trim(),
color_code: String(firstDefinedValue(row, ['ColorCode', 'colorCode', 'sRenk', 's_renk'])).trim(),
item_dim1_code: String(firstDefinedValue(row, ['ItemDim1Code', 'itemDim1Code', 'sBeden', 's_beden'])).trim(),
maliyet_tarihi: costDate.value,
trace_id: traceId.value
})
lineHistoryRows.value = normalizeLineHistoryRows(response)
} catch (err) {
lineHistoryError.value = await extractApiErrorDetail(err)
} finally {
lineHistoryLoading.value = false
}
}
function onDetailRowClick (evt, row) {
// Grid satırı tıklanabilir değil, artık ikonlar ve butonlar kullanılıyor.
}
function applyLineHistorySelection (historyRow) {
const targetKey = String(lineHistoryTargetRowKey.value || '').trim()
if (!targetKey) {
$q.notify({
type: 'negative',
message: 'Hedef satir bulunamadi (rowKey bos).',
position: 'top-right'
})
return
}
applyPriceSelectionToRow(
targetKey,
historyRow?.price,
historyRow?.currency,
historyRow?.priceType,
historyRow?.itemCode,
historyRow?.itemDescription,
historyRow?.companyCode
)
lineHistoryDialogOpen.value = false
$q.notify({
type: 'positive',
message: `Secilen fiyat satira uygulandi: ${formatMoney(historyRow?.price)} ${String(historyRow?.currency || '').trim() || 'USD'}`,
position: 'top-right'
})
}
function resolveDetailRowClass (row) {
const key = String(row?.__rowKey || '').trim()
if (key && requiredAttentionRowKeys.value?.[key]) return 'pcd-detail-row-required'
if (row?.requiredPlaceholder) return 'pcd-detail-row-required'
const code = String(row?.sKodu || '').trim()
if (code && isTbStokMissingCode(code)) return 'pcd-detail-row-missing-stock'
return row?.draftChanged ? 'pcd-detail-row-secondary' : ''
}
async function bootstrapRowEditorOptions () {
try {
const hammaddeSearch = String(rowEditorForm.value.nHammaddeTuruNo || '').trim()
const itemSearch = String(rowEditorForm.value.sKodu || '').trim()
const colorSearch = String(rowEditorForm.value.ColorCode || '').trim()
const [hammaddeRows, itemRows] = await Promise.all([
refreshRowEditorOptions('hammadde', hammaddeSearch),
itemSearch.length >= ROW_EDITOR_ITEM_MIN_SEARCH_LENGTH
? refreshRowEditorOptions('item', itemSearch)
: Promise.resolve([])
])
if (itemSearch.length < ROW_EDITOR_ITEM_MIN_SEARCH_LENGTH) {
rowEditorItemAllOptions.value = []
rowEditorItemOptions.value = []
primeRowEditorOptionsFromForm()
}
const selectedHammadde = hammaddeRows.find(opt => String(opt?.value || '') === hammaddeSearch)
if (selectedHammadde) {
rowEditorForm.value.sHammaddeTuruAdi = String(selectedHammadde.sHammaddeTuruAdi || rowEditorForm.value.sHammaddeTuruAdi || '').trim()
rowEditorForm.value.sAciklama3 = String(selectedHammadde.sAciklama3 || rowEditorForm.value.sAciklama3 || 'TANIMSIZ').trim() || 'TANIMSIZ'
// Parca adi should come from spUrtMTBolum.sAdi (backend sends as sParcaAdi). Never fall back to group label (DT/TP/...).
rowEditorForm.value.sParcaAdi = String(selectedHammadde.sParcaAdi || rowEditorForm.value.sParcaAdi || '').trim()
}
const selectedItem = itemRows.find(opt => String(opt?.value || '') === itemSearch)
if (selectedItem) {
rowEditorForm.value.nStokID = String(selectedItem.nStokID || rowEditorForm.value.nStokID || '').trim()
rowEditorForm.value.sModel = String(selectedItem.sModel || rowEditorForm.value.sModel || '').trim()
rowEditorForm.value.sAciklama = String(selectedItem.sAciklama || rowEditorForm.value.sAciklama || '').trim()
rowEditorForm.value.sBirim = extractPrimaryUnitValue(selectedItem.sBirim || rowEditorForm.value.sBirim || 'AD') || 'AD'
}
await refreshRowEditorOptions('color', colorSearch)
primeRowEditorOptionsFromForm()
} catch (err) {
rowEditorHammaddeAllOptions.value = []
rowEditorHammaddeOptions.value = []
rowEditorItemAllOptions.value = []
rowEditorItemOptions.value = []
rowEditorColorAllOptions.value = []
rowEditorColorOptions.value = []
$q.notify({
type: 'negative',
message: await extractApiErrorDetail(err),
position: 'top-right'
})
}
}
function onRowEditorHammaddeChange (value) {
const selected = rowEditorHammaddeOptions.value.find(opt => String(opt?.value || '') === String(value || ''))
rowEditorForm.value.nHammaddeTuruNo = String(value || '').trim()
if (!selected) return
rowEditorForm.value.sHammaddeTuruAdi = String(selected.sHammaddeTuruAdi || '').trim()
rowEditorForm.value.sAciklama3 = String(selected.sAciklama3 || 'TANIMSIZ').trim() || 'TANIMSIZ'
// Parca adi should come from spUrtMTBolum.sAdi (backend sends as sParcaAdi). Never fall back to group label (DT/TP/...).
rowEditorForm.value.sParcaAdi = String(selected.sParcaAdi || '').trim()
if (!isCMGroupName(rowEditorForm.value.sAciklama3)) {
rowEditorForm.value.cmPriceTypeChecked = false
}
}
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 || ''))
// Prevent free-typed values from being accepted as "code". User must pick from list.
if (!selected) {
const typed = String(value || '').trim()
// If user cleared the field, allow clearing (other validations will catch blank-on-save if needed).
if (!typed) {
rowEditorForm.value.sKodu = ''
rowEditorLastValidItemValue.value = ''
return
}
// Revert to last valid selection.
rowEditorForm.value.sKodu = String(rowEditorLastValidItemValue.value || '').trim()
$q.notify({
type: 'negative',
message: 'Kod secilmeden serbest metin girilemez. Listeden secim yapin.',
position: 'top-right'
})
return
}
rowEditorForm.value.sKodu = String(value || '').trim()
rowEditorLastValidItemValue.value = String(value || '').trim()
const previousColorCode = String(rowEditorForm.value.ColorCode || '').trim()
rowEditorForm.value.nStokID = String(selected.nStokID || '').trim()
rowEditorForm.value.sModel = String(selected.sModel || '').trim()
rowEditorForm.value.sAciklama = String(selected.sAciklama || '').trim()
rowEditorForm.value.sBirim = extractPrimaryUnitValue(selected.sBirim || rowEditorForm.value.sBirim || 'AD') || 'AD'
rowEditorForm.value.ColorCode = ''
rowEditorForm.value.sRenk = ''
rowEditorForm.value.ColorDescription = ''
try {
await refreshRowEditorOptions('color', previousColorCode)
} catch (err) {
$q.notify({
type: 'negative',
message: `Renk lookup getirilemedi: ${await extractApiErrorDetail(err)}`,
position: 'top-right'
})
}
const matchedColor = rowEditorColorOptions.value.find(opt => String(opt?.value || '') === previousColorCode)
if (matchedColor) {
rowEditorForm.value.ColorCode = previousColorCode
rowEditorForm.value.sRenk = previousColorCode
rowEditorForm.value.ColorDescription = String(matchedColor.colorDescription || '').trim()
}
}
function onRowEditorColorChange (value) {
const selected = rowEditorColorOptions.value.find(opt => String(opt?.value || '') === String(value || ''))
const normalizedValue = String(value || '').trim()
rowEditorForm.value.ColorCode = normalizedValue
rowEditorForm.value.sRenk = normalizedValue
if (!selected) return
rowEditorForm.value.ColorDescription = String(selected.colorDescription || '').trim()
}
function buildRowFromEditorForm () {
const form = normalizeLegacyParcaAndGroup(rowEditorForm.value)
const existingRow = flatDetailRows.value.find(row => row.__rowKey === rowEditorTargetRowKey.value)
const cmPriceTypeId = normalizeCMPriceTypeId(form.cmPriceTypeChecked ? 2 : 1, form.sAciklama3)
if (!existingRow) {
newRowSequence.value += 1
}
return recalculateDetailRow({
...(existingRow || {}),
__rowKey: existingRow?.__rowKey || `new-editor-row-${newRowSequence.value}`,
isNew: Boolean(existingRow?.isNew) || rowEditorMode.value === 'new',
nOnMLNo: detailHeader.value?.nOnMLNo || onMLNo.value || '',
nStokID: String(form.nStokID || '').trim(),
sModel: String(form.sModel || '').trim(),
nOnMLDetNo: String(form.nOnMLDetNo || '').trim(),
// Keep Parca Adi and Parca Grubu distinct. sAciklama3 is the group key (DT/TP/CM2/FABRIC).
sParcaAdi: String(form.sParcaAdi || '').trim(),
sAciklama3: String(form.sAciklama3 || 'TANIMSIZ').trim() || 'TANIMSIZ',
nHammaddeTuruNo: String(form.nHammaddeTuruNo || '').trim(),
sHammaddeTuruAdi: String(form.sHammaddeTuruAdi || '').trim(),
sKodu: String(form.sKodu || '').trim(),
sAciklama: String(form.sAciklama || '').trim(),
sRenk: String(form.sRenk || form.ColorCode || '').trim(),
ColorCode: String(form.ColorCode || form.sRenk || '').trim(),
ColorDescription: String(form.ColorDescription || '').trim(),
lMiktar: parseMoneyInput(form.miktarInput),
miktarInput: normalizeQuantityInput(form.miktarInput),
inputPrice: normalizeInputPrice(form.inputPrice),
inputPricePrBr: normalizePriceCurrency(form.inputPricePrBr) || 'USD',
fiyat_girilen: parseMoneyInput(form.inputPrice),
fiyat_doviz: normalizePriceCurrency(form.inputPricePrBr) || 'USD',
maliyeteDahil: Boolean(form.maliyeteDahil),
maliyete_dahil: form.maliyeteDahil ? 1 : 0,
Maliyete_dahil: form.maliyeteDahil ? 1 : 0,
cmPriceTypeId,
cm_price_type_id: cmPriceTypeId,
sBirim: String(form.sBirim || 'AD').trim() || 'AD',
draftChanged: true
}, {
preserveInputs: true,
priceType: 'MAN',
updateState: 'editor',
markChanged: true
})
}
function applyEditorRowToGroups (nextRow) {
const targetGroupName = String(nextRow?.sAciklama3 || 'TANIMSIZ').trim() || 'TANIMSIZ'
const nextGroups = detailGroups.value
.map(grp => ({
...grp,
items: (Array.isArray(grp?.items) ? grp.items : []).filter(row => row.__rowKey !== nextRow.__rowKey)
}))
.filter(grp => (Array.isArray(grp?.items) ? grp.items.length : 0) > 0 || String(grp?.sAciklama3 || '').trim() === targetGroupName)
const targetIndex = nextGroups.findIndex(grp => String(grp?.sAciklama3 || '').trim() === targetGroupName)
if (targetIndex >= 0) {
nextGroups[targetIndex] = {
...nextGroups[targetIndex],
items: [...(Array.isArray(nextGroups[targetIndex].items) ? nextGroups[targetIndex].items : []), nextRow]
.sort((left, right) => {
if (isNoCostDetail.value) {
const ha = parseInt(String(left?.nHammaddeTuruNo || '0'), 10) || 0
const hb = parseInt(String(right?.nHammaddeTuruNo || '0'), 10) || 0
if (ha !== hb) return ha - hb
}
return parseInt(String(left?.nOnMLDetNo || '0'), 10) - parseInt(String(right?.nOnMLDetNo || '0'), 10)
})
}
} else {
nextGroups.push({
sAciklama3: targetGroupName,
totalTutar: 0,
totalUSDTutar: 0,
items: [nextRow]
})
}
detailGroups.value = sortDetailGroups(nextGroups)
syncAllGroupsOpen()
schedulePersistLocalDraft()
// tbStok validation devre disi
}
function syncAllGroupsOpen () {
const openState = {}
detailGroups.value.forEach((grp, gi) => {
openState[groupKey(grp, gi)] = true
})
groupOpenState.value = openState
}
function normalizeHammaddeNo (value) {
return String(value || '').trim()
}
function normalizeGroupName (value) {
return String(value || 'TANIMSIZ').trim() || 'TANIMSIZ'
}
async function fetchRequiredParcaMappings () {
const ilk = String(detailHeader.value?.UrunIlkGrubu || '').trim()
const ana = String(detailHeader.value?.UrunAnaGrubu || '').trim()
// Some sources return NULL/'' for "no alt group". Mapping screen stores it as '-'.
const altRaw = String(detailHeader.value?.UrunAltGrubu || '').trim()
const alt = altRaw || '-'
if (!ilk || !ana) return []
const data = await get('/pricing/production-product-costing/maliyet-parca-eslestirme', {
trace_id: traceId.value,
only_active: 1,
urun_ilk_grubu: ilk,
urun_ana_grubu: ana,
urun_alt_grubu: alt
})
return Array.isArray(data) ? data : []
}
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]
try {
const rows = await get('/pricing/production-product-costing/detail-editor-options', {
kind: 'hammadde',
search: key,
limit: 25,
trace_id: traceId.value
})
const list = Array.isArray(rows) ? rows : []
const hit = list.find(x => String(x?.nHammaddeTuruNo ?? x?.value ?? '').trim() === key)
const meta = hit
? {
// In URETIM lookups, DT/TP/CM2/FABRIC is often stored in sAciklama2 (and sometimes in sAciklama3).
groupName: String(hit?.sAciklama3 || hit?.sAciklama2 || '').trim(),
// Hammadde type description is usually sAciklama (e.g. "CKT KUMAS").
// NOTE: detail-editor-options returns `sHammaddeTuruAdi` already without the number. Do not fall back to `label`,
// otherwise we end up rendering "3300 - 3300 - CKT ASKI".
hammaddeAdi: String(hit?.sHammaddeTuruAdi || hit?.sAciklama || '').trim(),
// Part info: spUrtOnMLHammaddeTuru.nUrtMTBolumID -> spUrtMTBolum.sAdi
mtBolumID: parseInt(String(hit?.mtUrtMTBolumID ?? hit?.MTUrtMTBolumID ?? hit?.nUrtMTBolumID ?? hit?.NUrtMTBolumID ?? '0'), 10) || 0,
parcaAdi: String(hit?.sParcaAdi || '').trim()
}
: null
requiredHammaddeMetaCache.value = { ...(requiredHammaddeMetaCache.value || {}), [key]: meta || { groupName: '', hammaddeAdi: '', mtBolumID: 0, parcaAdi: '' } }
return meta
} catch {
requiredHammaddeMetaCache.value = { ...(requiredHammaddeMetaCache.value || {}), [key]: { groupName: '', hammaddeAdi: '', mtBolumID: 0, parcaAdi: '' } }
return cached || null
}
}
async function ensureNoCostRequiredRowsFromMappings (mappings) {
const list = Array.isArray(mappings) ? mappings : []
requiredParcaMappings.value = list
if (list.length === 0) return
// Defensive: The mapping payload may contain duplicate hammadde numbers across rows (or even inside a single row).
// IMPORTANT: Placeholders are keyed by (nUrtMTBolumID + nHammaddeTuruNo). The same hammadde type can be required
// 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).
// sAciklama3 is reserved for the group header (DT/TP/CM2/FABRIC).
const mappingParcaAdi = normalizeGroupName(mapping?.parcaBolumAdi || mapping?.mtBolumAdi)
const mappingMtBolumID = parseInt(String(mapping?.nUrtMTBolumID ?? mapping?.NUrtMTBolumID ?? '0'), 10) || 0
const hList = Array.isArray(mapping?.nHammaddeTurleri) ? mapping.nHammaddeTurleri : []
for (const hNoRaw of hList) {
const hNo = normalizeHammaddeNo(hNoRaw)
if (!hNo) continue
const reqKey = `${mappingMtBolumID}:${hNo}`
if (suppressedRequiredKeys.value?.[reqKey]) continue
if (processedRequiredKeys.has(reqKey)) continue
processedRequiredKeys.add(reqKey)
// Prefer the hammadde lookup's groupName (DT/TP/CM2/FABRIC)
// so placeholder rows land under the correct group headers.
const meta = await resolveHammaddeMetaForRequired(hNo)
const groupName = normalizeGroupName(meta?.groupName || '')
const hammaddeAdi = String(meta?.hammaddeAdi || '').trim()
const effectiveGroupName = groupName || 'TANIMSIZ'
// The product-type mapping is authoritative for the required part. A raw
// material type can be required for multiple parts, while its master
// record only carries one default MT section. Using that default here
// incorrectly made an existing recipe row satisfy every mapped part.
const mtBolumID = mappingMtBolumID > 0 ? mappingMtBolumID : (meta?.mtBolumID || 0)
const desiredParcaAdi = mappingMtBolumID > 0
? mappingParcaAdi
: normalizeGroupName(meta?.parcaAdi || mappingParcaAdi)
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
// If we don't have row's nUrtMTBolumID (legacy rows / older placeholders), fall back to matching by part name.
// Also accept legacy placeholders where sParcaAdi accidentally contains the group label (DT/TP/...), so we can move/fix them.
const rowParca = normalizeGroupName(r?.sParcaAdi)
return rowParca === desiredParcaAdi || isKnownGroupName(rowParca)
})
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 (
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,
sHammaddeTuruAdi: hammaddeAdi || String(anyMatch?.sHammaddeTuruAdi || '').trim(),
requiredPlaceholder: true,
draftChanged: true
}, {
preserveInputs: true,
priceType: 'REQ',
updateState: 'required',
markChanged: true
})
applyEditorRowToGroups(moved)
}
}
continue
}
newRowSequence.value += 1
const rowKey = `req-auto-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(),
// Group header key
sAciklama3: effectiveGroupName,
// Parca adi column value (CEKET/PANTOLON/YELEK...)
sParcaAdi: desiredParcaAdi,
nHammaddeTuruNo: hNo,
// Make "Hammadde Turu" render like existing rows: "no - aciklama"
sHammaddeTuruAdi: hammaddeAdi,
sKodu: '',
sAciklama: '',
sRenk: '',
ColorCode: '',
ColorDescription: '',
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,
fiyat_doviz: 'USD',
maliyeteDahil: true,
maliyete_dahil: 1,
Maliyete_dahil: 1,
cmPriceTypeId: normalizeCMPriceTypeId(1, groupName),
cm_price_type_id: normalizeCMPriceTypeId(1, groupName),
sBirim: 'AD',
draftChanged: true,
requiredPlaceholder: true
}, {
preserveInputs: true,
priceType: 'REQ',
updateState: 'required',
markChanged: true
})
applyEditorRowToGroups(placeholder)
}
}
// 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.
// So we prefill ONLY code + description for required CM2 placeholders from last OnML history.
try {
const flatNow = detailGroups.value.flatMap(grp => Array.isArray(grp?.items) ? grp.items : [])
const cm2Targets = flatNow.filter(r =>
Boolean(r?.requiredPlaceholder) &&
normalizeGroupName(r?.sAciklama3) === 'CM2' &&
String(r?.sKodu || '').trim() === ''
)
const cm2Nos = Array.from(new Set(cm2Targets
.map(r => parseInt(String(r?.nHammaddeTuruNo || '').trim() || '0', 10))
.filter(n => n > 0)
))
if (cm2Nos.length > 0) {
const lastRows = await post('/pricing/production-product-costing/has-cost-detail/last-detail', {
nHammaddeTuruNos: cm2Nos,
before_date: normalizeDateInput(costDate.value),
exclude_onml_no: parseInt(String(onMLNo.value || detailHeader.value?.nOnMLNo || detailHeader.value?.NOnMLNo || '0'), 10) || 0,
n_firma_id: parseInt(String(detailHeader.value?.nFirmaID || detailHeader.value?.NFirmaID || '0').trim() || '0', 10) || 0,
only_i_code: true
}, { params: { trace_id: traceId.value } })
const list = Array.isArray(lastRows) ? lastRows : []
const byNo = {}
list.forEach(x => {
const no = parseInt(String(x?.nHammaddeTuruNo || '0'), 10) || 0
if (no > 0) byNo[no] = x
})
let filled = 0
detailGroups.value = detailGroups.value.map(grp => ({
...grp,
items: (Array.isArray(grp?.items) ? grp.items : []).map(row => {
if (!row?.requiredPlaceholder) return row
if (normalizeGroupName(row?.sAciklama3) !== 'CM2') return row
if (String(row?.sKodu || '').trim() !== '') return row
const no = parseInt(String(row?.nHammaddeTuruNo || '').trim() || '0', 10) || 0
const hit = byNo[no]
if (!hit) return row
const code = String(hit?.sKodu || '').trim()
if (!code) return row
const next = recalculateDetailRow({
...row,
sKodu: code,
sAciklama: String(hit?.sAciklama || '').trim(),
__autoFilledFromPrev: true,
__autoFilledFromPrevSameFirma: Boolean(hit?.is_same_firma || hit?.isSameFirma)
}, {
priceType: 'PREV',
updateState: 'autofill-icode',
markChanged: true
})
filled += 1
return next
})
}))
if (filled > 0) {
$q.notify({
type: 'info',
message: `${filled} CM2 satirinda I.* kod/aciklama onceki maliyetten otomatik getirildi.`,
position: 'top-right'
})
}
}
} catch (e) {
// Non-blocking
slog.error('production-product-costing.detail', 'cm2:autofill-icode:error', {
trace_id: traceId.value,
error: String(e?.message || e)
})
}
}
function computeMissingRequiredSlots () {
const list = Array.isArray(requiredParcaMappings.value) ? requiredParcaMappings.value : []
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.
const mappingMtBolumID = parseInt(String(mapping?.nUrtMTBolumID ?? mapping?.NUrtMTBolumID ?? '0'), 10) || 0
const mappingParcaAdi = normalizeGroupName(mapping?.parcaBolumAdi || mapping?.mtBolumAdi || '')
const hList = Array.isArray(mapping?.nHammaddeTurleri) ? mapping.nHammaddeTurleri : []
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
const rowMtBolumID = parseInt(String(r?.nUrtMTBolumID ?? r?.NUrtMTBolumID ?? '0'), 10) || 0
if (mappingMtBolumID > 0 && rowMtBolumID > 0) return mappingMtBolumID === rowMtBolumID
// Fallback: older rows might not have mtBolumID; use part name match.
return normalizeGroupName(r?.sParcaAdi) === mappingParcaAdi
})
const price = resolveNumericRowInputPrice(match)
if (!match || !(price > 0)) {
missing.push({
mtBolumID: mappingMtBolumID,
parcaAdi: mappingParcaAdi,
nHammaddeTuruNo: hNo,
rowKey: match?.__rowKey || ''
})
}
})
})
return missing
}
function removeDetailRowByKey (rowKey) {
const normalizedRowKey = String(rowKey || '').trim()
if (!normalizedRowKey) return false
const existingRow = flatDetailRows.value.find(r => String(r?.__rowKey || '').trim() === normalizedRowKey)
const nextGroups = detailGroups.value
.map(grp => ({
...grp,
items: (Array.isArray(grp?.items) ? grp.items : []).filter(row => row.__rowKey !== normalizedRowKey)
}))
.filter(grp => (Array.isArray(grp?.items) ? grp.items.length : 0) > 0)
const hasChanged = nextGroups.length !== detailGroups.value.length ||
nextGroups.some((grp, index) => (grp?.items?.length || 0) !== (detailGroups.value[index]?.items?.length || 0))
if (!hasChanged) return false
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 : []),
{
__rowKey: normalizedRowKey,
nOnMLNo: String(existingRow?.nOnMLNo || '').trim(),
nOnMLDetNo: String(existingRow?.nOnMLDetNo || '').trim(),
nHammaddeTuruNo: String(existingRow?.nHammaddeTuruNo || '').trim(),
sKodu: String(existingRow?.sKodu || '').trim(),
sRenk: String(existingRow?.sRenk || '').trim()
}
]
}
schedulePersistLocalDraft()
return true
}
function openNewRowDialog () {
if (!detailHeader.value) {
$q.notify({
type: 'warning',
message: 'Yeni satir eklemek icin once detay verisi olmali.',
position: 'top-right'
})
return
}
rowEditorMode.value = 'new'
rowEditorTargetRowKey.value = ''
rowEditorForm.value = createRowEditorForm({
isNew: true,
nOnMLDetNo: getNextDetailNo(),
inputPricePrBr: normalizePriceCurrency(detailHeader.value?.sDovizCinsi) || 'USD',
maliyeteDahil: true,
sBirim: 'AD'
})
rowEditorLastValidItemValue.value = ''
primeRowEditorOptionsFromForm()
rowEditorDialogOpen.value = true
void bootstrapRowEditorOptions()
}
function openRowEditorForEdit (row) {
rowEditorMode.value = 'edit'
rowEditorTargetRowKey.value = String(row?.__rowKey || '').trim()
rowEditorForm.value = createRowEditorForm({
...row,
sAciklama3: row?.sAciklama3 || ''
})
rowEditorLastValidItemValue.value = String(rowEditorForm.value?.sKodu || '').trim()
primeRowEditorOptionsFromForm()
rowEditorDialogOpen.value = true
void bootstrapRowEditorOptions()
}
function deleteRowEditor () {
const targetRowKey = String(rowEditorTargetRowKey.value || rowEditorForm.value.__rowKey || '').trim()
if (!targetRowKey) return
$q.dialog({
title: 'Satiri Sil',
message: 'Bu satir gridden kaldirilacak. Devam edilsin mi?',
cancel: {
flat: true,
color: 'grey-7',
label: 'Vazgec'
},
ok: {
unelevated: true,
color: 'negative',
label: 'Sil'
},
persistent: true
}).onOk(() => {
if (!removeDetailRowByKey(targetRowKey)) {
$q.notify({
type: 'warning',
message: 'Silinecek satir bulunamadi.',
position: 'top-right'
})
return
}
rowEditorDialogOpen.value = false
$q.notify({
type: 'positive',
message: 'Satir gridden kaldirildi.',
position: 'top-right'
})
})
}
function saveRowEditor () {
if (!String(rowEditorForm.value.nOnMLDetNo || '').trim()) {
rowEditorForm.value.nOnMLDetNo = getNextDetailNo()
}
if (!String(rowEditorForm.value.nHammaddeTuruNo || '').trim()) {
$q.notify({
type: 'warning',
message: 'Hammadde Turu secilmeden satir kaydedilemez.',
position: 'top-right'
})
return
}
if (!String(rowEditorForm.value.sKodu || '').trim()) {
$q.notify({
type: 'warning',
message: 'Kod secilmeden satir kaydedilemez.',
position: 'top-right'
})
return
}
const nextRow = buildRowFromEditorForm()
applyEditorRowToGroups(nextRow)
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
}
function formatSignedPercent (value) {
const n = Number(value || 0)
const sign = n >= 0 ? '+' : ''
return `${sign}${round1(n)}%`
}
function escapeHtml (input) {
const s = String(input ?? '')
return s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
function convertUSDToPriceCurrency (usdPrice, currency) {
const usd = Number(usdPrice || 0)
if (!Number.isFinite(usd) || usd <= 0) return 0
const cur = normalizePriceCurrency(currency) || 'USD'
if (cur === 'USD') return usd
const usdRate = resolveExchangeRateValue('USD')
if (!(usdRate > 0)) return usd
const tryPrice = usd * usdRate
if (cur === 'TRY') return tryPrice
const targetRate = resolveExchangeRateValue(cur)
return targetRate > 0 ? (tryPrice / targetRate) : usd
}
function setAllPriceDeviationRowsSelected (value) {
priceDeviationRows.value = priceDeviationRows.value.map(row => ({
...row,
selected: Boolean(value)
}))
}
function applySelectedPriceDeviationAverages () {
priceDeviationRows.value = priceDeviationRows.value.map(row => {
if (!row.selected) return row
const currency = normalizePriceCurrency(row.newCurrency) || 'USD'
const price = convertUSDToPriceCurrency(row.avgUSD, currency)
return {
...row,
newPrice: normalizeInputPrice(round4(price)),
newCurrency: currency
}
})
}
function applyPriceDeviationRowEdits () {
const selectedRows = priceDeviationRows.value.filter(row => row.selected)
for (const item of selectedRows) {
const target = item.row
if (!target) continue
target.inputPrice = normalizeInputPrice(item.newPrice)
target.inputPricePrBr = normalizePriceCurrency(item.newCurrency) || 'USD'
recalculateDetailRow(target, {
preserveInputs: true,
priceType: 'MAN',
updateState: 'manual',
markChanged: true
})
}
if (selectedRows.length > 0) {
schedulePersistLocalDraft()
triggerUIUpdate()
}
}
function closePriceDeviationDialog (result) {
const resolve = priceDeviationResolver.value
priceDeviationResolver.value = null
priceDeviationDialogOpen.value = false
if (typeof resolve === 'function') resolve(Boolean(result))
}
function cancelPriceDeviationDialog () {
closePriceDeviationDialog(false)
}
function confirmPriceDeviationDialog () {
applyPriceDeviationRowEdits()
closePriceDeviationDialog(true)
}
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 = {}
const descMap = {}
;(Array.isArray(defaults) ? defaults : []).forEach(it => {
const no = parseInt(String(it?.nHammaddeTuruNo || '0'), 10) || 0
const d = Number(it?.lDefaultMiktar || 0)
const desc = String(it?.sAciklama || '').trim()
if (no > 0 && d > 0) {
defMap[no] = d
if (desc) descMap[no] = desc
}
})
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 rowsHtml = outliers.slice(0, maxRows).map(x => {
const sign = x.pct >= 0 ? '+' : ''
const pct = `${sign}${round1(x.pct)}%`
const cls = x.pct >= 0 ? 'color:#b71c1c;' : 'color:#1b5e20;'
const desc = String(descMap[x.no] || '').trim()
const noLabel = desc ? `${x.no} - ${escapeHtml(desc)}` : String(x.no)
return `
<tr>
<td style="padding:6px 8px; white-space:nowrap; font-weight:600;">${noLabel}</td>
<td style="padding:6px 8px; text-align:right; white-space:nowrap;">${round4(x.defQty)}</td>
<td style="padding:6px 8px; text-align:right; white-space:nowrap;">${round4(x.enteredQty)}</td>
<td style="padding:6px 8px; text-align:right; white-space:nowrap; ${cls} font-weight:600;">${pct}</td>
</tr>
`
}).join('')
const truncatedNote = outliers.length > maxRows
? `<div style="margin-top:8px; color:#666;">Toplam ${outliers.length} satir var. Ilk ${maxRows} gosterildi.</div>`
: ''
const ok = await new Promise(resolve => {
$q.dialog({
title: 'Varsayilan Miktar Kontrolu',
html: true,
message: `
<div style="margin-bottom:10px;">
Bazi hammadde turlerinde varsayilan miktardan <b>%10</b>'dan fazla sapma var.
</div>
<div style="max-height: 360px; overflow:auto; border:1px solid #e0e0e0; border-radius:6px;">
<table style="width:100%; border-collapse:collapse; font-size:13px;">
<thead>
<tr style="background:#f5f5f5; position: sticky; top: 0;">
<th style="text-align:left; padding:6px 8px;">Hammadde</th>
<th style="text-align:right; padding:6px 8px;">Varsayilan</th>
<th style="text-align:right; padding:6px 8px;">Girilen</th>
<th style="text-align:right; padding:6px 8px;">Fark %</th>
</tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
</table>
</div>
${truncatedNote}
<div style="margin-top:10px;">
Onayliyorsaniz <b>Onayla ve Kaydet</b>'e basın. Duzenlemek icin <b>Geri Don</b>.
</div>
`,
cancel: { label: 'Geri Don' },
ok: { label: 'Onayla ve Kaydet', color: 'primary' },
persistent: true
}).onOk(() => resolve(true)).onCancel(() => resolve(false))
})
return ok
}
async function getPurchaseAvgUSDForCode (code) {
const normalized = String(code || '').trim()
if (!normalized) return { ok: false, code: '', avgUSD: 0, n: 0 }
const cached = purchaseAvgUSDCachedByCode.value?.[normalized]
if (cached && cached.ok) return cached
try {
const response = await get('/pricing/production-product-costing/has-cost-detail-line-history', {
n_onml_no: parseInt(String(onMLNo.value || detailHeader.value?.nOnMLNo || detailHeader.value?.NOnMLNo || '0'), 10) || 0,
s_kodu: normalized,
maliyet_tarihi: normalizeDateInput(costDate.value),
trace_id: traceId.value
})
const rows = Array.isArray(response?.purchaseRows) ? response.purchaseRows : []
const picked = []
for (const r of rows) {
const p = Number(r?.EvrakFiyat || 0)
if (!(p > 0)) continue
const cur = String(r?.EvrakDoviz || '').trim().toUpperCase() || 'USD'
const usd = convertPriceToUSD(p, cur)
if (!(usd > 0)) continue
picked.push(usd)
if (picked.length >= 10) break
}
const avgUSD = picked.length > 0 ? (picked.reduce((a, b) => a + b, 0) / picked.length) : 0
const result = { ok: picked.length > 0, code: normalized, avgUSD, n: picked.length }
purchaseAvgUSDCachedByCode.value = { ...(purchaseAvgUSDCachedByCode.value || {}), [normalized]: result }
return result
} catch (e) {
const result = { ok: false, code: normalized, avgUSD: 0, n: 0, error: String(e?.message || e) }
purchaseAvgUSDCachedByCode.value = { ...(purchaseAvgUSDCachedByCode.value || {}), [normalized]: result }
return result
}
}
async function confirmBrPriceDeviationIfNeeded () {
const rows = Array.isArray(flatDetailRows.value) ? flatDetailRows.value : []
if (rows.length === 0) return true
// Only consider rows that have a code + a non-zero entered price.
const candidates = rows
.filter(r => String(r?.sKodu || '').trim() !== '')
.map(r => {
const price = Number(resolveNumericRowInputPrice(r) || 0)
const cur = String(resolveInputCurrency(r) || '').trim().toUpperCase() || 'USD'
return { row: r, code: String(r?.sKodu || '').trim(), price, cur }
})
.filter(x => x.price > 0)
if (candidates.length === 0) return true
const uniqueCodes = Array.from(new Set(candidates.map(x => x.code)))
// Fetch averages with simple batching to avoid hammering the API.
const avgByCode = {}
const batchSize = 8
for (let i = 0; i < uniqueCodes.length; i += batchSize) {
const batch = uniqueCodes.slice(i, i + batchSize)
const results = await Promise.all(batch.map(c => getPurchaseAvgUSDForCode(c)))
results.forEach(r => { avgByCode[r.code] = r })
}
const outliers = []
for (const c of candidates) {
const avg = avgByCode[c.code]
if (!avg || !avg.ok || !(avg.avgUSD > 0) || avg.n < 3) continue // too little history -> ignore
const enteredUSD = convertPriceToUSD(c.price, c.cur)
if (!(enteredUSD > 0)) continue
const pct = ((enteredUSD - avg.avgUSD) / avg.avgUSD) * 100
if (Math.abs(pct) > 10) {
outliers.push({
row: c.row,
key: `${String(c.row?.__rowKey || c.code)}|${outliers.length}`,
code: c.code,
avgUSD: avg.avgUSD,
enteredUSD,
pct,
originalPrice: c.price,
originalCurrency: c.cur
})
}
}
if (outliers.length === 0) return true
outliers.sort((a, b) => Math.abs(b.pct) - Math.abs(a.pct))
priceDeviationRows.value = outliers.map(x => ({
...x,
selected: true,
newPrice: normalizeInputPrice(x.originalPrice),
newCurrency: normalizePriceCurrency(x.originalCurrency) || 'USD'
}))
return await new Promise(resolve => {
priceDeviationResolver.value = resolve
priceDeviationDialogOpen.value = true
})
const maxRows = 30
const rowsHtml = outliers.slice(0, maxRows).map(x => {
const sign = x.pct >= 0 ? '+' : ''
const pct = `${sign}${round1(x.pct)}%`
const cls = x.pct >= 0 ? 'color:#b71c1c;' : 'color:#1b5e20;'
return `
<tr>
<td style="padding:6px 8px; white-space:nowrap; font-weight:600;">${escapeHtml(x.code)}</td>
<td style="padding:6px 8px; text-align:right; white-space:nowrap;">${formatMoney(x.avgUSD)}</td>
<td style="padding:6px 8px; text-align:right; white-space:nowrap;">${formatMoney(x.enteredUSD)}</td>
<td style="padding:6px 8px; text-align:right; white-space:nowrap; ${cls} font-weight:600;">${pct}</td>
</tr>
`
}).join('')
const truncatedNote = outliers.length > maxRows
? `<div style="margin-top:8px; color:#666;">Toplam ${outliers.length} satir var. Ilk ${maxRows} gosterildi.</div>`
: ''
const ok = await new Promise(resolve => {
$q.dialog({
title: 'Fiyat Kontrolu (Satinalma Ortalama)',
html: true,
message: `
<div style="margin-bottom:10px;">
Bazı satırlarda girilen fiyat, NEBIM_V3 satınalma geçmişindeki <b>son 10</b> kaydın USD ortalamasından <b>%10</b>'dan fazla sapıyor.
</div>
<div style="max-height: 360px; overflow:auto; border:1px solid #e0e0e0; border-radius:6px;">
<table style="width:100%; border-collapse:collapse; font-size:13px;">
<thead>
<tr style="background:#f5f5f5; position: sticky; top: 0;">
<th style="text-align:left; padding:6px 8px;">Kod</th>
<th style="text-align:right; padding:6px 8px;">Ort USD</th>
<th style="text-align:right; padding:6px 8px;">Girilen USD</th>
<th style="text-align:right; padding:6px 8px;">Fark %</th>
</tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
</table>
</div>
${truncatedNote}
<div style="margin-top:10px;">
Onayliyorsaniz <b>Onayla ve Kaydet</b>'e basın. Duzenlemek icin <b>Geri Don</b>.
</div>
`,
cancel: { label: 'Geri Don' },
ok: { label: 'Onayla ve Kaydet', color: 'primary' },
persistent: true
}).onOk(() => resolve(true)).onCancel(() => resolve(false))
})
return ok
}
async function deleteCosting () {
if (!detailHeader.value) return
const n = parseInt(String(detailHeader.value?.nOnMLNo || detailHeader.value?.NOnMLNo || onMLNo.value || '0'), 10) || 0
if (!(n > 0)) {
$q.notify({ type: 'warning', message: 'Silinecek kayit bulunamadi.', position: 'top-right' })
return
}
const ok = await new Promise(resolve => {
$q.dialog({
title: 'Kaydi Sil',
message: `Bu maliyet kaydi silinecek. (OnMLNo=${n}) Devam edilsin mi?`,
cancel: true,
persistent: true
}).onOk(() => resolve(true)).onCancel(() => resolve(false))
})
if (!ok) return
saveLoading.value = true
try {
await post('/pricing/production-product-costing/onml/delete', {
n_onml_no: n
})
clearLocalDraft()
$q.notify({ type: 'positive', message: 'Kayit silindi.', position: 'top-right' })
router.replace({ name: 'production-product-costing-no-cost' })
} catch (e) {
$q.notify({ type: 'negative', message: String(e?.message || e || 'Silinemedi'), position: 'top-right' })
} finally {
saveLoading.value = false
}
}
async function confirmRefresh () {
if (detailLoading.value) return
const ok = await new Promise(resolve => {
$q.dialog({
title: 'Yenile',
message: hasUnsavedChanges.value
? 'Bu islem sayfayi sifirlar ve kaydedilmemis degisiklikler kaybolur. Devam edilsin mi?'
: 'Bu islem sayfayi sifirlar. Devam edilsin mi?',
cancel: { label: 'Geri Don' },
ok: { label: 'Onayla', color: 'negative' },
persistent: true
}).onOk(() => resolve(true)).onCancel(() => resolve(false))
})
if (!ok) return
clearLocalDraft()
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 = {}
// tbStok exists-bulk kontrolu devre disi (manual giris kapali): save bloke edilmez.
if (isNoCostDetail.value) {
const missing = computeMissingRequiredSlots()
slog.info('production-product-costing.detail', 'required:missing:computed', {
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',
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))
})
if (!ok) {
const next = {}
missing.forEach(x => {
if (x.rowKey) next[String(x.rowKey)] = true
})
requiredAttentionRowKeys.value = next
$q.notify({
type: 'warning',
message: 'Eksik parcalar isaretlendi. Fiyat girip tekrar deneyin.',
position: 'top-right'
})
// Scroll to first highlighted row.
nextTick(() => scrollToFirstRequiredAttentionRow())
return
}
}
}
const okDefaultQty = await confirmDefaultQtyDeviationIfNeeded()
if (!okDefaultQty) return
const okBrPrice = await confirmBrPriceDeviationIfNeeded()
if (!okBrPrice) return
if (!detailHeader.value) {
$q.notify({ type: 'negative', message: 'Header bulunamadi.', position: 'top-right' })
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 : [])
.filter(r => {
const hNo = parseInt(String(r?.nHammaddeTuruNo || '').trim() || '0', 10) || 0
if (!(hNo > 0)) return false
return String(r?.sKodu || '').trim() === ''
})
if (blankCodeRows.length > 0) {
const next = {}
blankCodeRows.forEach(r => {
const key = String(r?.__rowKey || '').trim()
if (key) next[key] = true
})
requiredAttentionRowKeys.value = next
$q.notify({
type: 'warning',
message: `Kod bos birakilan satirlar var. Kaydetmeden once Kod alanini doldurun. (Adet: ${blankCodeRows.length})`,
position: 'top-right'
})
return
}
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,
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,
s_aciklama3: String(r?.sAciklama3 || '').trim()
}))
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, {
params: { trace_id: traceId.value }
})
const newOnMLNo = parseInt(String(response?.n_onml_no || '0'), 10) || 0
$q.notify({ type: 'positive', message: 'Kaydedildi.', position: 'top-right' })
// Force clear local draft before fetching fresh data to ensure we don't re-hydrate old inputs.
clearLocalDraft()
// If we created a new OnML (no-cost), switch to has-cost detail mode.
if (isNoCostDetail.value && newOnMLNo > 0) {
await router.replace({
name: 'production-product-costing-has-cost-detail',
query: {
n_onml_no: String(newOnMLNo),
urun_kodu: String(header?.UrunKodu || productCode.value || '').trim()
}
})
$q.dialog({
title: 'Maliyet Kaydedildi',
message: 'Maliyeti olmayan urunler listesine donup kaldiginiz yerden devam etmek ister misiniz?',
ok: {
label: 'Listeye Don',
color: 'primary',
icon: 'arrow_back'
},
cancel: {
label: 'Bu Sayfada Devam Et',
color: 'grey-7',
flat: true
},
persistent: true
}).onOk(() => {
router.push({ name: 'production-product-costing-no-cost' })
})
return
}
// For existing costing, just refresh the detail.
await fetchDetail({ clearDraft: true, hydrateDraft: false })
// last10 warnings are computed async on backend; re-check shortly after save.
window.setTimeout(() => {
try { refreshLast10Warnings() } catch {}
}, 1200)
$q.dialog({
title: 'Maliyet Guncellendi',
message: 'Mevcut maliyeti olan urunler listesine donup kaldiginiz yerden devam etmek ister misiniz?',
ok: {
label: 'Listeye Don',
color: 'primary',
icon: 'arrow_back'
},
cancel: {
label: 'Bu Sayfada Devam Et',
color: 'grey-7',
flat: true
},
persistent: true
}).onOk(() => {
router.push({ name: 'production-product-costing-has-cost' })
})
} catch (e) {
// Surface backend message (http.Error text) when available.
const msg = String(
e?.response?.data?.message ||
e?.response?.data ||
e?.message ||
e ||
'Kaydedilemedi'
)
slog.error('production-product-costing.detail', 'save:error', {
trace_id: traceId.value,
status: e?.response?.status,
error: msg
})
$q.notify({ type: 'negative', message: msg, position: 'top-right' })
return
} finally {
saveLoading.value = false
}
}
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],
() => {
fetchDetail()
}
)
watch(
() => costDate.value,
value => {
const normalizedDate = normalizeDateInput(value)
if (detailHeader.value) {
detailHeader.value.dteKayitTarihi = normalizedDate
}
fetchExchangeRatesForCostDate(normalizedDate)
}
)
onMounted(() => {
if (!canReadOrder.value) return
fetchDetail()
nextTick(() => {
updateStickyTop()
})
window.addEventListener('resize', updateStickyTop)
})
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(
() => hasUnsavedChanges.value,
(value) => {
ensureBeforeUnloadGuard(Boolean(value))
}
)
onBeforeRouteLeave((to, from, next) => {
if (!hasUnsavedChanges.value) {
// Always clear local draft on page exit; backend is source of truth.
clearLocalDraft()
next()
return
}
$q.dialog({
title: 'Sayfadan ayriliyorsunuz',
message: pageMode.value === 'edit'
? 'Yaptiginiz degisiklikler kaybolacak. Devam edilsin mi?'
: 'Yaptiginiz degisiklikler kaybolacak. Devam edilsin mi?',
ok: { label: 'Evet', color: 'negative' },
cancel: { label: 'Hayir' },
persistent: true
})
.onOk(() => {
clearLocalDraft()
next()
})
.onCancel(() => next(false))
})
watch(
() => [detailLoading.value, detailError.value, !!detailHeader.value, headerInfoCollapsed.value],
() => {
nextTick(() => {
updateStickyTop()
})
}
)
</script>
<style scoped>
.pcd-page {
padding: 0;
}
.pcd-content-body {
margin-top: 4px;
}
.pcd-sticky-stack {
position: sticky;
top: 50px;
z-index: 1000;
background: #fff;
margin-bottom: 0;
border-bottom: 1px solid #ddd;
width: 100%;
}
.pcd-save-toolbar {
border-top: 0;
}
.pcd-toolbar-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.pcd-toolbar-left {
display: flex;
align-items: center;
gap: 10px;
flex: 1 1 auto;
min-width: 0;
}
.pcd-toolbar-title {
flex: 0 0 auto;
font-size: 15px;
font-weight: 800;
color: #223048;
white-space: nowrap;
}
.pcd-toolbar-summary {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
min-width: 0;
flex: 1 1 auto;
}
.pcd-toolbar-summary-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-width: 0;
}
.pcd-toolbar-pill {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 5px 8px;
border-radius: 8px;
border: 1px solid #d7e0ea;
white-space: nowrap;
}
.pcd-toolbar-pill-emphasis {
background: var(--q-primary);
border-color: #145ea8;
color: #ffffff;
}
.pcd-toolbar-pill-neutral {
background: #f5f7fa;
color: #2b3c54;
}
.pcd-toolbar-pill-warn {
background: #ef6c00;
border-color: #ef6c00;
color: #ffffff;
}
.pcd-summary-expansion :deep(.q-expansion-item__container) {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 6px;
background: #fff;
}
.pcd-missing-stock-code {
background: #c62828 !important;
color: #ffffff !important;
}
.pcd-missing-stock-code :deep(*) {
color: #ffffff !important;
}
.pcd-detail-row-missing-stock td {
background: #c62828 !important;
color: #ffffff !important;
}
.pcd-detail-row-missing-stock td :deep(*) {
color: #ffffff !important;
}
.pcd-summary-title {
font-weight: 800;
font-size: 12px;
color: #2b3c54;
margin: 2px 0 6px;
}
.pcd-summary-table td,
.pcd-summary-table th {
font-size: 12px;
line-height: 1.2;
}
.pcd-summary-total-row td {
border-top: 2px solid rgba(0, 0, 0, 0.15);
background: #fbfbfd;
}
.pcd-summary-k {
width: 60px;
color: #6b7680;
font-weight: 800;
}
.pcd-summary-v {
font-weight: 800;
}
.pcd-toolbar-pill-label {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.02em;
opacity: 0.88;
}
.pcd-toolbar-pill-value {
font-size: 12px;
font-weight: 800;
min-width: 0;
}
.pcd-toolbar-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex: 0 0 auto;
margin-left: auto;
}
.pcd-toolbar-btn {
font-size: 12px;
}
.pcd-toolbar-btn :deep(.q-btn__content) {
min-height: 34px;
padding: 0 4px;
}
.pcd-add-row-dialog {
min-width: 360px;
}
.pcd-row-editor-dialog {
width: min(960px, 96vw);
max-width: 96vw;
}
.pcd-row-editor-drag-handle {
cursor: move;
user-select: none;
}
.pcd-row-editor-dialog :deep(.pcd-row-editor-entry .q-field__control) {
background: color-mix(in srgb, var(--q-secondary) 18%, white) !important;
border: 1px solid var(--q-secondary) !important;
border-radius: 6px;
}
.pcd-row-editor-dialog :deep(.pcd-row-editor-entry .q-field__label),
.pcd-row-editor-dialog :deep(.pcd-row-editor-entry .q-field__native),
.pcd-row-editor-dialog :deep(.pcd-row-editor-entry .q-field__input),
.pcd-row-editor-dialog :deep(.pcd-row-editor-entry .q-field__marginal),
.pcd-row-editor-dialog :deep(.pcd-row-editor-entry .q-select__dropdown-icon),
.pcd-row-editor-dialog :deep(.pcd-row-editor-entry .q-field__selection) {
color: var(--q-secondary) !important;
font-weight: 700;
}
.pcd-row-editor-dialog :deep(.pcd-row-editor-entry.q-field--focused .q-field__control) {
box-shadow: 0 0 0 1px var(--q-secondary), 0 0 0 3px color-mix(in srgb, var(--q-secondary) 24%, white);
}
.pcd-row-editor-dialog :deep(.pcd-row-editor-entry .q-placeholder) {
color: var(--q-secondary) !important;
opacity: 0.76;
}
.pcd-price-deviation-dialog {
width: min(1120px, 96vw);
max-width: 96vw;
}
.pcd-price-deviation-table-wrap {
max-height: min(62vh, 620px);
overflow: auto;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 6px;
}
.pcd-price-deviation-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 12px;
}
.pcd-price-deviation-table th {
position: sticky;
top: 0;
z-index: 1;
background: #f4f6f8;
color: #263238;
font-weight: 700;
padding: 6px 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
white-space: nowrap;
}
.pcd-price-deviation-table td {
padding: 5px 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
vertical-align: middle;
}
.pcd-price-deviation-table td:nth-child(6) {
min-width: 120px;
}
.pcd-price-deviation-table td:nth-child(7) {
min-width: 96px;
}
.pcd-row-editor-flag {
background: color-mix(in srgb, var(--q-secondary) 10%, white);
border: 1px solid color-mix(in srgb, var(--q-secondary) 35%, white);
border-radius: 8px;
padding: 6px 10px;
}
.pcd-history-dialog {
background: #fff;
}
.pcd-detail-header-bar {
border: 1px solid #ddd;
border-radius: 6px;
}
.pcd-emphasis-field :deep(.q-field__control) {
background: var(--q-primary) !important;
border: 1px solid #145ea8;
}
.pcd-emphasis-field :deep(.q-field__label) {
color: #eaf3ff !important;
font-weight: 700;
}
.pcd-emphasis-field :deep(.q-field__native),
.pcd-emphasis-field :deep(.q-field__input) {
color: #ffffff !important;
font-weight: 800;
}
.pcd-emphasis-field-alt :deep(.q-field__control) {
background: color-mix(in srgb, var(--q-secondary) 25%, white) !important;
border: 1px solid var(--q-secondary);
}
.pcd-emphasis-field-alt :deep(.q-field__label) {
color: var(--q-primary) !important;
font-weight: 700;
}
.pcd-emphasis-field-alt :deep(.q-field__native),
.pcd-emphasis-field-alt :deep(.q-field__input),
.pcd-emphasis-field-alt :deep(.q-field__selection) {
color: var(--q-primary) !important;
font-weight: 800;
}
.pcd-readonly-header-input :deep(.q-field__control) {
background: #f8f9fa !important;
}
.pcd-group-card {
border: 1px solid #dfe3e8;
border-radius: 6px;
overflow: visible;
position: relative;
margin: 0 10px 15px;
}
.pcd-sub-header {
position: sticky !important;
top: var(--pcd-subheader-top) !important;
z-index: 990 !important;
display: flex !important;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
min-height: 42px;
height: 42px;
border-top: 1px solid #d6c06a;
border-bottom: 1px solid #d6c06a;
background: linear-gradient(90deg, #fffbe9 0%, #fff4c4 50%, #fff1b0 100%);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.pcd-sub-header .sub-left {
font-weight: 800;
color: #2b1f05;
text-transform: uppercase;
}
.pcd-sub-header .sub-right {
font-weight: 900;
color: #3b2f09;
font-size: 12px;
text-transform: uppercase;
text-align: right;
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: nowrap;
}
.pcd-sub-header .sub-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.pcd-sub-title {
flex: 0 1 auto;
min-width: 0;
}
.pcd-sub-right-clickable {
cursor: pointer;
user-select: none;
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
white-space: nowrap;
}
.pcd-sub-mt-qty {
flex: 0 0 auto;
white-space: nowrap;
opacity: 0.9;
font-weight: 900;
}
.pcd-detail-table :deep(.q-table__middle) {
overflow-x: hidden !important;
width: 100% !important;
max-width: 100% !important;
}
.pcd-detail-table :deep(.q-table) {
table-layout: fixed !important;
width: 100% !important;
min-width: 0 !important;
}
.pcd-detail-table :deep(.q-table th),
.pcd-detail-table :deep(.q-table td) {
overflow: hidden;
text-overflow: ellipsis;
min-width: 0 !important;
}
/* Fit every detail column into one desktop viewport. These percentages also
override the old pixel widths carried by a few editable columns. */
.pcd-detail-table :deep(.q-table th:nth-child(1)), .pcd-detail-table :deep(.q-table td:nth-child(1)) { width: 3.5% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(2)), .pcd-detail-table :deep(.q-table td:nth-child(2)) { width: 3% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(3)), .pcd-detail-table :deep(.q-table td:nth-child(3)) { width: 5% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(4)), .pcd-detail-table :deep(.q-table td:nth-child(4)) { width: 7% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(5)), .pcd-detail-table :deep(.q-table td:nth-child(5)) { width: 6% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(6)), .pcd-detail-table :deep(.q-table td:nth-child(6)) { width: 8% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(7)), .pcd-detail-table :deep(.q-table td:nth-child(7)) { width: 4.5% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(8)), .pcd-detail-table :deep(.q-table td:nth-child(8)) { width: 4.5% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(9)), .pcd-detail-table :deep(.q-table td:nth-child(9)) { width: 5% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(10)), .pcd-detail-table :deep(.q-table td:nth-child(10)) { width: 4% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(11)), .pcd-detail-table :deep(.q-table td:nth-child(11)) { width: 3.5% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(12)), .pcd-detail-table :deep(.q-table td:nth-child(12)) { width: 3% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(13)), .pcd-detail-table :deep(.q-table td:nth-child(13)) { width: 5% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(14)), .pcd-detail-table :deep(.q-table td:nth-child(14)) { width: 5% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(15)), .pcd-detail-table :deep(.q-table td:nth-child(15)) { width: 4% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(16)), .pcd-detail-table :deep(.q-table td:nth-child(16)) { width: 3.5% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(17)), .pcd-detail-table :deep(.q-table td:nth-child(17)) { width: 4.5% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(18)), .pcd-detail-table :deep(.q-table td:nth-child(18)) { width: 5% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(19)), .pcd-detail-table :deep(.q-table td:nth-child(19)) { width: 4.5% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(20)), .pcd-detail-table :deep(.q-table td:nth-child(20)) { width: 6% !important; }
.pcd-detail-table :deep(.q-table th:nth-child(21)), .pcd-detail-table :deep(.q-table td:nth-child(21)) { width: 3% !important; }
.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;
}
.pcd-detail-table :deep(.q-table thead tr:first-child th) {
position: sticky !important;
top: calc(var(--pcd-subheader-top) + 42px) !important;
z-index: 980 !important;
background: #f8f9fa !important;
opacity: 1 !important;
}
.pcd-detail-table :deep(.q-table thead th) {
font-size: 9px;
padding: 2px 2px;
white-space: normal;
line-height: 1.2;
vertical-align: bottom;
border-bottom: 1px solid rgba(0,0,0,0.12);
}
.pcd-detail-table :deep(.q-table tbody td) {
font-size: 9.5px;
padding: 1px 2px;
}
.pcd-detail-table :deep(.q-table tbody tr) {
cursor: pointer;
}
.pcd-detail-table :deep(.pcd-detail-row-secondary td) {
background: color-mix(in srgb, var(--q-secondary) 14%, white) !important;
}
.pcd-detail-table :deep(.pcd-detail-row-required td) {
background: color-mix(in srgb, #ff9800 18%, white) !important;
}
.pcd-detail-table :deep(.pcd-entry-header) {
background: color-mix(in srgb, var(--q-primary) 16%, #f8f9fa) !important;
color: var(--q-primary) !important;
font-weight: 800;
}
.pcd-detail-table :deep(.pcd-secondary-header) {
background: color-mix(in srgb, var(--q-secondary) 18%, #f8f9fa) !important;
color: var(--q-secondary) !important;
font-weight: 800;
}
.pcd-detail-table :deep(.pcd-secondary-cell) {
background: color-mix(in srgb, var(--q-secondary) 10%, white) !important;
}
.pcd-detail-table :deep(.pcd-entry-input .q-field__control) {
background: color-mix(in srgb, var(--q-primary) 12%, white) !important;
border: 1px solid var(--q-primary) !important;
border-radius: 6px;
}
.pcd-detail-table :deep(.pcd-entry-input .q-field__native),
.pcd-detail-table :deep(.pcd-entry-input .q-field__input),
.pcd-detail-table :deep(.pcd-entry-input .q-field__marginal),
.pcd-detail-table :deep(.pcd-entry-input .q-select__dropdown-icon) {
color: var(--q-primary) !important;
font-weight: 700;
}
.pcd-detail-table :deep(.pcd-entry-input.q-field--focused .q-field__control) {
box-shadow: 0 0 0 1px var(--q-primary), 0 0 0 3px color-mix(in srgb, var(--q-primary) 28%, white);
}
.pcd-detail-table :deep(.pcd-entry-input .q-placeholder) {
color: var(--q-primary) !important;
opacity: 0.72;
}
.pcd-detail-table :deep(.pcd-inline-input .q-field__control) {
min-height: 30px;
min-width: 0 !important;
width: 100% !important;
}
.pcd-detail-table :deep(.pcd-inline-input),
.pcd-detail-table :deep(.pcd-inline-input .q-field__inner),
.pcd-detail-table :deep(.pcd-inline-input .q-field__control-container) {
min-width: 0 !important;
width: 100% !important;
}
.pcd-detail-table :deep(.pcd-inline-input .q-field__native),
.pcd-detail-table :deep(.pcd-inline-input .q-field__input) {
padding-top: 0;
padding-bottom: 0;
}
.pcd-detail-table {
position: relative;
z-index: 1;
}
.pcd-part-summary-card {
margin-top: 8px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fcfdfe;
padding: 8px;
}
.pcd-part-summary-title {
font-size: 13px;
font-weight: 800;
color: #2b3c54;
margin-bottom: 6px;
padding-left: 4px;
border-left: 3px solid var(--q-primary);
}
.pcd-part-summary-table {
background: transparent !important;
}
.pcd-part-summary-table thead th {
background: #f5f7fa;
font-weight: 800;
color: #44556c;
}
.pcd-part-summary-table tbody td {
font-size: 12px;
}
.pcd-history-table :deep(.q-table thead th) {
font-size: 12px;
}
.pcd-history-table :deep(.q-table tbody td) {
font-size: 12px;
vertical-align: top;
}
.pcd-history-table :deep(.pcd-history-row-purchase td) {
background: #ffffff;
}
.pcd-history-table :deep(.pcd-history-row-recipe td) {
background: color-mix(in srgb, var(--q-secondary) 10%, white);
}
.pcd-history-table :deep(.pcd-history-row-similar td) {
background: #fff8e1 !important;
border-bottom: 1px solid #ffe082 !important;
}
.pcd-history-source-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.02em;
}
.pcd-history-source-chip--purchase {
background: color-mix(in srgb, var(--q-primary) 12%, white);
color: var(--q-primary);
}
.pcd-history-source-chip--bnz-v3,
.pcd-history-source-chip--bnz-rec {
background: #f3e5f5;
color: #7b1fa2;
font-weight: bold;
}
.pcd-history-source-chip--recipe {
background: color-mix(in srgb, var(--q-secondary) 18%, white);
color: color-mix(in srgb, var(--q-secondary) 82%, black);
}
.pcd-history-source-chip--history {
background: #eceff3;
color: #44556c;
}
</style>