Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 245 additions & 0 deletions assets/js/components/Config/FeedInFinalModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
<template>
<GenericModal
id="feedInFinalModal"
ref="modal"
:title="$t('config.feedinfinal.title')"
data-testid="feedinfinal-modal"
config-modal-name="feedinfinal"
size="lg"
@open="open"
>
<p>{{ $t("config.feedinfinal.description", { day: finalizeDay }) }}</p>
<p class="text-muted">
<strong>{{ $t("config.feedinfinal.published") }}</strong>
{{ publishedText }}
</p>

<h6 class="mt-4">{{ $t("config.feedinfinal.monthsLabel") }}</h6>
<p v-if="!months.length" class="text-muted">{{ $t("config.feedinfinal.none") }}</p>
<div v-else class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>{{ $t("config.feedinfinal.month") }}</th>
<th class="text-end">{{ $t("config.feedinfinal.market") }}</th>
<th class="text-end">{{ $t("config.feedinfinal.applied") }}</th>
<th class="text-end">{{ $t("config.feedinfinal.countsLabel") }}</th>
<th>{{ $t("config.feedinfinal.source") }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="m in months" :key="m.month" :data-testid="`feedinfinal-${m.month}`">
<td class="text-nowrap">{{ fmtMonthYear(monthDate(m.month)) }}</td>
<td class="text-end text-nowrap">
{{ m.market ? fmtMarket(m.market) : "–" }}
</td>
<td class="text-end text-nowrap">{{ fmtMarket(m.price) }}</td>
<td class="text-end text-nowrap">
{{
m.at && !m.at.startsWith("0001")
? $t("config.feedinfinal.counts", {
slots: m.slots,
sessions: m.sessions,
})
: "–"
}}
</td>
<td class="text-nowrap">
{{
$t(
m.manual
? "config.feedinfinal.sourceManual"
: "config.feedinfinal.sourceAuto"
)
}}
</td>
<td class="text-end">
<button
type="button"
class="btn btn-link btn-sm p-0"
@click="select(m.month)"
>
{{ $t("config.feedinfinal.edit") }}
</button>
</td>
</tr>
</tbody>
</table>
</div>

<h6 class="mt-4">{{ $t("config.feedinfinal.recalcTitle") }}</h6>
<p class="text-muted small">{{ $t("config.feedinfinal.recalcHelp") }}</p>
<p v-if="error" class="text-danger">{{ error }}</p>
<p v-if="done" class="text-success" data-testid="feedinfinal-done">{{ done }}</p>

<form class="container mx-0 px-0" @submit.prevent="recalculate">
<FormRow id="feedInFinalMonth" :label="$t('config.feedinfinal.month')">
<select
id="feedInFinalMonth"
v-model="month"
class="form-select"
data-testid="feedinfinal-month"
@change="prefill"
>
<option v-for="o in monthOptions" :key="o" :value="o">
{{ fmtMonthYear(monthDate(o)) }}
</option>
</select>
</FormRow>
<FormRow
id="feedInFinalPrice"
:label="$t('config.feedinfinal.priceLabel')"
:help="$t('config.feedinfinal.priceHelp')"
>
<div class="input-group">
<input
id="feedInFinalPrice"
v-model.number="priceCt"
type="number"
step="any"
class="form-control"
data-testid="feedinfinal-price"
/>
<span class="input-group-text">ct/kWh</span>
</div>
</FormRow>

<div class="mt-4 d-flex justify-content-between gap-2 flex-column flex-sm-row">
<button
type="button"
class="btn btn-link text-muted btn-cancel"
data-bs-dismiss="modal"
>
{{ $t("config.general.close") }}
</button>

<button
type="submit"
class="btn btn-primary order-1 order-sm-2 flex-grow-1 flex-sm-grow-0 px-4"
:disabled="saving"
data-testid="feedinfinal-recalculate"
>
<span
v-if="saving"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{ $t("config.feedinfinal.recalculate") }}
</button>
</div>
</form>
</GenericModal>
</template>

