Initial plugin commit

This commit is contained in:
2026-04-13 21:01:07 +02:00
commit 4367aef84a
254 changed files with 104260 additions and 0 deletions

43
includes/Activator.php Normal file
View File

@@ -0,0 +1,43 @@
<?php
/**
* Plugin activation handler.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Activator {
/**
* Activate plugin.
*
* @return void
*/
public static function activate() {
Schema::create_tables();
Roles::add_roles_and_caps();
$defaults = array(
'allow_multiple_member_parcels' => 1,
'water_usage_alert_threshold' => 25,
'power_usage_alert_threshold' => 1000,
'power_unit' => 'kwh',
'pdf_club_name' => get_bloginfo( 'name' ),
'pdf_logo_url' => '',
'pdf_contact_block' => '',
'pdf_intro_text' => __( 'Diese Jahresabrechnung wurde automatisch mit der KGV Vereinsverwaltung erstellt.', KGVVM_TEXT_DOMAIN ),
'pdf_footer_text' => __( 'Bitte prüfen Sie die Angaben und melden Sie Rückfragen an den Vorstand.', KGVVM_TEXT_DOMAIN ),
);
$settings = get_option( 'kgvvm_settings', array() );
update_option( 'kgvvm_settings', wp_parse_args( $settings, $defaults ), false );
update_option( 'kgvvm_plugin_version', KGVVM_VERSION, false );
Plugin::ensure_daily_optimization_schedule();
}
}

4053
includes/Admin/Admin.php Normal file

File diff suppressed because it is too large Load Diff

45
includes/Autoloader.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
/**
* Simple PSR-4 style autoloader for the plugin.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Autoloader {
/**
* Register the autoloader.
*
* @return void
*/
public static function register() {
spl_autoload_register( array( __CLASS__, 'autoload' ) );
}
/**
* Load matching class files.
*
* @param string $class Fully qualified class name.
* @return void
*/
public static function autoload( $class ) {
$prefix = __NAMESPACE__ . '\\';
if ( 0 !== strpos( $class, $prefix ) ) {
return;
}
$relative = substr( $class, strlen( $prefix ) );
$file = KGVVM_PLUGIN_DIR . 'includes/' . str_replace( '\\', '/', $relative ) . '.php';
if ( file_exists( $file ) ) {
require_once $file;
}
}
}

25
includes/Deactivator.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
/**
* Plugin deactivation handler.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Deactivator {
/**
* Deactivate plugin.
*
* @return void
*/
public static function deactivate() {
Plugin::clear_daily_optimization_schedule();
flush_rewrite_rules( false );
}
}

112
includes/Plugin.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
/**
* Main plugin runtime.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager;
use KGV\VereinManager\Admin\Admin;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Plugin {
const DAILY_OPTIMIZE_HOOK = 'kgvvm_daily_optimize_tables';
/**
* Singleton instance.
*
* @var Plugin|null
*/
private static $instance = null;
/**
* Admin controller.
*
* @var Admin|null
*/
private $admin = null;
/**
* Get singleton instance.
*
* @return Plugin
*/
public static function instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Bootstrap runtime hooks.
*
* @return void
*/
public function run() {
load_plugin_textdomain( KGVVM_TEXT_DOMAIN, false, dirname( plugin_basename( KGVVM_PLUGIN_FILE ) ) . '/languages' );
add_action( self::DAILY_OPTIMIZE_HOOK, array( $this, 'run_daily_table_optimization' ) );
self::ensure_daily_optimization_schedule();
$default_settings = array(
'allow_multiple_member_parcels' => 1,
'water_usage_alert_threshold' => 25,
'power_usage_alert_threshold' => 1000,
'power_unit' => 'kwh',
'pdf_club_name' => get_bloginfo( 'name' ),
'pdf_logo_url' => '',
'pdf_contact_block' => '',
'pdf_intro_text' => __( 'Diese Jahresabrechnung wurde automatisch mit der KGV Vereinsverwaltung erstellt.', KGVVM_TEXT_DOMAIN ),
'pdf_footer_text' => __( 'Bitte prüfen Sie die Angaben und melden Sie Rückfragen an den Vorstand.', KGVVM_TEXT_DOMAIN ),
);
$settings = get_option( 'kgvvm_settings', array() );
update_option( 'kgvvm_settings', wp_parse_args( $settings, $default_settings ), false );
if ( KGVVM_VERSION !== get_option( 'kgvvm_plugin_version', '' ) ) {
Schema::create_tables();
Roles::add_roles_and_caps();
update_option( 'kgvvm_plugin_version', KGVVM_VERSION, false );
}
if ( is_admin() ) {
$this->admin = new Admin();
$this->admin->register_hooks();
}
}
/**
* Ensure the daily optimize event is scheduled.
*
* @return void
*/
public static function ensure_daily_optimization_schedule() {
if ( ! wp_next_scheduled( self::DAILY_OPTIMIZE_HOOK ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::DAILY_OPTIMIZE_HOOK );
}
}
/**
* Remove the daily optimize event.
*
* @return void
*/
public static function clear_daily_optimization_schedule() {
wp_clear_scheduled_hook( self::DAILY_OPTIMIZE_HOOK );
}
/**
* Optimize plugin tables from the scheduled task.
*
* @return void
*/
public function run_daily_table_optimization() {
Schema::optimize_tables();
update_option( 'kgvvm_last_table_optimize', current_time( 'mysql' ), false );
}
}

View File

@@ -0,0 +1,98 @@
<?php
/**
* Shared repository helpers.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager\Repositories;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
abstract class AbstractRepository {
/**
* WordPress database object.
*
* @var \wpdb
*/
protected $wpdb;
/**
* Concrete table name.
*
* @var string
*/
protected $table = '';
/**
* Build repository instance.
*/
public function __construct() {
global $wpdb;
$this->wpdb = $wpdb;
$this->table = $this->resolve_table();
}
/**
* Resolve table name in child class.
*
* @return string
*/
abstract protected function resolve_table();
/**
* Find a single row by ID.
*
* @param int $id Record ID.
* @return object|null
*/
public function find( $id ) {
return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$this->table} WHERE id = %d", absint( $id ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Delete a record.
*
* @param int $id Record ID.
* @return bool
*/
public function delete( $id ) {
$result = $this->wpdb->delete( $this->table, array( 'id' => absint( $id ) ), array( '%d' ) );
return false !== $result;
}
/**
* Current local datetime.
*
* @return string
*/
protected function now() {
return current_time( 'mysql' );
}
/**
* Restrict sortable columns.
*
* @param string $orderby Requested column.
* @param array $allowed Allowed columns.
* @param string $default Default column.
* @return string
*/
protected function sanitize_orderby( $orderby, $allowed, $default ) {
return in_array( $orderby, $allowed, true ) ? $orderby : $default;
}
/**
* Restrict sort direction.
*
* @param string $order Requested order.
* @return string
*/
protected function sanitize_order( $order ) {
return 'ASC' === strtoupper( (string) $order ) ? 'ASC' : 'DESC';
}
}

View File

