Files
KGV-Verein-Manager/includes/Repositories/WorkRepository.php
Ronny Grobel 7d3d543954 Release 1.16.0
Arbeitsstunden-Modul hinzugefuegt

- Pflichtstunden pro Jahr inkl. Preis je fehlender Stunde

- Arbeitsarten, Arbeitseintraege und Mehrfachzuordnung von Mitgliedern

- Mitgliederuebersicht mit Berechnung fehlender Stunden und Aufschlag

- Datenbankschema fuer Arbeitsstunden erweitert

- Stable Tag und Changelog in README/readme.txt aktualisiert
2026-04-16 21:38:59 +02:00

471 lines
14 KiB
PHP

<?php
/**
* Work hours repository.
*
* @package KGV\VereinManager
*/
namespace KGV\VereinManager\Repositories;
use KGV\VereinManager\Schema;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class WorkRepository extends AbstractRepository {
/**
* Resolve main table (work_logs).
*
* @return string
*/
protected function resolve_table() {
return Schema::table( 'work_logs' );
}
/**
* Jobs table.
*
* @return string
*/
private function jobs_table() {
return Schema::table( 'work_jobs' );
}
/**
* Year config table.
*
* @return string
*/
private function year_config_table() {
return Schema::table( 'work_year_config' );
}
/**
* Log members table.
*
* @return string
*/
private function members_table() {
return Schema::table( 'work_log_members' );
}
// -------------------------------------------------------------------------
// Jobs
// -------------------------------------------------------------------------
/**
* Get all jobs.
*
* @return array
*/
public function get_jobs() {
$table = $this->jobs_table();
return $this->wpdb->get_results( "SELECT * FROM {$table} ORDER BY name ASC" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery
}
/**
* Get a single job by ID.
*
* @param int $id Job ID.
* @return object|null
*/
public function find_job( $id ) {
$table = $this->jobs_table();
return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", absint( $id ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Save or update a job.
*
* @param array $data Job data.
* @param int $id Optional ID.
* @return int|false
*/
public function save_job( $data, $id = 0 ) {
$table = $this->jobs_table();
$payload = array(
'name' => sanitize_text_field( $data['name'] ),
'description' => isset( $data['description'] ) ? sanitize_textarea_field( $data['description'] ) : '',
'updated_at' => $this->now(),
);
$formats = array( '%s', '%s', '%s' );
if ( $id > 0 ) {
$this->wpdb->update( $table, $payload, array( 'id' => $id ), $formats, array( '%d' ) );
return $id;
}
$payload['created_at'] = $this->now();
$this->wpdb->insert( $table, $payload, array( '%s', '%s', '%s', '%s' ) );
return $this->wpdb->insert_id;
}
/**
* Delete a job and related log members/logs.
*
* @param int $id Job ID.
* @return bool
*/
public function delete_job( $id ) {
$id = absint( $id );
$logs_table = $this->table;
$mem_table = $this->members_table();
$jobs_table = $this->jobs_table();
// Delete members of all logs for this job.
$log_ids = $this->wpdb->get_col( $this->wpdb->prepare( "SELECT id FROM {$logs_table} WHERE job_id = %d", $id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
foreach ( array_map( 'absint', (array) $log_ids ) as $log_id ) {
$this->wpdb->delete( $mem_table, array( 'log_id' => $log_id ), array( '%d' ) );
}
// Delete logs.
$this->wpdb->delete( $logs_table, array( 'job_id' => $id ), array( '%d' ) );
// Delete job.
$result = $this->wpdb->delete( $jobs_table, array( 'id' => $id ), array( '%d' ) );
return false !== $result;
}
/**
* Check if a job name already exists (for uniqueness).
*
* @param string $name Job name.
* @param int $exclude_id Exclude this ID.
* @return bool
*/
public function job_name_exists( $name, $exclude_id = 0 ) {
$table = $this->jobs_table();
$sql = $this->wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE name = %s", sanitize_text_field( $name ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
if ( $exclude_id > 0 ) {
$sql .= $this->wpdb->prepare( ' AND id != %d', absint( $exclude_id ) );
}
return (int) $this->wpdb->get_var( $sql ) > 0; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
// -------------------------------------------------------------------------
// Year config
// -------------------------------------------------------------------------
/**
* Get config for a specific year (creates if missing).
*
* @param int $year Year.
* @return object|null
*/
public function get_year_config( $year ) {
$year = absint( $year );
$table = $this->year_config_table();
$row = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$table} WHERE entry_year = %d", $year ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return $row;
}
/**
* Get all available configured years.
*
* @param int $selected_year Optional selected year to always include.
* @return array
*/
public function get_years( $selected_year = 0 ) {
$config_table = $this->year_config_table();
$log_table = $this->table;
$current_year = (int) current_time( 'Y' );
$config_years = $this->wpdb->get_col( "SELECT entry_year FROM {$config_table} ORDER BY entry_year DESC" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery
$log_years = $this->wpdb->get_col( "SELECT DISTINCT YEAR(work_date) FROM {$log_table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery
$years = array_map( 'absint', array_merge( (array) $config_years, (array) $log_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;
}
/**
* Save year configuration (upsert).
*
* @param int $year Year.
* @param float $required_hours Required hours per member.
* @param float $price_per_missing_hour Price per missing hour in €.
* @return bool
*/
public function save_year_config( $year, $required_hours, $price_per_missing_hour ) {
$year = absint( $year );
$required_hours = max( 0, (float) $required_hours );
$price_per_missing_hour = max( 0, (float) $price_per_missing_hour );
$table = $this->year_config_table();
$existing = $this->get_year_config( $year );
if ( $existing ) {
$result = $this->wpdb->update(
$table,
array(
'required_hours' => $required_hours,
'price_per_missing_hour' => $price_per_missing_hour,
'updated_at' => $this->now(),
),
array( 'entry_year' => $year ),
array( '%f', '%f', '%s' ),
array( '%d' )
);
return false !== $result;
}
$result = $this->wpdb->insert(
$table,
array(
'entry_year' => $year,
'required_hours' => $required_hours,
'price_per_missing_hour' => $price_per_missing_hour,
'created_at' => $this->now(),
'updated_at' => $this->now(),
),
array( '%d', '%f', '%f', '%s', '%s' )
);
return false !== $result;
}
// -------------------------------------------------------------------------
// Work logs
// -------------------------------------------------------------------------
/**
* Search work logs.
*
* @param array $args Query arguments (year, user_id, job_id, s, orderby, order).
* @return array
*/
public function search_logs( $args = array() ) {
$year = isset( $args['year'] ) ? absint( $args['year'] ) : 0;
$user_id = isset( $args['user_id'] ) ? absint( $args['user_id'] ) : 0;
$job_id = isset( $args['job_id'] ) ? absint( $args['job_id'] ) : 0;
$search = isset( $args['s'] ) ? sanitize_text_field( wp_unslash( $args['s'] ) ) : '';
$orderby = $this->sanitize_orderby(
isset( $args['orderby'] ) ? sanitize_key( wp_unslash( $args['orderby'] ) ) : 'work_date',
array( 'work_date', 'job_name', 'updated_at', 'created_at' ),
'work_date'
);
$order = $this->sanitize_order( isset( $args['order'] ) ? sanitize_key( wp_unslash( $args['order'] ) ) : 'DESC' );
$logs_table = $this->table;
$jobs_table = $this->jobs_table();
$mem_table = $this->members_table();
$sql = "SELECT l.*, COALESCE(j.name, '') AS job_name FROM {$logs_table} l
LEFT JOIN {$jobs_table} j ON j.id = l.job_id WHERE 1=1";
$params = array();
if ( $year > 0 ) {
$sql .= ' AND YEAR(l.work_date) = %d';
$params[] = $year;
}
if ( $job_id > 0 ) {
$sql .= ' AND l.job_id = %d';
$params[] = $job_id;
}
if ( $user_id > 0 ) {
$sql .= " AND l.id IN (SELECT log_id FROM {$mem_table} WHERE user_id = %d)";
$params[] = $user_id;
}
if ( '' !== $search ) {
$like = '%' . $this->wpdb->esc_like( $search ) . '%';
$sql .= ' AND (j.name LIKE %s OR l.note LIKE %s)';
$params[] = $like;
$params[] = $like;
}
$sql .= " ORDER BY l.{$orderby} {$order}, l.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
}
/**
* Get a work log with its member assignments.
*
* @param int $id Log ID.
* @return object|null
*/
public function find_log( $id ) {
$row = $this->find( $id );
if ( $row ) {
$row->members = $this->get_log_members( $id );
}
return $row;
}
/**
* Save or update a work log including member assignments.
*
* @param array $data Log data.
* @param int $id Optional log ID.
* @param array $members Array of [user_id => hours].
* @return int|false
*/
public function save_log( $data, $id = 0, $members = array() ) {
$payload = array(
'job_id' => absint( $data['job_id'] ),
'work_date' => sanitize_text_field( $data['work_date'] ),
'note' => isset( $data['note'] ) ? sanitize_textarea_field( $data['note'] ) : '',
'updated_at' => $this->now(),
);
$formats = array( '%d', '%s', '%s', '%s' );
if ( $id > 0 ) {
$this->wpdb->update( $this->table, $payload, array( 'id' => $id ), $formats, array( '%d' ) );
$log_id = $id;
} else {
$payload['created_at'] = $this->now();
$this->wpdb->insert( $this->table, $payload, array( '%d', '%s', '%s', '%s', '%s' ) );
$log_id = $this->wpdb->insert_id;
}
if ( $log_id && is_array( $members ) ) {
$this->sync_log_members( $log_id, $members );
}
return $log_id;
}
/**
* Delete a work log and its member assignments.
*
* @param int $id Log ID.
* @return bool
*/
public function delete_log( $id ) {
$id = absint( $id );
$this->wpdb->delete( $this->members_table(), array( 'log_id' => $id ), array( '%d' ) );
$result = $this->wpdb->delete( $this->table, array( 'id' => $id ), array( '%d' ) );
return false !== $result;
}
// -------------------------------------------------------------------------
// Log members
// -------------------------------------------------------------------------
/**
* Get member assignments for a log.
*
* @param int $log_id Log ID.
* @return array
*/
public function get_log_members( $log_id ) {
$table = $this->members_table();
return $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$table} WHERE log_id = %d", absint( $log_id ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Sync member assignments for a log.
*
* @param int $log_id Log ID.
* @param array $members Array of [user_id => hours].
* @return void
*/
public function sync_log_members( $log_id, $members ) {
$log_id = absint( $log_id );
$table = $this->members_table();
$now = $this->now();
$this->wpdb->delete( $table, array( 'log_id' => $log_id ), array( '%d' ) );
foreach ( $members as $user_id => $hours ) {
$user_id = absint( $user_id );
$hours = max( 0, (float) $hours );
if ( $user_id < 1 ) {
continue;
}
$this->wpdb->insert(
$table,
array(
'log_id' => $log_id,
'user_id' => $user_id,
'hours' => $hours,
'created_at' => $now,
),
array( '%d', '%d', '%f', '%s' )
);
}
}
// -------------------------------------------------------------------------
// Summary / statistics
// -------------------------------------------------------------------------
/**
* Get completed hours per member for a given year.
*
* @param int $year Year.
* @return array Array keyed by user_id with total hours.
*/
public function get_hours_per_member( $year ) {
$year = absint( $year );
$log_table = $this->table;
$mem_table = $this->members_table();
$rows = $this->wpdb->get_results(
$this->wpdb->prepare(
"SELECT m.user_id, SUM(m.hours) AS total_hours
FROM {$mem_table} m
INNER JOIN {$log_table} l ON l.id = m.log_id
WHERE YEAR(l.work_date) = %d
GROUP BY m.user_id", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$year
)
);
$result = array();
foreach ( $rows as $row ) {
$result[ absint( $row->user_id ) ] = (float) $row->total_hours;
}
return $result;
}
/**
* Build per-member work summary for a year.
*
* Returns array of objects with user_id, display_name, completed_hours,
* required_hours, missing_hours, surcharge.
*
* @param int $year Year.
* @param array $all_members Array of WP_User objects.
* @return array
*/
public function get_member_summary( $year, $all_members ) {
$config = $this->get_year_config( $year );
$required_hours = $config ? (float) $config->required_hours : 0.0;
$price = $config ? (float) $config->price_per_missing_hour : 0.0;
$done_map = $this->get_hours_per_member( $year );
$summary = array();
foreach ( $all_members as $member ) {
$uid = (int) $member->ID;
$completed = isset( $done_map[ $uid ] ) ? $done_map[ $uid ] : 0.0;
$missing = max( 0.0, $required_hours - $completed );
$surcharge = round( $missing * $price, 2 );
$obj = new \stdClass();
$obj->user_id = $uid;
$obj->display_name = $member->display_name;
$obj->completed_hours = $completed;
$obj->required_hours = $required_hours;
$obj->missing_hours = $missing;
$obj->surcharge = $surcharge;
$summary[] = $obj;
}
return $summary;
}
}