Files
KGV-Verein-Manager/includes/Repositories/CostRepository.php
Ronny Grobel 1a6b1199cd Feature: Parzellenspezifische Kostenpositionsenzuweisung
- Neue Tabelle wp_kgvvm_parcel_cost_assignments für 1:N Zuordnungen
- CostRepository erweitert: get_assigned_entry_ids(), get_entry_ids_with_assignments(), assign_to_parcel(), unassign_from_parcel(), delete_assignments_for_entry()
- Admin: Neue POST-Action toggle_parcel_cost_assignment() mit Nonce-Sicherung
- Jahresabrechnung Parzelle: Rechte Seitenleiste zeigt alle Kostenpositions mit Zuordnungsstatus (✓ zugeordnet, ✗ nicht zugeordnet, – alle Parzellen)
- Berechnung: Kostenposten mit Beschränkung werden nur berechnet wenn Parzelle zugeordnet ist
- DataTransfer.php: parcel_cost_assignments in table_keys integriert für Export/Import
- DELETE-Handler bereinigt Zuordnungen beim Löschen einer Kostenposition
2026-04-17 16:55:09 +02:00

456 lines
13 KiB
PHP
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.
<?php
/**
* Cost repository.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager\Repositories;
use KGV\VereinManager\Schema;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class CostRepository extends AbstractRepository {
/**
* Resolve table name.
*
* @return string
*/
protected function resolve_table() {
return Schema::table( 'cost_entries' );
}
/**
* Get the persistent year table name.
*
* @return string
*/
private function year_table() {
return Schema::table( 'cost_years' );
}
/**
* Get the yearly section rates table name.
*
* @return string
*/
private function rate_table() {
return Schema::table( 'cost_rates' );
}
/**
* Search cost entries.
*
* @param array $args Query arguments.
* @return array
*/
public function search( $args = array() ) {
$search = isset( $args['s'] ) ? sanitize_text_field( wp_unslash( $args['s'] ) ) : '';
$year = isset( $args['year'] ) ? absint( $args['year'] ) : 0;
$orderby = $this->sanitize_orderby( isset( $args['orderby'] ) ? sanitize_key( wp_unslash( $args['orderby'] ) ) : 'name', array( 'entry_year', 'name', 'distribution_type', 'unit_amount', 'total_cost', 'updated_at', 'created_at' ), 'name' );
$order = $this->sanitize_order( isset( $args['order'] ) ? sanitize_key( wp_unslash( $args['order'] ) ) : 'ASC' );
$sql = "SELECT * FROM {$this->table} WHERE 1=1";
$params = array();
if ( $year > 0 ) {
$this->ensure_year( $year );
$sql .= ' AND entry_year = %d';
$params[] = $year;
}
if ( '' !== $search ) {
$like = '%' . $this->wpdb->esc_like( $search ) . '%';
$sql .= ' AND (name LIKE %s OR note LIKE %s)';
$params[] = $like;
$params[] = $like;
}
$sql .= " ORDER BY {$orderby} {$order}, id DESC";
if ( ! empty( $params ) ) {
return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $params ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
return $this->wpdb->get_results( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery
}
/**
* Save or update a cost entry.
*
* @param array $data Entry data.
* @param int $id Optional ID.
* @return int|false
*/
public function save( $data, $id = 0 ) {
$payload = array(
'entry_year' => absint( $data['entry_year'] ),
'name' => $data['name'],
'distribution_type' => isset( $data['distribution_type'] ) ? $data['distribution_type'] : 'parcel',
'unit_amount' => isset( $data['unit_amount'] ) ? (float) $data['unit_amount'] : 0,
'total_cost' => (float) $data['total_cost'],
'note' => $data['note'],
'updated_at' => $this->now(),
);
$formats = array( '%d', '%s', '%s', '%f', '%f', '%s', '%s' );
$this->ensure_year( $payload['entry_year'] );
if ( $id > 0 ) {
$this->wpdb->update( $this->table, $payload, array( 'id' => $id ), $formats, array( '%d' ) );
return $id;
}
$payload['created_at'] = $this->now();
$this->wpdb->insert( $this->table, $payload, array( '%d', '%s', '%s', '%f', '%f', '%s', '%s', '%s' ) );
return $this->wpdb->insert_id;
}
/**
* Get available years for filters and dropdowns.
*
* @param int $selected_year Optional selected year.
* @return array
*/
public function get_years( $selected_year = 0 ) {
$year_table = $this->year_table();
$years = $this->wpdb->get_col( "SELECT DISTINCT entry_year FROM {$this->table} WHERE entry_year > 0 ORDER BY entry_year DESC" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery
$saved_years = $this->wpdb->get_col( "SELECT entry_year FROM {$year_table} ORDER BY entry_year DESC" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery
$current_year = (int) current_time( 'Y' );
$years = array_map( 'absint', array_merge( (array) $years, (array) $saved_years ) );
$years[] = $current_year;
$years[] = $current_year - 1;
$years[] = $current_year + 1;
if ( $selected_year > 0 ) {
$years[] = absint( $selected_year );
}
$years = array_values( array_unique( array_filter( $years ) ) );
rsort( $years, SORT_NUMERIC );
return $years;
}
/**
* Ensure one year exists so it remains selectable without cost entries.
*
* @param int $year Year value.
* @return bool
*/
public function ensure_year( $year ) {
$year = absint( $year );
$year_table = $this->year_table();
if ( $year < 1 || '' === $year_table ) {
return false;
}
$exists = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$year_table} WHERE entry_year = %d", $year ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
if ( $exists > 0 ) {
return true;
}
$result = $this->wpdb->insert(
$year_table,
array(
'entry_year' => $year,
'power_price_per_kwh' => null,
'water_price_per_m3' => null,
'created_at' => $this->now(),
'updated_at' => $this->now(),
),
array( '%d', '%s', '%s', '%s', '%s' )
);
return false !== $result;
}
/**
* Save the configured yearly prices for electricity and water.
*
* @param int $year Year value.
* @param float|null $power_price_per_kwh Price per kWh.
* @param float|null $water_price_per_m3 Price per m³.
* @return bool
*/
public function save_year( $year, $power_price_per_kwh = null, $water_price_per_m3 = null ) {
$year = absint( $year );
$year_table = $this->year_table();
if ( ! $this->ensure_year( $year ) ) {
return false;
}
$result = $this->wpdb->update(
$year_table,
array(
'power_price_per_kwh' => null !== $power_price_per_kwh ? (float) $power_price_per_kwh : null,
'water_price_per_m3' => null !== $water_price_per_m3 ? (float) $water_price_per_m3 : null,
'updated_at' => $this->now(),
),
array( 'entry_year' => $year ),
array( '%f', '%f', '%s' ),
array( '%d' )
);
return false !== $result;
}
/**
* Load one year's saved price details.
*
* @param int $year Selected year.
* @return object|null
*/
public function get_year_details( $year ) {
$year = absint( $year );
$year_table = $this->year_table();
if ( $year < 1 || '' === $year_table ) {
return null;
}
return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$year_table} WHERE entry_year = %d", $year ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Save section-specific yearly prices.
*
* @param array $data Section pricing data.
* @return bool
*/
public function save_section_prices( $data ) {
$year = absint( $data['entry_year'] );
$section_id = absint( $data['section_id'] );
$rate_table = $this->rate_table();
if ( ! $this->ensure_year( $year ) || $section_id < 1 || '' === $rate_table ) {
return false;
}
$payload = array(
'entry_year' => $year,
'section_id' => $section_id,
'power_price_per_kwh' => null !== $data['power_price_per_kwh'] ? (float) $data['power_price_per_kwh'] : null,
'water_price_per_m3' => null !== $data['water_price_per_m3'] ? (float) $data['water_price_per_m3'] : null,
'updated_at' => $this->now(),
);
$exists = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$rate_table} WHERE entry_year = %d AND section_id = %d", $year, $section_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
if ( $exists > 0 ) {
$result = $this->wpdb->update(
$rate_table,
array(
'power_price_per_kwh' => $payload['power_price_per_kwh'],
'water_price_per_m3' => $payload['water_price_per_m3'],
'updated_at' => $payload['updated_at'],
),
array(
'entry_year' => $year,
'section_id' => $section_id,
),
array( '%f', '%f', '%s' ),
array( '%d', '%d' )
);
return false !== $result;
}
$payload['created_at'] = $this->now();
$result = $this->wpdb->insert( $rate_table, $payload, array( '%d', '%d', '%f', '%f', '%s', '%s' ) );
return false !== $result;
}
/**
* Get all saved section prices for one year.
*
* @param int $year Selected year.
* @return array
*/
public function get_section_prices( $year ) {
$year = absint( $year );
$rate_table = $this->rate_table();
$sections = Schema::table( 'sections' );
if ( $year < 1 || '' === $rate_table ) {
return array();
}
$sql = "SELECT r.*, s.name AS section_name
FROM {$rate_table} r
LEFT JOIN {$sections} s ON s.id = r.section_id
WHERE r.entry_year = %d
ORDER BY s.name ASC, r.section_id ASC";
return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $year ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Sum all costs for a year.
*
* @param int $year Selected year.
* @return float
*/
public function get_total_for_year( $year ) {
if ( $year < 1 ) {
return 0.0;
}
$total = $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COALESCE(SUM(total_cost), 0) FROM {$this->table} WHERE entry_year = %d", absint( $year ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return (float) $total;
}
// -------------------------------------------------------------------------
// Parcel cost assignments
// -------------------------------------------------------------------------
/**
* Get the parcel cost assignments table name.
*
* @return string
*/
private function assignment_table() {
return Schema::table( 'parcel_cost_assignments' );
}
/**
* Get cost entry IDs assigned to a specific parcel.
*
* @param int $parcel_id Parcel ID.
* @return int[]
*/
public function get_assigned_entry_ids( $parcel_id ) {
$parcel_id = absint( $parcel_id );
$table = $this->assignment_table();
if ( $parcel_id < 1 || '' === $table ) {
return array();
}
$ids = $this->wpdb->get_col( $this->wpdb->prepare( "SELECT cost_entry_id FROM {$table} WHERE parcel_id = %d", $parcel_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return array_map( 'intval', (array) $ids );
}
/**
* Get all cost entry IDs that have at least one parcel assignment.
*
* @param int $year Optional filter by entry year (0 = all years).
* @return int[]
*/
public function get_entry_ids_with_assignments( $year = 0 ) {
$table = $this->assignment_table();
if ( '' === $table ) {
return array();
}
$year = absint( $year );
if ( $year > 0 ) {
$ids = $this->wpdb->get_col( $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
"SELECT DISTINCT a.cost_entry_id FROM {$table} a
INNER JOIN {$this->table} e ON e.id = a.cost_entry_id
WHERE e.entry_year = %d",
$year
) );
} else {
$ids = $this->wpdb->get_col( "SELECT DISTINCT cost_entry_id FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery
}
return array_map( 'intval', (array) $ids );
}
/**
* Assign a cost entry to a parcel.
*
* @param int $parcel_id Parcel ID.
* @param int $cost_entry_id Cost entry ID.
* @return bool
*/
public function assign_to_parcel( $parcel_id, $cost_entry_id ) {
$parcel_id = absint( $parcel_id );
$cost_entry_id = absint( $cost_entry_id );
$table = $this->assignment_table();
if ( $parcel_id < 1 || $cost_entry_id < 1 || '' === $table ) {
return false;
}
$exists = (int) $this->wpdb->get_var( $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
"SELECT COUNT(*) FROM {$table} WHERE parcel_id = %d AND cost_entry_id = %d",
$parcel_id,
$cost_entry_id
) );
if ( $exists > 0 ) {
return true;
}
$result = $this->wpdb->insert(
$table,
array(
'parcel_id' => $parcel_id,
'cost_entry_id' => $cost_entry_id,
'created_at' => $this->now(),
),
array( '%d', '%d', '%s' )
);
return false !== $result;
}
/**
* Remove a cost entry assignment from a parcel.
*
* @param int $parcel_id Parcel ID.
* @param int $cost_entry_id Cost entry ID.
* @return bool
*/
public function unassign_from_parcel( $parcel_id, $cost_entry_id ) {
$parcel_id = absint( $parcel_id );
$cost_entry_id = absint( $cost_entry_id );
$table = $this->assignment_table();
if ( $parcel_id < 1 || $cost_entry_id < 1 || '' === $table ) {
return false;
}
$result = $this->wpdb->delete(
$table,
array(
'parcel_id' => $parcel_id,
'cost_entry_id' => $cost_entry_id,
),
array( '%d', '%d' )
);
return false !== $result;
}
/**
* Remove all parcel assignments for a cost entry (e.g. when the entry is deleted).
*
* @param int $cost_entry_id Cost entry ID.
* @return void
*/
public function delete_assignments_for_entry( $cost_entry_id ) {
$table = $this->assignment_table();
if ( '' !== $table && absint( $cost_entry_id ) > 0 ) {
$this->wpdb->delete( $table, array( 'cost_entry_id' => absint( $cost_entry_id ) ), array( '%d' ) );
}
}
}