@@ -0,0 +1,279 @@
<?php
/**
* Parcel/member and parcel/tenant assignments.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager\Repositories;
use KGV\VereinManager\Roles;
use KGV\VereinManager\Schema;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class AssignmentRepository {
/**
* WordPress database object.
*
* @var \wpdb
*/
private $wpdb;
/**
* Member assignment table.
*
* @var string
*/
private $member_table;
/**
* Tenant assignment table.
*
* @var string
*/
private $tenant_table;
/**
* Construct repository.
*/
public function __construct() {
global $wpdb;
$this->wpdb = $wpdb;
$this->member_table = Schema::table( 'parcel_members' );
$this->tenant_table = Schema::table( 'parcel_tenants' );
}
/**
* Get WordPress users with the role "Mitglied".
*
* @return array
*/
public function get_member_users() {
$query = new \WP_User_Query(
array(
'role' => Roles::MEMBER_ROLE,
'orderby' => 'display_name',
'order' => 'ASC',
'number' => 500,
)
);
return $query->get_results();
}
/**
* Get assigned member IDs for a parcel.
*
* @param int $parcel_id Parcel ID.
* @return array
*/
public function get_member_ids_for_parcel( $parcel_id ) {
$ids = $this->wpdb->get_col( $this->wpdb->prepare( "SELECT user_id FROM {$this->member_table} WHERE parcel_id = %d", $parcel_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return array_map( 'absint', $ids );
}
/**
* Get all members assigned to one parcel.
*
* @param int $parcel_id Parcel ID.
* @return array
*/
public function get_members_for_parcel( $parcel_id ) {
$users = $this->wpdb->users;
$sql = "SELECT u.ID, u.display_name, u.user_email
FROM {$this->member_table} pm
INNER JOIN {$users} u ON u.ID = pm.user_id
WHERE pm.parcel_id = %d
ORDER BY u.display_name ASC";
return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $parcel_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Get all parcels assigned to a specific user.
*
* @param int $user_id User ID.
* @return array
*/
public function get_parcels_for_user( $user_id ) {
$parcels = Schema::table( 'parcels' );
$sections = Schema::table( 'sections' );
$meters = Schema::table( 'meters' );
$sql = "SELECT p.*, s.name AS section_name,
(SELECT meter_number FROM {$meters} wm WHERE wm.parcel_id = p.id AND wm.type = 'water' LIMIT 1) AS water_meter_number,
(SELECT meter_number FROM {$meters} em WHERE em.parcel_id = p.id AND em.type = 'power' LIMIT 1) AS power_meter_number
FROM {$this->member_table} pm
INNER JOIN {$parcels} p ON p.id = pm.parcel_id
LEFT JOIN {$sections} s ON s.id = p.section_id
WHERE pm.user_id = %d
ORDER BY s.name ASC, p.label ASC";
return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $user_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Check whether a parcel belongs to a given user.
*
* @param int $user_id User ID.
* @param int $parcel_id Parcel ID.
* @return bool
*/
public function user_has_parcel( $user_id, $parcel_id ) {
$sql = "SELECT COUNT(*) FROM {$this->member_table} WHERE user_id = %d AND parcel_id = %d";
return (int) $this->wpdb->get_var( $this->wpdb->prepare( $sql, $user_id, $parcel_id ) ) > 0; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Get assigned tenant IDs for a parcel.
*
* @param int $parcel_id Parcel ID.
* @return array
*/
public function get_tenant_ids_for_parcel( $parcel_id ) {
$ids = $this->wpdb->get_col( $this->wpdb->prepare( "SELECT tenant_id FROM {$this->tenant_table} WHERE parcel_id = %d", $parcel_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return array_map( 'absint', $ids );
}
/**
* Get all members linked to a tenant via assigned parcels.
*
* @param int $tenant_id Tenant ID.
* @return array
*/
public function get_members_for_tenant( $tenant_id ) {
$parcels = Schema::table( 'parcels' );
$users = $this->wpdb->users;
$sql = "SELECT DISTINCT u.ID, u.display_name, u.user_email, p.label AS parcel_label
FROM {$this->tenant_table} pt
INNER JOIN {$parcels} p ON p.id = pt.parcel_id
INNER JOIN {$this->member_table} pm ON pm.parcel_id = pt.parcel_id
INNER JOIN {$users} u ON u.ID = pm.user_id
WHERE pt.tenant_id = %d
ORDER BY p.label ASC, u.display_name ASC";
return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $tenant_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Get all tenants assigned to one parcel.
*
* @param int $parcel_id Parcel ID.
* @return array
*/
public function get_tenants_for_parcel( $parcel_id ) {
$tenants = Schema::table( 'tenants' );
$sql = "SELECT t.*
FROM {$this->tenant_table} pt
INNER JOIN {$tenants} t ON t.id = pt.tenant_id
WHERE pt.parcel_id = %d
ORDER BY t.last_name ASC, t.first_name ASC";
return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $parcel_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Get all parcels assigned to one tenant.
*
* @param int $tenant_id Tenant ID.
* @return array
*/
public function get_parcels_for_tenant( $tenant_id ) {
$parcels = Schema::table( 'parcels' );
$sections = Schema::table( 'sections' );
$sql = "SELECT p.*, s.name AS section_name
FROM {$this->tenant_table} pt
INNER JOIN {$parcels} p ON p.id = pt.parcel_id
LEFT JOIN {$sections} s ON s.id = p.section_id
WHERE pt.tenant_id = %d
ORDER BY s.name ASC, p.label ASC";
return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $tenant_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Synchronize member assignments.
*
* @param int $parcel_id Parcel ID.
* @param array $user_ids User IDs.
* @return void
*/
public function sync_member_ids( $parcel_id, $user_ids ) {
$user_ids = array_values( array_unique( array_filter( array_map( 'absint', (array) $user_ids ) ) ) );
$this->wpdb->delete( $this->member_table, array( 'parcel_id' => $parcel_id ), array( '%d' ) );
foreach ( $user_ids as $user_id ) {
$this->wpdb->insert(
$this->member_table,
array(
'parcel_id' => $parcel_id,
'user_id' => $user_id,
'created_at' => current_time( 'mysql' ),
),
array( '%d', '%d', '%s' )
);
}
}
/**
* Synchronize tenant assignments.
*
* @param int $parcel_id Parcel ID.
* @param array $tenant_ids Tenant IDs.
* @return void
*/
public function sync_tenant_ids( $parcel_id, $tenant_ids ) {
$tenant_ids = array_values( array_unique( array_filter( array_map( 'absint', (array) $tenant_ids ) ) ) );
$this->wpdb->delete( $this->tenant_table, array( 'parcel_id' => $parcel_id ), array( '%d' ) );
foreach ( $tenant_ids as $tenant_id ) {
$this->wpdb->insert(
$this->tenant_table,
array(
'parcel_id' => $parcel_id,
'tenant_id' => $tenant_id,
'created_at' => current_time( 'mysql' ),
),
array( '%d', '%d', '%s' )
);
}
}
/**
* Check whether a member is assigned to a different parcel.
*
* @param int $user_id User ID.
* @param int $parcel_id Current parcel ID.
* @return bool
*/
public function member_has_other_parcels( $user_id, $parcel_id ) {
$sql = "SELECT COUNT(*) FROM {$this->member_table} WHERE user_id = %d AND parcel_id != %d";
return (int) $this->wpdb->get_var( $this->wpdb->prepare( $sql, $user_id, $parcel_id ) ) > 0; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Remove all assignments for a parcel.
*
* @param int $parcel_id Parcel ID.
* @return void
*/
public function purge_parcel( $parcel_id ) {
$this->wpdb->delete( $this->member_table, array( 'parcel_id' => $parcel_id ), array( '%d' ) );
$this->wpdb->delete( $this->tenant_table, array( 'parcel_id' => $parcel_id ), array( '%d' ) );
}
/**
* Remove all tenant relations before deleting the tenant.
*
* @param int $tenant_id Tenant ID.
* @return void
*/
public function purge_tenant( $tenant_id ) {
$this->wpdb->delete( $this->tenant_table, array( 'tenant_id' => $tenant_id ), array( '%d' ) );
}
}

View File

@@ -0,0 +1,117 @@
<?php
/**
* Chat repository for member communication.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager\Repositories;
use KGV\VereinManager\Schema;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class ChatRepository extends AbstractRepository {
/**
* Resolve table name.
*
* @return string
*/
protected function resolve_table() {
return Schema::table( 'chat_messages' );
}
/**
* Get recent chat messages for one room.
*
* @param string $room_key Room key.
* @param int $limit Maximum result count.
* @param int $after_id Optional lower ID boundary for polling.
* @return array
*/
public function get_recent_messages( $room_key, $limit = 60, $after_id = 0 ) {
$users = $this->wpdb->users;
$room_key = sanitize_key( $room_key );
$limit = max( 1, min( 200, absint( $limit ) ) );
$after_id = absint( $after_id );
if ( $after_id > 0 ) {
$sql = "SELECT m.*, u.display_name
FROM {$this->table} m
LEFT JOIN {$users} u ON u.ID = m.user_id
WHERE m.room_key = %s AND m.id > %d
ORDER BY m.id ASC
LIMIT %d";
return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $room_key, $after_id, $limit ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
$sql = "SELECT *
FROM (
SELECT m.*, u.display_name
FROM {$this->table} m
LEFT JOIN {$users} u ON u.ID = m.user_id
WHERE m.room_key = %s
ORDER BY m.id DESC
LIMIT %d
) recent_messages
ORDER BY id ASC";
return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $room_key, $limit ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Store one new message.
*
* @param string $room_key Room key.
* @param int $user_id Sender user ID.
* @param string $message Message body.
* @return object|null
*/
public function save_message( $room_key, $user_id, $message ) {
$room_key = sanitize_key( $room_key );
$user_id = absint( $user_id );
$message = trim( sanitize_textarea_field( $message ) );
if ( '' === $room_key || $user_id < 1 || '' === $message ) {
return null;
}
$result = $this->wpdb->insert(
$this->table,
array(
'room_key' => $room_key,
'user_id' => $user_id,
'message' => $message,
'created_at' => $this->now(),
),
array( '%s', '%d', '%s', '%s' )
);
if ( false === $result ) {
return null;
}
return $this->find_with_author( (int) $this->wpdb->insert_id );
}
/**
* Find one message together with the author's display name.
*
* @param int $id Message ID.
* @return object|null
*/
private function find_with_author( $id ) {
$users = $this->wpdb->users;
$sql = "SELECT m.*, u.display_name
FROM {$this->table} m
LEFT JOIN {$users} u ON u.ID = m.user_id
WHERE m.id = %d
LIMIT 1";
return $this->wpdb->get_row( $this->wpdb->prepare( $sql, absint( $id ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
}

View File

@@ -0,0 +1,313 @@
<?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;
}
}

View File

@@ -0,0 +1,247 @@
<?php
/**
* Meter reading repository.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager\Repositories;
use KGV\VereinManager\Schema;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class MeterReadingRepository extends AbstractRepository {
/**
* Resolve table name.
*
* @return string
*/
protected function resolve_table() {
return Schema::table( 'meter_readings' );
}
/**
* Store a new reading.
*
* @param array $data Reading data.
* @return int|false
*/
public function save( $data ) {
$payload = array(
'meter_id' => absint( $data['meter_id'] ),
'parcel_id' => absint( $data['parcel_id'] ),
'reading_value' => (float) $data['reading_value'],
'reading_date' => $data['reading_date'],
'note' => $data['note'],
'submitted_by' => absint( $data['submitted_by'] ),
'is_self_reading' => ! empty( $data['is_self_reading'] ) ? 1 : 0,
'created_at' => $this->now(),
);
$this->wpdb->insert(
$this->table,
$payload,
array( '%d', '%d', '%f', '%s', '%s', '%d', '%d', '%s' )
);
return $this->wpdb->insert_id;
}
/**
* Get the latest reading for one meter.
*
* @param int $meter_id Meter ID.
* @return object|null
*/
public function get_latest_for_meter( $meter_id ) {
$sql = "SELECT * FROM {$this->table} WHERE meter_id = %d ORDER BY reading_date DESC, id DESC LIMIT 1";
return $this->wpdb->get_row( $this->wpdb->prepare( $sql, $meter_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Get recent readings for a parcel.
*
* @param int $parcel_id Parcel ID.
* @param int $limit Number of rows.
* @return array
*/
public function get_recent_for_parcel( $parcel_id, $limit = 10 ) {
$meters = Schema::table( 'meters' );
$sql = "SELECT r.*, m.type, m.meter_number
FROM {$this->table} r
LEFT JOIN {$meters} m ON m.id = r.meter_id
WHERE r.parcel_id = %d
ORDER BY r.reading_date DESC, r.id DESC
LIMIT %d";
return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $parcel_id, absint( $limit ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Get a filtered consumption report including deltas since the previous reading.
*
* @param int $section_id Optional section filter.
* @param string $date_from Optional start date.
* @param string $date_to Optional end date.
* @param string $order ASC|DESC by reading date.
* @return array
*/
public function get_consumption_report( $section_id = 0, $date_from = '', $date_to = '', $order = 'DESC' ) {
$meters = Schema::table( 'meters' );
$sections = Schema::table( 'sections' );
$parcels = Schema::table( 'parcels' );
$sql = "SELECT r.*, m.type, m.meter_number, m.section_id, s.name AS section_name, p.label AS parcel_label
FROM {$this->table} r
LEFT JOIN {$meters} m ON m.id = r.meter_id
LEFT JOIN {$sections} s ON s.id = m.section_id
LEFT JOIN {$parcels} p ON p.id = r.parcel_id
WHERE 1=1";
$params = array();
$order = 'ASC' === strtoupper( (string) $order ) ? 'ASC' : 'DESC';
if ( $section_id > 0 ) {
$sql .= ' AND m.section_id = %d';
$params[] = $section_id;
}
if ( '' !== $date_to ) {
$sql .= ' AND r.reading_date <= %s';
$params[] = $date_to;
}
$sql .= ' ORDER BY r.meter_id ASC, r.reading_date ASC, r.id ASC';
$rows = ! empty( $params )
? $this->wpdb->get_results( $this->wpdb->prepare( $sql, $params ) ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
: $this->wpdb->get_results( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery
$report = array();
$last_by_meter = array();
foreach ( $rows as $row ) {
$previous = isset( $last_by_meter[ $row->meter_id ] ) ? $last_by_meter[ $row->meter_id ] : null;
$row->previous_value = $previous ? (float) $previous->reading_value : null;
$row->consumption = $previous && (float) $row->reading_value >= (float) $previous->reading_value ? (float) $row->reading_value - (float) $previous->reading_value : null;
$last_by_meter[$row->meter_id] = $row;
if ( '' !== $date_from && $row->reading_date < $date_from ) {
continue;
}
$report[] = $row;
}
usort(
$report,
function( $left, $right ) use ( $order ) {
$left_key = $left->reading_date . ' ' . str_pad( (string) $left->id, 10, '0', STR_PAD_LEFT );
$right_key = $right->reading_date . ' ' . str_pad( (string) $right->id, 10, '0', STR_PAD_LEFT );
if ( $left_key === $right_key ) {
return 0;
}
if ( 'ASC' === $order ) {
return $left_key < $right_key ? -1 : 1;
}
return $left_key > $right_key ? -1 : 1;
}
);
return $report;
}
/**
* Get all readings for a parcel, suitable for export.
*
* @param int $parcel_id Parcel ID.
* @return array
*/
public function get_all_for_parcel( $parcel_id ) {
$meters = Schema::table( 'meters' );
$sql = "SELECT r.*, m.type, m.meter_number
FROM {$this->table} r
LEFT JOIN {$meters} m ON m.id = r.meter_id
WHERE r.parcel_id = %d
ORDER BY r.reading_date DESC, r.id DESC";
return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $parcel_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Get reading history for a specific meter.
*
* @param int $meter_id Meter ID.
* @param int $limit Max rows.
* @return array
*/
public function get_all_for_meter( $meter_id, $limit = 50 ) {
$parcels = Schema::table( 'parcels' );
$sql = "SELECT r.*, p.label AS parcel_label
FROM {$this->table} r
LEFT JOIN {$parcels} p ON p.id = r.parcel_id
WHERE r.meter_id = %d
ORDER BY r.reading_date DESC, r.id DESC
LIMIT %d";
return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $meter_id, absint( $limit ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Build a monthly summary for one meter.
*
* @param int $meter_id Meter ID.
* @return array
*/
public function get_monthly_summary_for_meter( $meter_id ) {
$rows = $this->wpdb->get_results(
$this->wpdb->prepare(
"SELECT * FROM {$this->table} WHERE meter_id = %d ORDER BY reading_date ASC, id ASC",
$meter_id
)
); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
if ( empty( $rows ) ) {
return array();
}
$monthly = array();
$last = null;
foreach ( $rows as $row ) {
$key = gmdate( 'Y-m', strtotime( $row->reading_date ) );
if ( ! isset( $monthly[ $key ] ) ) {
$monthly[ $key ] = array(
'month' => $key,
'from_value' => null,
'to_value' => (float) $row->reading_value,
'consumption' => 0.0,
'readings' => 0,
'last_date' => $row->reading_date,
);
}
$monthly[ $key ]['to_value'] = (float) $row->reading_value;
$monthly[ $key ]['last_date'] = $row->reading_date;
$monthly[ $key ]['readings']++;
if ( $last && (float) $row->reading_value >= (float) $last->reading_value ) {
if ( null === $monthly[ $key ]['from_value'] ) {
$monthly[ $key ]['from_value'] = (float) $last->reading_value;
}
$monthly[ $key ]['consumption'] += (float) $row->reading_value - (float) $last->reading_value;
}
$last = $row;
}
return array_reverse( array_values( $monthly ) );
}
}

View File

@@ -0,0 +1,304 @@
<?php
/**
* Meter repository.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager\Repositories;
use KGV\VereinManager\Schema;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class MeterRepository extends AbstractRepository {
/**
* Resolve table name.
*
* @return string
*/
protected function resolve_table() {
return Schema::table( 'meters' );
}
/**
* Search meters.
*
* @param array $args Query arguments.
* @return array
*/
public function search( $args = array() ) {
$search = isset( $args['s'] ) ? sanitize_text_field( wp_unslash( $args['s'] ) ) : '';
$type = isset( $args['type'] ) ? sanitize_key( wp_unslash( $args['type'] ) ) : '';
$section_id = isset( $args['section_id'] ) ? absint( $args['section_id'] ) : 0;
$assignment = isset( $args['assignment'] ) ? sanitize_key( wp_unslash( $args['assignment'] ) ) : '';
$orderby = $this->sanitize_orderby( isset( $args['orderby'] ) ? sanitize_key( wp_unslash( $args['orderby'] ) ) : 'meter_number', array( 'meter_number', 'type', 'installed_at', 'calibration_year', 'is_active', 'created_at' ), 'meter_number' );
$order = $this->sanitize_order( isset( $args['order'] ) ? sanitize_key( wp_unslash( $args['order'] ) ) : 'ASC' );
$sections = Schema::table( 'sections' );
$parcels = Schema::table( 'parcels' );
$sql = "SELECT m.*, s.name AS section_name, p.label AS parcel_label
FROM {$this->table} m
LEFT JOIN {$sections} s ON s.id = m.section_id
LEFT JOIN {$parcels} p ON p.id = m.parcel_id
WHERE 1=1";
$params = array();
if ( '' !== $search ) {
$like = '%' . $this->wpdb->esc_like( $search ) . '%';
$sql .= ' AND (m.meter_number LIKE %s OR m.note LIKE %s)';
$params[] = $like;
$params[] = $like;
}
if ( in_array( $type, array( 'water', 'power' ), true ) ) {
$sql .= ' AND m.type = %s';
$params[] = $type;
}
if ( $section_id > 0 ) {
$sql .= ' AND m.section_id = %d';
$params[] = $section_id;
}
if ( 'free' === $assignment ) {
$sql .= ' AND m.parcel_id IS NULL AND m.is_main_meter = 0';
} elseif ( 'assigned' === $assignment ) {
$sql .= ' AND m.parcel_id IS NOT NULL';
} elseif ( 'main' === $assignment ) {
$sql .= ' AND m.is_main_meter = 1';
}
$sql .= " ORDER BY m.{$orderby} {$order}, m.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 meter.
*
* @param array $data Meter data.
* @param int $id Optional ID.
* @return int|false
*/
public function save( $data, $id = 0 ) {
$current = $id > 0 ? $this->find( $id ) : null;
$is_main_meter = isset( $data['is_main_meter'] ) ? absint( $data['is_main_meter'] ) : ( $current ? (int) $current->is_main_meter : 0 );
if ( empty( $data['is_active'] ) ) {
$is_main_meter = 0;
}
$payload = array(
'type' => $data['type'],
'meter_number' => $data['meter_number'],
'section_id' => $data['section_id'],
'parcel_id' => $current ? $current->parcel_id : null,
'installed_at' => $data['installed_at'] ? $data['installed_at'] : null,
'calibration_year' => isset( $data['calibration_year'] ) ? $data['calibration_year'] : null,
'is_main_meter' => $is_main_meter,
'is_active' => $data['is_active'],
'note' => $data['note'],
'updated_at' => $this->now(),
);
$formats = array( '%s', '%s', '%d', '%s', '%s', '%d', '%d', '%d', '%s', '%s' );
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( '%s', '%s', '%d', '%s', '%s', '%d', '%d', '%d', '%s', '%s', '%s' ) );
return $this->wpdb->insert_id;
}
/**
* Check if a meter number already exists for the same type.
*
* @param string $meter_number Meter number.
* @param string $type Meter type.
* @param int $exclude_id Optional ID to exclude.
* @return bool
*/
public function meter_number_exists( $meter_number, $type, $exclude_id = 0 ) {
$sql = "SELECT COUNT(*) FROM {$this->table} WHERE meter_number = %s AND type = %s";
$params = array( $meter_number, $type );
if ( $exclude_id > 0 ) {
$sql .= ' AND id != %d';
$params[] = $exclude_id;
}
return (int) $this->wpdb->get_var( $this->wpdb->prepare( $sql, $params ) ) > 0; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Return free meters for selection.
*
* @param string $type water|power.
* @param int $section_id Optional section filter.
* @param int $current_parcel_id Current parcel in edit mode.
* @return array
*/
public function get_free_by_type( $type, $section_id = 0, $current_parcel_id = 0 ) {
$sql = "SELECT * FROM {$this->table} WHERE type = %s AND is_active = 1 AND is_main_meter = 0 AND (parcel_id IS NULL OR parcel_id = %d)";
$params = array( $type, $current_parcel_id );
if ( $section_id > 0 ) {
$sql .= ' AND section_id = %d';
$params[] = $section_id;
}
$sql .= ' ORDER BY meter_number ASC';
return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $params ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Get the meter assigned to a parcel by type.
*
* @param int $parcel_id Parcel ID.
* @param string $type Meter type.
* @return object|null
*/
public function get_assigned_to_parcel( $parcel_id, $type ) {
return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$this->table} WHERE parcel_id = %d AND type = %s LIMIT 1", $parcel_id, $type ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Load a meter if it can legally be assigned to a parcel.
*
* @param int $id Meter ID.
* @param string $type Expected type.
* @param int $section_id Target section ID.
* @param int $current_parcel_id Current parcel ID.
* @return object|null
*/
public function get_assignable_meter( $id, $type, $section_id, $current_parcel_id ) {
$sql = "SELECT * FROM {$this->table} WHERE id = %d AND type = %s AND section_id = %d AND is_active = 1 AND is_main_meter = 0 AND (parcel_id IS NULL OR parcel_id = %d) LIMIT 1";
return $this->wpdb->get_row( $this->wpdb->prepare( $sql, $id, $type, $section_id, $current_parcel_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Release all meters assigned to a parcel.
*
* @param int $parcel_id Parcel ID.
* @return void
*/
public function release_parcel( $parcel_id ) {
$this->wpdb->query( $this->wpdb->prepare( "UPDATE {$this->table} SET parcel_id = NULL, updated_at = %s WHERE parcel_id = %d", $this->now(), $parcel_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Assign a meter to a parcel.
*
* @param int $meter_id Meter ID.
* @param int $parcel_id Parcel ID.
* @return void
*/
public function assign_to_parcel( $meter_id, $parcel_id ) {
$this->wpdb->update(
$this->table,
array(
'parcel_id' => $parcel_id,
'is_main_meter' => 0,
'updated_at' => $this->now(),
),
array( 'id' => $meter_id ),
array( '%d', '%d', '%s' ),
array( '%d' )
);
}
/**
* Release one specific meter from its parcel.
*
* @param int $meter_id Meter ID.
* @return void
*/
public function release_meter( $meter_id ) {
$this->wpdb->update(
$this->table,
array(
'parcel_id' => null,
'updated_at' => $this->now(),
),
array( 'id' => $meter_id ),
array( '%d', '%s' ),
array( '%d' )
);
}
/**
* Return all available main meters for one section.
*
* @param int $section_id Section ID.
* @return array
*/
public function get_available_main_for_section( $section_id ) {
$sql = "SELECT * FROM {$this->table} WHERE section_id = %d AND is_active = 1 AND parcel_id IS NULL ORDER BY type ASC, meter_number ASC";
return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $section_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Return all selected main meters for one section.
*
* @param int $section_id Section ID.
* @return array
*/
public function get_main_for_section( $section_id ) {
$sql = "SELECT * FROM {$this->table} WHERE section_id = %d AND is_main_meter = 1 ORDER BY type ASC, meter_number ASC";
return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $section_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Sync selected main meters for a section.
*
* @param int $section_id Section ID.
* @param array $meter_ids Selected meter IDs.
* @return void
*/
public function sync_main_meters_for_section( $section_id, $meter_ids ) {
$section_id = absint( $section_id );
$meter_ids = array_values( array_unique( array_filter( array_map( 'absint', (array) $meter_ids ) ) ) );
if ( $section_id < 1 ) {
return;
}
$this->wpdb->query( $this->wpdb->prepare( "UPDATE {$this->table} SET is_main_meter = 0, updated_at = %s WHERE section_id = %d AND parcel_id IS NULL", $this->now(), $section_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
if ( empty( $meter_ids ) ) {
return;
}
$placeholders = implode( ', ', array_fill( 0, count( $meter_ids ), '%d' ) );
$params = array_merge( array( $this->now(), $section_id ), $meter_ids );
$sql = "UPDATE {$this->table} SET is_main_meter = 1, parcel_id = NULL, updated_at = %s WHERE section_id = %d AND is_active = 1 AND parcel_id IS NULL AND id IN ({$placeholders})";
$this->wpdb->query( $this->wpdb->prepare( $sql, $params ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Check whether the meter is already assigned.
*
* @param int $id Meter ID.
* @return bool
*/
public function is_assigned( $id ) {
$meter = $this->find( $id );
return $meter && ! empty( $meter->parcel_id );
}
}

View File

@@ -0,0 +1,195 @@
<?php
/**
* Parcel repository.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager\Repositories;
use KGV\VereinManager\Schema;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class ParcelRepository extends AbstractRepository {
/**
* Resolve table name.
*
* @return string
*/
protected function resolve_table() {
return Schema::table( 'parcels' );
}
/**
* Search parcels with relation information.
*
* @param array $args Query arguments.
* @return array
*/
public function search( $args = array() ) {
$search = isset( $args['s'] ) ? sanitize_text_field( wp_unslash( $args['s'] ) ) : '';
$status = isset( $args['status'] ) ? sanitize_key( wp_unslash( $args['status'] ) ) : '';
$section_id = isset( $args['section_id'] ) ? absint( $args['section_id'] ) : 0;
$orderby = $this->sanitize_orderby( isset( $args['orderby'] ) ? sanitize_key( wp_unslash( $args['orderby'] ) ) : 'label', array( 'label', 'status', 'area', 'annual_rent', 'created_at' ), 'label' );
$order = $this->sanitize_order( isset( $args['order'] ) ? sanitize_key( wp_unslash( $args['order'] ) ) : 'ASC' );
$limit = isset( $args['limit'] ) ? absint( $args['limit'] ) : 0;
$paged = isset( $args['paged'] ) ? max( 1, absint( $args['paged'] ) ) : 1;
$offset = $limit > 0 ? ( $paged - 1 ) * $limit : 0;
$meters = Schema::table( 'meters' );
$parcel_members = Schema::table( 'parcel_members' );
$parcel_tenants = Schema::table( 'parcel_tenants' );
$sections = Schema::table( 'sections' );
$sql = "SELECT p.*, s.name AS section_name,
MAX(CASE WHEN m.type = 'water' THEN m.meter_number ELSE NULL END) AS water_meter_number,
MAX(CASE WHEN m.type = 'power' THEN m.meter_number ELSE NULL END) AS power_meter_number,
COUNT(DISTINCT pm.user_id) AS member_count,
COUNT(DISTINCT pt.tenant_id) AS tenant_count
FROM {$this->table} p
LEFT JOIN {$sections} s ON s.id = p.section_id
LEFT JOIN {$meters} m ON m.parcel_id = p.id
LEFT JOIN {$parcel_members} pm ON pm.parcel_id = p.id
LEFT JOIN {$parcel_tenants} pt ON pt.parcel_id = p.id
WHERE 1=1";
$params = array();
if ( '' !== $search ) {
$like = '%' . $this->wpdb->esc_like( $search ) . '%';
$sql .= ' AND (p.label LIKE %s OR p.note LIKE %s)';
$params[] = $like;
$params[] = $like;
}
if ( in_array( $status, array( 'free', 'assigned', 'reserved', 'inactive' ), true ) ) {
$sql .= ' AND p.status = %s';
$params[] = $status;
}
if ( $section_id > 0 ) {
$sql .= ' AND p.section_id = %d';
$params[] = $section_id;
}
$sql .= " GROUP BY p.id ORDER BY p.{$orderby} {$order}, p.id DESC";
if ( $limit > 0 ) {
$sql .= ' LIMIT %d';
$params[] = $limit;
if ( $offset > 0 ) {
$sql .= ' OFFSET %d';
$params[] = $offset;
}
}
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
}
/**
* Count parcels for the current filter set.
*
* @param array $args Query arguments.
* @return int
*/
public function count_filtered( $args = array() ) {
$search = isset( $args['s'] ) ? sanitize_text_field( wp_unslash( $args['s'] ) ) : '';
$status = isset( $args['status'] ) ? sanitize_key( wp_unslash( $args['status'] ) ) : '';
$section_id = isset( $args['section_id'] ) ? absint( $args['section_id'] ) : 0;
$sql = "SELECT COUNT(*) FROM {$this->table} p WHERE 1=1";
$params = array();
if ( '' !== $search ) {
$like = '%' . $this->wpdb->esc_like( $search ) . '%';
$sql .= ' AND (p.label LIKE %s OR p.note LIKE %s)';
$params[] = $like;
$params[] = $like;
}
if ( in_array( $status, array( 'free', 'assigned', 'reserved', 'inactive' ), true ) ) {
$sql .= ' AND p.status = %s';
$params[] = $status;
}
if ( $section_id > 0 ) {
$sql .= ' AND p.section_id = %d';
$params[] = $section_id;
}
if ( ! empty( $params ) ) {
return (int) $this->wpdb->get_var( $this->wpdb->prepare( $sql, $params ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
return (int) $this->wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery
}
/**
* Save or update parcel.
*
* @param array $data Parcel data.
* @param int $id Optional parcel ID.
* @return int|false
*/
public function save( $data, $id = 0 ) {
$payload = array(
'label' => $data['label'],
'section_id' => $data['section_id'],
'area' => $data['area'],
'annual_rent' => $data['annual_rent'],
'status' => $data['status'],
'note' => $data['note'],
'updated_at' => $this->now(),
);
$formats = array( '%s', '%d', '%f', '%f', '%s', '%s', '%s' );
if ( null === $payload['area'] ) {
$payload['area'] = null;
$formats[2] = '%s';
}
if ( null === $payload['annual_rent'] ) {
$payload['annual_rent'] = null;
$formats[3] = '%s';
}
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( '%s', '%d', $formats[2], $formats[3], '%s', '%s', '%s', '%s' ) );
return $this->wpdb->insert_id;
}
/**
* Check whether a parcel label already exists in the same section.
*
* @param string $label Parcel label.
* @param int $section_id Section ID.
* @param int $exclude_id Optional parcel ID.
* @return bool
*/
public function label_exists( $label, $section_id, $exclude_id = 0 ) {
$sql = "SELECT COUNT(*) FROM {$this->table} WHERE label = %s AND section_id = %d";
$params = array( $label, $section_id );
if ( $exclude_id > 0 ) {
$sql .= ' AND id != %d';
$params[] = $exclude_id;
}
return (int) $this->wpdb->get_var( $this->wpdb->prepare( $sql, $params ) ) > 0; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
}

View File

@@ -0,0 +1,139 @@
<?php
/**
* Section repository.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager\Repositories;
use KGV\VereinManager\Schema;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class SectionRepository extends AbstractRepository {
/**
* Resolve table name.
*
* @return string
*/
protected function resolve_table() {
return Schema::table( 'sections' );
}
/**
* Search section list.
*
* @param array $args Query arguments.
* @return array
*/
public function search( $args = array() ) {
$search = isset( $args['s'] ) ? sanitize_text_field( wp_unslash( $args['s'] ) ) : '';
$status = isset( $args['status'] ) ? sanitize_key( wp_unslash( $args['status'] ) ) : '';
$orderby = $this->sanitize_orderby( isset( $args['orderby'] ) ? sanitize_key( wp_unslash( $args['orderby'] ) ) : 'name', array( 'name', 'status', 'created_at', 'updated_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 ( '' !== $search ) {
$like = '%' . $this->wpdb->esc_like( $search ) . '%';
$sql .= ' AND (name LIKE %s OR description LIKE %s)';
$params[] = $like;
$params[] = $like;
}
if ( in_array( $status, array( 'active', 'inactive' ), true ) ) {
$sql .= ' AND status = %s';
$params[] = $status;
}
$sql .= " ORDER BY {$orderby} {$order}";
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 section.
*
* @param array $data Section data.
* @param int $id Optional ID.
* @return int|false
*/
public function save( $data, $id = 0 ) {
$payload = array(
'name' => $data['name'],
'description' => $data['description'],
'status' => $data['status'],
'updated_at' => $this->now(),
);
$formats = array( '%s', '%s', '%s', '%s' );
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( '%s', '%s', '%s', '%s', '%s' ) );
return $this->wpdb->insert_id;
}
/**
* Check if a section name already exists.
*
* @param string $name Section name.
* @param int $exclude_id Optional ID to exclude.
* @return bool
*/
public function name_exists( $name, $exclude_id = 0 ) {
$sql = "SELECT COUNT(*) FROM {$this->table} WHERE name = %s";
if ( $exclude_id > 0 ) {
$sql .= ' AND id != %d';
return (int) $this->wpdb->get_var( $this->wpdb->prepare( $sql, $name, $exclude_id ) ) > 0; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
return (int) $this->wpdb->get_var( $this->wpdb->prepare( $sql, $name ) ) > 0; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Lightweight options list.
*
* @param bool $active_only Restrict to active sections.
* @return array
*/
public function all_for_options( $active_only = false ) {
$sql = "SELECT * FROM {$this->table}";
if ( $active_only ) {
$sql .= " WHERE status = 'active'";
}
$sql .= ' ORDER BY name ASC';
return $this->wpdb->get_results( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery
}
/**
* Check whether section still has related records.
*
* @param int $id Section ID.
* @return bool
*/
public function is_in_use( $id ) {
$parcel_count = (int) $this->wpdb->get_var( $this->wpdb->prepare( 'SELECT COUNT(*) FROM ' . Schema::table( 'parcels' ) . ' WHERE section_id = %d', $id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$meter_count = (int) $this->wpdb->get_var( $this->wpdb->prepare( 'SELECT COUNT(*) FROM ' . Schema::table( 'meters' ) . ' WHERE section_id = %d', $id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return $parcel_count > 0 || $meter_count > 0;
}
}

View File

@@ -0,0 +1,112 @@
<?php
/**
* Tenant repository.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager\Repositories;
use KGV\VereinManager\Schema;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class TenantRepository extends AbstractRepository {
/**
* Resolve table name.
*
* @return string
*/
protected function resolve_table() {
return Schema::table( 'tenants' );
}
/**
* Search tenant list.
*
* @param array $args Query arguments.
* @return array
*/
public function search( $args = array() ) {
$search = isset( $args['s'] ) ? sanitize_text_field( wp_unslash( $args['s'] ) ) : '';
$status = isset( $args['status'] ) ? sanitize_key( wp_unslash( $args['status'] ) ) : '';
$orderby = $this->sanitize_orderby( isset( $args['orderby'] ) ? sanitize_key( wp_unslash( $args['orderby'] ) ) : 'last_name', array( 'last_name', 'first_name', 'contract_start', 'is_active', 'created_at' ), 'last_name' );
$order = $this->sanitize_order( isset( $args['order'] ) ? sanitize_key( wp_unslash( $args['order'] ) ) : 'ASC' );
$parcel_tenants = Schema::table( 'parcel_tenants' );
$sql = "SELECT t.*, (SELECT COUNT(*) FROM {$parcel_tenants} pt WHERE pt.tenant_id = t.id) AS parcel_count
FROM {$this->table} t
WHERE 1=1";
$params = array();
if ( '' !== $search ) {
$like = '%' . $this->wpdb->esc_like( $search ) . '%';
$sql .= ' AND (t.first_name LIKE %s OR t.last_name LIKE %s OR t.email LIKE %s OR t.phone LIKE %s)';
$params[] = $like;
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
if ( in_array( $status, array( 'active', 'inactive' ), true ) ) {
$sql .= ' AND t.is_active = ' . ( 'active' === $status ? '1' : '0' );
}
$sql .= " ORDER BY t.{$orderby} {$order}, t.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 tenant.
*
* @param array $data Tenant data.
* @param int $id Optional ID.
* @return int|false
*/
public function save( $data, $id = 0 ) {
$payload = array(
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'address' => $data['address'],
'phone' => $data['phone'],
'email' => $data['email'],
'contract_start' => $data['contract_start'],
'contract_end' => $data['contract_end'] ? $data['contract_end'] : null,
'is_active' => $data['is_active'],
'note' => $data['note'],
'updated_at' => $this->now(),
);
$formats = array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s' );
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( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s' ) );
return $this->wpdb->insert_id;
}
/**
* Return active tenants for selection lists.
*
* @return array
*/
public function all_active() {
$sql = "SELECT * FROM {$this->table} WHERE is_active = 1 ORDER BY last_name ASC, first_name ASC";
return $this->wpdb->get_results( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery
}
}

137
includes/Roles.php Normal file
View File

@@ -0,0 +1,137 @@
<?php
/**
* Roles and capabilities.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Roles {
const MEMBER_ROLE = 'kgv_member';
const SETTINGS_CAP = 'manage_kleingarten_settings';
/**
* Capability list.
*
* @return array
*/
public static function caps() {
return array(
'manage_kleingarten',
'edit_sparten',
'edit_parzellen',
'edit_zaehler',
'edit_paechter',
'view_assigned_parcels',
'submit_meter_readings',
);
}
/**
* Resolve all role slugs that should count as "Vorstand".
*
* @return array
*/
private static function get_vorstand_roles() {
$roles = array( 'vorstand', 'kgv_vorstand' );
$wp_roles = wp_roles();
if ( $wp_roles instanceof \WP_Roles ) {
foreach ( $wp_roles->roles as $role_slug => $role_data ) {
$role_name = isset( $role_data['name'] ) ? sanitize_key( str_replace( ' ', '_', strtolower( (string) $role_data['name'] ) ) ) : '';
if ( 'vorstand' === $role_name ) {
$roles[] = $role_slug;
}
}
}
return array_values( array_unique( array_filter( $roles ) ) );
}
/**
* Create custom role and attach caps to admin/editor.
*
* @return void
*/
public static function add_roles_and_caps() {
if ( ! get_role( self::MEMBER_ROLE ) ) {
add_role(
self::MEMBER_ROLE,
__( 'Mitglied', KGVVM_TEXT_DOMAIN ),
array(
'read' => true,
)
);
}
$member_role = get_role( self::MEMBER_ROLE );
if ( $member_role ) {
$member_role->add_cap( 'read' );
$member_role->add_cap( 'view_assigned_parcels' );
$member_role->add_cap( 'submit_meter_readings' );
}
$management_roles = array_values( array_unique( array_merge( array( 'administrator', 'editor' ), self::get_vorstand_roles() ) ) );
$settings_roles = array_values( array_unique( array_merge( array( 'administrator' ), self::get_vorstand_roles() ) ) );
foreach ( $management_roles as $role_name ) {
$role = get_role( $role_name );
if ( ! $role ) {
continue;
}
foreach ( self::caps() as $cap ) {
$role->add_cap( $cap );
}
}
foreach ( $settings_roles as $role_name ) {
$role = get_role( $role_name );
if ( $role ) {
$role->add_cap( self::SETTINGS_CAP );
}
}
}
/**
* Remove custom role and capabilities.
*
* @return void
*/
public static function remove_roles_and_caps() {
remove_role( self::MEMBER_ROLE );
$management_roles = array_values( array_unique( array_merge( array( 'administrator', 'editor' ), self::get_vorstand_roles() ) ) );
$settings_roles = array_values( array_unique( array_merge( array( 'administrator' ), self::get_vorstand_roles() ) ) );
foreach ( $management_roles as $role_name ) {
$role = get_role( $role_name );
if ( ! $role ) {
continue;
}
foreach ( self::caps() as $cap ) {
$role->remove_cap( $cap );
}
}
foreach ( $settings_roles as $role_name ) {
$role = get_role( $role_name );
if ( $role ) {
$role->remove_cap( self::SETTINGS_CAP );
}
}
}
}

270
includes/Schema.php Normal file
View File

@@ -0,0 +1,270 @@
<?php
/**
* Database schema management.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Schema {
/**
* Get the full table name by key.
*
* @param string $key Table key.
* @return string
*/
public static function table( $key ) {
global $wpdb;
$map = array(
'sections' => $wpdb->prefix . 'kgvvm_sections',
'parcels' => $wpdb->prefix . 'kgvvm_parcels',
'meters' => $wpdb->prefix . 'kgvvm_meters',
'tenants' => $wpdb->prefix . 'kgvvm_tenants',
'parcel_members' => $wpdb->prefix . 'kgvvm_parcel_members',
'parcel_tenants' => $wpdb->prefix . 'kgvvm_parcel_tenants',
'chat_messages' => $wpdb->prefix . 'kgvvm_chat_messages',
'meter_readings' => $wpdb->prefix . 'kgvvm_meter_readings',
'cost_years' => $wpdb->prefix . 'kgvvm_cost_years',
'cost_rates' => $wpdb->prefix . 'kgvvm_cost_rates',
'cost_entries' => $wpdb->prefix . 'kgvvm_cost_entries',
);
return isset( $map[ $key ] ) ? $map[ $key ] : '';
}
/**
* Create plugin tables.
*
* @return void
*/
public static function create_tables() {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset_collate = $wpdb->get_charset_collate();
$sql = array();
$sql[] = "CREATE TABLE " . self::table( 'sections' ) . " (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(190) NOT NULL,
description TEXT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY name (name),
KEY status (status)
) {$charset_collate};";
$sql[] = "CREATE TABLE " . self::table( 'parcels' ) . " (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
label VARCHAR(100) NOT NULL,
section_id BIGINT UNSIGNED NOT NULL,
area DECIMAL(10,2) NULL,
annual_rent DECIMAL(12,2) NULL,
status VARCHAR(20) NOT NULL DEFAULT 'free',
note TEXT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY section_label (section_id, label),
KEY status (status),
KEY section_id (section_id)
) {$charset_collate};";
$sql[] = "CREATE TABLE " . self::table( 'meters' ) . " (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
type VARCHAR(20) NOT NULL,
meter_number VARCHAR(100) NOT NULL,
section_id BIGINT UNSIGNED NOT NULL,
parcel_id BIGINT UNSIGNED NULL,
installed_at DATE NULL,
calibration_year SMALLINT UNSIGNED NULL,
is_main_meter TINYINT(1) NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
note TEXT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY type_meter_number (type, meter_number),
KEY section_id (section_id),
KEY parcel_id (parcel_id),
KEY type (type),
KEY is_main_meter (is_main_meter),
KEY is_active (is_active)
) {$charset_collate};";
$sql[] = "CREATE TABLE " . self::table( 'tenants' ) . " (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
address TEXT NULL,
phone VARCHAR(100) NULL,
email VARCHAR(190) NULL,
contract_start DATE NOT NULL,
contract_end DATE NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
note TEXT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY name_lookup (last_name, first_name),
KEY is_active (is_active)
) {$charset_collate};";
$sql[] = "CREATE TABLE " . self::table( 'parcel_members' ) . " (
parcel_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (parcel_id, user_id),
KEY user_id (user_id)
) {$charset_collate};";
$sql[] = "CREATE TABLE " . self::table( 'parcel_tenants' ) . " (
parcel_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (parcel_id, tenant_id),
KEY tenant_id (tenant_id)
) {$charset_collate};";
$sql[] = "CREATE TABLE " . self::table( 'chat_messages' ) . " (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
room_key VARCHAR(50) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
message TEXT NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY room_key (room_key),
KEY user_id (user_id),
KEY created_at (created_at)
) {$charset_collate};";
$sql[] = "CREATE TABLE " . self::table( 'meter_readings' ) . " (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
meter_id BIGINT UNSIGNED NOT NULL,
parcel_id BIGINT UNSIGNED NOT NULL,
reading_value DECIMAL(12,3) NOT NULL,
reading_date DATE NOT NULL,
note TEXT NULL,
submitted_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
is_self_reading TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY meter_id (meter_id),
KEY parcel_id (parcel_id),
KEY reading_date (reading_date)
) {$charset_collate};";
$sql[] = "CREATE TABLE " . self::table( 'cost_years' ) . " (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
entry_year SMALLINT UNSIGNED NOT NULL,
power_price_per_kwh DECIMAL(12,4) NULL,
water_price_per_m3 DECIMAL(12,4) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY entry_year (entry_year)
) {$charset_collate};";
$sql[] = "CREATE TABLE " . self::table( 'cost_rates' ) . " (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
entry_year SMALLINT UNSIGNED NOT NULL,
section_id BIGINT UNSIGNED NOT NULL,
power_price_per_kwh DECIMAL(12,4) NULL,
water_price_per_m3 DECIMAL(12,4) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY year_section (entry_year, section_id),
KEY section_id (section_id)
) {$charset_collate};";
$sql[] = "CREATE TABLE " . self::table( 'cost_entries' ) . " (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
entry_year SMALLINT UNSIGNED NOT NULL,
name VARCHAR(190) NOT NULL,
distribution_type VARCHAR(20) NOT NULL DEFAULT 'parcel',
unit_amount DECIMAL(12,2) NULL,
total_cost DECIMAL(12,2) NOT NULL DEFAULT 0.00,
note TEXT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY entry_year (entry_year),
KEY name (name),
KEY distribution_type (distribution_type)
) {$charset_collate};";
foreach ( $sql as $statement ) {
dbDelta( $statement );
}
}
/**
* Optimize plugin tables.
*
* @return void
*/
public static function optimize_tables() {
global $wpdb;
$tables = array(
self::table( 'sections' ),
self::table( 'parcels' ),
self::table( 'meters' ),
self::table( 'tenants' ),
self::table( 'parcel_members' ),
self::table( 'parcel_tenants' ),
self::table( 'chat_messages' ),
self::table( 'meter_readings' ),
self::table( 'cost_years' ),
self::table( 'cost_rates' ),
self::table( 'cost_entries' ),
);
foreach ( $tables as $table ) {
if ( ! empty( $table ) ) {
$wpdb->query( 'OPTIMIZE TABLE `' . esc_sql( $table ) . '`' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
}
}
}
/**
* Drop plugin tables during uninstall.
*
* @return void
*/
public static function drop_tables() {
global $wpdb;
$tables = array(
self::table( 'cost_entries' ),
self::table( 'cost_rates' ),
self::table( 'cost_years' ),
self::table( 'chat_messages' ),
self::table( 'meter_readings' ),
self::table( 'parcel_members' ),
self::table( 'parcel_tenants' ),
self::table( 'meters' ),
self::table( 'parcels' ),
self::table( 'tenants' ),
self::table( 'sections' ),
);
foreach ( $tables as $table ) {
if ( $table ) {
$wpdb->query( "DROP TABLE IF EXISTS {$table}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
}
}
}
}

View File

@@ -0,0 +1,147 @@
<?php
/**
* Business logic for parcel + relations.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager\Services;
use KGV\VereinManager\Validator;
use KGV\VereinManager\Repositories\AssignmentRepository;
use KGV\VereinManager\Repositories\MeterRepository;
use KGV\VereinManager\Repositories\ParcelRepository;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class ParcelService {
/**
* Validator instance.
*
* @var Validator
*/
private $validator;
/**
* Parcel repository.
*
* @var ParcelRepository
*/
private $parcels;
/**
* Meter repository.
*
* @var MeterRepository
*/
private $meters;
/**
* Assignment repository.
*
* @var AssignmentRepository
*/
private $assignments;
/**
* Build service.
*/
public function __construct() {
$this->validator = new Validator();
$this->parcels = new ParcelRepository();
$this->meters = new MeterRepository();
$this->assignments = new AssignmentRepository();
}
/**
* Save parcel plus relations.
*
* @param int $id Optional parcel ID.
* @param array $raw_data Raw submitted data.
* @return int|\WP_Error
*/
public function save( $id, $raw_data ) {
$data = $this->validator->sanitize_parcel( $raw_data );
$errors = $this->validator->validate_parcel( $data );
$water_meter_id = absint( isset( $raw_data['water_meter_id'] ) ? $raw_data['water_meter_id'] : 0 );
$power_meter_id = absint( isset( $raw_data['power_meter_id'] ) ? $raw_data['power_meter_id'] : 0 );
$member_ids = array_values( array_unique( array_filter( array_map( 'absint', isset( $raw_data['member_ids'] ) ? (array) wp_unslash( $raw_data['member_ids'] ) : array() ) ) ) );
$tenant_ids = array_values( array_unique( array_filter( array_map( 'absint', isset( $raw_data['tenant_ids'] ) ? (array) wp_unslash( $raw_data['tenant_ids'] ) : array() ) ) ) );
if ( $this->parcels->label_exists( $data['label'], $data['section_id'], $id ) ) {
$errors->add( 'duplicate_label', __( 'Diese Parzellenbezeichnung existiert in der gewählten Sparte bereits.', KGVVM_TEXT_DOMAIN ) );
}
if ( $water_meter_id < 1 ) {
$errors->add( 'water_meter_required', __( 'Bitte einen freien Wasserzähler auswählen.', KGVVM_TEXT_DOMAIN ) );
}
if ( $power_meter_id < 1 ) {
$errors->add( 'power_meter_required', __( 'Bitte einen freien Stromzähler auswählen.', KGVVM_TEXT_DOMAIN ) );
}
$water_meter = $water_meter_id ? $this->meters->get_assignable_meter( $water_meter_id, 'water', $data['section_id'], $id ) : null;
$power_meter = $power_meter_id ? $this->meters->get_assignable_meter( $power_meter_id, 'power', $data['section_id'], $id ) : null;
if ( $water_meter_id && ! $water_meter ) {
$errors->add( 'invalid_water_meter', __( 'Der ausgewählte Wasserzähler ist nicht verfügbar oder gehört zu einer anderen Sparte.', KGVVM_TEXT_DOMAIN ) );
}
if ( $power_meter_id && ! $power_meter ) {
$errors->add( 'invalid_power_meter', __( 'Der ausgewählte Stromzähler ist nicht verfügbar oder gehört zu einer anderen Sparte.', KGVVM_TEXT_DOMAIN ) );
}
if ( ! $this->allow_multiple_member_parcels() ) {
foreach ( $member_ids as $user_id ) {
if ( $this->assignments->member_has_other_parcels( $user_id, $id ) ) {
$errors->add( 'member_conflict', __( 'Mindestens ein Mitglied ist bereits einer anderen Parzelle zugeordnet. Die Mehrfachzuordnung ist derzeit deaktiviert.', KGVVM_TEXT_DOMAIN ) );
break;
}
}
}
if ( $errors->has_errors() ) {
return $errors;
}
$parcel_id = $this->parcels->save( $data, $id );
if ( ! $parcel_id ) {
return new \WP_Error( 'save_failed', __( 'Die Parzelle konnte nicht gespeichert werden.', KGVVM_TEXT_DOMAIN ) );
}
$this->meters->release_parcel( $parcel_id );
$this->meters->assign_to_parcel( $water_meter_id, $parcel_id );
$this->meters->assign_to_parcel( $power_meter_id, $parcel_id );
$this->assignments->sync_member_ids( $parcel_id, $member_ids );
$this->assignments->sync_tenant_ids( $parcel_id, $tenant_ids );
return $parcel_id;
}
/**
* Delete a parcel and clean up relations.
*
* @param int $id Parcel ID.
* @return bool
*/
public function delete( $id ) {
$this->meters->release_parcel( $id );
$this->assignments->purge_parcel( $id );
return $this->parcels->delete( $id );
}
/**
* Determine whether a member may own multiple parcels.
*
* @return bool
*/
private function allow_multiple_member_parcels() {
$settings = get_option( 'kgvvm_settings', array() );
return ! isset( $settings['allow_multiple_member_parcels'] ) || 1 === (int) $settings['allow_multiple_member_parcels'];
}
}

389
includes/Validator.php Normal file
View File

@@ -0,0 +1,389 @@
<?php
/**
* Sanitizing and validation helpers.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Validator {
/**
* Sanitize section input.
*
* @param array $data Raw request data.
* @return array
*/
public function sanitize_section( $data ) {
return array(
'name' => sanitize_text_field( wp_unslash( isset( $data['name'] ) ? $data['name'] : '' ) ),
'description' => sanitize_textarea_field( wp_unslash( isset( $data['description'] ) ? $data['description'] : '' ) ),
'status' => sanitize_key( wp_unslash( isset( $data['status'] ) ? $data['status'] : 'active' ) ),
);
}
/**
* Validate section.
*
* @param array $data Sanitized data.
* @return \WP_Error
*/
public function validate_section( $data ) {
$errors = new \WP_Error();
if ( '' === $data['name'] ) {
$errors->add( 'name_required', __( 'Bitte einen Namen für die Sparte eingeben.', KGVVM_TEXT_DOMAIN ) );
}
if ( ! in_array( $data['status'], array( 'active', 'inactive' ), true ) ) {
$errors->add( 'invalid_status', __( 'Der Status der Sparte ist ungültig.', KGVVM_TEXT_DOMAIN ) );
}
return $errors;
}
/**
* Sanitize parcel input.
*
* @param array $data Raw request data.
* @return array
*/
public function sanitize_parcel( $data ) {
$area = isset( $data['area'] ) ? str_replace( ',', '.', wp_unslash( $data['area'] ) ) : '';
$annual_rent = isset( $data['annual_rent'] ) ? str_replace( ',', '.', wp_unslash( $data['annual_rent'] ) ) : '';
$area = '' === trim( (string) $area ) ? null : (float) $area;
$annual_rent = '' === trim( (string) $annual_rent ) ? null : (float) $annual_rent;
return array(
'label' => sanitize_text_field( wp_unslash( isset( $data['label'] ) ? $data['label'] : '' ) ),
'section_id' => absint( isset( $data['section_id'] ) ? $data['section_id'] : 0 ),
'area' => $area,
'annual_rent' => $annual_rent,
'status' => sanitize_key( wp_unslash( isset( $data['status'] ) ? $data['status'] : 'free' ) ),
'note' => sanitize_textarea_field( wp_unslash( isset( $data['note'] ) ? $data['note'] : '' ) ),
);
}
/**
* Validate parcel.
*
* @param array $data Sanitized data.
* @return \WP_Error
*/
public function validate_parcel( $data ) {
$errors = new \WP_Error();
if ( '' === $data['label'] ) {
$errors->add( 'label_required', __( 'Bitte eine Parzellennummer oder Bezeichnung eingeben.', KGVVM_TEXT_DOMAIN ) );
}
if ( $data['section_id'] < 1 ) {
$errors->add( 'section_required', __( 'Bitte eine Sparte auswählen.', KGVVM_TEXT_DOMAIN ) );
}
if ( ! in_array( $data['status'], array( 'free', 'assigned', 'reserved', 'inactive' ), true ) ) {
$errors->add( 'invalid_status', __( 'Der Parzellenstatus ist ungültig.', KGVVM_TEXT_DOMAIN ) );
}
if ( null !== $data['area'] && $data['area'] < 0 ) {
$errors->add( 'invalid_area', __( 'Die Fläche darf nicht negativ sein.', KGVVM_TEXT_DOMAIN ) );
}
if ( null !== $data['annual_rent'] && $data['annual_rent'] < 0 ) {
$errors->add( 'invalid_annual_rent', __( 'Die Pacht darf nicht negativ sein.', KGVVM_TEXT_DOMAIN ) );
}
return $errors;
}
/**
* Sanitize meter input.
*
* @param array $data Raw request data.
* @return array
*/
public function sanitize_meter( $data ) {
$calibration_year = isset( $data['calibration_year'] ) ? absint( $data['calibration_year'] ) : 0;
return array(
'type' => sanitize_key( wp_unslash( isset( $data['type'] ) ? $data['type'] : '' ) ),
'meter_number' => sanitize_text_field( wp_unslash( isset( $data['meter_number'] ) ? $data['meter_number'] : '' ) ),
'section_id' => absint( isset( $data['section_id'] ) ? $data['section_id'] : 0 ),
'installed_at' => $this->normalize_date( isset( $data['installed_at'] ) ? $data['installed_at'] : '' ),
'calibration_year' => $calibration_year > 0 ? $calibration_year : null,
'is_active' => ! empty( $data['is_active'] ) ? 1 : 0,
'note' => sanitize_textarea_field( wp_unslash( isset( $data['note'] ) ? $data['note'] : '' ) ),
);
}
/**
* Validate meter data.
*
* @param array $data Sanitized data.
* @return \WP_Error
*/
public function validate_meter( $data ) {
$errors = new \WP_Error();
if ( ! in_array( $data['type'], array( 'water', 'power' ), true ) ) {
$errors->add( 'invalid_type', __( 'Bitte einen gültigen Zählertyp auswählen.', KGVVM_TEXT_DOMAIN ) );
}
if ( '' === $data['meter_number'] ) {
$errors->add( 'meter_number_required', __( 'Bitte eine Zählernummer eingeben.', KGVVM_TEXT_DOMAIN ) );
}
if ( $data['section_id'] < 1 ) {
$errors->add( 'section_required', __( 'Bitte eine Sparte für den Zähler auswählen.', KGVVM_TEXT_DOMAIN ) );
}
if ( null !== $data['calibration_year'] ) {
$current_year = (int) gmdate( 'Y' ) + 20;
if ( $data['calibration_year'] < 2000 || $data['calibration_year'] > $current_year ) {
$errors->add( 'invalid_calibration_year', __( 'Bitte ein gültiges Eichjahr angeben.', KGVVM_TEXT_DOMAIN ) );
}
}
return $errors;
}
/**
* Sanitize tenant input.
*
* @param array $data Raw request data.
* @return array
*/
public function sanitize_tenant( $data ) {
return array(
'first_name' => sanitize_text_field( wp_unslash( isset( $data['first_name'] ) ? $data['first_name'] : '' ) ),
'last_name' => sanitize_text_field( wp_unslash( isset( $data['last_name'] ) ? $data['last_name'] : '' ) ),
'address' => sanitize_textarea_field( wp_unslash( isset( $data['address'] ) ? $data['address'] : '' ) ),
'phone' => sanitize_text_field( wp_unslash( isset( $data['phone'] ) ? $data['phone'] : '' ) ),
'email' => sanitize_email( wp_unslash( isset( $data['email'] ) ? $data['email'] : '' ) ),
'contract_start' => $this->normalize_date( isset( $data['contract_start'] ) ? $data['contract_start'] : '' ),
'contract_end' => $this->normalize_date( isset( $data['contract_end'] ) ? $data['contract_end'] : '' ),
'is_active' => ! empty( $data['is_active'] ) ? 1 : 0,
'note' => sanitize_textarea_field( wp_unslash( isset( $data['note'] ) ? $data['note'] : '' ) ),
);
}
/**
* Validate tenant.
*
* @param array $data Sanitized data.
* @return \WP_Error
*/
public function validate_tenant( $data ) {
$errors = new \WP_Error();
if ( '' === $data['first_name'] || '' === $data['last_name'] ) {
$errors->add( 'name_required', __( 'Bitte Vor- und Nachnamen des Pächters angeben.', KGVVM_TEXT_DOMAIN ) );
}
if ( '' === $data['contract_start'] ) {
$errors->add( 'contract_start_required', __( 'Bitte einen Vertragsbeginn angeben.', KGVVM_TEXT_DOMAIN ) );
}
if ( $data['email'] && ! is_email( $data['email'] ) ) {
$errors->add( 'invalid_email', __( 'Die angegebene E-Mail-Adresse ist ungültig.', KGVVM_TEXT_DOMAIN ) );
}
if ( $data['contract_start'] && $data['contract_end'] && strtotime( $data['contract_end'] ) < strtotime( $data['contract_start'] ) ) {
$errors->add( 'invalid_contract_range', __( 'Das Vertragsende darf nicht vor dem Vertragsbeginn liegen.', KGVVM_TEXT_DOMAIN ) );
}
return $errors;
}
/**
* Sanitize meter reading input.
*
* @param array $data Raw request data.
* @return array
*/
public function sanitize_meter_reading( $data ) {
$reading_value = isset( $data['reading_value'] ) ? str_replace( ',', '.', wp_unslash( $data['reading_value'] ) ) : '';
return array(
'parcel_id' => absint( isset( $data['parcel_id'] ) ? $data['parcel_id'] : 0 ),
'meter_id' => absint( isset( $data['meter_id'] ) ? $data['meter_id'] : 0 ),
'reading_value' => '' === trim( (string) $reading_value ) ? '' : (float) $reading_value,
'reading_date' => $this->normalize_date( isset( $data['reading_date'] ) ? $data['reading_date'] : '' ),
'note' => sanitize_textarea_field( wp_unslash( isset( $data['note'] ) ? $data['note'] : '' ) ),
);
}
/**
* Validate one meter reading.
*
* @param array $data Sanitized data.
* @return \WP_Error
*/
public function validate_meter_reading( $data ) {
$errors = new \WP_Error();
if ( $data['parcel_id'] < 0 ) {
$errors->add( 'parcel_required', __( 'Die Parzelle für die Ablesung ist ungültig.', KGVVM_TEXT_DOMAIN ) );
}
if ( $data['meter_id'] < 1 ) {
$errors->add( 'meter_required', __( 'Der ausgewählte Zähler ist ungültig.', KGVVM_TEXT_DOMAIN ) );
}
if ( '' === $data['reading_date'] ) {
$errors->add( 'reading_date_required', __( 'Bitte ein Ablesedatum angeben.', KGVVM_TEXT_DOMAIN ) );
}
if ( '' === $data['reading_value'] || ! is_numeric( $data['reading_value'] ) ) {
$errors->add( 'reading_value_required', __( 'Bitte einen gültigen Zählerstand eingeben.', KGVVM_TEXT_DOMAIN ) );
} elseif ( (float) $data['reading_value'] < 0 ) {
$errors->add( 'reading_value_negative', __( 'Der Zählerstand darf nicht negativ sein.', KGVVM_TEXT_DOMAIN ) );
}
return $errors;
}
/**
* Sanitize one cost entry.
*
* @param array $data Raw request data.
* @return array
*/
public function sanitize_cost_entry( $data ) {
$unit_amount = isset( $data['unit_amount'] ) ? str_replace( ',', '.', wp_unslash( $data['unit_amount'] ) ) : '';
$entry_year = $this->sanitize_cost_year( $data );
$distribution_type = sanitize_key( wp_unslash( isset( $data['distribution_type'] ) ? $data['distribution_type'] : 'parcel' ) );
return array(
'entry_year' => $entry_year,
'name' => sanitize_text_field( wp_unslash( isset( $data['name'] ) ? $data['name'] : '' ) ),
'distribution_type' => $distribution_type,
'unit_amount' => '' === trim( (string) $unit_amount ) ? '' : (float) $unit_amount,
'total_cost' => 0.0,
'note' => sanitize_textarea_field( wp_unslash( isset( $data['note'] ) ? $data['note'] : '' ) ),
);
}
/**
* Validate one cost entry.
*
* @param array $data Sanitized data.
* @return \WP_Error
*/
public function validate_cost_entry( $data ) {
$errors = $this->validate_cost_year( $data['entry_year'] );
if ( '' === $data['name'] ) {
$errors->add( 'cost_name_required', __( 'Bitte einen Namen für den Kostenposten eingeben.', KGVVM_TEXT_DOMAIN ) );
}
if ( ! in_array( $data['distribution_type'], array( 'parcel', 'member' ), true ) ) {
$errors->add( 'cost_distribution_required', __( 'Bitte auswählen, ob der Betrag pro Parzelle oder pro Mitglied gilt.', KGVVM_TEXT_DOMAIN ) );
}
if ( '' === $data['unit_amount'] || ! is_numeric( $data['unit_amount'] ) ) {
$errors->add( 'cost_unit_amount_required', __( 'Bitte einen gültigen Betrag eingeben.', KGVVM_TEXT_DOMAIN ) );
} elseif ( (float) $data['unit_amount'] < 0 ) {
$errors->add( 'cost_unit_amount_negative', __( 'Der Betrag darf nicht negativ sein.', KGVVM_TEXT_DOMAIN ) );
}
return $errors;
}
/**
* Sanitize the selected cost year.
*
* @param array $data Raw request data.
* @return int
*/
public function sanitize_cost_year( $data ) {
return isset( $data['entry_year'] ) ? absint( $data['entry_year'] ) : 0;
}
/**
* Sanitize yearly water and electricity prices.
*
* @param array $data Raw request data.
* @return array
*/
public function sanitize_cost_year_settings( $data ) {
$power_price = isset( $data['power_price_per_kwh'] ) ? str_replace( ',', '.', wp_unslash( $data['power_price_per_kwh'] ) ) : '';
$water_price = isset( $data['water_price_per_m3'] ) ? str_replace( ',', '.', wp_unslash( $data['water_price_per_m3'] ) ) : '';
return array(
'entry_year' => $this->sanitize_cost_year( $data ),
'section_id' => absint( isset( $data['section_id'] ) ? $data['section_id'] : 0 ),
'power_price_per_kwh' => '' === trim( (string) $power_price ) ? null : (float) $power_price,
'water_price_per_m3' => '' === trim( (string) $water_price ) ? null : (float) $water_price,
);
}
/**
* Validate the selected cost year.
*
* @param int $entry_year Year value.
* @return \WP_Error
*/
public function validate_cost_year( $entry_year ) {
$errors = new \WP_Error();
$current_year = (int) gmdate( 'Y' ) + 10;
if ( $entry_year < 2000 || $entry_year > $current_year ) {
$errors->add( 'invalid_entry_year', __( 'Bitte ein gültiges Jahr für den Kostenposten auswählen.', KGVVM_TEXT_DOMAIN ) );
}
return $errors;
}
/**
* Validate yearly price settings.
*
* @param array $data Sanitized year settings.
* @return \WP_Error
*/
public function validate_cost_year_settings( $data ) {
$errors = $this->validate_cost_year( $data['entry_year'] );
if ( $data['section_id'] < 1 ) {
$errors->add( 'invalid_section', __( 'Bitte eine Sparte auswählen.', KGVVM_TEXT_DOMAIN ) );
}
if ( null !== $data['power_price_per_kwh'] && ( ! is_numeric( $data['power_price_per_kwh'] ) || (float) $data['power_price_per_kwh'] < 0 ) ) {
$errors->add( 'invalid_power_price', __( 'Bitte einen gültigen Preis pro kWh eingeben.', KGVVM_TEXT_DOMAIN ) );
}
if ( null !== $data['water_price_per_m3'] && ( ! is_numeric( $data['water_price_per_m3'] ) || (float) $data['water_price_per_m3'] < 0 ) ) {
$errors->add( 'invalid_water_price', __( 'Bitte einen gültigen Preis pro m³ eingeben.', KGVVM_TEXT_DOMAIN ) );
}
return $errors;
}
/**
* Normalize optional date fields to Y-m-d.
*
* @param string $value Raw value.
* @return string|null
*/
private function normalize_date( $value ) {
$value = trim( (string) wp_unslash( $value ) );
if ( '' === $value ) {
return '';
}
$timestamp = strtotime( $value );
if ( false === $timestamp ) {
return '';
}
return date( 'Y-m-d', $timestamp );
}
}