update dashboard with a warning if the chicken counting accuracy result drop to less than 95%
This commit is contained in:
1 parent
155a24c7bd
commit
61fae64aa6
7 files changed
+479
-2
No files matched your search
@@ -7,6 +7,7 @@ import type {
|
||||
Karung,
|
||||
KPI,
|
||||
LatestAvg,
|
||||
ManualInput,
|
||||
} from '../types/api.ts';
|
||||
import type { View } from '../types/common.ts';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
@@ -20,9 +21,11 @@ import {
|
||||
resolveCycleStatus,
|
||||
} from '../utils/format.ts';
|
||||
import { isOnOrBeforeVisibleDate, throughVisibleDate } from '../utils/visibility.ts';
|
||||
import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts';
|
||||
import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
|
||||
import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx';
|
||||
import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx';
|
||||
import CountingAccuracyWarningBanner from './counting/CountingAccuracyWarningBanner.tsx';
|
||||
import type { DashboardReportType } from '../types/dashboardReport.ts';
|
||||
|
||||
type Props = { setView: (view: View) => void };
|
||||
@@ -37,6 +40,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [kpis, setKpis] = useState<KPI[]>([]);
|
||||
const [countings, setCountings] = useState<ChickenCounting[]>([]);
|
||||
const [manualInputs, setManualInputs] = useState<ManualInput[]>([]);
|
||||
const [weights, setWeights] = useState<ChickenWeight[]>([]);
|
||||
const [karung, setKarung] = useState<Karung | null>(null);
|
||||
const [latestAvg, setLatestAvg] = useState<LatestAvg | null>(null);
|
||||
@@ -48,6 +52,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
if (!selectedCycle) {
|
||||
setKpis([]);
|
||||
setCountings([]);
|
||||
setManualInputs([]);
|
||||
setWeights([]);
|
||||
setKarung(null);
|
||||
setLatestAvg(null);
|
||||
@@ -60,9 +65,10 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [kpiRows, countingRows, weightRows, insightRows] = await Promise.all([
|
||||
const [kpiRows, countingRows, manualRows, weightRows, insightRows] = await Promise.all([
|
||||
api.kpis.list({ cycle_id: cycleId }),
|
||||
api.countings.list({ cycle_id: cycleId }),
|
||||
api.manualInputs.list({ cycle_id: cycleId }),
|
||||
api.weights.list({ cycle_id: cycleId }),
|
||||
api.insights.list({ cycle_id: cycleId }),
|
||||
]);
|
||||
@@ -81,6 +87,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
if (cancelled) return;
|
||||
setKpis(kpiRows);
|
||||
setCountings(countingRows);
|
||||
setManualInputs(manualRows);
|
||||
setWeights(weightRows);
|
||||
setKarung(latestKarung);
|
||||
setLatestAvg(avg);
|
||||
@@ -192,6 +199,16 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
];
|
||||
}, [kpis, countings, weights, karung, latestAvg, selectedCycle]);
|
||||
|
||||
const countingAccuracyWarning = useMemo(() => {
|
||||
if (!selectedCycle) return null;
|
||||
return getCountingAccuracyWarning({
|
||||
cycle: selectedCycle,
|
||||
countings,
|
||||
kpis,
|
||||
manualInputs,
|
||||
});
|
||||
}, [selectedCycle, countings, kpis, manualInputs]);
|
||||
|
||||
if (!selectedCycle) return <EmptyFarm onSettings={() => setView('kandangSettings')} />;
|
||||
|
||||
const feedTotal = karung?.feed_use_total ?? kpis[0]?.feed_total ?? 0;
|
||||
@@ -230,6 +247,9 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
<Banner kind="error">{error}</Banner>
|
||||
</div>
|
||||
)}
|
||||
{countingAccuracyWarning && (
|
||||
<CountingAccuracyWarningBanner warning={countingAccuracyWarning} />
|
||||
)}
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import type { CountingAccuracyWarning } from '../../utils/countingAccuracyWarning.ts';
|
||||
import { formatNumber } from '../../utils/format.ts';
|
||||
|
||||
type Props = {
|
||||
warning: CountingAccuracyWarning;
|
||||
};
|
||||
|
||||
const CountingAccuracyWarningBanner: React.FC<Props> = ({ warning }) => {
|
||||
const isOver = warning.direction === 'over';
|
||||
const title = isOver
|
||||
? 'Hitung ayam IoT lebih tinggi dari stock manual'
|
||||
: 'Akurasi hitung ayam IoT rendah (IoT lebih rendah dari stock)';
|
||||
const body = isOver
|
||||
? 'Manager perlu cek: kemungkinan fraud / ayam baru yang belum tercatat, atau AI overcount; pastikan juga video kamera lengkap dan tidak bermasalah.'
|
||||
: 'Manager perlu cek: video kamera lengkap atau tidak, kemungkinan masalah AI, atau ada selisih stok.';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-6 rounded-lg border-l-4 border-amber-500 bg-amber-50 p-4 text-amber-950"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<i className="fa-solid fa-triangle-exclamation mt-0.5 text-xl text-amber-600" aria-hidden />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<p className="text-sm">
|
||||
Hari {warning.day} · IoT {formatNumber(warning.iotCount, 0)} vs stock{' '}
|
||||
{formatNumber(warning.stockAkhir, 0)} (akurasi {formatNumber(warning.accuracy, 1)}%)
|
||||
</p>
|
||||
<p className="text-sm">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CountingAccuracyWarningBanner;
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
todayIso,
|
||||
} from '../../utils/format.ts';
|
||||
import PopulationTrendChart from './PopulationTrendChart.tsx';
|
||||
import CountingAccuracyWarningBanner from './CountingAccuracyWarningBanner.tsx';
|
||||
import { getCountingAccuracyWarning } from '../../utils/countingAccuracyWarning.ts';
|
||||
|
||||
type Row = {
|
||||
day: number;
|
||||
@@ -213,6 +215,16 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
[rows]
|
||||
);
|
||||
|
||||
const countingAccuracyWarning = useMemo(() => {
|
||||
if (!selectedCycle) return null;
|
||||
return getCountingAccuracyWarning({
|
||||
cycle: selectedCycle,
|
||||
countings,
|
||||
kpis,
|
||||
manualInputs: manualRows,
|
||||
});
|
||||
}, [selectedCycle, countings, kpis, manualRows]);
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
let filtered = rows;
|
||||
if (searchDay) {
|
||||
@@ -289,6 +301,10 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
}
|
||||
/>
|
||||
|
||||
{countingAccuracyWarning && (
|
||||
<CountingAccuracyWarningBanner warning={countingAccuracyWarning} />
|
||||
)}
|
||||
|
||||
{/* AI Insight */}
|
||||
<section className="mb-6">
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import CountingAccuracyWarningBanner from '../CountingAccuracyWarningBanner.tsx';
|
||||
import type { CountingAccuracyWarning } from '../../../utils/countingAccuracyWarning.ts';
|
||||
|
||||
const base: CountingAccuracyWarning = {
|
||||
day: 2,
|
||||
date: '2026-08-12',
|
||||
iotCount: 900,
|
||||
stockAkhir: 1000,
|
||||
accuracy: 90,
|
||||
ratioPercent: 90,
|
||||
direction: 'under',
|
||||
};
|
||||
|
||||
describe('CountingAccuracyWarningBanner', () => {
|
||||
it('renders under-count manager checklist', () => {
|
||||
render(<CountingAccuracyWarningBanner warning={base} />);
|
||||
expect(screen.getByText(/IoT lebih rendah dari stock/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/video kamera lengkap/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Hari 2/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/90%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders over-count fraud / AI checklist', () => {
|
||||
render(
|
||||
<CountingAccuracyWarningBanner
|
||||
warning={{ ...base, iotCount: 1060, stockAkhir: 1000, accuracy: 94.3, ratioPercent: 106, direction: 'over' }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/lebih tinggi dari stock manual/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/fraud/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/akurasi 94,3%/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/106%/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import DataManagementPage from '../DataManagementPage.tsx';
|
||||
import { api } from '../../../services/apiClient.ts';
|
||||
@@ -76,6 +76,7 @@ vi.mock('../../../utils/format.ts', async () => {
|
||||
|
||||
describe('DataManagementPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.kpis.list).mockResolvedValue([]);
|
||||
vi.mocked(api.countings.list).mockResolvedValue([]);
|
||||
vi.mocked(api.countings.sync).mockResolvedValue({
|
||||
upserted: 2,
|
||||
@@ -123,4 +124,57 @@ describe('DataManagementPage', () => {
|
||||
expect(api.countings.sync).toHaveBeenCalledWith({ cycle_id: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
it('shows under-count IoT accuracy warning for the latest IoT day', async () => {
|
||||
vi.mocked(api.kpis.list).mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
kandang: 1,
|
||||
flock_id: 1,
|
||||
date: '2026-08-12',
|
||||
age: 2,
|
||||
mortality: 0,
|
||||
mortality_total: 10,
|
||||
feed: 0,
|
||||
feed_total: 0,
|
||||
harvest: 0,
|
||||
harvest_total: 0,
|
||||
harvest_weight: 0,
|
||||
harvest_weight_total: 0,
|
||||
chicken_life: 19990,
|
||||
chicken_life_percentage: 99.95,
|
||||
iot_weight: 0,
|
||||
average_harvest_day: 0,
|
||||
average_harvest_weight: 0,
|
||||
fcr: 0,
|
||||
eef: 0,
|
||||
cc: 0,
|
||||
cw: 0,
|
||||
fs: 0,
|
||||
manual: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
]);
|
||||
vi.mocked(api.countings.list).mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
kandang: 1,
|
||||
flock_id: 1,
|
||||
date: '2026-08-12',
|
||||
total_count: 18000,
|
||||
mortality_count: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
]);
|
||||
|
||||
render(<DataManagementPage onSettings={() => undefined} />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(within(alert).getByText(/IoT lebih rendah dari stock/i)).toBeInTheDocument();
|
||||
expect(within(alert).getByText(/Hari 2/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ChickenCounting, Cycle, KPI, ManualInput } from '../../types/api.ts';
|
||||
import { getCountingAccuracyWarning } from '../countingAccuracyWarning.ts';
|
||||
|
||||
const cycle = {
|
||||
id: 1,
|
||||
kandang: 1,
|
||||
kandang_name: 'K1',
|
||||
total_days: 35,
|
||||
current_day: 5,
|
||||
visible_through_date: '2026-08-15',
|
||||
start_date: '2026-08-10',
|
||||
end_date: '2026-09-13',
|
||||
chick_in_weight: 45,
|
||||
doc_in_count: 1000,
|
||||
feed_initial_balance: 0,
|
||||
status: 'active',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
} satisfies Cycle;
|
||||
|
||||
function kpi(partial: Partial<KPI> & Pick<KPI, 'date' | 'chicken_life'>): KPI {
|
||||
return {
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
kandang: 1,
|
||||
flock_id: 1,
|
||||
age: 0,
|
||||
mortality: 0,
|
||||
mortality_total: 0,
|
||||
feed: 0,
|
||||
feed_total: 0,
|
||||
harvest: 0,
|
||||
harvest_total: 0,
|
||||
harvest_weight: 0,
|
||||
harvest_weight_total: 0,
|
||||
chicken_life_percentage: 100,
|
||||
iot_weight: 0,
|
||||
average_harvest_day: 0,
|
||||
average_harvest_weight: 0,
|
||||
fcr: 0,
|
||||
eef: 0,
|
||||
cc: 0,
|
||||
cw: 0,
|
||||
fs: 0,
|
||||
manual: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function counting(partial: Partial<ChickenCounting> & Pick<ChickenCounting, 'date' | 'total_count'>): ChickenCounting {
|
||||
return {
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
kandang: 1,
|
||||
flock_id: 1,
|
||||
mortality_count: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getCountingAccuracyWarning', () => {
|
||||
it('returns null when there is no IoT counting', () => {
|
||||
expect(
|
||||
getCountingAccuracyWarning({
|
||||
cycle,
|
||||
countings: [],
|
||||
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
|
||||
manualInputs: [],
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns under when latest IoT is below stock and accuracy <= 95', () => {
|
||||
const result = getCountingAccuracyWarning({
|
||||
cycle,
|
||||
countings: [counting({ date: '2026-08-10', total_count: 930 })],
|
||||
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
|
||||
manualInputs: [],
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
day: 0,
|
||||
date: '2026-08-10',
|
||||
iotCount: 930,
|
||||
stockAkhir: 1000,
|
||||
direction: 'under',
|
||||
});
|
||||
expect(result!.accuracy).toBe(93);
|
||||
expect(result!.ratioPercent).toBe(93);
|
||||
});
|
||||
|
||||
it('returns over when latest IoT is above stock and accuracy <= 95', () => {
|
||||
// 1060/1000 → ratio 106%, accuracy min/max = 1000/1060 ≈ 94.3%
|
||||
const result = getCountingAccuracyWarning({
|
||||
cycle,
|
||||
countings: [counting({ date: '2026-08-10', total_count: 1060 })],
|
||||
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
|
||||
manualInputs: [],
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
day: 0,
|
||||
iotCount: 1060,
|
||||
stockAkhir: 1000,
|
||||
direction: 'over',
|
||||
});
|
||||
expect(result!.accuracy).toBe(94.3);
|
||||
expect(result!.ratioPercent).toBe(106);
|
||||
});
|
||||
|
||||
it('warns when accuracy is exactly 95', () => {
|
||||
const result = getCountingAccuracyWarning({
|
||||
cycle,
|
||||
countings: [counting({ date: '2026-08-10', total_count: 950 })],
|
||||
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
|
||||
manualInputs: [],
|
||||
});
|
||||
expect(result).toMatchObject({ accuracy: 95, direction: 'under' });
|
||||
});
|
||||
|
||||
it('returns null when accuracy is above 95', () => {
|
||||
expect(
|
||||
getCountingAccuracyWarning({
|
||||
cycle,
|
||||
countings: [counting({ date: '2026-08-10', total_count: 960 })],
|
||||
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
|
||||
manualInputs: [],
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the latest IoT day even if an older day is worse', () => {
|
||||
const result = getCountingAccuracyWarning({
|
||||
cycle,
|
||||
countings: [
|
||||
counting({ id: 1, date: '2026-08-10', total_count: 800 }), // worse under
|
||||
counting({ id: 2, date: '2026-08-12', total_count: 970 }), // latest, accuracy 97% → no warn
|
||||
],
|
||||
kpis: [
|
||||
kpi({ date: '2026-08-10', chicken_life: 1000 }),
|
||||
kpi({ date: '2026-08-11', chicken_life: 1000 }),
|
||||
kpi({ date: '2026-08-12', chicken_life: 1000 }),
|
||||
],
|
||||
manualInputs: [],
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('warns from the latest IoT day when that day is low', () => {
|
||||
const result = getCountingAccuracyWarning({
|
||||
cycle,
|
||||
countings: [
|
||||
counting({ id: 1, date: '2026-08-10', total_count: 990 }), // fine
|
||||
counting({ id: 2, date: '2026-08-12', total_count: 900 }), // latest under
|
||||
],
|
||||
kpis: [
|
||||
kpi({ date: '2026-08-10', chicken_life: 1000 }),
|
||||
kpi({ date: '2026-08-11', chicken_life: 1000 }),
|
||||
kpi({ date: '2026-08-12', chicken_life: 1000 }),
|
||||
],
|
||||
manualInputs: [],
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
day: 2,
|
||||
date: '2026-08-12',
|
||||
iotCount: 900,
|
||||
direction: 'under',
|
||||
});
|
||||
});
|
||||
|
||||
it('derives stock akhir from chain when chicken_life is missing', () => {
|
||||
const manuals: ManualInput[] = [
|
||||
{
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
kandang: 1,
|
||||
flock_id: 1,
|
||||
date: '2026-08-10',
|
||||
age_manual: 0,
|
||||
mortality_manual: 50,
|
||||
mortality_manual_total: 50,
|
||||
feed_in_manual: 0,
|
||||
feed_use_manual: 0,
|
||||
feed_in_manual_total: 0,
|
||||
feed_use_manual_total: 0,
|
||||
feed_out_manual: 0,
|
||||
feed_out_manual_total: 0,
|
||||
harvest_manual: 0,
|
||||
harvest_manual_total: 0,
|
||||
harvest_weight_manual: 0,
|
||||
harvest_weight_manual_total: 0,
|
||||
manual_weight: 0,
|
||||
average_harvest_day_manual: 0,
|
||||
average_harvest_weight_manual: 0,
|
||||
fcr_manual: 0,
|
||||
eef_manual: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
];
|
||||
const result = getCountingAccuracyWarning({
|
||||
cycle,
|
||||
countings: [counting({ date: '2026-08-10', total_count: 900 })],
|
||||
kpis: [],
|
||||
manualInputs: manuals,
|
||||
});
|
||||
// stock = 1000 - 50 = 950; IoT 900 → under, accuracy 900/950 ≈ 94.7
|
||||
expect(result).toMatchObject({
|
||||
stockAkhir: 950,
|
||||
iotCount: 900,
|
||||
direction: 'under',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ChickenCounting, Cycle, KPI, ManualInput } from '../types/api.ts';
|
||||
import { addDaysIso, cycleDayForDate } from './format.ts';
|
||||
|
||||
export const COUNTING_ACCURACY_WARNING_THRESHOLD = 95;
|
||||
|
||||
export type CountingAccuracyWarning = {
|
||||
day: number;
|
||||
date: string;
|
||||
iotCount: number;
|
||||
stockAkhir: number;
|
||||
accuracy: number;
|
||||
ratioPercent: number;
|
||||
direction: 'over' | 'under';
|
||||
};
|
||||
|
||||
export type CountingAccuracyWarningInput = {
|
||||
cycle: Pick<Cycle, 'start_date' | 'end_date' | 'total_days' | 'doc_in_count'>;
|
||||
countings: ChickenCounting[];
|
||||
kpis: KPI[];
|
||||
manualInputs: ManualInput[];
|
||||
};
|
||||
|
||||
function round1(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function matchAccuracy(iot: number, stockAkhir: number): number {
|
||||
return round1((Math.min(iot, stockAkhir) / Math.max(iot, stockAkhir)) * 100);
|
||||
}
|
||||
|
||||
/** Build stock-akhir by date using the same rules as Hitung Ayam. */
|
||||
function stockAkhirByDate(
|
||||
cycle: CountingAccuracyWarningInput['cycle'],
|
||||
kpis: KPI[],
|
||||
manualInputs: ManualInput[]
|
||||
): Map<string, number> {
|
||||
const kpiByDate = new Map(kpis.map((k) => [k.date, k]));
|
||||
const manualByDate = new Map(manualInputs.map((m) => [m.date, m]));
|
||||
|
||||
const lastCycleDay = Math.max(0, (cycle.total_days || 1) - 1);
|
||||
const endDay = cycleDayForDate(cycle.start_date, cycle.end_date);
|
||||
const maxDay = Math.min(lastCycleDay, endDay);
|
||||
|
||||
const result = new Map<string, number>();
|
||||
let previousStockAkhir = 0;
|
||||
|
||||
for (let day = 0; day <= maxDay; day++) {
|
||||
const date = addDaysIso(cycle.start_date, day);
|
||||
const kpi = kpiByDate.get(date);
|
||||
const manual = manualByDate.get(date);
|
||||
const stockAwal = day === 0 ? cycle.doc_in_count || 0 : previousStockAkhir;
|
||||
const mati = manual?.mortality_manual ?? kpi?.mortality ?? 0;
|
||||
const panen = manual?.harvest_manual ?? kpi?.harvest ?? 0;
|
||||
const stockAkhir = kpi?.chicken_life ?? Math.max(0, stockAwal - mati - panen);
|
||||
result.set(date, stockAkhir);
|
||||
previousStockAkhir = stockAkhir;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function latestIotCounting(countings: ChickenCounting[]): ChickenCounting | null {
|
||||
let best: ChickenCounting | null = null;
|
||||
for (const row of countings) {
|
||||
if (row.total_count == null) continue;
|
||||
if (!best || row.date > best.date) best = row;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function getCountingAccuracyWarning(
|
||||
input: CountingAccuracyWarningInput
|
||||
): CountingAccuracyWarning | null {
|
||||
const latest = latestIotCounting(input.countings);
|
||||
if (!latest) return null;
|
||||
|
||||
const stockMap = stockAkhirByDate(input.cycle, input.kpis, input.manualInputs);
|
||||
const stockAkhir = stockMap.get(latest.date);
|
||||
if (stockAkhir == null || stockAkhir <= 0) return null;
|
||||
|
||||
const iotCount = latest.total_count;
|
||||
if (iotCount === stockAkhir) return null;
|
||||
|
||||
const accuracy = matchAccuracy(iotCount, stockAkhir);
|
||||
if (accuracy > COUNTING_ACCURACY_WARNING_THRESHOLD) return null;
|
||||
|
||||
return {
|
||||
day: cycleDayForDate(input.cycle.start_date, latest.date),
|
||||
date: latest.date,
|
||||
iotCount,
|
||||
stockAkhir,
|
||||
accuracy,
|
||||
ratioPercent: round1((iotCount / stockAkhir) * 100),
|
||||
direction: iotCount > stockAkhir ? 'over' : 'under',
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user