<script>
import GenericModal from "../Helper/GenericModal.vue";
import FormRow from "./FormRow.vue";
import store from "@/store";
import api from "@/api";
import formatter from "@/mixins/formatter";
import { fmtMarketPrice, monthDate, monthKey } from "./feedInFinal";

// months that can be recalculated, counting back from the previous one
const SELECTABLE_MONTHS = 12;

// Custom extension: the finalized OeMAG months and a recalculation by hand, see
// core/site_feedin.go. A month is recalculated at a market price: the one
// published, or a corrected one.
export default {
name: "FeedInFinalModal",
components: { FormRow, GenericModal },
mixins: [formatter],
data() {
return {
saving: false,
error: "",
done: "",
month: "",
priceCt: undefined,
};
},
computed: {
state() {
return store.state?.feedInFinal;
},
months() {
return this.state?.months || [];
},
finalizeDay() {
return this.state?.finalizeDay ?? 15;
},
publishedText() {
return this.state?.market
? this.fmtMarket(this.state.market)
: this.$t("config.feedinfinal.nonePublished");
},
monthOptions() {
const now = new Date();
return Array.from({ length: SELECTABLE_MONTHS }, (_, i) =>
monthKey(new Date(now.getFullYear(), now.getMonth() - 1 - i, 1))
);
},
},
methods: {
monthDate,
fmtMarket(eur) {
return fmtMarketPrice(eur, this.$i18n?.locale);
},
open() {
this.saving = false;
this.error = "";
this.done = "";
this.month = this.monthOptions[0];
this.prefill();
},
select(month) {
if (!this.monthOptions.includes(month)) return;
this.month = month;
this.done = "";
this.prefill();
},
// the price the month was finalized at, or for the previous month the one
// published now
prefill() {
const entry = this.months.find((m) => m.month === this.month);
let eur = entry?.market;
if (!eur && this.month === this.monthOptions[0]) {
eur = this.state?.market;
}
this.priceCt = eur ? Math.round(eur * 100000) / 1000 : undefined;
},
async recalculate() {
const ct = this.priceCt;
if (typeof ct !== "number" || Number.isNaN(ct) || ct <= 0 || ct >= 100) {
this.error = this.$t("config.feedinfinal.invalidPrice");
return;
}

this.saving = true;
this.error = "";
this.done = "";

try {
const eur = Math.round(ct * 1000) / 100000;
await api.post(`feedinfinalize/${this.month}/${eur}`);
this.done = this.$t("config.feedinfinal.doneMessage", {
month: this.fmtMonthYear(monthDate(this.month)),
});
} catch (e) {
this.error = e?.response?.data?.error || e.message;
}

this.saving = false;
},
},
};
</script>
<style scoped>
.container {
margin-left: calc(var(--bs-gutter-x) * -0.5);
margin-right: calc(var(--bs-gutter-x) * -0.5);
padding-right: 0;
}
</style>
72 changes: 72 additions & 0 deletions assets/js/components/Config/FeedInFinalSummary.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<template>
<div v-if="state" class="feedin-final mt-2" data-testid="feedin-final-summary">
<div class="d-flex gap-2">
<div class="label flex-grow-1">{{ $t("config.feedinfinal.lastLabel") }}</div>
<div class="value text-end">{{ lastText }}</div>
</div>
<div class="d-flex gap-2">
<div class="label flex-grow-1">{{ $t("config.feedinfinal.nextLabel") }}</div>
<div class="value text-end">{{ nextText }}</div>
</div>
<button
type="button"
class="btn btn-link btn-sm p-0 mt-1"
data-testid="feedin-final-open"
@click.stop="openModal('feedinfinal')"
>
{{ $t("config.feedinfinal.show") }}
</button>
</div>
</template>

<script>
import store from "@/store";
import { openModal } from "@/configModal";
import formatter from "@/mixins/formatter";
import { feedInNextFinalization, fmtMarketPrice, monthDate } from "./feedInFinal";

// Custom extension: the OeMAG finalization status inside the feed-in tariff's
// card, see core/site_feedin.go. Shown only for a tariff with a final price.
export default {
name: "FeedInFinalSummary",
mixins: [formatter],
computed: {
state() {
return store.state?.feedInFinal;
},
last() {
return this.state?.months?.[0];
},
lastText() {
if (!this.last) {
return this.$t("config.feedinfinal.none");
}
const month = this.fmtMonthYear(monthDate(this.last.month));
if (!this.last.market) {
return month;
}
return `${month} · ${fmtMarketPrice(this.last.market, this.$i18n?.locale)}`;
},
nextText() {
const next = feedInNextFinalization(
new Date(),
this.state.finalizeDay,
this.last?.month
);
return this.fmtDayMonth(next);
},
},
methods: { openModal },
};
</script>

<style scoped>
.feedin-final {
display: grid;
grid-gap: 0.5rem;
}
.value {
font-weight: bold;
color: var(--bs-primary);
}
</style>
6 changes: 6 additions & 0 deletions assets/js/components/Config/TariffCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
</template>
<template #tags>
<DeviceTags :tags="tags" :currency="currency" />
<!-- custom: OeMAG finalization, see core/site_feedin.go -->
<FeedInFinalSummary
v-if="tariffType === 'feedIn' && tariff.config?.template === 'oemag'"
/>
<!-- custom: counter of the second feed-in tariff, see core/site_feedin_eeg.go -->
<FeedInEegSummary v-if="tariffType === 'feedInEeg'" />
</template>
Expand All @@ -32,6 +36,7 @@ import { type PropType } from "vue";
import type { TariffType, CURRENCY } from "@/types/evcc";
import DeviceCard from "./DeviceCard.vue";
import DeviceTags from "./DeviceTags.vue";
import FeedInFinalSummary from "./FeedInFinalSummary.vue";
import FeedInEegSummary from "./FeedInEegSummary.vue";

type ConfigTariff = {
Expand All @@ -49,6 +54,7 @@ export default {
components: {
DeviceCard,
DeviceTags,
FeedInFinalSummary,
FeedInEegSummary,
},
props: {
Expand Down
37 changes: 37 additions & 0 deletions assets/js/components/Config/feedInFinal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, test } from "vite-plus/test";
import { feedInNextFinalization, fmtMarketPrice, monthDate, monthKey } from "./feedInFinal";

const day = (y: number, m: number, d: number) => new Date(y, m - 1, d);

describe("feedInNextFinalization", () => {
test("before the finalize day, previous month open: this month", () => {
expect(feedInNextFinalization(day(2026, 9, 10), 15, "2026-07")).toEqual(day(2026, 9, 15));
});
test("past the finalize day, previous month still open: due now", () => {
const now = day(2026, 9, 20);
expect(feedInNextFinalization(now, 15, "2026-07")).toEqual(now);
});
test("previous month done: next month", () => {
expect(feedInNextFinalization(day(2026, 9, 20), 15, "2026-08")).toEqual(day(2026, 10, 15));
});
test("nothing finalized yet", () => {
expect(feedInNextFinalization(day(2026, 9, 10), 15)).toEqual(day(2026, 9, 15));
});
test("january looks at december", () => {
expect(feedInNextFinalization(day(2027, 1, 20), 15, "2026-12")).toEqual(day(2027, 2, 15));
});
});

describe("months", () => {
test("round trip", () => {
expect(monthKey(monthDate("2026-08"))).toBe("2026-08");
expect(monthKey(day(2026, 1, 31))).toBe("2026-01");
});
});

describe("fmtMarketPrice", () => {
test("three decimals in ct", () => {
expect(fmtMarketPrice(0.08997, "de")).toBe("8,997 ct/kWh");
expect(fmtMarketPrice(0.09, "en")).toBe("9.000 ct/kWh");
});
});
Loading
Loading