Merge branch 'master' into 'spidev'
# Conflicts: # Layout/default/Device/Detail.php
This commit is contained in:
@@ -189,6 +189,12 @@ class ADBHausnummerModel {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param $filter
|
||||
* @param $limit
|
||||
* @param $returnDBRessource
|
||||
* @return ADBHausnummer[] ADBHausnummer Objects
|
||||
*/
|
||||
public static function search($filter, $limit = false, $returnDBRessource = false) {
|
||||
$items = [];
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
|
||||
@@ -1,78 +1,124 @@
|
||||
<?php
|
||||
|
||||
class ADBNetzgebiet extends mfBaseModel {
|
||||
private $gemeinden;
|
||||
private $json_options;
|
||||
|
||||
protected function init() {
|
||||
$this->db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
$this->table = "Netzgebiet";
|
||||
}
|
||||
|
||||
public function loadByExtref($extref) {
|
||||
$extref = $this->db->escape(trim($extref));
|
||||
if(!$extref) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$res = $this->db->select("Netzgebiet", "*", "extref='$extref'");
|
||||
if(!$this->db->num_rows($res)) {
|
||||
return false;
|
||||
}
|
||||
$data = $this->db->fetch_object($res);
|
||||
$this->load($data);
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getOption($opt) {
|
||||
$options = $this->getOptions();
|
||||
if(!$options) return null;
|
||||
if(property_exists($options, $opt)) {
|
||||
return $options->$opt;
|
||||
}
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
public function getOptions() {
|
||||
if(!$this->options) {
|
||||
return false;
|
||||
}
|
||||
$opts = json_decode($this->options);
|
||||
if(json_last_error() != JSON_ERROR_NONE) {
|
||||
return null;
|
||||
}
|
||||
return $opts;
|
||||
}
|
||||
|
||||
public function getProperty($name) {
|
||||
if($this->$name == null) {
|
||||
|
||||
if($name == "gemeinden") {
|
||||
$gemeinden = [];
|
||||
foreach(ADBGemeindeNetzgebietModel::search(["netzgebiet_id" => $this->id]) as $gem_netz) {
|
||||
$g = $gem_netz->gemeinde;
|
||||
if(!$g || array_key_exists($g->gemeinde_id, $gemeinden)) continue;
|
||||
//var_dump($g);exit;
|
||||
$gemeinden[$g->id] = $g;
|
||||
}
|
||||
if(count($gemeinden)) {
|
||||
$this->gemeinden = $gemeinden;
|
||||
}
|
||||
return $this->gemeinden;
|
||||
}
|
||||
|
||||
$classname = ucfirst($name);
|
||||
$idfield = $name."_id";
|
||||
$this->$name = new $classname($this->$idfield);
|
||||
|
||||
if($this->$name->id) {
|
||||
return $this->$name;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->$name;
|
||||
}
|
||||
require_once LIBDIR . '/mfBaseModelV2/mfBaseModelV2.php';
|
||||
|
||||
class ADBNetzgebietRelations {
|
||||
/** @var array{id: int, name: string}[] */
|
||||
public array $networks = [];
|
||||
/** @var array{id: int, name: string}[] */
|
||||
public array $campaigns = [];
|
||||
/** @var array{id: int, name: string}[] */
|
||||
public array $consentProjects = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @property-read ADBGemeinde[] $gemeinden
|
||||
* @property-read ADBNetzgebietRelations $relations
|
||||
*/
|
||||
class ADBNetzgebiet extends mfBaseModelV2 {
|
||||
|
||||
protected static string $__tableName = 'Netzgebiet';
|
||||
protected static string $__primaryKey = 'id';
|
||||
|
||||
protected static ?array $__databaseConfig = [
|
||||
'host' => ADDRESSDB_DBHOST,
|
||||
'user' => ADDRESSDB_DBUSER,
|
||||
'pass' => ADDRESSDB_DBPASS,
|
||||
'name' => ADDRESSDB_DBNAME
|
||||
];
|
||||
|
||||
protected static array $__journalFieldMap = [
|
||||
'name' => 'Name', 'extref' => 'ExtRef', 'rimo_id' => 'RIMO ID',
|
||||
'source' => 'Source', 'source_id' => 'Source ID', 'borderpoly' => 'Border Polygon',
|
||||
'freigabe' => 'Freigaben', 'options' => 'Options', 'create' => 'Erstellt', 'edit' => 'Bearbeitet',
|
||||
];
|
||||
|
||||
public int $id;
|
||||
public ?string $name = null;
|
||||
public ?string $extref = null;
|
||||
public ?string $rimo_id = null;
|
||||
public ?string $source = null;
|
||||
public ?string $source_id = null;
|
||||
public ?string $borderpoly = null;
|
||||
public ?string $freigabe = '["interest", "provision", "order", "reorder"]';
|
||||
public ?string $options = '{"create_address_parts": 0, "update_freigabe": 1, "update_address": 1, "hausnummer_dont_overwrite_netzgebiet": 0, "create_preorder": 0, "preorder_only_oaid": 0, "wo_ignore_status": 0, "delete_units": 0, "mph_min_homes_tool_automatic_count": 3, "unit_create_oaid": 0}';
|
||||
public int $create;
|
||||
public int $edit;
|
||||
|
||||
private ?array $__gemeinden = null;
|
||||
private ?ADBNetzgebietRelations $__relations = null;
|
||||
|
||||
public function __get(string $name) {
|
||||
if ($name === 'gemeinden') {
|
||||
if ($this->__gemeinden === null) {
|
||||
$this->__gemeinden = [];
|
||||
foreach (ADBGemeindeNetzgebietModel::search(["netzgebiet_id" => $this->id]) as $gem_netz) {
|
||||
$g = $gem_netz->gemeinde;
|
||||
if (!$g || array_key_exists($g->id, $this->__gemeinden)) continue;
|
||||
$this->__gemeinden[$g->id] = $g;
|
||||
}
|
||||
}
|
||||
return $this->__gemeinden;
|
||||
}
|
||||
|
||||
if ($name === 'relations') {
|
||||
return $this->__relations ??= $this->loadRelations();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function loadByExtref(string $extref): bool {
|
||||
$extref = trim($extref);
|
||||
if (empty($extref)) return false;
|
||||
|
||||
$found = static::getFirst(['=extref' => $extref]);
|
||||
if ($found) {
|
||||
foreach (get_object_vars($found) as $key => $value) {
|
||||
if (property_exists($this, $key) && !str_starts_with($key, '__')) {
|
||||
$this->$key = $value;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getOption(string $opt): mixed {
|
||||
$options = $this->getOptions();
|
||||
return $options && property_exists($options, $opt) ? $options->$opt : null;
|
||||
}
|
||||
|
||||
public function getOptions(): ?object {
|
||||
if (empty($this->options)) return null;
|
||||
$opts = json_decode($this->options);
|
||||
return json_last_error() === JSON_ERROR_NONE ? $opts : null;
|
||||
}
|
||||
|
||||
public function getFreigabe(): array {
|
||||
if (empty($this->freigabe)) return [];
|
||||
$freigabe = json_decode($this->freigabe, true);
|
||||
return json_last_error() === JSON_ERROR_NONE ? $freigabe : [];
|
||||
}
|
||||
|
||||
public function loadRelations(): ADBNetzgebietRelations {
|
||||
$rel = new ADBNetzgebietRelations();
|
||||
|
||||
$networks = NetworkModel::search(['adb_netzgebiet_id' => $this->id]);
|
||||
foreach ($networks as $network) {
|
||||
$rel->networks[] = ['id' => $network->id, 'name' => $network->name];
|
||||
}
|
||||
|
||||
$networkIds = array_column($rel->networks, 'id');
|
||||
if (!empty($networkIds)) {
|
||||
$campaigns = PreordercampaignModel::search(['network_id' => $networkIds]);
|
||||
foreach ($campaigns as $campaign) {
|
||||
$rel->campaigns[] = ['id' => $campaign->id, 'name' => $campaign->name];
|
||||
}
|
||||
}
|
||||
|
||||
$rel->consentProjects = ConstructionConsentProject::getByAdbNetzgebietId($this->id);
|
||||
|
||||
return $rel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
class ADBNetzgebietController extends mfBaseController {
|
||||
|
||||
public User $me;
|
||||
private array $postData = [];
|
||||
|
||||
protected function init(): void {
|
||||
$this->needlogin = true;
|
||||
$this->me = new User();
|
||||
$this->me->loadMe();
|
||||
$this->layout()->set("me", $this->me);
|
||||
|
||||
if (!$this->me->is("Admin")) {
|
||||
$this->redirect("Dashboard");
|
||||
}
|
||||
|
||||
$rawInput = file_get_contents('php://input');
|
||||
if ($rawInput) $this->postData = json_decode($rawInput, true) ?? [];
|
||||
}
|
||||
|
||||
protected function indexAction(): void {
|
||||
Helper::renderVue3($this, $this->mod, "Netzgebietverwaltung", [
|
||||
"GET_URL" => $this::getUrl("ADBNetzgebiet/getNetzgebiete"),
|
||||
"SAVE_URL" => $this::getUrl("ADBNetzgebiet/save"),
|
||||
"HISTORY_URL" => $this::getUrl("ADBNetzgebiet/getHistory"),
|
||||
"NETWORK_URL" => $this::getUrl("Network/Index"),
|
||||
"NETWORK_CREATE_URL" => $this::getUrl("Network/add"),
|
||||
"CAMPAIGN_URL" => $this::getUrl("Preordercampaign/edit"),
|
||||
"CAMPAIGN_CREATE_URL" => $this::getUrl("Preordercampaign/add"),
|
||||
"CONSENT_URL" => $this::getUrl("ConstructionConsentProject/edit"),
|
||||
"CONSENT_CREATE_URL" => $this::getUrl("ConstructionConsentProject/add"),
|
||||
"HIDE_PAGE_TITLE" => true,
|
||||
"USER_ID" => $this->me->id,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getNetzgebieteAction(): void {
|
||||
$filter = [];
|
||||
if (!empty($_GET['name'])) $filter['name'] = $_GET['name'];
|
||||
if (!empty($_GET['extref'])) $filter['extref'] = $_GET['extref'];
|
||||
if (!empty($_GET['source'])) $filter['=source'] = $_GET['source'];
|
||||
if (!empty($_GET['source_id'])) $filter['source_id'] = $_GET['source_id'];
|
||||
|
||||
$allNetzgebiete = ADBNetzgebiet::getAll($filter, null, 0, ['column' => 'name', 'dir' => 'ASC']);
|
||||
|
||||
$response = [];
|
||||
foreach ($allNetzgebiete as $netzgebiet) {
|
||||
$response[] = [
|
||||
'netzgebiet' => $netzgebiet->toArray(),
|
||||
'related' => [
|
||||
'networks' => $netzgebiet->relations->networks,
|
||||
'campaigns' => $netzgebiet->relations->campaigns,
|
||||
'consent_projects' => $netzgebiet->relations->consentProjects
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'data' => $response, 'total' => count($response)]);
|
||||
}
|
||||
|
||||
protected function saveAction(): void {
|
||||
$data = $this->postData;
|
||||
if (empty($data)) { self::sendError("No data received."); return; }
|
||||
|
||||
$isNew = empty($data['id']);
|
||||
$model = $isNew ? new ADBNetzgebiet() : ADBNetzgebiet::get($data['id']);
|
||||
if (!$model) { self::sendError("Netzgebiet not found."); return; }
|
||||
|
||||
if (isset($data['name'])) $model->name = trim($data['name']) ?: null;
|
||||
if (array_key_exists('extref', $data)) $model->extref = trim($data['extref']) ?: null;
|
||||
if (array_key_exists('rimo_id', $data)) $model->rimo_id = trim($data['rimo_id']) ?: null;
|
||||
if (isset($data['source'])) $model->source = $data['source'] ?: null;
|
||||
if (array_key_exists('source_id', $data)) $model->source_id = trim($data['source_id']) ?: null;
|
||||
if (array_key_exists('borderpoly', $data)) $model->borderpoly = $data['borderpoly'] ?: null;
|
||||
|
||||
if (isset($data['freigabe'])) {
|
||||
$model->freigabe = is_array($data['freigabe'])
|
||||
? json_encode(array_values($data['freigabe']))
|
||||
: $data['freigabe'];
|
||||
}
|
||||
|
||||
if (isset($data['options'])) {
|
||||
if (is_array($data['options'])) {
|
||||
$options = $data['options'];
|
||||
if (isset($options['mph_min_homes_tool_automatic_count'])) {
|
||||
$options['mph_min_homes_tool_automatic_count'] = (int)$options['mph_min_homes_tool_automatic_count'];
|
||||
}
|
||||
$boolFields = ['create_address_parts', 'update_freigabe', 'update_address',
|
||||
'hausnummer_dont_overwrite_netzgebiet', 'create_preorder', 'preorder_only_oaid',
|
||||
'wo_ignore_status', 'delete_units', 'unit_create_oaid'];
|
||||
foreach ($boolFields as $field) {
|
||||
if (isset($options[$field])) $options[$field] = $options[$field] ? 1 : 0;
|
||||
}
|
||||
$model->options = json_encode($options);
|
||||
} else {
|
||||
$model->options = $data['options'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$model->save()) { self::sendError("Failed to save Netzgebiet."); return; }
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => $isNew ? 'Netzgebiet created.' : 'Netzgebiet saved.',
|
||||
'id' => $model->getId()
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getHistoryAction(): void {
|
||||
$id = $_GET['id'] ?? $this->postData['id'] ?? null;
|
||||
if (empty($id)) { self::sendError("ID required."); return; }
|
||||
|
||||
$model = ADBNetzgebiet::get($id);
|
||||
if (!$model) { self::sendError("Netzgebiet not found."); return; }
|
||||
|
||||
$history = $model->getJournalHistory();
|
||||
$userIds = array_unique(array_filter(array_column($history, 'user_id')));
|
||||
$users = [];
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
$user = new User($userId);
|
||||
if ($user->id) $users[$user->id] = $user->name ?? 'User #' . $user->id;
|
||||
}
|
||||
|
||||
foreach ($history as $entry) {
|
||||
$entry->user_name = $users[$entry->user_id] ?? 'System';
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'data' => $history]);
|
||||
}
|
||||
|
||||
// TODO: Implement RIMO API check
|
||||
protected function checkRimoSourceIdAction(): void {
|
||||
self::returnJson(['success' => false, 'message' => "RIMO API check not available."]);
|
||||
}
|
||||
}
|
||||
@@ -1,188 +1,4 @@
|
||||
<?php
|
||||
|
||||
class ADBNetzgebietModel {
|
||||
public $name;
|
||||
public $extref;
|
||||
public $source;
|
||||
public $source_id;
|
||||
public $rimo_id;
|
||||
public $freigabe;
|
||||
|
||||
public $create = null;
|
||||
public $edit = null;
|
||||
|
||||
public static function create(Array $data) {
|
||||
$model = new ADBNetzgebiet();
|
||||
|
||||
foreach($data as $field => $value) {
|
||||
if(property_exists(get_called_class(), $field)) {
|
||||
$model ->$field = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$me = mfValuecache::singleton()->get("me");
|
||||
if(!$me) {
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
mfValuecache::singleton()->set("me", $me);
|
||||
}
|
||||
/*
|
||||
if($model->create_by === null) {
|
||||
$model->create_by = $me->id;
|
||||
}
|
||||
if($model->edit_by === null) {
|
||||
$model->edit_by = $me->id;
|
||||
}*/
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
public static function getFirst($filter) {
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
|
||||
$where = self::getSqlFilter($filter);
|
||||
$res = $db->select("Netzgebiet", "*", "$where ORDER BY name LIMIT 1");
|
||||
if($db->num_rows($res)) {
|
||||
$data = $db->fetch_object($res);
|
||||
$item = new ADBNetzgebiet($data);
|
||||
if($item->id) {
|
||||
return $item;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function getAll($indexed_by_id = false) {
|
||||
$items = [];
|
||||
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
|
||||
$res = $db->select("Netzgebiet", "*", "1=1 ORDER BY name");
|
||||
if($db->num_rows($res)) {
|
||||
while($data = $db->fetch_object($res)) {
|
||||
if($indexed_by_id) {
|
||||
$items[$data->id] = new ADBNetzgebiet($data);
|
||||
} else {
|
||||
$items[] = new ADBNetzgebiet($data);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
return $items;
|
||||
|
||||
}
|
||||
|
||||
|
||||
public static function count($filter) {
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
|
||||
$where = self::getSqlFilter($filter);
|
||||
$sql = "SELECT COUNT(*) as cnt FROM Netzgebiet
|
||||
WHERE $where
|
||||
";
|
||||
|
||||
$res = $db->query($sql);
|
||||
if($db->num_rows($res)) {
|
||||
$data = $db->fetch_object($res);
|
||||
return $data->cnt;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static function search($filter, $limit = false) {
|
||||
$items = [];
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
|
||||
$where = self::getSqlFilter($filter);
|
||||
$sql = "SELECT Netzgebiet.* FROM Netzgebiet
|
||||
WHERE $where
|
||||
ORDER BY name";
|
||||
|
||||
mfLoghandler::singleton()->debug($sql);
|
||||
if(is_array($limit) && count($limit)) {
|
||||
if(is_numeric($limit['start']) && is_numeric($limit['count'])) {
|
||||
$sql .= " LIMIT ".$limit['start'].", ".$limit['count'];
|
||||
} elseif(is_numeric($limit['count'])) {
|
||||
$sql .= " LIMIT ".$limit['count'];
|
||||
}
|
||||
}
|
||||
|
||||
$res = $db->query($sql);
|
||||
if($db->num_rows($res)) {
|
||||
while($data = $db->fetch_object($res)) {
|
||||
$items[] = new ADBNetzgebiet($data);
|
||||
}
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
private static function getSqlFilter($filter) {
|
||||
$where = "1=1 ";
|
||||
|
||||
if(array_key_exists("netzgebiet_id", $filter)) {
|
||||
$netzgebiet_id = $filter['netzgebiet_id'];
|
||||
if(is_numeric($netzgebiet_id)) {
|
||||
$where .= " AND Netzgebiet.id=$netzgebiet_id";
|
||||
} elseif(is_array($netzgebiet_id) && count($netzgebiet_id)) {
|
||||
$where .= " AND Netzgebiet.id IN (". implode(",", $netzgebiet_id).")";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("name", $filter)) {
|
||||
$name = FronkDB::singleton()->escape($filter['name']);
|
||||
if($name) {
|
||||
$where .= " AND Netzgebiet.`name` = '$name'";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("name%", $filter)) {
|
||||
$name = FronkDB::singleton()->escape($filter['name%']);
|
||||
if($name) {
|
||||
$where .= " AND Netzgebiet.`name` LIKE '$name%'";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("extref", $filter)) {
|
||||
$extref = FronkDB::singleton()->escape($filter['extref']);
|
||||
if($extref) {
|
||||
$where .= " AND Netzgebiet.`extref` = '$extref'";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("rimo_id", $filter)) {
|
||||
$rimo_id = FronkDB::singleton()->escape($filter['rimo_id']);
|
||||
if($rimo_id) {
|
||||
$where .= " AND Netzgebiet.`rimo_id` LIKE '%$rimo_id%'";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("source_id", $filter)) {
|
||||
$source_id = FronkDB::singleton()->escape($filter['source_id']);
|
||||
if($source_id) {
|
||||
$where .= " AND Netzgebiet.`source_id` LIKE '%$source_id%'";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("source", $filter)) {
|
||||
$source = FronkDB::singleton()->escape($filter['source']);
|
||||
if($source) {
|
||||
$where .= " AND Netzgebiet.`source` = '$source'";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("borderpoly", $filter)) {
|
||||
$borderpoly = $filter['borderpoly'];
|
||||
if($borderpoly === true) {
|
||||
$where .= " AND Netzgebiet.`borderpoly` IS NOT NULL";
|
||||
} elseif($borderpoly === false || $borderpoly === null) {
|
||||
$where .= " AND (Netzgebiet.`borderpoly` IS NULL OR Netzgebiet.`borderpoly` = '')";
|
||||
}
|
||||
}
|
||||
|
||||
//var_dump($filter, $where);exit;
|
||||
return $where;
|
||||
}
|
||||
|
||||
}
|
||||
/** @deprecated Use ADBNetzgebiet directly */
|
||||
class ADBNetzgebietModel extends ADBNetzgebiet {}
|
||||
|
||||
@@ -50,6 +50,8 @@ class ADBWohneinheit extends mfBaseModel {
|
||||
if(!array_key_exists("no_updates", $_params) || !$_params['no_updates']) {
|
||||
// Statuschange from Rimo statuschange
|
||||
AddressDB::handleRimoStatusUpdate($this->id);
|
||||
// Check if tuer has changed and sync to RIMO
|
||||
$this->syncTuerToRimo();
|
||||
}
|
||||
|
||||
$this->refreshUnitCount();
|
||||
@@ -59,6 +61,59 @@ class ADBWohneinheit extends mfBaseModel {
|
||||
return true;
|
||||
}
|
||||
|
||||
private function syncTuerToRimo() {
|
||||
// Check if tuer field has changed
|
||||
$changes = $this->getChangedFields();
|
||||
if(!in_array("tuer", $changes)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if we have a RIMO external ID
|
||||
if(!$this->extref) {
|
||||
$this->log->debug("[".$this->_ruid."] ".__METHOD__.": No extref set, skipping RIMO sync");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get the hausnummer to access network owner
|
||||
$hausnummer = $this->getProperty("hausnummer");
|
||||
if(!$hausnummer || !$hausnummer->id) {
|
||||
$this->log->debug("[".$this->_ruid."] ".__METHOD__.": No hausnummer found, skipping RIMO sync");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get RIMO API credentials
|
||||
$creds = $hausnummer->getNetownerRimoApiCredentials();
|
||||
if(!$creds) {
|
||||
$this->log->warn("[".$this->_ruid."] ".__METHOD__.": No API credentials found for network owner");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get the API key (using prod environment)
|
||||
$apikey = $creds["prod"]["key"];
|
||||
if(!$apikey) {
|
||||
$this->log->warn("[".$this->_ruid."] ".__METHOD__.": No API key found in credentials");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Call RIMO API to update subAddress (tuer)
|
||||
$this->log->info("[".$this->_ruid."] ".__METHOD__.": Syncing tuer change to RIMO for home ".$this->extref.": '".$this->tuer."'");
|
||||
|
||||
$subaddress = "";
|
||||
if($this->tuer) {
|
||||
$subaddress = "Top ".$this->tuer;
|
||||
}
|
||||
|
||||
$result = Rimoapi::changeHomeSubAddress($apikey, $this->extref, $subaddress);
|
||||
|
||||
if($result) {
|
||||
$this->log->info("[".$this->_ruid."] ".__METHOD__.": Successfully synced tuer to RIMO");
|
||||
} else {
|
||||
$this->log->error("[".$this->_ruid."] ".__METHOD__.": Failed to sync tuer to RIMO");
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function refreshUnitCount() {
|
||||
// ADBWohneinheit_onSave_noAutoUnitCount can be defined if doing bulk
|
||||
// operations where unit count is calculated seperately
|
||||
|
||||
@@ -28,7 +28,10 @@ class ADBWohneinheitController extends mfBaseController {
|
||||
foreach($my_networks as $network) {
|
||||
if($network->adb_netzgebiet_id && !in_array($network->adb_netzgebiet_id, $netzgebiet_ids)) {
|
||||
$netzgebiet_ids[] = $network->adb_netzgebiet_id;
|
||||
$my_adb_networks[$network->adb_netzgebiet_id] = new ADBNetzgebiet($network->adb_netzgebiet_id);
|
||||
|
||||
$adb_network = new ADBNetzgebiet($network->adb_netzgebiet_id);
|
||||
if(!$adb_network->isLoaded()) continue;
|
||||
$my_adb_networks[$network->adb_netzgebiet_id] = $adb_network;
|
||||
}
|
||||
}
|
||||
$this->layout()->set("my_adb_networks", $my_adb_networks);
|
||||
|
||||
@@ -726,16 +726,24 @@ class AddressController extends mfBaseController {
|
||||
}
|
||||
|
||||
$xinon_project = new XinonProject();
|
||||
$tickets = $xinon_project->searchSupportTickets('', 0, ['pageSize' => 100,
|
||||
'filters' => json_encode([['customField6' => ['operator' => '=', 'values' => [$address->customer_number]]]])]);
|
||||
$filterParams = ['pageSize' => 100,
|
||||
'filters' => json_encode([['customField6' => ['operator' => '=', 'values' => [(string)$address->customer_number]]]])];
|
||||
|
||||
$tickets = $xinon_project->searchSupportTickets('', 0, $filterParams) ?? [];
|
||||
|
||||
$shippingNotes = array_map(function ($shippingNote) {
|
||||
$shippingNote->createByName = (new User($shippingNote->createBy))->getAbbrName();
|
||||
return $shippingNote;
|
||||
}, WarehouseShippingNoteModel::getAll(['billingAddressId' => $address->id]));
|
||||
|
||||
Helper::renderVue($this,"AddressTickets",
|
||||
"Tickets und Lieferscheine von Kunden: " . $address->getCompanyOrName() . '(' . $address->customer_number . ')', ["TICKETS" => $tickets,"SHIPPING_NOTES" => $shippingNotes,"ADDRESS" => $address]);
|
||||
$customerName = str_replace(["\r", "\n"], ' ', $address->getCompanyOrName());
|
||||
Helper::renderVue($this,"AddressTickets", "Tickets und Lieferscheine", [
|
||||
"TICKETS" => $tickets,
|
||||
"SHIPPING_NOTES" => $shippingNotes,
|
||||
"CUSTOMER_NAME" => $customerName,
|
||||
"CUSTOMER_NUMBER" => $address->customer_number,
|
||||
"HIDE_PAGE_TITLE" => true
|
||||
]);
|
||||
}
|
||||
|
||||
protected function sendServicePinAction() {
|
||||
|
||||
@@ -130,9 +130,13 @@ class AddressDB {
|
||||
}
|
||||
|
||||
|
||||
$netowner = $hausnummer->getNetowner();
|
||||
if(!$netowner || !$netowner->id) {
|
||||
$log->debug(__METHOD__.": Unable to determine netowner for Hausnummer ".$hausnummer->id);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
$status_matrix = array_reverse(TT_PREORDER_RIMO_STATUS_MATRIX);
|
||||
$status_matrix = array_reverse(TT_PREORDER_RIMO_STATUS_MATRIX[$netowner->id]);
|
||||
|
||||
$log->debug(__METHOD__.": b_ex_state: ".$b_ex_state);
|
||||
$log->debug(__METHOD__.": b_op_state: ".$b_op_state);
|
||||
@@ -218,7 +222,6 @@ class AddressDB {
|
||||
if($hausnummer->status_id != $old_status) {
|
||||
$hausnummer->save();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//$wohneinheit = new ADBWohneinheit($wohneinheit->id);
|
||||
@@ -234,9 +237,18 @@ class AddressDB {
|
||||
|
||||
}
|
||||
|
||||
if(array_key_exists("hf", $matrix)) {
|
||||
$hausnummer_flag = $matrix["hf"];
|
||||
if($hausnummer_flag) {
|
||||
$log->debug(__METHOD__.": new Hausnummer (".$wohneinheit->id.") statusflag: ".$matrix["hf"]);
|
||||
$hausnummer->setStatusflag($hausnummer_flag, 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
break;
|
||||
|
||||
// commented, for 140/141
|
||||
//break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
class AddressDBController extends mfBaseController {
|
||||
|
||||
|
||||
protected function init() {
|
||||
$this->needlogin=true;
|
||||
$me = new User();
|
||||
@@ -66,7 +66,10 @@ class AddressDBController extends mfBaseController {
|
||||
foreach($my_networks as $network) {
|
||||
if($network->adb_netzgebiet_id && !in_array($network->adb_netzgebiet_id, $netzgebiet_ids)) {
|
||||
$netzgebiet_ids[] = $network->adb_netzgebiet_id;
|
||||
$my_adb_networks[$network->adb_netzgebiet_id] = new ADBNetzgebiet($network->adb_netzgebiet_id);
|
||||
|
||||
$adb_network = new ADBNetzgebiet($network->adb_netzgebiet_id);
|
||||
if(!$adb_network->isLoaded()) continue;
|
||||
$my_adb_networks[$network->adb_netzgebiet_id] = $adb_network;
|
||||
}
|
||||
}
|
||||
//var_dump($my_networks, $my_adb_networks);
|
||||
@@ -108,7 +111,13 @@ class AddressDBController extends mfBaseController {
|
||||
}
|
||||
$this->layout()->set("ortschaften", ADBOrtschaftModel::search($filter_filter));
|
||||
}
|
||||
|
||||
|
||||
if($this->request->rimoAddressUpdate) {
|
||||
$this->updateAddressesInRimo(ADBHausnummerModel::search($addressdb_filter));
|
||||
unset($filter["rimoAddressUpdate"]);
|
||||
$qs = http_build_query($filter);
|
||||
$this->redirect("AddressDB", "index", $filter);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -209,6 +218,13 @@ class AddressDBController extends mfBaseController {
|
||||
|
||||
return $new_filter;
|
||||
}
|
||||
|
||||
private function updateAddressesInRimo($addresses) {
|
||||
foreach($addresses as $address) {
|
||||
$address->updateAddressInRimo();
|
||||
}
|
||||
$this->layout()->setFlash(count($addresses)." Adressen in Rimo aktualisiert", "success");
|
||||
}
|
||||
|
||||
protected function viewAction() {
|
||||
$this->layout()->setTemplate("AddressDB/View");
|
||||
@@ -247,7 +263,10 @@ class AddressDBController extends mfBaseController {
|
||||
foreach($my_networks as $network) {
|
||||
if($network->adb_netzgebiet_id && !in_array($network->adb_netzgebiet_id, $netzgebiet_ids)) {
|
||||
$netzgebiet_ids[] = $network->adb_netzgebiet_id;
|
||||
$my_adb_networks[$network->adb_netzgebiet_id] = new ADBNetzgebiet($network->adb_netzgebiet_id);
|
||||
|
||||
$adb_network = new ADBNetzgebiet($network->adb_netzgebiet_id);
|
||||
if(!$adb_network->isLoaded()) continue;
|
||||
$my_adb_networks[$network->adb_netzgebiet_id] = $adb_network;
|
||||
}
|
||||
}
|
||||
$this->layout()->set("my_adb_networks", $my_adb_networks);
|
||||
@@ -490,7 +509,10 @@ class AddressDBController extends mfBaseController {
|
||||
foreach($my_networks as $network) {
|
||||
if($network->adb_netzgebiet_id && !in_array($network->adb_netzgebiet_id, $netzgebiet_ids)) {
|
||||
$netzgebiet_ids[] = $network->adb_netzgebiet_id;
|
||||
$my_adb_networks[$network->adb_netzgebiet_id] = new ADBNetzgebiet($network->adb_netzgebiet_id);
|
||||
|
||||
$adb_network = new ADBNetzgebiet($network->adb_netzgebiet_id);
|
||||
if(!$adb_network->isLoaded()) continue;
|
||||
$my_adb_networks[$network->adb_netzgebiet_id] = $adb_network;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -941,12 +963,15 @@ class AddressDBController extends mfBaseController {
|
||||
|
||||
if($updated) {
|
||||
$hausnummer->save(["no_aftersave" => true]);
|
||||
if($do_rimo_update) {
|
||||
$hausnummer->updateAddressInRimo();
|
||||
}
|
||||
$u++;
|
||||
}
|
||||
|
||||
if($do_rimo_update) {
|
||||
// reload to make sure we have the latest data in caches
|
||||
$hausnummer = new ADBHausnummer($hausnummer->id);
|
||||
$hausnummer->updateAddressInRimo();
|
||||
}
|
||||
|
||||
|
||||
$i++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
class AddressPriceTypeController extends mfBaseController {
|
||||
|
||||
protected function init() {
|
||||
$this->needlogin = true;
|
||||
$user = new User();
|
||||
$user->loadMe();
|
||||
$this->me = $user;
|
||||
}
|
||||
|
||||
public function getAction() {
|
||||
$addressId = $_GET['address_id'] ?? null;
|
||||
|
||||
if (!$addressId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Address ID required']);
|
||||
}
|
||||
|
||||
$priceType = AddressPriceTypeModel::getFirst(['address_id' => $addressId]);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'priceType_id' => $priceType ? $priceType->priceType_id : null
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveAction() {
|
||||
$addressId = $_POST['address_id'] ?? null;
|
||||
$priceTypeId = $_POST['priceType_id'] ?? null;
|
||||
|
||||
if (!$addressId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Address ID required']);
|
||||
}
|
||||
|
||||
$existing = AddressPriceTypeModel::getFirst(['address_id' => $addressId]);
|
||||
|
||||
if (empty($priceTypeId)) {
|
||||
if ($existing) {
|
||||
AddressPriceTypeModel::delete($existing->id);
|
||||
}
|
||||
self::returnJson(['success' => true, 'message' => 'Preistyp entfernt']);
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$updateData = (array) $existing;
|
||||
$updateData['priceType_id'] = $priceTypeId;
|
||||
$result = AddressPriceTypeModel::update($updateData);
|
||||
} else {
|
||||
$result = AddressPriceTypeModel::create([
|
||||
'address_id' => $addressId,
|
||||
'priceType_id' => $priceTypeId,
|
||||
'create' => time(),
|
||||
'createBy' => $this->me->id
|
||||
]);
|
||||
}
|
||||
|
||||
if ($result) {
|
||||
self::returnJson(['success' => true, 'message' => 'Preistyp gespeichert']);
|
||||
} else {
|
||||
self::returnJson(['success' => false, 'message' => 'Fehler beim Speichern']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
class AddressPriceTypeModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public int $address_id;
|
||||
public int $priceType_id;
|
||||
public int $create;
|
||||
public int $createBy;
|
||||
}
|
||||
@@ -80,6 +80,64 @@ class AdminController extends mfBaseController {
|
||||
|
||||
}
|
||||
|
||||
protected function downloadBusinessCustomers() {
|
||||
$bc_sql = "SELECT COUNT(Contract.id) as contract_count, Address.* FROM `Address`
|
||||
LEFT JOIN Contract ON (Contract.owner_id = Address.id)
|
||||
WHERE customer_number > 0 AND company <> ''
|
||||
AND (Contract.cancel_date IS NULL OR Contract.cancel_date > UNIX_TIMESTAMP())
|
||||
GROUP BY Address.id HAVING contract_count > 0
|
||||
ORDER BY Address.company, Address.lastname
|
||||
";
|
||||
//$this->log->debug($bc_sql);
|
||||
$bc_res = $this->db()->query($bc_sql);
|
||||
|
||||
$csv = "customer_number;fibu_account_number;uid;company;firstname;lastname;street;zip;city;country;email;phone;fax;mobile;birthdate;allow_contact;allow_spin\n";
|
||||
while($address = $this->db()->fetch_object($bc_res)) {
|
||||
$country = new Country($address->country_id);
|
||||
$birthdate = false;
|
||||
|
||||
if($address->birthdate)
|
||||
try {
|
||||
$birthdate = new DateTime($address->birthdate);
|
||||
} catch(Exception $e) {
|
||||
$birthdate = false;
|
||||
}
|
||||
|
||||
$csv .= "{$address->customer_number};";
|
||||
$csv .= "{$address->fibu_account_number};";
|
||||
$csv .= "\"{$address->uid}\";";
|
||||
$csv .= "\"{$address->company}\";";
|
||||
$csv .= "\"{$address->firstname}\";";
|
||||
$csv .= "\"{$address->lastname}\";";
|
||||
$csv .= "\"{$address->street}\";";
|
||||
$csv .= "\"{$address->zip}\";";
|
||||
$csv .= "\"{$address->city}\";";
|
||||
if($country->id) {
|
||||
$csv .= "\"{$country->name}\";";
|
||||
} else {
|
||||
$csv .= ";";
|
||||
}
|
||||
$csv .= "\"{$address->email}\";";
|
||||
$csv .= "\"".(string)$address->phone."\";";
|
||||
$csv .= "\"".(string)$address->fax."\";";
|
||||
$csv .= "\"".(string)$address->mobile."\";";
|
||||
if($birthdate) {
|
||||
$csv .= "\"".$birthdate->format('d.m.Y')."\";";
|
||||
} else {
|
||||
$csv .= ";";
|
||||
}
|
||||
$csv .= "{$address->allow_contact};";
|
||||
$csv .= "{$address->allow_spin};";
|
||||
$csv .= "\n";
|
||||
}
|
||||
|
||||
header("Content-type: text/csv; charset=utf-8");
|
||||
header('Content-disposition: attachment; filename="tt-business_customers-'.date('Y-m-d_H-i-s').'.csv"');
|
||||
echo $csv;
|
||||
exit;
|
||||
|
||||
}
|
||||
|
||||
protected function rtrReporting() {
|
||||
require_once(realpath(dirname(__FILE__)."/functions")."/RtrReporting.php");
|
||||
|
||||
|
||||
@@ -691,7 +691,11 @@ class AddressdbApicontroller extends mfBaseApicontroller {
|
||||
}
|
||||
|
||||
$addresses = [];
|
||||
$netzgebiete = ADBNetzgebietModel::getAll(true);
|
||||
$netzgebiete = [];
|
||||
$netzgebiete_unindexed = ADBNetzgebietModel::getAll();
|
||||
foreach($netzgebiete_unindexed as $nu) {
|
||||
$netzgebiete[$nu->id] = $nu;
|
||||
}
|
||||
|
||||
$stati = ADBStatusModel::getAll(true);
|
||||
|
||||
|
||||
@@ -17,6 +17,65 @@ class SnoppCitycom extends Modules\ApiControllerModule
|
||||
|
||||
}
|
||||
|
||||
public function getOntStatus($req_id) {
|
||||
if(!$req_id) {
|
||||
return \mfResponse::BadRequest(["message" => "id missing"]);
|
||||
}
|
||||
|
||||
$wohneinheit = false;
|
||||
if(is_numeric($req_id)) {
|
||||
$id = $req_id;
|
||||
$wohneinheit = new \ADBWohneinheit($id);
|
||||
}
|
||||
if(!$wohneinheit || !$wohneinheit->id) {
|
||||
$oaid = $req_id;
|
||||
$wohneinheit = \ADBWohneinheitModel::getFirst(["oaid" => $oaid]);
|
||||
if (!$wohneinheit) {
|
||||
return \mfResponse::NotFound(["message" => "Home not found"]);
|
||||
}
|
||||
}
|
||||
|
||||
$cc = new \Citycom_OanApiClient(CITYCOM_OAN_API_USER, CITYCOM_OAN_API_PASS);
|
||||
$data = $cc->getOntStatus($wohneinheit->oaid);
|
||||
|
||||
$status = [];
|
||||
$status["online"] = ($data->{"oper-status"} == "present" && $data->status == "Confirmed") ? 1 : 0;
|
||||
$status["uptime"] = ($data->up_time) ? (date('U') - $data->up_time) : null;
|
||||
$status["downtime"] = null;
|
||||
$status["rx"] = $data->{"opt-signal-level"};
|
||||
$status["tx"] = $data->tx_opt_level_dbm;
|
||||
$status["distance"] = null;
|
||||
|
||||
|
||||
return \mfResponse::Ok(["status" => $status]);
|
||||
}
|
||||
|
||||
public function getOntTelemetry($req_id) {
|
||||
if(!$req_id) {
|
||||
return \mfResponse::BadRequest(["message" => "id missing"]);
|
||||
}
|
||||
|
||||
$wohneinheit = false;
|
||||
if(is_numeric($req_id)) {
|
||||
$id = $req_id;
|
||||
$wohneinheit = new \ADBWohneinheit($id);
|
||||
}
|
||||
if(!$wohneinheit || !$wohneinheit->id) {
|
||||
$oaid = $req_id;
|
||||
$wohneinheit = \ADBWohneinheitModel::getFirst(["oaid" => $oaid]);
|
||||
if (!$wohneinheit) {
|
||||
return \mfResponse::NotFound(["message" => "Home not found"]);
|
||||
}
|
||||
}
|
||||
|
||||
$cc = new \Citycom_OanApiClient(CITYCOM_OAN_API_USER, CITYCOM_OAN_API_PASS);
|
||||
$data = $cc->getOntStatusDetail($wohneinheit->oaid);
|
||||
|
||||
print_r($data);exit;
|
||||
|
||||
return \mfResponse::Ok(["status" => $status]);
|
||||
}
|
||||
|
||||
public function orderService($req_id) {
|
||||
if(!$req_id) {
|
||||
return \mfResponse::BadRequest(["message" => "id missing"]);
|
||||
|
||||
@@ -50,8 +50,9 @@ class OperationaldataApicontroller extends mfBaseApicontroller
|
||||
$this->addRoute("/operationaldata/home/:id/connected", [$modules["Snopp"], "setPreorderConnected"], "POST");
|
||||
$this->addRoute("/operationaldata/home/:id/active", [$modules["Snopp"], "setPreorderActive"], "POST");
|
||||
|
||||
//$this->addRoute("/operationaldata/preorder/:id/orderServiceTest", [$modules["SnoppCitycom"], "orderServiceTest"], "POST");
|
||||
$this->addRoute("/operationaldata/preorder/:id/orderService", [$modules["SnoppCitycom"], "orderService"], "POST");
|
||||
$this->addRoute("/operationaldata/preorder/:id/ontStatus", [$modules["SnoppCitycom"], "getOntStatus"], "GET");
|
||||
$this->addRoute("/operationaldata/preorder/:id/ontTelemetry", [$modules["SnoppCitycom"], "getOntTelemetry"], "GET");
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -136,7 +136,10 @@ class PreorderApicontroller extends mfBaseApicontroller {
|
||||
foreach($my_networks as $network) {
|
||||
if($network->adb_netzgebiet_id && !in_array($network->adb_netzgebiet_id, $netzgebiet_ids)) {
|
||||
$netzgebiet_ids[] = $network->id;
|
||||
$my_adb_networks[$network->adb_netzgebiet_id] = new ADBNetzgebiet($network->adb_netzgebiet_id);
|
||||
|
||||
$adb_network = new ADBNetzgebiet($network->adb_netzgebiet_id);
|
||||
if(!$adb_network->isLoaded()) continue;
|
||||
$my_adb_networks[$network->adb_netzgebiet_id] = $adb_network;
|
||||
}
|
||||
}
|
||||
$preorder_filter = [];
|
||||
|
||||
@@ -11,7 +11,6 @@ class AssetManagementController extends TTCrud
|
||||
['key' => 'currentUser', 'text' => 'Status', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]],
|
||||
['key' => 'location', 'text' => 'Lagerort', 'required' => true, 'modal' => ['type' => 'text'], 'table' => ['filter' => 'search']],
|
||||
['key' => 'serviceDueDate', 'text' => 'Service fällig', 'required' => false, 'modal' => ['type' => 'date'], 'table' => ['filter' => 'date']],
|
||||
['key' => 'journal', 'text' => 'Historie', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]],
|
||||
['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]],
|
||||
];
|
||||
|
||||
@@ -21,7 +20,7 @@ class AssetManagementController extends TTCrud
|
||||
// Restrict actions if the user does not have the 'AssetAdmin' permission.
|
||||
if (!$this->user->can('AssetAdmin')) {
|
||||
$this->additionalJSVariables['ASSET_ADMIN'] = '0';
|
||||
$this->columns = array_filter($this->columns, fn($col) => !in_array($col['key'], ['actions', 'journal']));
|
||||
$this->columns = array_filter($this->columns, fn($col) => $col['key'] !== 'actions');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,4 +276,40 @@ class AssetManagementController extends TTCrud
|
||||
AssetManagementReservationModel::delete($post['id']);
|
||||
self::returnJson(['success' => true, 'message' => 'Reservierung gelöscht.']);
|
||||
}
|
||||
|
||||
protected function printLabelAction() {
|
||||
if (!$this->user->can('AssetAdmin')) {
|
||||
self::sendError("Permission denied", 403);
|
||||
}
|
||||
|
||||
$assetId = $this->request->id;
|
||||
$size = $this->request->size ?? '25'; // Default to 25mm
|
||||
|
||||
$asset = AssetManagementModel::get($assetId);
|
||||
if (!$asset) {
|
||||
self::sendError("Asset not found", 404);
|
||||
}
|
||||
|
||||
$pdf_vars = [
|
||||
'companyAddress' => 'Fladnitz 150, 8322 Studenzen',
|
||||
'companyPhone' => '+43 3115 40800',
|
||||
'invNumber' => $asset->assetNumber,
|
||||
'size' => $size
|
||||
];
|
||||
|
||||
$pdf = new PdfForm("AssetManagement/LABEL", $pdf_vars);
|
||||
|
||||
if ($size == '50') {
|
||||
$wkhtmltopdfArgs = "--page-height 50mm --page-width 80mm --margin-top 1mm --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8";
|
||||
} else { // 25mm
|
||||
$wkhtmltopdfArgs = "--page-height 25mm --page-width 50mm --margin-top 1mm --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8";
|
||||
}
|
||||
|
||||
$filename = $pdf->render($wkhtmltopdfArgs);
|
||||
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="label-' . $asset->assetNumber . '.pdf"');
|
||||
readfile($filename);
|
||||
die();
|
||||
}
|
||||
}
|
||||
@@ -191,7 +191,7 @@ class BillingController extends mfBaseController {
|
||||
$contract_search = [
|
||||
"finish_date<" => mktime(2,0,0,$now_month, $now_day, $now_year),
|
||||
"cancel_date_null_or_gte" => mktime(0,0,0,$now_month, 1, $now_year),
|
||||
//"owner_id" => 1221
|
||||
//"owner_id" => 7719
|
||||
];
|
||||
|
||||
foreach(ContractModel::search($contract_search) as $contract) {
|
||||
@@ -715,6 +715,7 @@ class BillingController extends mfBaseController {
|
||||
}
|
||||
if (!array_key_exists($zone->id, $voicebills[$number][$call_date_start])) {
|
||||
$voicebills[$number][$call_date_start][$zone->id] = [
|
||||
//"zone_id" => $zone->id,
|
||||
"zone_name" => $zone->name,
|
||||
"voiceplan" => $voiceplan->name,
|
||||
"duration" => 0,
|
||||
@@ -738,8 +739,8 @@ class BillingController extends mfBaseController {
|
||||
// save to BillingVoicenumber
|
||||
foreach($voicebills as $vbnumber => $zones) {
|
||||
|
||||
foreach($zones as $zone_id => $zone) {
|
||||
foreach($zone as $zone_start_date => $vb) {
|
||||
foreach($zones as $zone_start_date => $zone) {
|
||||
foreach($zone as $zone_id => $vb) {
|
||||
$vbdata = [];
|
||||
$vbdata["billing_id"] = $billing->id;
|
||||
$vbdata["contract_id"] = $contract->id;
|
||||
@@ -747,6 +748,7 @@ class BillingController extends mfBaseController {
|
||||
$vbdata["start_date"] = $vb["start_date"];
|
||||
$vbdata["end_date"] = $vb["end_date"];
|
||||
$vbdata["voiceplan"] = $vb["voiceplan"];
|
||||
$vbdata["zone_id"] = $zone_id;
|
||||
$vbdata["zone"] = $vb["zone_name"];
|
||||
$vbdata["call_count"] = $vb["count"];
|
||||
$vbdata["duration"] = $vb["duration"];
|
||||
@@ -756,6 +758,7 @@ class BillingController extends mfBaseController {
|
||||
$vbdata["increment_first"] = $vb["increment_first"];
|
||||
|
||||
$bill_voice = BillingVoicenumberModel::create($vbdata);
|
||||
$this->log->debug(__METHOD__.": billingvoicenumber record created: ".print_r($vbdata, true));
|
||||
if(!$bill_voice->save()) {
|
||||
var_dump($vbdata);
|
||||
die("Error saving Billing Voicenumber!");
|
||||
|
||||
@@ -7,6 +7,7 @@ class BillingVoicenumberModel {
|
||||
public $start_date;
|
||||
public $end_date;
|
||||
public $voiceplan;
|
||||
public $zone_id;
|
||||
public $zone;
|
||||
public $call_count;
|
||||
public $duration;
|
||||
|
||||
@@ -303,7 +303,7 @@ class Building extends mfBaseModel {
|
||||
}
|
||||
|
||||
if($name == "termination_workflow_comments") {
|
||||
$comments = "";
|
||||
$comment = "";
|
||||
foreach($this->getProperty("terminations") as $term) {
|
||||
if($term->workflow_comment) {
|
||||
$comment .= $term->code.": ".trim($term->workflow_comment)."\n\n";
|
||||
|
||||
@@ -177,6 +177,9 @@ class BuildingController extends mfBaseController {
|
||||
protected function saveAction() {
|
||||
$r = $this->request;
|
||||
$id = $r->id;
|
||||
|
||||
$adb_hausnummer_id = null;
|
||||
|
||||
//var_dump($r);exit;
|
||||
if(is_numeric($id) && $id > 0) {
|
||||
$mode = "edit";
|
||||
@@ -187,6 +190,9 @@ class BuildingController extends mfBaseController {
|
||||
}
|
||||
} else {
|
||||
$mode = "add";
|
||||
if($r->adb_hausnummer_id) {
|
||||
$adb_hausnummer_id = $r->adb_hausnummer_id;
|
||||
}
|
||||
}
|
||||
|
||||
if(!$r->network_id || !$r->type_id) {
|
||||
@@ -215,10 +221,17 @@ class BuildingController extends mfBaseController {
|
||||
$data['contact'] = trim($r->contact);
|
||||
$data['phone'] = trim($r->phone);
|
||||
$data['email'] = trim($r->email);
|
||||
$data['units'] = trim($r->units);
|
||||
|
||||
$data['description'] = trim($r->description);
|
||||
$data['note'] = trim($r->note);
|
||||
|
||||
|
||||
if($adb_hausnummer_id) {
|
||||
$data["adb_hausnummer_id"] = $adb_hausnummer_id;
|
||||
$data['units'] = 0;
|
||||
} else {
|
||||
$data['units'] = trim($r->units);
|
||||
}
|
||||
|
||||
if($this->me->is(["Admin", "netowner"])) {
|
||||
if($r->gps_lat) $data['gps_lat'] = trim($r->gps_lat);
|
||||
if($r->gps_long) $data['gps_long'] = trim($r->gps_long);
|
||||
@@ -235,7 +248,7 @@ class BuildingController extends mfBaseController {
|
||||
|
||||
// check if building exists already
|
||||
$checkBuilding = BuildingModel::search(['=street' => $data['street'], '=city' => $data['city'], '=zip' => $data['zip']]);
|
||||
|
||||
|
||||
if($checkBuilding) {
|
||||
$this->layout()->setFlash("Objekt ist <a target='_blank' href='".self::getUrl("Building")."#building=".$checkBuilding[0]->id."'>bereits vorhanden</a>!", "error");
|
||||
$this->layout()->set("building", $building);
|
||||
@@ -283,30 +296,49 @@ class BuildingController extends mfBaseController {
|
||||
}
|
||||
|
||||
// Anschlüsse anlegen
|
||||
|
||||
if(!$building->terminations && $building->units > 0) {
|
||||
for($i = 1; $i <= $building->units; $i++) {
|
||||
$data = [];
|
||||
$data['building_id'] = $building->id;
|
||||
$data['code'] = $building->code . "." . sprintf("%03d", $i);
|
||||
|
||||
if($building->units == 1) {
|
||||
$data['contact'] = $building->contact;
|
||||
$data['phone'] = $building->phone;
|
||||
$data['email'] = $building->email;
|
||||
}
|
||||
/*
|
||||
// no more lineworker_id in Termination
|
||||
if($building->lineworker_id) {
|
||||
$data['lineworker_id'] = $building->lineworker_id;
|
||||
}*/
|
||||
if($building->oaid) {
|
||||
$data['oaid'] = $building->oaid. "." . sprintf("%03d", $i);
|
||||
}
|
||||
|
||||
$term = TerminationModel::create($data);
|
||||
$term->save();
|
||||
}
|
||||
|
||||
if($mode == "add" && $adb_hausnummer_id) {
|
||||
$i = 1;
|
||||
foreach(ADBWohneinheitModel::search(["hausnummer_id" => $adb_hausnummer_id]) as $wohneinheit) {
|
||||
$data = [];
|
||||
$data['building_id'] = $building->id;
|
||||
|
||||
$oaid_parts = explode(".", $wohneinheit->oaid);
|
||||
if($wohneinheit->oaid && array_key_exists(1, $oaid_parts)) {
|
||||
$data['code'] = $building->oaid . "." . $oaid_parts[1];
|
||||
} else {
|
||||
$data['code'] = $building->code . "." . sprintf("%04d", $i);
|
||||
}
|
||||
$data['oaid'] = $data['code'];
|
||||
|
||||
$term = TerminationModel::create($data);
|
||||
$term->save();
|
||||
|
||||
$i++;
|
||||
}
|
||||
} elseif(!$building->terminations && $building->units > 0) {
|
||||
for($i = 1; $i <= $building->units; $i++) {
|
||||
$data = [];
|
||||
$data['building_id'] = $building->id;
|
||||
$data['code'] = $building->code . "." . sprintf("%03d", $i);
|
||||
|
||||
if($building->units == 1) {
|
||||
$data['contact'] = $building->contact;
|
||||
$data['phone'] = $building->phone;
|
||||
$data['email'] = $building->email;
|
||||
}
|
||||
/*
|
||||
// no more lineworker_id in Termination
|
||||
if($building->lineworker_id) {
|
||||
$data['lineworker_id'] = $building->lineworker_id;
|
||||
}*/
|
||||
if($building->oaid) {
|
||||
$data['oaid'] = $building->oaid. "." . sprintf("%03d", $i);
|
||||
}
|
||||
|
||||
$term = TerminationModel::create($data);
|
||||
$term->save();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -343,7 +375,55 @@ class BuildingController extends mfBaseController {
|
||||
$this->layout()->setFlash("Objekt gelöscht", "success");
|
||||
$this->redirect("Building");
|
||||
}
|
||||
|
||||
|
||||
protected function createFromAdbAction() {
|
||||
$adb_hausnummer_id = $this->request->adb_hausnummer_id;
|
||||
|
||||
if(!$adb_hausnummer_id) {
|
||||
$this->layout()->setFlash("AddressDB Adresse nicht gefunden.", "error");
|
||||
$this->redirect("AddressDB");
|
||||
}
|
||||
|
||||
$hausnummer = new ADBHausnummer($adb_hausnummer_id);
|
||||
if(!$hausnummer->id) {
|
||||
$this->layout()->setFlash("AddressDB Adresse nicht gefunden.", "error");
|
||||
$this->redirect("AddressDB");
|
||||
}
|
||||
|
||||
$network = NetworkModel::getFirst(["adb_netzgebiet_id" => $hausnummer->netzgebiet_id]);
|
||||
if(!$network) {
|
||||
$this->layout()->setFlash("AddressDB-Netzgebiet ist keinem Netzbau-Netzgebiet zugeordnet!", "error");
|
||||
$this->redirect("AddressDB");
|
||||
}
|
||||
|
||||
$building_data = [];
|
||||
$building_data["adb_hausnummer_id"] = $hausnummer->id;
|
||||
$building_data["network_id"] = $network->id;
|
||||
$building_data["type_id"] = ($hausnummer->tool_building_type <= 1) ? 1 : 3; // 1 = EFH | 2 = MPH
|
||||
$building_data["status_id"] = 1;
|
||||
|
||||
if($hausnummer->oaid) {
|
||||
if(!BuildingModel::getFirst(["code" => $hausnummer->oaid])) {
|
||||
$building_data["code"] = $hausnummer->oaid;
|
||||
}
|
||||
$building_data["oaid"] = $hausnummer->oaid;
|
||||
}
|
||||
|
||||
$building_data["street"] = $hausnummer->strasse->name . " ".$hausnummer->hausnummer;
|
||||
$building_data["city"] = $hausnummer->strasse->gemeinde->name;
|
||||
$building_data["zip"] = $hausnummer->plz->plz;
|
||||
$building_data["gps_lat"] = $hausnummer->gps_lat;
|
||||
$building_data["gps_long"] = $hausnummer->gps_long;
|
||||
$building_data["units"] = "from_adb";
|
||||
$building_data["note"] = "Created from ADB Address {$hausnummer->id}";
|
||||
|
||||
$building = BuildingModel::create($building_data);
|
||||
$this->layout()->set("building", $building);
|
||||
$this->layout()->set("adb_hausnummer_id", $hausnummer->id);
|
||||
|
||||
return $this->addAction();
|
||||
}
|
||||
|
||||
|
||||
protected function apiAction() {
|
||||
if(!$this->me->is(["Admin","netowner","pipeplanner"])) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
class BuildingModel {
|
||||
public $adb_hausnummer_id = null;
|
||||
public $network_id = null;
|
||||
public $pop_id = null;
|
||||
public $type_id = null;
|
||||
|
||||
@@ -1043,10 +1043,12 @@ class ConstructionConsentController extends mfBaseController {
|
||||
|
||||
private function generateStats($baseFilter = array()): array {
|
||||
function getFilteredCount($wantedFilter, $filterValue, $baseFilter) {
|
||||
if (!empty($baseFilter[$wantedFilter]) && $baseFilter[$wantedFilter] != $filterValue) return 0;
|
||||
if ($wantedFilter !== 'deferred' && !empty($baseFilter[$wantedFilter]) && $baseFilter[$wantedFilter] != $filterValue) return 0;
|
||||
return ConstructionConsent::count(array_merge($baseFilter, [$wantedFilter => $filterValue]));
|
||||
}
|
||||
|
||||
$baseFilter["deferred"] = "NULL";
|
||||
|
||||
return [
|
||||
"all" => ConstructionConsent::count($baseFilter),
|
||||
"street" => getFilteredCount("object_type", "street", $baseFilter),
|
||||
@@ -1058,7 +1060,8 @@ class ConstructionConsentController extends mfBaseController {
|
||||
"status_light_blue" => getFilteredCount("status_light", "blue", $baseFilter),
|
||||
"status_light_red" => getFilteredCount("status_light", "red", $baseFilter),
|
||||
"status_light_yellow" => getFilteredCount("status_light", "yellow", $baseFilter),
|
||||
"status_light_green" => getFilteredCount("status_light", "green", $baseFilter)
|
||||
"status_light_green" => getFilteredCount("status_light", "green", $baseFilter),
|
||||
"status_deferred" => getFilteredCount("deferred", "!NULL", $baseFilter),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,20 @@ class ConstructionConsentProject extends mfBaseModel {
|
||||
return $where;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, name: string}[]
|
||||
*/
|
||||
public static function getByAdbNetzgebietId(int $adbNetzgebietId): array {
|
||||
$db = FronkDB::singleton();
|
||||
$id = $db->escape($adbNetzgebietId);
|
||||
$res = $db->query(
|
||||
"SELECT ccp.id, ccp.name FROM `ConstructionConsentProject` ccp
|
||||
JOIN `ConstructionConsentNetwork` ccn ON ccp.id = ccn.constructionconsentproject_id
|
||||
WHERE ccn.adb_netzgebiet_id = '{$id}'"
|
||||
);
|
||||
return $db->fetch_all_assoc($res) ?? [];
|
||||
}
|
||||
|
||||
public static function hasFaultyOwnerEntries(int $projectId): bool {
|
||||
if (empty($projectId)) return false;
|
||||
|
||||
|
||||
@@ -451,8 +451,22 @@ class CpeprovisioningController extends mfBaseController {
|
||||
$attrs = $prod->product->attributes ?? [];
|
||||
if (empty($attrs) || !is_array($attrs)) continue;
|
||||
|
||||
if ($attrs['hw_only']->value ?? false) $orderInfo['hw'][] = (int)$prod->amount . "x " . $prod->product->name;
|
||||
if ($attrs['addon']->value ?? false) $orderInfo['hw'][] = $prod->product->name;
|
||||
if ($attrs['bras_type']->value ?? false) continue;
|
||||
|
||||
$added = false;
|
||||
if ($attrs['hw_only']->value ?? false) {
|
||||
$orderInfo['hw'][] = (int)$prod->amount . "x " . $prod->product->name;
|
||||
$added = true;
|
||||
}
|
||||
if ($attrs['addon']->value ?? false) {
|
||||
$orderInfo['hw'][] = $prod->product->name;
|
||||
$added = true;
|
||||
}
|
||||
|
||||
if (!$added && in_array($prod->product->productgroup_id, [6, 4, 8])) {
|
||||
$orderInfo['hw'][] = (int)$prod->amount . "x " . $prod->product->name;
|
||||
}
|
||||
|
||||
if ($attrs['voip_chan']->value ?? false) $orderInfo['voip'] = true;
|
||||
if ($attrs['vot']->value ?? false) $orderInfo['vot'] = true;
|
||||
}
|
||||
@@ -529,6 +543,8 @@ class CpeprovisioningController extends mfBaseController {
|
||||
// Pass API URLs and initial data to the frontend
|
||||
"CPE_PROV_API_GET_URL" => $this->getUrl("Cpeprovisioning", "apiGet"),
|
||||
"CPE_PROV_API_SAVE_URL" => $this->getUrl("Cpeprovisioning", "apiSave"),
|
||||
"CPE_PROV_API_TEST_ACS_VLAN_URL" => $this->getUrl("Cpeprovisioning", "getAcsVlan"),
|
||||
"CPE_PROV_API_CREATE_RADIUS_USER_URL" => $this->getUrl("Cpeprovisioning", "createRadiusUser"),
|
||||
"CPE_PROV_PRINT_PDF_URL" => $this->getUrl("Cpeprovisioning", "printPDF"),
|
||||
"ORDER_URL" => $this->getUrl("Order"),
|
||||
"NETWORKS" => NetworkModel::getAll(),
|
||||
@@ -565,6 +581,338 @@ class CpeprovisioningController extends mfBaseController {
|
||||
);
|
||||
}
|
||||
|
||||
protected function getAcsVlanAction() {
|
||||
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? null;
|
||||
$isApiCall = defined('TT_CPE_PROV_ACS_API_KEY') && $apiKey && $apiKey === TT_CPE_PROV_ACS_API_KEY;
|
||||
$isLoggedInUser = $this->me && $this->me->id;
|
||||
|
||||
if (!$isApiCall && !$isLoggedInUser) {
|
||||
http_response_code(403);
|
||||
self::returnJson(['success' => false, 'message' => 'Forbidden']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$p = json_decode(file_get_contents('php://input'), true);
|
||||
$mac = $p['mac'] ?? null;
|
||||
|
||||
if (empty($mac)) {
|
||||
throw new Exception("MAC address is required.");
|
||||
}
|
||||
|
||||
$cpe = CpeprovisioningModel::getFirst(['mac' => $mac]);
|
||||
if (!$cpe || !$cpe->termination_id) {
|
||||
throw new Exception("No active provisioning entry found for this MAC address.");
|
||||
}
|
||||
|
||||
$term = new Termination($cpe->termination_id);
|
||||
$product = $cpe->orderproduct;
|
||||
if (!$term->id || !$product->id) {
|
||||
throw new Exception("Could not load termination or product details.");
|
||||
}
|
||||
|
||||
$attrs = $product->product->attributes;
|
||||
|
||||
// First, check if any VLAN is explicitly saved (checked in frontend)
|
||||
// The saved values take priority over defaults
|
||||
$assignedVlan = $cpe->vlan_public ?? $cpe->vlan_nat ?? $cpe->vlan_ipv6;
|
||||
|
||||
// If no VLAN is explicitly saved, fall back to defaults
|
||||
if (!$assignedVlan) {
|
||||
$vlanPublicDefault = $term->getPop()->vlan_public ?? $attrs['vlan_default_public']->value ?? null;
|
||||
$vlanNatDefault = $term->getPop()->vlan_nat ?? $attrs['vlan_default_nat']->value ?? null;
|
||||
$vlanIpv6Default = $term->getPop()->vlan_ipv6 ?? $attrs['vlan_default_ipv6']->value ?? null;
|
||||
$assignedVlan = $vlanPublicDefault ?? $vlanNatDefault ?? $vlanIpv6Default;
|
||||
}
|
||||
|
||||
if ($assignedVlan) {
|
||||
self::returnJson(['success' => true, 'vlan_id' => $assignedVlan]);
|
||||
} else {
|
||||
throw new Exception("No default VLAN could be determined for this product/POP combination.");
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(400);
|
||||
self::returnJson(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function createRadiusUserAction() {
|
||||
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? null;
|
||||
$isApiCall = defined('TT_CPE_PROV_ACS_API_KEY') && $apiKey && $apiKey === TT_CPE_PROV_ACS_API_KEY;
|
||||
$isLoggedInUser = $this->me && $this->me->id;
|
||||
|
||||
if (!$isApiCall && !$isLoggedInUser) {
|
||||
http_response_code(403);
|
||||
self::returnJson(['success' => false, 'message' => 'Forbidden']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$p = json_decode(file_get_contents('php://input'), true);
|
||||
$mac = $p['mac'] ?? null;
|
||||
|
||||
if (empty($mac)) {
|
||||
throw new Exception("MAC address is required.");
|
||||
}
|
||||
|
||||
// Normalize MAC address format to uppercase with colons
|
||||
$mac = strtoupper(str_replace(['-', '.'], ':', $mac));
|
||||
|
||||
// Look up CPE provisioning entry
|
||||
$cpe = CpeprovisioningModel::getFirst(['mac' => $mac]);
|
||||
if (!$cpe || !$cpe->termination_id) {
|
||||
throw new Exception("No active provisioning entry found for this MAC address.");
|
||||
}
|
||||
|
||||
$term = new Termination($cpe->termination_id);
|
||||
$product = $cpe->orderproduct;
|
||||
$order = new Order($cpe->order_id);
|
||||
|
||||
if (!$term->id || !$product->id || !$order->id) {
|
||||
throw new Exception("Could not load termination, product, or order details.");
|
||||
}
|
||||
|
||||
// Gather all data needed for RADIUS user
|
||||
$customerNumber = $order->owner->customer_number ?? $order->partner_number ?? '';
|
||||
$ontSn = $term->getWorkflowValue("ont_sn", "string") ?? '';
|
||||
$wifiKey = $cpe->wifi_pass ?? '';
|
||||
|
||||
// Check if RADIUS credentials are configured
|
||||
if (!defined('TT_RADIUS_URL') || !defined('TT_RADIUS_USERNAME') || !defined('TT_RADIUS_PASSWORD')) {
|
||||
throw new Exception("RADIUS server credentials are not configured.");
|
||||
}
|
||||
|
||||
$radiusUrl = TT_RADIUS_URL;
|
||||
$radiusUsername = TT_RADIUS_USERNAME;
|
||||
$radiusPassword = TT_RADIUS_PASSWORD;
|
||||
|
||||
// Step 1: Check if RADIUS user already exists
|
||||
$existingUser = $this->checkRadiusUserExists($mac);
|
||||
|
||||
if ($existingUser) {
|
||||
$this->log->info("RADIUS user {$mac} already exists, skipping creation and updating details only.");
|
||||
} else {
|
||||
// Step 2: Login to RADIUS server
|
||||
$cookieFile = tempnam(sys_get_temp_dir(), 'radius_cookie_');
|
||||
|
||||
// Generate random password for RADIUS user
|
||||
$radiusUserPassword = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz0123456789'), 0, 8);
|
||||
|
||||
$loginResult = $this->radiusHttpRequest(
|
||||
$radiusUrl . '/login.php',
|
||||
[
|
||||
'username' => $radiusUsername,
|
||||
'password' => $radiusPassword,
|
||||
'submit' => 'Login'
|
||||
],
|
||||
$cookieFile
|
||||
);
|
||||
|
||||
if (strpos($loginResult, 'Login') === false && strpos($loginResult, 'logout') === false) {
|
||||
throw new Exception("Failed to login to RADIUS server.");
|
||||
}
|
||||
|
||||
// Step 3: Create user with MAC address
|
||||
$createResult = $this->radiusHttpRequest(
|
||||
$radiusUrl . '/add_user.php',
|
||||
[
|
||||
'user' => $mac,
|
||||
'pass' => $radiusUserPassword,
|
||||
'submit' => ' Anlegen '
|
||||
],
|
||||
$cookieFile
|
||||
);
|
||||
|
||||
// Check if user creation was successful
|
||||
if (strpos($createResult, 'already exists') !== false) {
|
||||
// Somehow it exists now (race condition?), continue to update
|
||||
$this->log->info("RADIUS user {$mac} already exists, updating details.");
|
||||
} elseif (strpos($createResult, 'error') !== false || strpos($createResult, 'Error') !== false) {
|
||||
throw new Exception("Failed to create RADIUS user: User creation returned an error.");
|
||||
} else {
|
||||
$this->log->info("RADIUS user {$mac} created successfully.");
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Login to RADIUS server for update (in case we skipped creation)
|
||||
$cookieFile = tempnam(sys_get_temp_dir(), 'radius_cookie_');
|
||||
$loginResult = $this->radiusHttpRequest(
|
||||
$radiusUrl . '/login.php',
|
||||
[
|
||||
'username' => $radiusUsername,
|
||||
'password' => $radiusPassword,
|
||||
'submit' => 'Login'
|
||||
],
|
||||
$cookieFile
|
||||
);
|
||||
|
||||
if (strpos($loginResult, 'Login') === false && strpos($loginResult, 'logout') === false) {
|
||||
throw new Exception("Failed to login to RADIUS server for update.");
|
||||
}
|
||||
|
||||
// Step 5: Update user details
|
||||
$updateData = [
|
||||
'userid' => '',
|
||||
'user' => $mac,
|
||||
'Cleartext-Password' => '',
|
||||
'Custnum' => (strpos($customerNumber, '7000') === 0) ? $customerNumber : '',
|
||||
'Custnume' => (strpos($customerNumber, '7000') === 0) ? '' : $customerNumber,
|
||||
'Hotspot_Info' => '',
|
||||
'ont_sn' => $ontSn,
|
||||
'Wifikey' => $wifiKey,
|
||||
'Mikrotik-Group' => '',
|
||||
'Framed-Pool' => '',
|
||||
'Pool-Name' => '',
|
||||
'Framed-IP-Address' => '',
|
||||
'Framed-IP-Netmask' => '',
|
||||
'Framed-Route' => '',
|
||||
'MS-Primary-DNS-Server' => '195.191.252.62',
|
||||
'MS-Secondary-DNS-Server' => '193.105.204.194',
|
||||
'DHCP-IP-Address-Lease-Time' => '',
|
||||
'MaxLogins' => '',
|
||||
'Valid-From' => '',
|
||||
'Valid-To' => '',
|
||||
'Hotspot_Duration' => '',
|
||||
'Hotspot_Duration_Multiplicant' => '86400',
|
||||
'Rate-Limit-Down' => '',
|
||||
'Rate-Limit-Up' => '',
|
||||
'ContractDown' => '',
|
||||
'ContractUp' => '',
|
||||
'Rate-Limit-Down-Burst' => '',
|
||||
'Rate-Limit-Up-Burst' => '',
|
||||
'Rate-Limit-Burst-Sec' => '',
|
||||
'Rate-Limit-Down-Thresh' => '',
|
||||
'Rate-Limit-Up-Thresh' => '',
|
||||
'Session-Timeout' => '',
|
||||
'timeout_max' => '',
|
||||
'Mikrotik-Recv-Limit' => '',
|
||||
'transfer_max' => '',
|
||||
'cisco-avpair[vrf]' => '',
|
||||
'cisco-avpair[interface]' => '',
|
||||
'submit' => 'Update'
|
||||
];
|
||||
|
||||
$updateResult = $this->radiusHttpRequest(
|
||||
$radiusUrl . '/edit_user.php',
|
||||
$updateData,
|
||||
$cookieFile
|
||||
);
|
||||
|
||||
// Clean up cookie file
|
||||
@unlink($cookieFile);
|
||||
|
||||
// Check if update was successful
|
||||
if (strpos($updateResult, 'error') !== false || strpos($updateResult, 'Error') !== false) {
|
||||
throw new Exception("Failed to update RADIUS user details.");
|
||||
}
|
||||
|
||||
$this->log->info("Successfully created/updated RADIUS user for MAC: {$mac}, Customer: {$customerNumber}");
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => 'RADIUS user created/updated successfully.',
|
||||
'data' => [
|
||||
'mac' => $mac,
|
||||
'customer_number' => $customerNumber,
|
||||
'ont_sn' => $ontSn,
|
||||
'wifi_key' => $wifiKey
|
||||
]
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Clean up cookie file on error
|
||||
if (isset($cookieFile) && file_exists($cookieFile)) {
|
||||
@unlink($cookieFile);
|
||||
}
|
||||
|
||||
$this->log->error("Failed to create RADIUS user: " . $e->getMessage());
|
||||
http_response_code(400);
|
||||
self::returnJson(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a RADIUS user exists by username (MAC address)
|
||||
*
|
||||
* @param string $username The username/MAC address to check
|
||||
* @return bool True if user exists, false otherwise
|
||||
*/
|
||||
private function checkRadiusUserExists($username) {
|
||||
try {
|
||||
if (!defined('TT_RADIUS_API_URL')) {
|
||||
// Fallback to default if not configured
|
||||
$apiUrl = 'http://radius.xinon.at/api.php';
|
||||
} else {
|
||||
$apiUrl = TT_RADIUS_API_URL;
|
||||
}
|
||||
|
||||
// Query the RADIUS API to check if user exists
|
||||
$queryParams = http_build_query(['username' => $username]);
|
||||
$url = $apiUrl . '?' . $queryParams;
|
||||
|
||||
$opts = [
|
||||
"http" => [
|
||||
"method" => "GET",
|
||||
"header" => "Authorization: Basic " . base64_encode(TT_RADIUS_USERNAME . ":" . TT_RADIUS_PASSWORD),
|
||||
"timeout" => 10
|
||||
]
|
||||
];
|
||||
|
||||
$context = stream_context_create($opts);
|
||||
$response = @file_get_contents($url, false, $context);
|
||||
|
||||
if ($response === false) {
|
||||
$this->log->warning("Could not check if RADIUS user exists: API request failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
|
||||
// If we get results back, the user exists
|
||||
if (is_array($data) && count($data) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->log->error("Error checking RADIUS user existence: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function radiusHttpRequest($url, $postData, $cookieFile) {
|
||||
$ch = curl_init($url);
|
||||
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postData));
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_COOKIEJAR, $cookieFile);
|
||||
curl_setopt($ch, CURLOPT_COOKIEFILE, $cookieFile);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
throw new Exception("HTTP request failed: " . $error);
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
throw new Exception("HTTP request returned status code: " . $httpCode);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function fixCpeData($data) {
|
||||
if (!$data) return [];
|
||||
$data->shipping = (bool)$data->shipping;
|
||||
|
||||
@@ -181,6 +181,13 @@ class CpeprovisioningModel {
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("mac", $filter)) {
|
||||
$mac = FronkDB::singleton()->escape($filter['mac']);
|
||||
if($mac) {
|
||||
$where .= " AND mac='$mac'";
|
||||
}
|
||||
}
|
||||
|
||||
//var_dump($filter, $where);exit;
|
||||
return $where;
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ class DashboardNewController extends mfBaseController {
|
||||
$campaign_ids = array_map(fn($campaign) => $campaign->id, $owner_campaigns);
|
||||
}
|
||||
|
||||
$order_max_homes = $this->getTotalHomes($campaign_ids, $gemeinde_ids);
|
||||
$order_max_homes = PreorderModel::countTotalUnits($campaign_ids, $gemeinde_ids)['total_unit_count'];
|
||||
|
||||
$efh_connection_types = ["single-dwelling", "business"];
|
||||
$mph_connection_types = ["apartment-building", "apartment", "multi-dwelling"];
|
||||
@@ -370,7 +370,7 @@ class DashboardNewController extends mfBaseController {
|
||||
$campaign_ids = [$campaign->id];
|
||||
$gemeinde_ids = []; // Empty array as in original
|
||||
|
||||
$order_max_homes = $this->getTotalHomes($campaign_ids, $gemeinde_ids);
|
||||
$order_max_homes = PreorderModel::countTotalUnits($campaign_ids, $gemeinde_ids)['total_unit_count'];
|
||||
|
||||
$efh_connection_types = [0, 1]; // Single-dwelling and business
|
||||
$mph_connection_types = [2]; // Apartment-building, apartment, multi-dwelling
|
||||
@@ -568,43 +568,6 @@ class DashboardNewController extends mfBaseController {
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
private function getTotalHomes(array $preordercampaign_id = [], array $gemeinde_id = []) {
|
||||
$baseSQL = "SELECT COUNT(adb_wohneinheit.id) as cnt FROM `" . ADDRESSDB_DBNAME . "`.Wohneinheit adb_wohneinheit
|
||||
LEFT JOIN `" . ADDRESSDB_DBNAME . "`.Hausnummer adb_hausnummer ON (adb_wohneinheit.hausnummer_id = adb_hausnummer.id)
|
||||
LEFT JOIN `" . ADDRESSDB_DBNAME . "`.Strasse adb_strasse ON (adb_hausnummer.strasse_id = adb_strasse.id)
|
||||
WHERE 1=1";
|
||||
|
||||
$where = "";
|
||||
|
||||
if (!empty($preordercampaign_id)) {
|
||||
$netzgebiet_ids = [];
|
||||
foreach ($preordercampaign_id as $campaign_id) {
|
||||
$campaign = new Preordercampaign($campaign_id);
|
||||
if ($campaign->network_id) {
|
||||
$network = new Network($campaign->network_id);
|
||||
$netzgebiet_ids[] = $network->adb_netzgebiet_id;
|
||||
}
|
||||
}
|
||||
|
||||
$where .= " AND adb_hausnummer.netzgebiet_id IN (" . implode(',', array_map('intval', $netzgebiet_ids)) . ")";
|
||||
}
|
||||
|
||||
if (!empty($gemeinde_id)) {
|
||||
$where .= " AND adb_strasse.gemeinde_id IN (" . implode(',', array_map('intval', $gemeinde_id)) . ")";
|
||||
}
|
||||
|
||||
$sql = $baseSQL . $where;
|
||||
|
||||
$res = $this->db()->query($sql);
|
||||
if ($this->db()->num_rows($res)) {
|
||||
$data = $this->db()->fetch_object($res);
|
||||
return $data->cnt;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
protected function getDashboardAddressDBDataAction() {
|
||||
if (!$this->me->is("Admin")) self::sendError("Keine Berechtigung");
|
||||
$baseFilter = [];
|
||||
|
||||
@@ -212,6 +212,119 @@ class InvoiceController extends mfBaseController {
|
||||
exit;
|
||||
}
|
||||
|
||||
protected function downloadInvoiceVoiceDetailsAction() {
|
||||
$id = $this->request->id;
|
||||
if (!is_numeric($id) || !$id) {
|
||||
$this->layout()->setFlash("Rechnung nicht gefunden", "error");
|
||||
$this->redirect("Invoice");
|
||||
}
|
||||
|
||||
$invoice = new Invoice($id);
|
||||
if (!$invoice->id) {
|
||||
$this->layout()->setFlash("Rechnung nicht gefunden", "error");
|
||||
$this->redirect("Invoice");
|
||||
}
|
||||
|
||||
$csv = "Startzeit;Abgehende Nummer;Zielnummer;Zone;Dauer;Kosten\n";
|
||||
|
||||
$total = 0;
|
||||
$destinations_cache = [];
|
||||
//var_dump($invoice->voicenumbers);exit;
|
||||
foreach($invoice->voicenumbers as $voicenumber) {
|
||||
$start_date = new DateTime($voicenumber->start_date);
|
||||
//$start_date->setTimezone(new DateTimeZone("Europe/Vienna"));
|
||||
$start_date->setTime(0,0,0);
|
||||
$end_date = new DateTime($voicenumber->end_date);
|
||||
//$end_date->setTimezone(new DateTimeZone("Europe/Vienna"));
|
||||
$end_date->setTime(23,59,59);
|
||||
|
||||
$call_date_start = $start_date->format("Y-m-d");
|
||||
|
||||
foreach(VoiceCallHistoryModel::getVoiceCallHistoryAsEntity([
|
||||
"voice_account" => $voicenumber->voicenumber,
|
||||
"start" => [
|
||||
"from" => $start_date->getTimestamp(),
|
||||
"to" => $end_date->getTimestamp()
|
||||
],
|
||||
"billable" => 1,
|
||||
], null, 0, ["key" => "start", "order" => "ASC"]) as $call) {
|
||||
if(!$call->contract_id) continue;
|
||||
|
||||
//$voiceplan = new Voiceplan($voicenumber->voiceplan_id);
|
||||
$voiceplan = VoiceplanModel::getFirst(["name" => $voicenumber->voiceplan]);
|
||||
if(!$voiceplan) {
|
||||
$this->log->warning(__METHOD__.": Voiceplan not found");
|
||||
exit;
|
||||
}
|
||||
|
||||
$number = $voicenumber->voicenumber;
|
||||
$dest_nummer = $call->destination;
|
||||
if (substr($dest_nummer, 0, 2) == "00") {
|
||||
$dest_nummer = substr($dest_nummer, 2);
|
||||
}
|
||||
|
||||
if (substr($dest_nummer, 0, 1) == "+") {
|
||||
$dest_nummer = substr($dest_nummer, 1);
|
||||
}
|
||||
|
||||
if (array_key_exists($dest_nummer, $destinations_cache)) {
|
||||
$destination = $destinations_cache[$dest_nummer];
|
||||
} else {
|
||||
$destination = $voiceplan->getDestinationByNumber($dest_nummer);
|
||||
if (!$destination) {
|
||||
die("Destination für Zielrufnummer " . $call->destination . " nicht gefunden");
|
||||
}
|
||||
$destinations_cache[$dest_nummer] = $destination;
|
||||
}
|
||||
//var_dump($destination);
|
||||
|
||||
$zone = $destination->voiceplanzone;
|
||||
|
||||
if (!$zone) {
|
||||
die("Keine Zone für Destination " . $dest_nummer . " gefunden");
|
||||
}
|
||||
|
||||
//var_dump($zone);exit;
|
||||
|
||||
// inc_first - first minimumm duration to bill
|
||||
// inc - subsequent minimum duration to bill
|
||||
$inc_first = $zone->increment_first;
|
||||
$inc = $zone->increment;
|
||||
|
||||
$billable_duration = $call->duration;
|
||||
if ($billable_duration <= 0) continue;
|
||||
|
||||
// calculate price of first duration unit
|
||||
// then subtract first minimum duration from duration
|
||||
$sec_price = $zone->price / 60;
|
||||
$call_price = $inc_first * $sec_price;
|
||||
$billable_duration -= $inc_first;
|
||||
|
||||
// calculate price of remaining duration and make sure to bill in full duration units
|
||||
if ($billable_duration > 0) {
|
||||
$multi = ceil($billable_duration / $inc);
|
||||
$call_price += ($multi * $inc) * $sec_price;
|
||||
}
|
||||
|
||||
$csv .= '"'.$call->start.'";';
|
||||
$csv .= '"'.$call->source.'"; ';
|
||||
$csv .= '"'.$call->destination.'"; ';
|
||||
$csv .= '"'.$zone->name.'"; ';
|
||||
$csv .= $call->duration.'; ';
|
||||
$csv .= '"'.str_replace(".",",", $call_price).'"';
|
||||
$csv .= "\n";
|
||||
|
||||
$total += $call_price;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
header("Content-type: text/csv; charset=utf-8");
|
||||
header('Content-disposition: attachment; filename="'.$invoice->invoice_number.'-egn.csv"');
|
||||
echo $csv;
|
||||
exit;
|
||||
}
|
||||
|
||||
protected function createJob() {
|
||||
$r = $this->request;
|
||||
@@ -380,34 +493,38 @@ class InvoiceController extends mfBaseController {
|
||||
$inc = reset($voicebills)->increment;
|
||||
$inc_first = reset($voicebills)->increment_first;
|
||||
|
||||
$zoneId2ZoneName = [];
|
||||
|
||||
$voice_rows = [];
|
||||
foreach ($voicebills as $voicebill) {
|
||||
$number = $voicebill->voicenumber;
|
||||
$zone_id = $voicebill->zone_id;
|
||||
$zone = $voicebill->zone;
|
||||
$call_count = $voicebill->call_count;
|
||||
$duration = $voicebill->duration;
|
||||
$price = $voicebill->price;
|
||||
$price_total = $voicebill->price_total;
|
||||
|
||||
$zoneId2ZoneName[$zone_id] = $zone;
|
||||
|
||||
if (!array_key_exists($number, $voice_rows)) {
|
||||
$voice_rows[$number] = [];
|
||||
}
|
||||
|
||||
if (!array_key_exists($zone, $voice_rows[$number])) {
|
||||
$voice_rows[$number][$zone] = [];
|
||||
if (!array_key_exists($zone_id, $voice_rows[$number])) {
|
||||
$voice_rows[$number][$zone_id] = [];
|
||||
}
|
||||
|
||||
if (!array_key_exists($price, $voice_rows[$number][$zone])) {
|
||||
$voice_rows[$number][$zone][$price] = [];
|
||||
$voice_rows[$number][$zone][$price]["call_count"] = 0;
|
||||
$voice_rows[$number][$zone][$price]["duration"] = 0;
|
||||
$voice_rows[$number][$zone][$price]["price_total"] = 0;
|
||||
if (!array_key_exists($price, $voice_rows[$number][$zone_id])) {
|
||||
$voice_rows[$number][$zone_id][$price] = [];
|
||||
$voice_rows[$number][$zone_id][$price]["call_count"] = 0;
|
||||
$voice_rows[$number][$zone_id][$price]["duration"] = 0;
|
||||
$voice_rows[$number][$zone_id][$price]["price_total"] = 0;
|
||||
}
|
||||
|
||||
$voice_rows[$number][$zone][$price]["call_count"] += $call_count;
|
||||
$voice_rows[$number][$zone][$price]["duration"] = $duration;
|
||||
$voice_rows[$number][$zone][$price]["price_total"] = $price_total;
|
||||
$voice_rows[$number][$zone_id][$price]["call_count"] += $call_count;
|
||||
$voice_rows[$number][$zone_id][$price]["duration"] = $duration;
|
||||
$voice_rows[$number][$zone_id][$price]["price_total"] = $price_total;
|
||||
}
|
||||
|
||||
//var_dump($voice_rows);exit;
|
||||
@@ -427,10 +544,10 @@ class InvoiceController extends mfBaseController {
|
||||
$invoice_voicenumber->voicenumberzones = [];
|
||||
|
||||
|
||||
foreach ($zones as $zone => $prices) {
|
||||
foreach ($zones as $zone_id => $prices) {
|
||||
foreach ($prices as $price => $row_values) {
|
||||
$zone_data = [];
|
||||
$zone_data["zone"] = $zone;
|
||||
$zone_data["zone"] = $zoneId2ZoneName[$zone_id];
|
||||
$zone_data["call_count"] = $row_values["call_count"];
|
||||
$zone_data["duration"] = $row_values["duration"];
|
||||
$zone_data["price"] = $price;
|
||||
|
||||
@@ -121,8 +121,33 @@ class LineworkController extends mfBaseController {
|
||||
}*/
|
||||
//var_dump($termination_search);exit;
|
||||
$networks = [];
|
||||
$pagination['maxItems'] = TerminationModel::count($termination_search);
|
||||
foreach(TerminationModel::search($termination_search, $pagination) as $term) {
|
||||
|
||||
// Store ap_name filter separately for post-processing
|
||||
$ap_name_filter = null;
|
||||
if(array_key_exists('ap_name', $termination_search) && $termination_search['ap_name']) {
|
||||
$ap_name_filter = $termination_search['ap_name'];
|
||||
unset($termination_search['ap_name']); // Remove from search as it's a workflow value
|
||||
}
|
||||
|
||||
if($ap_name_filter) {
|
||||
$all_terminations = TerminationModel::search($termination_search, false);
|
||||
$filtered_terminations = [];
|
||||
|
||||
foreach($all_terminations as $term) {
|
||||
$ap_name = $term->building->getWorkflowvalue('ist_anschlusspunkt_name') ?: $term->building->getWorkflowvalue('anschlusspunkt_name');
|
||||
if($ap_name && stripos($ap_name, $ap_name_filter) !== false) {
|
||||
$filtered_terminations[] = $term;
|
||||
}
|
||||
}
|
||||
|
||||
$pagination['maxItems'] = count($filtered_terminations);
|
||||
$terminations = array_slice($filtered_terminations, $pagination['start'], $pagination['count']);
|
||||
} else {
|
||||
$pagination['maxItems'] = TerminationModel::count($termination_search);
|
||||
$terminations = TerminationModel::search($termination_search, $pagination);
|
||||
}
|
||||
|
||||
foreach($terminations as $term) {
|
||||
if(!array_key_exists($term->building->network->name, $networks)) {
|
||||
$networks[$term->building->network->name] = [];
|
||||
}
|
||||
@@ -188,10 +213,12 @@ class LineworkController extends mfBaseController {
|
||||
$new_filter['id'] = $value;
|
||||
continue;
|
||||
}*/
|
||||
|
||||
|
||||
// AP-Name filter is passed through to be handled by the model
|
||||
// It will filter by workflow values: ist_anschlusspunkt_name or anschlusspunkt_name
|
||||
$new_filter[$name] = $value;
|
||||
}
|
||||
|
||||
|
||||
return $new_filter;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,735 @@
|
||||
<?php
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
class ManualInvoiceController extends TTCrud
|
||||
{
|
||||
protected string $headerTitle = 'Manuelle Rechnungen';
|
||||
protected bool $createText = false;
|
||||
protected array $additionalJS = ["js/pages/ManualInvoice/ManualInvoice.js"];
|
||||
protected array $additionalHead = ["<link rel='stylesheet' href='/js/pages/ManualInvoice/ManualInvoice.css'>"];
|
||||
private array $tempPositions = [];
|
||||
|
||||
//@formatter:off
|
||||
protected array $columns = [
|
||||
['key' => 'invoiceNumber', 'text' => 'Rechnungsnr.', 'table' => ['sortable' => true, 'filter' => 'search']],
|
||||
['key' => 'customerName', 'text' => 'Kunde', 'table' => ['sortable' => true, 'filter' => 'search']],
|
||||
['key' => 'invoiceDate', 'text' => 'Datum', 'table' => ['sortable' => true, 'filter' => 'date']],
|
||||
['key' => 'totalAmount', 'text' => 'Betrag', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
|
||||
['key' => 'id', 'text' => 'ID', 'table' => ['visible' => false], 'modal' => false],
|
||||
['key' => 'invoice_number', 'text' => 'Rechnungsnr.', 'table' => ['sortable' => true, 'filter' => 'search']],
|
||||
['key' => 'invoice_date', 'text' => 'Datum', 'type' => 'timestamp', 'table' => ['sortable' => true, 'filter' => 'date', 'formatter' => 'formatDate']],
|
||||
['key' => 'company', 'text' => 'Firma', 'table' => ['sortable' => true, 'filter' => 'search']],
|
||||
['key' => 'firstname', 'text' => 'Vorname', 'table' => ['visible' => false], 'modal' => false],
|
||||
['key' => 'lastname', 'text' => 'Nachname', 'table' => ['visible' => false], 'modal' => false],
|
||||
['key' => 'customer_number', 'text' => 'Kundennr.', 'table' => ['sortable' => true, 'filter' => 'search']],
|
||||
['key' => 'total', 'text' => 'Netto', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
|
||||
['key' => 'total_gross', 'text' => 'Brutto', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
|
||||
['key' => 'status', 'text' => 'Status', 'table' => ['filter' => 'select', 'filterOptions' => [
|
||||
['value' => 'draft', 'text' => 'Entwurf'],
|
||||
['value' => 'sent', 'text' => 'Gesendet'],
|
||||
['value' => 'paid', 'text' => 'Bezahlt'],
|
||||
['value' => 'erstellt', 'text' => 'Erstellt'],
|
||||
['value' => 'gesendet', 'text' => 'Gesendet'],
|
||||
['value' => 'exportiert', 'text' => 'Exportiert'],
|
||||
]]],
|
||||
['key' => 'billing_type', 'text' => 'Zahlungsart', 'table' => ['filter' => 'select', 'filterOptions' => [
|
||||
['value' => 'invoice', 'text' => 'Rechnung'],
|
||||
['value' => 'sepa', 'text' => 'SEPA'],
|
||||
]]],
|
||||
['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]],
|
||||
];
|
||||
//@formatter:on
|
||||
|
||||
protected array $additionalActions = [
|
||||
['key' => 'createGutschrift', 'title' => 'Gutschrift erstellen', 'class' => 'fas fa-file-invoice text-warning'],
|
||||
['key' => 'pdfPreview', 'title' => 'PDF Vorschau', 'class' => 'fas fa-file-pdf text-danger'],
|
||||
['key' => 'sendInvoice', 'title' => 'Rechnung aussenden', 'class' => 'fas fa-paper-plane text-success']
|
||||
];
|
||||
|
||||
protected function createPDFAction($returnFilename = false) {
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
$invoice = (object)[];
|
||||
$positions = [];
|
||||
|
||||
if (isset($post['preview']) && $post['preview'] === true) {
|
||||
$invoice = (object) array_merge([
|
||||
'id' => 0, 'invoice_number' => null, 'invoice_date' => time(),
|
||||
'customer_number' => 0, 'fibu_account_number' => 0,
|
||||
'company' => '', 'firstname' => '', 'lastname' => '',
|
||||
'street' => '', 'zip' => '', 'city' => '', 'country' => 'Österreich',
|
||||
'email' => '', 'uid' => '', 'tax_text' => '', 'billing_type' => 'invoice',
|
||||
'leistungszeitraum' => '', 'einleitender_text' => '', 'externe_referenz' => '', 'gesamtrabatt' => 0,
|
||||
'total' => 0, 'total_gross' => 0
|
||||
], $post);
|
||||
|
||||
// Convert invoice_date from string to timestamp if needed
|
||||
if (isset($invoice->invoice_date) && is_string($invoice->invoice_date)) {
|
||||
$invoice->invoice_date = strtotime($invoice->invoice_date);
|
||||
}
|
||||
|
||||
$positions = array_map(function($p) {
|
||||
$obj = (object)$p;
|
||||
// Map _group to position_group for preview
|
||||
if (isset($p['_group'])) {
|
||||
$obj->position_group = $p['_group'];
|
||||
}
|
||||
return $obj;
|
||||
}, $post['positions'] ?? []);
|
||||
} else {
|
||||
$id = $this->request->id ?? $post['id'] ?? null;
|
||||
if (!$id || !($invoice = ManualInvoiceModel::get($id))) {
|
||||
http_response_code(500);
|
||||
self::returnJson(['success' => false, 'message' => 'Rechnung wurde nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
$positions = $invoice->getProperty('positions');
|
||||
}
|
||||
|
||||
$vat = [];
|
||||
foreach ($positions as $p) {
|
||||
$pObj = (object)$p;
|
||||
$vat[$pObj->vatrate] = ($vat[$pObj->vatrate] ?? 0) + ($pObj->price_gross ?? 0) - ($pObj->price_total ?? 0);
|
||||
}
|
||||
|
||||
$invoice->positions = $positions;
|
||||
$pdf_vars = [
|
||||
"invoice" => $invoice,
|
||||
"vat" => $vat,
|
||||
"bank_iban" => TT_INVOICE_BANK_IBAN,
|
||||
"bank_bic" => TT_INVOICE_BANK_BIC,
|
||||
"bank_bank" => TT_INVOICE_BANK_BANK,
|
||||
"bank_owner" => TT_INVOICE_BANK_OWNER
|
||||
];
|
||||
|
||||
$replacements = [
|
||||
"{{ basedir }}" => BASEDIR,
|
||||
"{{ addressLine_1 }}" => $invoice->company ?: "",
|
||||
"{{ addressLine_2 }}" => trim($invoice->firstname . " " . $invoice->lastname),
|
||||
"{{ addressLine_3 }}" => $invoice->street ?? '',
|
||||
"{{ addressLine_4 }}" => ($invoice->zip ?? '') . " " . ($invoice->city ?? ''),
|
||||
"{{ addressLine_5 }}" => ($invoice->country ?? '') != "Österreich" ? ($invoice->country ?? '') : "",
|
||||
"{{ customerNumber }}" => $invoice->customer_number ?? '',
|
||||
"{{ billingAccount }}" => $invoice->fibu_account_number ?? '',
|
||||
"{{ invoiceNumber }}" => $invoice->invoice_number ?? "VORSCHAU",
|
||||
"{{ invoiceDate }}" => date("d.m.Y", $invoice->invoice_date ?? time()),
|
||||
"{{ leistungszeitraumHtml }}" => ($invoice->leistungszeitraum ?? '') ? "<tr><td>Leistungszeitraum:</td><td>" . htmlspecialchars($invoice->leistungszeitraum) . "</td></tr>" : "",
|
||||
"{{ externeReferenzHtml }}" => ($invoice->externe_referenz ?? '') ? "<tr><td>Externe Referenz:</td><td>" . htmlspecialchars($invoice->externe_referenz) . "</td></tr>" : "",
|
||||
"{{ vatHtml }}" => ($invoice->uid ?? '') ? "<tr><td>Ihre UID:</td><td>" . $invoice->uid . "</td></tr>" : "",
|
||||
"{{ qrCodeSrc }}" => $this->generateSepaQRCode($invoice->invoice_number ?? "VORSCHAU", round($invoice->total_gross ?? 0, 2))
|
||||
];
|
||||
|
||||
$headerHtml = str_replace(array_keys($replacements), array_values($replacements), file_get_contents(BASEDIR . "/Layout/default/ManualInvoice/PDF_HEADER.html"));
|
||||
$headerFile = BASEDIR . "/var/temp/manualinvoice_header-" . date("U") . "-" . rand(1000, 9999) . ".html";
|
||||
file_put_contents($headerFile, $headerHtml);
|
||||
|
||||
$footerReplacements = [
|
||||
"{{ bank_iban }}" => TT_INVOICE_BANK_IBAN_FORMATTED,
|
||||
"{{ bank_bic }}" => TT_INVOICE_BANK_BIC,
|
||||
"{{ bank_bank }}" => TT_INVOICE_BANK_BANK,
|
||||
"{{ bank_owner }}" => TT_INVOICE_BANK_OWNER
|
||||
];
|
||||
$footerHtml = str_replace(array_keys($footerReplacements), array_values($footerReplacements), file_get_contents(BASEDIR . "/Layout/default/ManualInvoice/PDF_FOOTER.html"));
|
||||
$footerFile = BASEDIR . "/var/temp/manualinvoice_footer-" . date("U") . "-" . rand(1000, 9999) . ".html";
|
||||
file_put_contents($footerFile, $footerHtml);
|
||||
|
||||
$pdf = new PdfForm("ManualInvoice/PDF_MAIN", $pdf_vars);
|
||||
$filename = $pdf->render("--header-html $headerFile --footer-html $footerFile");
|
||||
|
||||
if ($returnFilename === true) return $filename;
|
||||
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="' . ($invoice->invoice_number ?? 'preview') . '.pdf"');
|
||||
readfile($filename);
|
||||
die();
|
||||
}
|
||||
|
||||
protected function downloadInvoicePdfAction() {
|
||||
$id = $this->request->id;
|
||||
if (!is_numeric($id) || !$id || !($invoice = ManualInvoiceModel::get($id))) {
|
||||
$this->layout()->setFlash("Rechnung nicht gefunden", "error");
|
||||
$this->redirect("ManualInvoice");
|
||||
}
|
||||
|
||||
if(!($pdf_filename = $this->createPDFAction(true)) || !file_exists($pdf_filename)) {
|
||||
$this->layout()->setFlash("PDF-Datei konnte nicht erstellt werden");
|
||||
$this->redirect("ManualInvoice");
|
||||
}
|
||||
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-disposition: attachment; filename="'.$invoice->invoice_number.'.pdf"');
|
||||
header('Content-Length: ' . filesize($pdf_filename));
|
||||
readfile($pdf_filename);
|
||||
exit;
|
||||
}
|
||||
|
||||
protected function pdfPreviewAction() {
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
$id = $post['id'] ?? null;
|
||||
|
||||
if (!$id || !($invoice = ManualInvoiceModel::get($id))) {
|
||||
self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Log PDF preview in journal
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
ManualInvoiceJournalModel::create([
|
||||
'manualinvoiceId' => $id,
|
||||
'text' => 'PDF Vorschau geöffnet',
|
||||
'createBy' => $me->id,
|
||||
'create' => time()
|
||||
]);
|
||||
|
||||
// Return URL to open in new tab
|
||||
$url = "?action=ManualInvoice_createPDF&id=" . $id;
|
||||
self::returnJson(['success' => true, 'url' => $url]);
|
||||
}
|
||||
|
||||
protected function getInvoiceEmailAction() {
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
$id = $post['id'] ?? null;
|
||||
|
||||
if (!$id || !($invoice = ManualInvoiceModel::get($id))) {
|
||||
self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'invoice' => [
|
||||
'id' => $invoice->id,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'email' => $invoice->email,
|
||||
'customerName' => trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname)
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
protected function sendInvoiceEmailAction() {
|
||||
// Enable error reporting for debugging
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('display_startup_errors', 1);
|
||||
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
$id = $post['id'] ?? null;
|
||||
$recipientEmail = $post['email'] ?? null;
|
||||
$subject = $post['subject'] ?? 'Ihre Rechnung von XINON GmbH';
|
||||
$bodyText = $post['body'] ?? 'Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung.\n\nMit freundlichen Grüßen\nIhr Xinon Team';
|
||||
|
||||
if (!$id || !$recipientEmail) {
|
||||
self::returnJson(['success' => false, 'message' => 'ID oder E-Mail-Adresse fehlt']);
|
||||
return;
|
||||
}
|
||||
|
||||
$invoice = ManualInvoiceModel::get($id);
|
||||
if (!$invoice) {
|
||||
self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate PDF
|
||||
$pdf_filename = $this->createPDFAction(true);
|
||||
if (!$pdf_filename || !file_exists($pdf_filename)) {
|
||||
self::returnJson(['success' => false, 'message' => 'PDF konnte nicht erstellt werden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$pdfContent = file_get_contents($pdf_filename);
|
||||
|
||||
// --- HTML Email Generation ---
|
||||
$logoToolPath = BASEDIR . '/public/assets/images/the-tool-logo.png';
|
||||
$logoXinonPath = BASEDIR . '/public/assets/images/xinon-full.png';
|
||||
$logoToolExists = file_exists($logoToolPath);
|
||||
$logoXinonExists = file_exists($logoXinonPath);
|
||||
|
||||
// Construct HTML Body
|
||||
$html = '<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Rechnung</title><style>body { font-family: Arial, sans-serif; color: #333; }</style></head><body style="margin:0;padding:20px;background-color:#f3f4f6;">';
|
||||
$html .= '<div style="background-color:#fff;padding:20px;border-radius:8px;max-width:600px;margin:0 auto;box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">';
|
||||
|
||||
// Logos
|
||||
$html .= '<div style="text-align:center;margin-bottom:20px;border-bottom: 1px solid #e5e7eb;padding-bottom: 15px;">';
|
||||
if ($logoToolExists) $html .= '<img src="cid:logo_thetool" alt="The Tool" style="height:40px;margin-right:15px;vertical-align:middle;">';
|
||||
if ($logoXinonExists) $html .= '<img src="cid:logo_xinon" alt="Xinon" style="height:40px;vertical-align:middle;">';
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '<h2 style="color:#00558c;text-align:center;font-size:20px;margin-bottom:20px;">' . htmlspecialchars($subject) . '</h2>';
|
||||
$html .= '<div style="font-size:14px;line-height:1.6;color:#333;">';
|
||||
$html .= nl2br(htmlspecialchars($bodyText));
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '<br><div style="border-top:1px solid #eee;padding-top:20px;font-size:12px;color:#999;text-align:center;">';
|
||||
$html .= 'XINON GmbH | <a href="https://www.xinon.at" style="color:#00558c;text-decoration:none;">www.xinon.at</a>';
|
||||
$html .= '</div></div></body></html>';
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
try {
|
||||
// Server settings
|
||||
$mail->isSMTP();
|
||||
$mail->Host = TT_PIPEWORK_SMTP_HOST;
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Username = TT_PIPEWORK_SMTP_USER;
|
||||
$mail->Password = TT_PIPEWORK_SMTP_PASS;
|
||||
$mail->CharSet = PHPMailer::CHARSET_UTF8;
|
||||
$mail->Encoding = 'base64';
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||
$mail->Port = 587;
|
||||
|
||||
// Logos
|
||||
if ($logoToolExists) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool');
|
||||
if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon');
|
||||
|
||||
$mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice');
|
||||
$mail->setFrom('thetool@xinon.at', 'XINON TheTool');
|
||||
|
||||
$customerName = trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname);
|
||||
$mail->addAddress($recipientEmail, $customerName);
|
||||
$mail->Subject = $subject;
|
||||
$mail->isHTML(true);
|
||||
$mail->Body = $html;
|
||||
$mail->AltBody = strip_tags($bodyText);
|
||||
|
||||
$mail->addStringAttachment($pdfContent, $invoice->invoice_number . '_Rechnung.pdf', 'base64', 'application/pdf');
|
||||
|
||||
$mail->send();
|
||||
|
||||
// Update invoice status
|
||||
$invoice->status = 'gesendet';
|
||||
$invoice->save();
|
||||
|
||||
// Add Journal Entry
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
ManualInvoiceJournalModel::create([
|
||||
'manualinvoiceId' => $id,
|
||||
'text' => "Rechnung per E-Mail an $recipientEmail gesendet.",
|
||||
'statusChange' => 'gesendet',
|
||||
'createBy' => $me->id,
|
||||
'create' => time()
|
||||
]);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'E-Mail erfolgreich versendet an ' . $recipientEmail]);
|
||||
} catch (Exception $e) {
|
||||
self::returnJson(['success' => false, 'message' => 'E-Mail konnte nicht gesendet werden. Fehler: ' . $mail->ErrorInfo]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function downloadInvoiceAction() {
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
$id = $post['id'] ?? null;
|
||||
|
||||
if (!$id || !($invoice = ManualInvoiceModel::get($id))) {
|
||||
self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
|
||||
// Log download in journal
|
||||
ManualInvoiceJournalModel::create([
|
||||
'manualinvoiceId' => $id,
|
||||
'text' => 'Rechnung heruntergeladen',
|
||||
'createBy' => $me->id,
|
||||
'create' => time()
|
||||
]);
|
||||
|
||||
$downloadUrl = "?action=ManualInvoice_downloadInvoicePdf&id=" . $id;
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'url' => $downloadUrl
|
||||
]);
|
||||
}
|
||||
|
||||
protected function beforeCreate(&$data): bool {
|
||||
if (isset($data['positions']) && is_array($data['positions'])) {
|
||||
$this->tempPositions = $data['positions'];
|
||||
unset($data['positions']);
|
||||
}
|
||||
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
|
||||
// Convert invoice_date from string to timestamp if needed
|
||||
if (isset($data['invoice_date']) && is_string($data['invoice_date'])) {
|
||||
$data['invoice_date'] = strtotime($data['invoice_date']);
|
||||
}
|
||||
|
||||
$data = array_merge([
|
||||
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
|
||||
'invoice_date' => $data['invoice_date'] ?? time(),
|
||||
'status' => 'erstellt',
|
||||
'fibu_payment_skonto' => 0,
|
||||
'fibu_payment_skonto_rate' => 0,
|
||||
'gesamtrabatt' => 0,
|
||||
'total' => 0,
|
||||
'total_gross' => 0,
|
||||
'create_by' => $me->id,
|
||||
'edit_by' => $me->id,
|
||||
'create' => time(),
|
||||
'edit' => time()
|
||||
], $data);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function afterCreate($data) {
|
||||
$this->savePositions($data['id']);
|
||||
$this->recalculateTotals($data['id']);
|
||||
|
||||
// Log creation in journal
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
ManualInvoiceJournalModel::create([
|
||||
'manualinvoiceId' => $data['id'],
|
||||
'text' => 'Rechnung erstellt',
|
||||
'statusChange' => 'erstellt',
|
||||
'createBy' => $me->id,
|
||||
'create' => time()
|
||||
]);
|
||||
}
|
||||
|
||||
protected function beforeUpdate(&$data): bool {
|
||||
if (isset($data['positions']) && is_array($data['positions'])) {
|
||||
$this->tempPositions = $data['positions'];
|
||||
unset($data['positions']);
|
||||
}
|
||||
|
||||
if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id'])) && $invoice->status === 'exportiert') {
|
||||
$this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert invoice_date from string to timestamp if needed
|
||||
if (isset($data['invoice_date']) && is_string($data['invoice_date'])) {
|
||||
$data['invoice_date'] = strtotime($data['invoice_date']);
|
||||
}
|
||||
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
$data['edit_by'] = $me->id;
|
||||
$data['edit'] = time();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function afterUpdate($data) {
|
||||
$existingPositions = ManualInvoicepositionModel::search(['manualinvoice_id' => $data['id']]);
|
||||
foreach ($existingPositions as $pos) ManualInvoicepositionModel::delete($pos->id);
|
||||
|
||||
$this->savePositions($data['id']);
|
||||
$this->recalculateTotals($data['id']);
|
||||
|
||||
// Log update in journal
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
ManualInvoiceJournalModel::create([
|
||||
'manualinvoiceId' => $data['id'],
|
||||
'text' => 'Rechnung aktualisiert',
|
||||
'createBy' => $me->id,
|
||||
'create' => time()
|
||||
]);
|
||||
}
|
||||
|
||||
private function savePositions($invoiceId) {
|
||||
if (empty($this->tempPositions)) return;
|
||||
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
|
||||
foreach ($this->tempPositions as $position) {
|
||||
// Skip empty positions
|
||||
if (empty($position['product_name']) || ($position['amount'] ?? 0) == 0) continue;
|
||||
|
||||
// Map _group to position_group
|
||||
$groupName = $position['_group'] ?? null;
|
||||
unset($position['_group']);
|
||||
|
||||
ManualInvoicepositionModel::create(array_merge([
|
||||
'manualinvoice_id' => $invoiceId,
|
||||
'position_group' => $groupName,
|
||||
'unit' => 'Stk.',
|
||||
'discount' => 0,
|
||||
'create_by' => $me->id,
|
||||
'edit_by' => $me->id,
|
||||
'create' => time(),
|
||||
'edit' => time()
|
||||
], $position));
|
||||
}
|
||||
$this->tempPositions = [];
|
||||
}
|
||||
|
||||
protected function recalculateTotals($invoiceId) {
|
||||
if (!($invoice = ManualInvoiceModel::get($invoiceId))) return;
|
||||
|
||||
$positions = ManualInvoicepositionModel::search(['manualinvoice_id' => $invoiceId]);
|
||||
$subtotal = array_sum(array_column($positions, 'price_total'));
|
||||
|
||||
// Apply gesamtrabatt (total discount) if exists
|
||||
$gesamtrabatt = $invoice->gesamtrabatt ?? 0;
|
||||
$discountAmount = $subtotal * ($gesamtrabatt / 100);
|
||||
$netTotal = $subtotal - $discountAmount;
|
||||
|
||||
// Calculate gross total with VAT applied after discount
|
||||
$grossTotal = 0;
|
||||
foreach ($positions as $pos) {
|
||||
$positionNet = $pos->price_total;
|
||||
$positionAfterDiscount = $positionNet * (1 - $gesamtrabatt / 100);
|
||||
$grossTotal += $positionAfterDiscount * (1 + $pos->vatrate / 100);
|
||||
}
|
||||
|
||||
$invoice->total = $netTotal;
|
||||
$invoice->total_gross = $grossTotal;
|
||||
$invoice->save();
|
||||
}
|
||||
|
||||
protected function getAction() {
|
||||
$filter = $this->postData['filters'] ?? [];
|
||||
$order = $this->postData['order']['key'] ? $this->postData['order'] : ($this->defaultOrder ?? ['key' => null, 'order' => 'ASC']);
|
||||
$page = $this->postData['pagination']['page'] ?? 1;
|
||||
$perPage = $this->postData['pagination']['per_page'] ?? 10;
|
||||
|
||||
$rows = ManualInvoiceModel::getAll($filter, $perPage, ($page - 1) * $perPage, $order);
|
||||
$filteredAvailable = ManualInvoiceModel::count($filter);
|
||||
$totalRows = ManualInvoiceModel::count();
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$row->customerName = trim(($row->company ?: '') . ' ' . $row->firstname . ' ' . $row->lastname);
|
||||
$row->positions = array_map([$this, 'formatPosition'], $row->getProperty('positions'));
|
||||
}
|
||||
|
||||
self::returnJson([
|
||||
"rows" => $rows,
|
||||
"autoCompleteData" => [],
|
||||
"pagination" => [
|
||||
"page" => $page,
|
||||
"total_pages" => ceil($filteredAvailable / $perPage),
|
||||
"per_page" => $perPage,
|
||||
"filtered_available" => intval($filteredAvailable),
|
||||
"total_rows" => intval($totalRows)
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getByIdAction() {
|
||||
$id = $_GET['id'] ?? null;
|
||||
if (!$id || !is_numeric($id)) {
|
||||
http_response_code(500);
|
||||
self::returnJson(['success' => false, 'message' => 'No ID provided.']);
|
||||
die();
|
||||
}
|
||||
|
||||
if (!($invoice = ManualInvoiceModel::get($id))) {
|
||||
http_response_code(404);
|
||||
self::returnJson(['success' => false, 'message' => 'Invoice not found.']);
|
||||
die();
|
||||
}
|
||||
|
||||
$data = (array) $invoice;
|
||||
$data['positions'] = array_map([$this, 'formatPosition'], $invoice->getProperty('positions'));
|
||||
|
||||
self::returnJson($data);
|
||||
}
|
||||
|
||||
private function formatPosition($pos) {
|
||||
return [
|
||||
'id' => $pos->id,
|
||||
'manualinvoice_id' => $pos->manualinvoice_id,
|
||||
'_group' => $pos->position_group ?? '',
|
||||
'billing_id' => $pos->billing_id,
|
||||
'contract_id' => $pos->contract_id,
|
||||
'matchcode' => $pos->matchcode,
|
||||
'product_id' => $pos->product_id,
|
||||
'product_name' => $pos->product_name,
|
||||
'product_info' => $pos->product_info,
|
||||
'amount' => $pos->amount,
|
||||
'unit' => $pos->unit ?? 'Stk.',
|
||||
'price' => $pos->price,
|
||||
'discount' => $pos->discount ?? 0,
|
||||
'price_total' => $pos->price_total,
|
||||
'price_gross' => $pos->price_gross,
|
||||
'vatrate' => $pos->vatrate,
|
||||
'fibu_cost_account' => $pos->fibu_cost_account,
|
||||
'fibu_cost_account_legacy' => $pos->fibu_cost_account_legacy,
|
||||
'fibu_taxcode' => $pos->fibu_taxcode,
|
||||
'options' => $pos->options
|
||||
];
|
||||
}
|
||||
|
||||
protected function customRowsHandler($rows) {
|
||||
foreach ($rows as &$row) {
|
||||
$row->customerName = trim(($row->company ?: '') . ' ' . $row->firstname . ' ' . $row->lastname);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
protected function generateSepaQRCode($paymentReference, $amount) {
|
||||
$xinonIBAN = TT_INVOICE_BANK_IBAN;
|
||||
$xinonBIC = TT_INVOICE_BANK_BIC;
|
||||
$xinonOwner = TT_INVOICE_BANK_OWNER;
|
||||
$epc = "BCD\n001\n1\nSCT\n$xinonBIC\n$xinonOwner\n$xinonIBAN\nEUR$amount\nXINO\n$paymentReference\n\nXINON GmbH";
|
||||
return (new \chillerlan\QRCode\QRCode)->render($epc);
|
||||
}
|
||||
|
||||
protected function getInvoiceForGutschriftAction() {
|
||||
if (!($id = $_GET['id'] ?? null) || !($invoice = ManualInvoiceModel::get($id))) {
|
||||
self::returnJson(['success' => false, 'message' => 'Invoice not found']);
|
||||
}
|
||||
|
||||
if ($invoice->total < 0) {
|
||||
self::returnJson(['success' => false, 'message' => 'Kann keine Gutschrift für eine Gutschrift erstellen']);
|
||||
}
|
||||
|
||||
$positions = $invoice->getProperty('positions');
|
||||
$existingCredits = ManualInvoiceModel::getAll(['credit_for_invoice_id' => $id]);
|
||||
$creditedAmounts = [];
|
||||
|
||||
foreach ($existingCredits as $credit) {
|
||||
foreach ($credit->getProperty('positions') as $creditPos) {
|
||||
$key = $creditPos->product_id . '_' . $creditPos->matchcode;
|
||||
$creditedAmounts[$key] = ($creditedAmounts[$key] ?? 0) + abs($creditPos->amount);
|
||||
}
|
||||
}
|
||||
|
||||
$availablePositions = [];
|
||||
foreach ($positions as $pos) {
|
||||
$key = $pos->product_id . '_' . $pos->matchcode;
|
||||
$availableAmount = $pos->amount - ($creditedAmounts[$key] ?? 0);
|
||||
if ($availableAmount > 0) {
|
||||
$availablePositions[] = [
|
||||
'id' => $pos->id,
|
||||
'product_name' => $pos->product_name,
|
||||
'product_info' => $pos->product_info,
|
||||
'original_amount' => $pos->amount,
|
||||
'credited_amount' => $creditedAmounts[$key] ?? 0,
|
||||
'available_amount' => $availableAmount,
|
||||
'unit' => $pos->unit ?? 'Stk.',
|
||||
'price' => $pos->price,
|
||||
'vatrate' => $pos->vatrate,
|
||||
'product_id' => $pos->product_id,
|
||||
'matchcode' => $pos->matchcode,
|
||||
'fibu_cost_account' => $pos->fibu_cost_account,
|
||||
'fibu_taxcode' => $pos->fibu_taxcode
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'invoice' => [
|
||||
'id' => $invoice->id,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'customer_name' => trim(($invoice->company ?? '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname),
|
||||
'positions' => $availablePositions
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
protected function createGutschriftAction() {
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
$originalInvoiceId = $post['original_invoice_id'] ?? null;
|
||||
$positions = $post['positions'] ?? [];
|
||||
|
||||
if (!$originalInvoiceId || empty($positions) || !($originalInvoice = ManualInvoiceModel::get($originalInvoiceId))) {
|
||||
self::returnJson(['success' => false, 'message' => 'Ungültige Anfrage']);
|
||||
}
|
||||
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
|
||||
$invoiceData = [
|
||||
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
|
||||
'invoice_date' => time(),
|
||||
'leistungszeitraum' => $originalInvoice->leistungszeitraum ?? null,
|
||||
'einleitender_text' => 'Gutschrift zur Rechnung ' . $originalInvoice->invoice_number,
|
||||
'externe_referenz' => $originalInvoice->externe_referenz ?? null,
|
||||
'gesamtrabatt' => 0,
|
||||
'owner_id' => $originalInvoice->owner_id,
|
||||
'billingaddress_id' => $originalInvoice->billingaddress_id,
|
||||
'customer_number' => $originalInvoice->customer_number,
|
||||
'fibu_account_number' => $originalInvoice->fibu_account_number,
|
||||
'fibu_payment_due' => $originalInvoice->fibu_payment_due,
|
||||
'fibu_payment_skonto' => $originalInvoice->fibu_payment_skonto,
|
||||
'fibu_payment_skonto_rate' => $originalInvoice->fibu_payment_skonto_rate,
|
||||
'sepa_date' => $originalInvoice->sepa_date,
|
||||
'sepa_id' => $originalInvoice->sepa_id,
|
||||
'sepa_last_date' => $originalInvoice->sepa_last_date,
|
||||
'fibu_cost_area' => $originalInvoice->fibu_cost_area,
|
||||
'fibu_cost_account' => $originalInvoice->fibu_cost_account,
|
||||
'fibu_cost_account_legacy' => $originalInvoice->fibu_cost_account_legacy,
|
||||
'fibu_taxcode' => $originalInvoice->fibu_taxcode,
|
||||
'tax_text' => $originalInvoice->tax_text,
|
||||
'company' => $originalInvoice->company,
|
||||
'firstname' => $originalInvoice->firstname,
|
||||
'lastname' => $originalInvoice->lastname,
|
||||
'street' => $originalInvoice->street,
|
||||
'zip' => $originalInvoice->zip,
|
||||
'city' => $originalInvoice->city,
|
||||
'country' => $originalInvoice->country,
|
||||
'email' => $originalInvoice->email,
|
||||
'uid' => $originalInvoice->uid,
|
||||
'billing_type' => $originalInvoice->billing_type,
|
||||
'billing_delivery' => $originalInvoice->billing_delivery,
|
||||
'bank_account_bank' => $originalInvoice->bank_account_bank,
|
||||
'bank_account_owner' => $originalInvoice->bank_account_owner,
|
||||
'bank_account_iban' => $originalInvoice->bank_account_iban,
|
||||
'bank_account_bic' => $originalInvoice->bank_account_bic,
|
||||
'total' => 0,
|
||||
'total_gross' => 0,
|
||||
'vatgroup_id' => $originalInvoice->vatgroup_id,
|
||||
'credit_for_invoice_id' => $originalInvoiceId,
|
||||
'status' => 'erstellt',
|
||||
'create' => time(),
|
||||
'edit' => time(),
|
||||
'create_by' => $me->id,
|
||||
'edit_by' => $me->id
|
||||
];
|
||||
|
||||
if (!($creditInvoiceId = ManualInvoiceModel::create($invoiceData))) {
|
||||
self::returnJson(['success' => false, 'message' => 'Fehler beim Erstellen der Gutschrift']);
|
||||
}
|
||||
|
||||
foreach ($positions as $pos) {
|
||||
$priceTotal = (-abs($pos['amount'])) * $pos['price'];
|
||||
ManualInvoicepositionModel::create([
|
||||
'manualinvoice_id' => $creditInvoiceId,
|
||||
'position_group' => null,
|
||||
'product_id' => $pos['product_id'],
|
||||
'product_name' => $pos['product_name'],
|
||||
'product_info' => $pos['product_info'] ?? '',
|
||||
'amount' => -abs($pos['amount']),
|
||||
'unit' => $pos['unit'] ?? 'Stk.',
|
||||
'price' => $pos['price'],
|
||||
'discount' => 0,
|
||||
'vatrate' => $pos['vatrate'],
|
||||
'price_total' => $priceTotal,
|
||||
'price_gross' => $priceTotal * (1 + $pos['vatrate'] / 100),
|
||||
'matchcode' => $pos['matchcode'] ?? null,
|
||||
'fibu_cost_account' => $pos['fibu_cost_account'] ?? null,
|
||||
'fibu_taxcode' => $pos['fibu_taxcode'] ?? null,
|
||||
'contract_id' => 0,
|
||||
'billing_id' => null,
|
||||
'create_by' => $me->id,
|
||||
'edit_by' => $me->id,
|
||||
'create' => time(),
|
||||
'edit' => time()
|
||||
]);
|
||||
}
|
||||
|
||||
$this->recalculateTotals($creditInvoiceId);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Gutschrift erfolgreich erstellt', 'credit_invoice_id' => $creditInvoiceId]);
|
||||
}
|
||||
|
||||
protected function beforeDelete(): bool {
|
||||
if ($id = $this->request->id) {
|
||||
$invoice = ManualInvoiceModel::get($id);
|
||||
if ($invoice && $invoice->status === 'exported') {
|
||||
$this->infoMessages['delete'] = 'Rechnung wurde bereits exportiert und kann nicht gelöscht werden';
|
||||
return false;
|
||||
}
|
||||
if (ManualInvoiceModel::count(['credit_for_invoice_id' => $id]) > 0) {
|
||||
$this->infoMessages['delete'] = 'Rechnung kann nicht gelöscht werden, da bereits Gutschriften existieren';
|
||||
return false;
|
||||
}
|
||||
foreach (ManualInvoicepositionModel::search(['manualinvoice_id' => $id]) as $pos) {
|
||||
ManualInvoicepositionModel::delete($pos->id);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,186 +1,80 @@
|
||||
<?php
|
||||
|
||||
function getMockData() {
|
||||
$mockData = [
|
||||
[
|
||||
'id' => 1, 'invoiceNumber' => 'RE-2025-001', 'customerName' => 'Musterfirma GmbH', 'billingAddressId' => 1,
|
||||
'invoiceDate' => strtotime('2025-09-11'), 'dueDate' => strtotime('2025-09-25'), 'totalAmount' => 948.00, 'status' => 'paid',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'IT-Support-Stunden', 'product_info' => 'Remote-Hilfe für Mitarbeiter', 'start_date' => '2025-09-10', 'end_date' => '2025-09-10', 'amount' => 2, 'price' => 120.00, 'vatrate' => 20],
|
||||
['product_name' => 'Netzwerk-Switch 24-Port', 'product_info' => 'Modell: XYZ-24G', 'start_date' => '2025-09-10', 'end_date' => '2025-09-10', 'amount' => 1, 'price' => 550.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 2, 'invoiceNumber' => 'RE-2025-002', 'customerName' => 'Beispiel AG', 'billingAddressId' => 2,
|
||||
'invoiceDate' => strtotime('2025-09-14'), 'dueDate' => strtotime('2025-09-28'), 'totalAmount' => 720.00, 'status' => 'sent',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Beratung Digitalisierungsstrategie', 'product_info' => 'Workshop am 05.09.2025', 'start_date' => '2025-09-05', 'end_date' => '2025-09-05', 'amount' => 4, 'price' => 150.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Wir freuen uns auf eine weiterhin gute Zusammenarbeit.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 3, 'invoiceNumber' => 'RE-2025-003', 'customerName' => 'John Doe Services', 'billingAddressId' => 3,
|
||||
'invoiceDate' => strtotime('2025-09-16'), 'dueDate' => strtotime('2025-09-30'), 'totalAmount' => 912.00, 'status' => 'draft',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Kabelverlegung LWL', 'product_info' => 'Inhouse-Verkabelung Bürogebäude', 'start_date' => '2025-09-15', 'end_date' => '2025-09-15', 'amount' => 8, 'price' => 85.00, 'vatrate' => 20],
|
||||
['product_name' => 'LWL-Kabel 8 Fasern', 'product_info' => 'Pro Meter', 'start_date' => '2025-09-15', 'end_date' => '2025-09-15', 'amount' => 100, 'price' => 0.80, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Bei Fragen stehen wir Ihnen gerne zur Verfügung.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 4, 'invoiceNumber' => 'RE-2025-004', 'customerName' => 'Bau & Co KG', 'billingAddressId' => 4,
|
||||
'invoiceDate' => strtotime('2025-09-06'), 'dueDate' => strtotime('2025-09-20'), 'totalAmount' => 1890.00, 'status' => 'paid',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Netzwerk-Grundinstallation Baustelle', 'product_info' => 'Containerdorf Einrichtung', 'start_date' => '2025-09-02', 'end_date' => '2025-09-02', 'amount' => 1, 'price' => 1200.00, 'vatrate' => 20],
|
||||
['product_name' => 'Stunden Elektriker', 'product_info' => 'Anpassungen Verteilerkasten', 'start_date' => '2025-09-02', 'end_date' => '2025-09-02', 'amount' => 5, 'price' => 75.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 5, 'invoiceNumber' => 'RE-2025-005', 'customerName' => 'Creative Solutions', 'billingAddressId' => 5,
|
||||
'invoiceDate' => strtotime('2025-09-15'), 'dueDate' => strtotime('2025-09-29'), 'totalAmount' => 1920.00, 'status' => 'sent',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Web-Entwicklung', 'product_info' => 'Umsetzung Landingpage "Herbst-Aktion"', 'start_date' => '2025-09-01', 'end_date' => '2025-09-12', 'amount' => 10, 'price' => 110.00, 'vatrate' => 20],
|
||||
['product_name' => 'Domain-Registrierung (.at)', 'product_info' => 'herbst-aktion.at', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31', 'amount' => 1, 'price' => 500.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Wir freuen uns auf eine weiterhin gute Zusammenarbeit.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 6, 'invoiceNumber' => 'RE-2025-006', 'customerName' => 'Logistik Express', 'billingAddressId' => 6,
|
||||
'invoiceDate' => strtotime('2025-08-28'), 'dueDate' => strtotime('2025-09-11'), 'totalAmount' => 3432.00, 'status' => 'paid',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Software-Lizenz WMS Pro', 'product_info' => 'Jahreslizenz für 10 User', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31', 'amount' => 1, 'price' => 2500.00, 'vatrate' => 20],
|
||||
['product_name' => 'Mitarbeiterschulung WMS', 'product_info' => 'Vor Ort am 27.08.2025', 'start_date' => '2025-08-27', 'end_date' => '2025-08-27', 'amount' => 4, 'price' => 90.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 7, 'invoiceNumber' => 'RE-2025-007', 'customerName' => 'Gastro Profi', 'billingAddressId' => 7,
|
||||
'invoiceDate' => strtotime('2025-09-10'), 'dueDate' => strtotime('2025-09-24'), 'totalAmount' => 2577.60, 'status' => 'draft',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Kassensystem "GastroTouch"', 'product_info' => '2x Terminal, 1x Bondrucker', 'start_date' => '2025-09-09', 'end_date' => '2025-09-09', 'amount' => 2, 'price' => 899.00, 'vatrate' => 20],
|
||||
['product_name' => 'Installationspauschale', 'product_info' => 'Inkl. Einschulung', 'start_date' => '2025-09-09', 'end_date' => '2025-09-09', 'amount' => 1, 'price' => 350.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Bei Fragen stehen wir Ihnen gerne zur Verfügung.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 8, 'invoiceNumber' => 'RE-2025-008', 'customerName' => 'Sicherheitsdienst Huber', 'billingAddressId' => 8,
|
||||
'invoiceDate' => strtotime('2025-09-01'), 'dueDate' => strtotime('2025-09-15'), 'totalAmount' => 1782.00, 'status' => 'sent',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'IP Kamera 4K Dome', 'product_info' => 'Modell SEC-4K-D', 'start_date' => '2025-08-29', 'end_date' => '2025-08-29', 'amount' => 8, 'price' => 180.00, 'vatrate' => 20],
|
||||
['product_name' => 'Monatliche Wartungspauschale', 'product_info' => 'September 2025', 'start_date' => '2025-09-01', 'end_date' => '2025-09-30', 'amount' => 1, 'price' => 45.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Wir freuen uns auf eine weiterhin gute Zusammenarbeit.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 9, 'invoiceNumber' => 'RE-2025-009', 'customerName' => 'Praxis Dr. Eder', 'billingAddressId' => 9,
|
||||
'invoiceDate' => strtotime('2025-09-12'), 'dueDate' => strtotime('2025-09-26'), 'totalAmount' => 3090.00, 'status' => 'draft',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Arbeitsstunden IT-Migration', 'product_info' => 'Serverumzug und Client-Setup', 'start_date' => '2025-09-11', 'end_date' => '2025-09-11', 'amount' => 5, 'price' => 95.00, 'vatrate' => 20],
|
||||
['product_name' => 'Server-Hardware "MedServ"', 'product_info' => 'Spez. für Arztpraxen', 'start_date' => '2025-09-11', 'end_date' => '2025-09-11', 'amount' => 1, 'price' => 1800.00, 'vatrate' => 20],
|
||||
['product_name' => 'Datensicherungslösung "CloudSafe"', 'product_info' => 'Einrichtungspauschale', 'start_date' => '2025-09-11', 'end_date' => '2025-09-11', 'amount' => 1, 'price' => 300.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Bei Fragen stehen wir Ihnen gerne zur Verfügung.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 10, 'invoiceNumber' => 'RE-2025-010', 'customerName' => 'Architekturbüro Planweit', 'billingAddressId' => 10,
|
||||
'invoiceDate' => strtotime('2025-09-08'), 'dueDate' => strtotime('2025-09-22'), 'totalAmount' => 357.60, 'status' => 'paid',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Plotter Service', 'product_info' => 'Wartung und Reinigung', 'start_date' => '2025-09-04', 'end_date' => '2025-09-04', 'amount' => 1, 'price' => 250.00, 'vatrate' => 20],
|
||||
['product_name' => 'Netzwerkkabel Cat7', 'product_info' => 'Pro Meter', 'start_date' => '2025-09-04', 'end_date' => '2025-09-04', 'amount' => 40, 'price' => 1.20, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => ''
|
||||
],
|
||||
];
|
||||
|
||||
return $mockData;
|
||||
}
|
||||
|
||||
|
||||
class ManualInvoiceModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public ?string $invoiceNumber;
|
||||
public ?int $invoiceDate;
|
||||
public ?int $dueDate;
|
||||
public int $billingAddressId;
|
||||
public ?string $customerName;
|
||||
public ?float $totalAmount;
|
||||
public ?string $invoice_number;
|
||||
public int $invoice_date;
|
||||
public ?string $leistungszeitraum;
|
||||
public ?string $einleitender_text;
|
||||
public ?string $externe_referenz;
|
||||
public float $gesamtrabatt;
|
||||
public int $owner_id;
|
||||
public int $billingaddress_id;
|
||||
public int $customer_number;
|
||||
public ?int $fibu_account_number;
|
||||
public ?int $fibu_payment_due;
|
||||
public int $fibu_payment_skonto;
|
||||
public int $fibu_payment_skonto_rate;
|
||||
public ?string $sepa_date;
|
||||
public ?string $sepa_id;
|
||||
public ?string $sepa_last_date;
|
||||
public ?string $fibu_cost_area;
|
||||
public ?int $fibu_cost_account;
|
||||
public ?int $fibu_cost_account_legacy;
|
||||
public ?int $fibu_taxcode;
|
||||
public ?string $tax_text;
|
||||
public ?string $company;
|
||||
public ?string $firstname;
|
||||
public ?string $lastname;
|
||||
public string $street;
|
||||
public string $zip;
|
||||
public string $city;
|
||||
public ?string $country;
|
||||
public ?string $email;
|
||||
public ?string $uid;
|
||||
public string $billing_type;
|
||||
public string $billing_delivery;
|
||||
public ?string $bank_account_bank;
|
||||
public ?string $bank_account_owner;
|
||||
public ?string $bank_account_iban;
|
||||
public ?string $bank_account_bic;
|
||||
public float $total;
|
||||
public float $total_gross;
|
||||
public int $vatgroup_id;
|
||||
public ?int $bmd_export_date;
|
||||
public ?int $date_delivered;
|
||||
public string $status;
|
||||
public string $positions;
|
||||
public string $closingText;
|
||||
public string $taxText;
|
||||
public ?int $credit_for_invoice_id;
|
||||
public int $create_by;
|
||||
public int $edit_by;
|
||||
public int $create;
|
||||
public int $edit;
|
||||
|
||||
private static function applyFilter(array $data, array $filter): array {
|
||||
if (empty($filter)) {
|
||||
return $data;
|
||||
}
|
||||
return array_filter($data, function ($row) use ($filter) {
|
||||
foreach ($filter as $key => $value) {
|
||||
if (!isset($row[$key]) || empty($value)) {
|
||||
continue;
|
||||
}
|
||||
if (is_array($value)) { // Handle date ranges
|
||||
if (isset($value['from']) && $row[$key] < $value['from']) return false;
|
||||
if (isset($value['to']) && $row[$key] > $value['to']) return false;
|
||||
} else if (is_array($row[$key])) {
|
||||
if (!in_array($value, $row[$key])) return false;
|
||||
} else if (stripos($row[$key], $value) === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
public static function getNextInvoiceNumber() {
|
||||
$invoices = parent::getAll(['invoice_number' => '!NULL'], 1, 0, ['key' => 'invoice_number', 'order' => 'DESC']);
|
||||
$last = $invoices[0]->invoice_number ?? null;
|
||||
$year = date("Y");
|
||||
|
||||
public static function getAll($filter = [], $limit = null, $offset = 0, $order = ["key" => null]): array
|
||||
|
||||
{
|
||||
$mockData = getMockData();
|
||||
$filteredData = self::applyFilter($mockData, $filter);
|
||||
|
||||
if ($order['key'] !== null) {
|
||||
usort($filteredData, function ($a, $b) use ($order) {
|
||||
if ($a[$order['key']] == $b[$order['key']]) return 0;
|
||||
if ($order['order'] === 'ASC') {
|
||||
return $a[$order['key']] < $b[$order['key']] ? -1 : 1;
|
||||
} else {
|
||||
return $a[$order['key']] > $b[$order['key']] ? -1 : 1;
|
||||
}
|
||||
});
|
||||
if ($last && preg_match('/^RN(\d+)-C(\d+)$/', $last, $m)) {
|
||||
$num = ($m[1] == $year) ? $m[2] + 1 : 1;
|
||||
} else {
|
||||
$num = 1;
|
||||
}
|
||||
|
||||
if ($limit !== null) {
|
||||
return array_slice($filteredData, $offset, $limit);
|
||||
return sprintf("RN%s-C%06d", $year, $num);
|
||||
}
|
||||
|
||||
public function getProperty($name) {
|
||||
if (!$this->id) return null;
|
||||
|
||||
switch ($name) {
|
||||
case 'positions': return ManualInvoicepositionModel::search(['manualinvoice_id' => $this->id]);
|
||||
case 'creator': return $this->create_by ? new User($this->create_by) : null;
|
||||
case 'editor': return $this->edit_by ? new User($this->edit_by) : null;
|
||||
default:
|
||||
$classname = ucfirst($name);
|
||||
$idfield = $name . '_id';
|
||||
return (property_exists($this, $idfield) && class_exists($classname)) ? new $classname($this->$idfield) : null;
|
||||
}
|
||||
return $filteredData;
|
||||
}
|
||||
|
||||
public static function count($filter = []): int {
|
||||
$mockData = getMockData();
|
||||
return count(self::applyFilter($mockData, $filter));
|
||||
}
|
||||
|
||||
public static function get($id) {
|
||||
$mockData = getMockData();
|
||||
foreach ($mockData as $row)
|
||||
if ($row['id'] == $id)
|
||||
return new self($row);
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function create($data) {
|
||||
error_log("ManualInvoiceModel::create called with: " . json_encode($data));
|
||||
return time();
|
||||
}
|
||||
|
||||
public static function update($data) {
|
||||
error_log("ManualInvoiceModel::update called with: " . json_encode($data));
|
||||
return 1;
|
||||
}
|
||||
|
||||
public static function delete($id) {
|
||||
error_log("ManualInvoiceModel::delete called with ID: " . $id);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
class ManualInvoiceJournalModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public int $manualinvoiceId;
|
||||
public ?string $text;
|
||||
public ?string $data;
|
||||
public ?string $statusChange;
|
||||
public ?string $fileIds;
|
||||
public int $create;
|
||||
public int $createBy;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
class ManualInvoicepositionModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public ?int $manualinvoice_id;
|
||||
public ?string $position_group;
|
||||
public ?int $billing_id;
|
||||
public int $contract_id;
|
||||
public ?string $matchcode;
|
||||
public int $product_id;
|
||||
public string $product_name;
|
||||
public ?string $product_info;
|
||||
public float $amount;
|
||||
public string $unit;
|
||||
public float $price;
|
||||
public float $discount;
|
||||
public float $price_total;
|
||||
public float $price_gross;
|
||||
public float $vatrate;
|
||||
public ?int $fibu_cost_account;
|
||||
public ?int $fibu_cost_account_legacy;
|
||||
public ?int $fibu_taxcode;
|
||||
public ?string $options;
|
||||
public int $create_by;
|
||||
public int $edit_by;
|
||||
public int $create;
|
||||
public int $edit;
|
||||
}
|
||||
@@ -358,6 +358,13 @@ class OrderModel {
|
||||
$where .= " AND `Order`.name='$name'";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("product_name", $filter)) {
|
||||
$product_name = FronkDB::singleton()->escape($filter['product_name']);
|
||||
if($product_name) {
|
||||
$where .= " AND `Product`.name LIKE '%$product_name%'";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("product_id", $filter)) {
|
||||
$product_id = $filter['product_id'];
|
||||
|
||||
@@ -113,9 +113,33 @@ class PipeworkController extends mfBaseController {
|
||||
$this->log->debug("is pipeworker");
|
||||
$building_search["pipeworker_id"] = ($this->me->address->parent_id) ? $this->me->address->parent_id : $this->me->address_id;
|
||||
}
|
||||
|
||||
$pagination['maxItems'] = BuildingModel::count($building_search);
|
||||
foreach(BuildingModel::search($building_search, $pagination) as $b) {
|
||||
|
||||
// Store ap_name filter separately for post-processing
|
||||
$ap_name_filter = null;
|
||||
if(array_key_exists('ap_name', $building_search) && $building_search['ap_name']) {
|
||||
$ap_name_filter = $building_search['ap_name'];
|
||||
unset($building_search['ap_name']); // Remove from search as it's a workflow value
|
||||
}
|
||||
|
||||
if($ap_name_filter) {
|
||||
$all_buildings = BuildingModel::search($building_search, false);
|
||||
$filtered_buildings = [];
|
||||
|
||||
foreach($all_buildings as $b) {
|
||||
$ap_name = $b->getWorkflowvalue('ist_anschlusspunkt_name') ?: $b->getWorkflowvalue('anschlusspunkt_name');
|
||||
if($ap_name && stripos($ap_name, $ap_name_filter) !== false) {
|
||||
$filtered_buildings[] = $b;
|
||||
}
|
||||
}
|
||||
|
||||
$pagination['maxItems'] = count($filtered_buildings);
|
||||
$buildings = array_slice($filtered_buildings, $pagination['start'], $pagination['count']);
|
||||
} else {
|
||||
$pagination['maxItems'] = BuildingModel::count($building_search);
|
||||
$buildings = BuildingModel::search($building_search, $pagination);
|
||||
}
|
||||
|
||||
foreach($buildings as $b) {
|
||||
if(!array_key_exists($b->network->name, $networks)) {
|
||||
$networks[$b->network->name] = [];
|
||||
}
|
||||
|
||||
@@ -734,15 +734,22 @@ class Preorder extends mfBaseModel {
|
||||
|
||||
// get start of ctag range
|
||||
$first_ctag = $search_ctag - ($search_ctag % $ctags_per_home);
|
||||
$last_ctag = $first_ctag + $ctags_per_home - 1;
|
||||
|
||||
$mgmt_ctag = null;
|
||||
$ctag_range = [];
|
||||
for($i = $first_ctag; $i < $first_ctag + $ctags_per_home; $i++) {
|
||||
for($i = $first_ctag; $i <= $last_ctag; $i++) {
|
||||
if(!PreorderCtag::getFirstActive(["stag" => $stag, "ctag" => $i, "network" => $network_name])) {
|
||||
if($i == $last_ctag) {
|
||||
// mgmt ctag should be the last in range
|
||||
$mgmt_ctag = $i;
|
||||
continue;
|
||||
}
|
||||
$ctag_range[] = $i;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
return $ctag_range;
|
||||
return [$ctag_range, $mgmt_ctag];
|
||||
}
|
||||
|
||||
public function getNextFreeCtags() {
|
||||
@@ -790,7 +797,9 @@ class Preorder extends mfBaseModel {
|
||||
$new_ctags[] = $i;
|
||||
}
|
||||
|
||||
return $new_ctags;
|
||||
$mgmt_ctag = array_pop($new_ctags);
|
||||
|
||||
return [$new_ctags, $mgmt_ctag];
|
||||
}
|
||||
|
||||
public function setOrCreateOaid($oaid_attributes = false) {
|
||||
|
||||
@@ -791,6 +791,10 @@ class PreorderController extends mfBaseController {
|
||||
$qs = http_build_query($qs);
|
||||
}
|
||||
|
||||
if(!$this->me->isAdmin()) {
|
||||
$this->redirect("Preorder", "Index", $qs);
|
||||
}
|
||||
|
||||
$id = $this->request->id;
|
||||
if(!is_numeric($id) || $id < 1) {
|
||||
$this->layout()->setFlash("Vorbestellung nicht gefunden!", "error");
|
||||
@@ -988,7 +992,9 @@ class PreorderController extends mfBaseController {
|
||||
foreach($my_networks as $network) {
|
||||
if($network->adb_netzgebiet_id && !in_array($network->id, $netzgebiet_ids)) {
|
||||
$netzgebiet_ids[] = $network->id;
|
||||
$my_adb_networks[$network->adb_netzgebiet_id] = new ADBNetzgebiet($network->adb_netzgebiet_id);
|
||||
$adb_network = new ADBNetzgebiet($network->adb_netzgebiet_id);
|
||||
if(!$adb_network->isLoaded()) continue;
|
||||
$my_adb_networks[$network->adb_netzgebiet_id] = $adb_network;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -997,34 +1003,38 @@ class PreorderController extends mfBaseController {
|
||||
|
||||
$campaign_ids = [];
|
||||
foreach(PreordercampaignModel::search(["network_id" => $netzgebiet_ids]) as $campaign) {
|
||||
echo "campaign: ".$campaign->id."<br />";
|
||||
if(!in_array($campaign->id, $campaign_ids)) {
|
||||
$campaign_ids[] = $campaign->id;
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("preordercampaign_id", $filter) && in_array($filter['preordercampaign_id'], $campaign_ids)) {
|
||||
$preorder_filter["preordercampaign_id"] = $filter['preordercampaign_id'];
|
||||
if($this->me->is("Admin")) {
|
||||
if(array_key_exists("preordercampaign_id", $filter) && $filter['preordercampaign_id']) {
|
||||
$preorder_filter["preordercampaign_id"] = $filter['preordercampaign_id'];
|
||||
}
|
||||
} else {
|
||||
$preorder_filter["preordercampaign_id"] = $campaign_ids;
|
||||
}
|
||||
if(array_key_exists("preordercampaign_id", $filter) && in_array($filter['preordercampaign_id'], $campaign_ids)) {
|
||||
$preorder_filter["preordercampaign_id"] = $filter['preordercampaign_id'];
|
||||
} else {
|
||||
$preorder_filter["preordercampaign_id"] = $campaign_ids;
|
||||
}
|
||||
|
||||
if($preorder_filter['preordercampaign_id'] && in_array($preorder_filter['preordercampaign_id'], $campaign_ids)) {
|
||||
$campaign_id = $preorder_filter['preordercampaign_id'];
|
||||
if(is_numeric($campaign_id) && $campaign_id > 0) {
|
||||
$campaign = new Preordercampaign($campaign_id);
|
||||
$this->layout()->set("campaign", $campaign);
|
||||
if($preorder_filter['preordercampaign_id'] && in_array($preorder_filter['preordercampaign_id'], $campaign_ids)) {
|
||||
$campaign_id = $preorder_filter['preordercampaign_id'];
|
||||
if(is_numeric($campaign_id) && $campaign_id > 0) {
|
||||
$campaign = new Preordercampaign($campaign_id);
|
||||
$this->layout()->set("campaign", $campaign);
|
||||
|
||||
if($campaign->network->owner_id != $this->me->address_id && NetworkAddressModel::getFirst(["network_id" => $campaign->network_id, "address_id" => $this->me->address_id, "addresstype" => "salespartner"])) {
|
||||
if($campaign->network->owner_id != $this->me->address_id && NetworkAddressModel::getFirst(["network_id" => $campaign->network_id, "address_id" => $this->me->address_id, "addresstype" => "salespartner"])) {
|
||||
$preorder_filter["operator_id"] = $this->me->address_id;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$preorder_filter['preordercampaign_id'] = $campaign_ids;
|
||||
if(NetworkAddressModel::getFirst(["address_id" => $this->me->address_id, "addresstype" => "salespartner"])) {
|
||||
$preorder_filter["operator_id"] = $this->me->address_id;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$preorder_filter['preordercampaign_id'] = $campaign_ids;
|
||||
if(NetworkAddressModel::getFirst(["address_id" => $this->me->address_id, "addresstype" => "salespartner"])) {
|
||||
$preorder_filter["operator_id"] = $this->me->address_id;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//$preorder_filter['<status_code'] = 800;
|
||||
@@ -1035,6 +1045,7 @@ class PreorderController extends mfBaseController {
|
||||
|
||||
$this->layout()->setTemplate("Preorder/export.csv");
|
||||
$this->layout()->set("res", $res);
|
||||
$this->layout()->set("no_filename", false);
|
||||
}
|
||||
|
||||
protected function apiAction() {
|
||||
@@ -1479,10 +1490,19 @@ class PreorderController extends mfBaseController {
|
||||
|
||||
private function getFilteredPreordersApi() {
|
||||
$preorders = [];
|
||||
$fttxlocations = [];
|
||||
|
||||
$filter = [];
|
||||
$type = ["preorders", "fttx"];
|
||||
if(is_array($this->request->filter)) {
|
||||
$filter = $this->request->filter;
|
||||
}
|
||||
if($this->request->type == "preorders") {
|
||||
$type = ["preorders"];
|
||||
}
|
||||
if($this->request->type == "fttxlocations") {
|
||||
$type = ["fttx"];
|
||||
}
|
||||
|
||||
$filter = $this->getPreparedFilter($filter);
|
||||
|
||||
@@ -1545,42 +1565,106 @@ class PreorderController extends mfBaseController {
|
||||
}
|
||||
}
|
||||
|
||||
if(!$filter['preordercampaign_id']) $filter['preordercampaign_id'] = 0;
|
||||
if(!array_key_exists("preordercampaign_id", $filter) || !$filter['preordercampaign_id']) $filter['preordercampaign_id'] = 0;
|
||||
|
||||
//var_dump($filter);exit;
|
||||
$results = PreorderModel::searchActive($filter);
|
||||
foreach($results as $preorder) {
|
||||
//$this->log->debug("building status: ".print_r($building->status,true));
|
||||
$data = clone($preorder->data);
|
||||
unset($data->workorder_export_data);
|
||||
unset($data->submit_request);
|
||||
unset($data->addon_services);
|
||||
$data->id = $preorder->id;
|
||||
$data->status_code = $preorder->status->code;
|
||||
$data->adrcd = $preorder->adb_hausnummer->adrcd;
|
||||
$data->extref = $preorder->adb_hausnummer->extref;
|
||||
$data->adb_strasse = $preorder->adb_hausnummer->strasse->name;
|
||||
$data->adb_hausnummer = $preorder->adb_hausnummer->hausnummer;
|
||||
$data->adb_plz = $preorder->adb_hausnummer->plz->plz;
|
||||
$data->adb_ort = $preorder->adb_hausnummer->ortschaft->name;
|
||||
$data->adb_gemeinde = $preorder->adb_hausnummer->strasse->gemeinde->name;
|
||||
$data->gps_lat = $preorder->adb_hausnummer->gps_lat;
|
||||
$data->gps_long = $preorder->adb_hausnummer->gps_long;
|
||||
if(in_array("preorders", $type)) {
|
||||
$this->log->debug(__METHOD__.": requested preorders");
|
||||
//var_dump($filter);exit;
|
||||
$results = PreorderModel::searchActive($filter);
|
||||
foreach($results as $preorder) {
|
||||
//$this->log->debug("building status: ".print_r($building->status,true));
|
||||
$data = clone($preorder->data);
|
||||
unset($data->workorder_export_data);
|
||||
unset($data->submit_request);
|
||||
unset($data->addon_services);
|
||||
$data->id = $preorder->id;
|
||||
$data->status_code = $preorder->status->code;
|
||||
$data->adrcd = $preorder->adb_hausnummer->adrcd;
|
||||
$data->extref = $preorder->adb_hausnummer->extref;
|
||||
$data->adb_strasse = $preorder->adb_hausnummer->strasse->name;
|
||||
$data->adb_hausnummer = $preorder->adb_hausnummer->hausnummer;
|
||||
$data->adb_plz = $preorder->adb_hausnummer->plz->plz;
|
||||
$data->adb_ort = $preorder->adb_hausnummer->ortschaft->name;
|
||||
$data->adb_gemeinde = $preorder->adb_hausnummer->strasse->gemeinde->name;
|
||||
$data->adb_ex_state = $preorder->adb_hausnummer->rimo_ex_state;
|
||||
$data->adb_op_state = $preorder->adb_hausnummer->rimo_op_state;
|
||||
$data->gps_lat = $preorder->adb_hausnummer->gps_lat;
|
||||
$data->gps_long = $preorder->adb_hausnummer->gps_long;
|
||||
|
||||
if($this->me->is("Admin")) {
|
||||
$data->borderpoint_lat = ($preorder->adb_hausnummer->borderpoint_lat) ? json_decode($preorder->adb_hausnummer->borderpoint_lat) : null;
|
||||
$data->borderpoint_long = ($preorder->adb_hausnummer->borderpoint_long) ? json_decode($preorder->adb_hausnummer->borderpoint_long) : null;
|
||||
//$data->trenches = ($preorder->adb_hausnummer->trenches) ? json_decode($preorder->adb_hausnummer->trenches) : null;
|
||||
$data->home_trench = ($preorder->adb_hausnummer->home_trench) ? json_decode($preorder->adb_hausnummer->home_trench) : null;
|
||||
if($this->me->is("Admin")) {
|
||||
$data->borderpoint_lat = ($preorder->adb_hausnummer->borderpoint_lat) ? json_decode($preorder->adb_hausnummer->borderpoint_lat) : null;
|
||||
$data->borderpoint_long = ($preorder->adb_hausnummer->borderpoint_long) ? json_decode($preorder->adb_hausnummer->borderpoint_long) : null;
|
||||
//$data->trenches = ($preorder->adb_hausnummer->trenches) ? json_decode($preorder->adb_hausnummer->trenches) : null;
|
||||
$data->home_trench = ($preorder->adb_hausnummer->home_trench) ? json_decode($preorder->adb_hausnummer->home_trench) : null;
|
||||
}
|
||||
|
||||
$data->type_label = __($data->type, "preorder");
|
||||
$data->connection_type_label = __($data->connection_type, "preorder");
|
||||
|
||||
$preorders[] = $data;
|
||||
}
|
||||
|
||||
$data->type_label = __($data->type, "preorder");
|
||||
$data->connection_type_label = __($data->connection_type, "preorder");
|
||||
|
||||
$preorders[] = $data;
|
||||
}
|
||||
|
||||
return ["preorders" => $preorders];
|
||||
if(in_array("fttx", $type)) {
|
||||
$this->log->debug(__METHOD__.": requested fttxlocations");
|
||||
// get all fttp locations in current campaign network with status
|
||||
|
||||
if($filter["preordercampaign_id"]) {
|
||||
$my_adb_networks = [];
|
||||
|
||||
$campaign = new Preordercampaign($filter["preordercampaign_id"]);
|
||||
if($campaign->id) {
|
||||
$salesclusters = $campaign->salesclusters;
|
||||
if(is_array($salesclusters) && count($salesclusters)) {
|
||||
foreach($salesclusters as $sc) {
|
||||
$my_adb_networks[] = new ADBNetzgebiet($sc->id);
|
||||
}
|
||||
} else {
|
||||
$my_adb_networks[] = $campaign->adb_netzgebiet;
|
||||
}
|
||||
}
|
||||
|
||||
foreach($my_adb_networks as $adb_network) {
|
||||
if(!$adb_network->isLoaded()) continue;
|
||||
|
||||
/*foreach(ADBHausnummerModel::search(['netzgebiet_id' => $adb_network->id]) as $hausnummer) {
|
||||
$loc = [];
|
||||
$loc["street"] = $hausnummer->strasse->name . " " . $hausnummer->hausnummer;
|
||||
$loc["zip"] = $hausnummer->plz->plz;
|
||||
$loc["city"] = $hausnummer->strasse->gemeinde->name;
|
||||
|
||||
$loc["gps_lat"] = $hausnummer->gps_lat;
|
||||
$loc["gps_long"] = $hausnummer->gps_long;
|
||||
$loc["ex_state"] = $hausnummer->rimo_ex_state;
|
||||
$loc["op_state"] = $hausnummer->rimo_op_state;
|
||||
|
||||
$fttxlocations[] = $loc;
|
||||
}*/
|
||||
|
||||
$adb = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
$sql = "SELECT * FROM view_hausnummer WHERE netzgebiet_id=".$adb_network->id;
|
||||
$res = $adb->query($sql);
|
||||
while($data = $adb->fetch_object($res)) {
|
||||
$loc = [];
|
||||
$loc["street"] = $data->strasse . " " . $data->hausnummer;
|
||||
$loc["zip"] = $data->plz;
|
||||
$loc["city"] = $data->gemeinde;
|
||||
|
||||
$loc["gps_lat"] = $data->gps_lat;
|
||||
$loc["gps_long"] = $data->gps_long;
|
||||
$loc["ex_state"] = $data->rimo_ex_state;
|
||||
$loc["op_state"] = $data->rimo_op_state;
|
||||
|
||||
$fttxlocations[] = $loc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
"preorders" => $preorders,
|
||||
"fttxlocations" => $fttxlocations
|
||||
];
|
||||
}
|
||||
|
||||
private function saveAttributeApi() {
|
||||
|
||||
@@ -482,6 +482,13 @@ class PreorderModel
|
||||
return self::count($filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $filter
|
||||
* @param $limit
|
||||
* @param $returnDBRessource
|
||||
* @param $returnArray
|
||||
* @return Preorder[]
|
||||
*/
|
||||
public static function searchActive($filter = [], $limit = false, $returnDBRessource = false, $returnArray = false)
|
||||
{
|
||||
if (!is_array($filter)) return false;
|
||||
@@ -556,13 +563,13 @@ class PreorderModel
|
||||
mfLoghandler::singleton()->debug($sql);
|
||||
|
||||
$res = $db->query($sql);
|
||||
|
||||
// hack for Preorder::exportAction
|
||||
if ($returnDBRessource) {
|
||||
return $res;
|
||||
}
|
||||
|
||||
if ($db->num_rows($res)) {
|
||||
|
||||
// hack for Preorder::exportAction
|
||||
if ($returnDBRessource) {
|
||||
return $res;
|
||||
}
|
||||
|
||||
while ($data = $db->fetch_object($res)) {
|
||||
if ($returnArray) {
|
||||
$items[] = $data;
|
||||
@@ -864,9 +871,9 @@ class PreorderModel
|
||||
if (is_array($tool_building_type) && count($tool_building_type)) {
|
||||
$where .= " AND adb_hausnummer.tool_building_type IN ('" . implode("','", $tool_building_type) . "')";
|
||||
} else {
|
||||
$tool_building_type = FronkDB::singleton()->escape($filter['connection_type']);
|
||||
if ($tool_building_type) {
|
||||
$where .= " AND adb_hausnummer.tool_building_type like '%$tool_building_type%'";
|
||||
$tool_building_type = FronkDB::singleton()->escape($filter['tool_building_type']);
|
||||
if ($tool_building_type === '0' || $tool_building_type) {
|
||||
$where .= " AND adb_hausnummer.tool_building_type = $tool_building_type ";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1254,46 +1261,34 @@ class PreorderModel
|
||||
];
|
||||
}
|
||||
|
||||
public static function countTotalUnits($preorderCampaignId = null) {
|
||||
public static function countTotalUnits($preorderCampaignId = null, $gemeindeId = null) {
|
||||
$db = FronkDB::singleton();
|
||||
$where = ["1=1"];
|
||||
|
||||
// The new WHERE condition is more complex and implemented directly in the main query.
|
||||
$where = "1=1";
|
||||
// Support both array and single campaign ID
|
||||
if ($preorderCampaignId) {
|
||||
$where .= " AND pc.id = " . (int)$preorderCampaignId;
|
||||
$campaignIds = is_array($preorderCampaignId) ? array_map('intval', $preorderCampaignId) : [(int)$preorderCampaignId];
|
||||
$where[] = "pc.id IN (" . implode(',', $campaignIds) . ")";
|
||||
}
|
||||
|
||||
// This query now implements the conditional logic for counting units.
|
||||
// A unit is counted if its building type is standard, OR if its type is special AND has an active preorder.
|
||||
$sql = "SELECT
|
||||
pc.id AS campaign_id,
|
||||
|
||||
-- Total unit count based on the new logic
|
||||
if ($gemeindeId) {
|
||||
$gemeindeIds = is_array($gemeindeId) ? array_map('intval', $gemeindeId) : [(int)$gemeindeId];
|
||||
$where[] = "s.gemeinde_id IN (" . implode(',', $gemeindeIds) . ")";
|
||||
}
|
||||
|
||||
$whereClause = implode(' AND ', $where);
|
||||
|
||||
$sql = "SELECT
|
||||
COUNT(w.id) AS total_unit_count,
|
||||
|
||||
-- SD unit count (Single Dwelling)
|
||||
SUM(CASE
|
||||
WHEN h.tool_building_type IN (0, 1) THEN 1
|
||||
ELSE 0
|
||||
END) AS total_unit_count_sd,
|
||||
|
||||
-- MD unit count (Multi Dwelling)
|
||||
SUM(CASE
|
||||
WHEN h.tool_building_type = 2 THEN 1
|
||||
ELSE 0
|
||||
END) AS total_unit_count_md,
|
||||
|
||||
-- NEW Not2Connect unit count
|
||||
SUM(CASE
|
||||
WHEN h.rimo_op_state = 'Not2Connect' THEN 1
|
||||
ELSE 0
|
||||
END) AS total_unit_count_not2connect
|
||||
SUM(CASE WHEN h.tool_building_type IN (0, 1) THEN 1 ELSE 0 END) AS total_unit_count_sd,
|
||||
SUM(CASE WHEN h.tool_building_type = 2 THEN 1 ELSE 0 END) AS total_unit_count_md,
|
||||
SUM(CASE WHEN h.rimo_op_state = 'Not2Connect' THEN 1 ELSE 0 END) AS total_unit_count_not2connect
|
||||
FROM `".FRONKDB_DBNAME."`.Preordercampaign pc
|
||||
LEFT JOIN `".FRONKDB_DBNAME."`.PreordercampaignSalescluster pcs ON pc.id = pcs.preordercampaign_id
|
||||
LEFT JOIN `".ADDRESSDB_DBNAME."`.Netzgebiet n ON pcs.salescluster_id = n.id
|
||||
LEFT JOIN `".ADDRESSDB_DBNAME."`.Hausnummer h ON n.id = h.netzgebiet_id
|
||||
LEFT JOIN `".ADDRESSDB_DBNAME."`.Strasse s ON h.strasse_id = s.id
|
||||
LEFT JOIN `".ADDRESSDB_DBNAME."`.Wohneinheit w ON h.id = w.hausnummer_id
|
||||
-- Subquery to find all buildings that have at least one active preorder
|
||||
LEFT JOIN (
|
||||
SELECT p_sub.adb_hausnummer_id
|
||||
FROM `".FRONKDB_DBNAME."`.Preorder p_sub
|
||||
@@ -1301,26 +1296,12 @@ class PreorderModel
|
||||
WHERE p_sub.deleted = 0 AND ps_sub.code NOT IN (20) AND ps_sub.code < 899
|
||||
GROUP BY p_sub.adb_hausnummer_id
|
||||
) AS active_preorders ON h.id = active_preorders.adb_hausnummer_id
|
||||
WHERE
|
||||
($where)
|
||||
AND
|
||||
(
|
||||
-- Condition 1: Include unit if its building rimo_type is NOT one of the special types.
|
||||
h.rimo_type NOT IN ('greenfield', 'other', 'transmitting station', 'transformer station', 'outdoor cabinet')
|
||||
|
||||
OR
|
||||
|
||||
-- Condition 2: OR if the rimo_type IS special (or NULL), include it ONLY IF an active preorder exists for the building.
|
||||
(
|
||||
(h.rimo_type IS NULL OR h.rimo_type IN ('greenfield', 'other', 'transmitting station', 'transformer station', 'outdoor cabinet'))
|
||||
AND active_preorders.adb_hausnummer_id IS NOT NULL
|
||||
)
|
||||
)
|
||||
GROUP BY pc.id";
|
||||
WHERE ($whereClause)
|
||||
AND (h.rimo_type NOT IN ('greenfield', 'other', 'transmitting station', 'transformer station', 'outdoor cabinet')
|
||||
OR ((h.rimo_type IS NULL OR h.rimo_type IN ('greenfield', 'other', 'transmitting station', 'transformer station', 'outdoor cabinet'))
|
||||
AND active_preorders.adb_hausnummer_id IS NOT NULL))";
|
||||
|
||||
$queryStart = microtime(true);
|
||||
$res = $db->query($sql);
|
||||
mfLoghandler::singleton()->debug("[Query took: ".(microtime(true) - $queryStart)." seconds] " . $sql);
|
||||
|
||||
if ($db->num_rows($res)) {
|
||||
$data = $db->fetch_object($res);
|
||||
@@ -1328,16 +1309,11 @@ class PreorderModel
|
||||
'total_unit_count' => (int)$data->total_unit_count,
|
||||
'total_unit_count_sd' => (int)$data->total_unit_count_sd,
|
||||
'total_unit_count_md' => (int)$data->total_unit_count_md,
|
||||
'total_unit_count_not2connect' => (int)$data->total_unit_count_not2connect // New return value
|
||||
'total_unit_count_not2connect' => (int)$data->total_unit_count_not2connect
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total_unit_count' => 0,
|
||||
'total_unit_count_sd' => 0,
|
||||
'total_unit_count_md' => 0,
|
||||
'total_unit_count_not2connect' => 0
|
||||
];
|
||||
return ['total_unit_count' => 0, 'total_unit_count_sd' => 0, 'total_unit_count_md' => 0, 'total_unit_count_not2connect' => 0];
|
||||
}
|
||||
|
||||
public static function countHistoryStatus($filter = [], $status_code = null) {
|
||||
@@ -1415,7 +1391,7 @@ ORDER BY
|
||||
}
|
||||
|
||||
public static function getPreorderRimoTypeData(int $campaignId): array {
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
$db = FronkDB::singleton();
|
||||
$fronkDbName = defined('FRONKDB_DBNAME') ? FRONKDB_DBNAME : 'thetool';
|
||||
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
|
||||
$safeCampaignId = (int)$campaignId;
|
||||
@@ -1423,6 +1399,23 @@ ORDER BY
|
||||
// add time debug
|
||||
$startTime = microtime(true);
|
||||
|
||||
$campaign = PreordercampaignModel::getFirst(['id' => $safeCampaignId]);
|
||||
$faultyHausnummerIds = [];
|
||||
if ($campaign && !empty($campaign->rimo_type_map_faults)) {
|
||||
$faults = json_decode($campaign->rimo_type_map_faults, true);
|
||||
foreach ($faults as $fault) {
|
||||
if (empty($fault['done'])) {
|
||||
$faultyHausnummerIds[] = (int)$fault['hausnummer_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$visibilityCondition = "AND (h.visibility IS NULL OR h.visibility != 'private')";
|
||||
if (!empty($faultyHausnummerIds)) {
|
||||
$ids = implode(',', $faultyHausnummerIds);
|
||||
$visibilityCondition = "AND ((h.visibility IS NULL OR h.visibility != 'private') OR h.id IN ({$ids}))";
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
h.id AS hausnummer_id, h.gps_lat, h.gps_long, h.rimo_type, h.rimo_op_state, h.rimo_ex_state, h.hausnummer,
|
||||
@@ -1444,6 +1437,7 @@ ORDER BY
|
||||
JOIN `{$fronkDbName}`.`Network` n ON pc.network_id = n.id
|
||||
WHERE pc.id = {$safeCampaignId}
|
||||
) AND h.gps_lat IS NOT NULL AND h.gps_long IS NOT NULL
|
||||
{$visibilityCondition}
|
||||
GROUP BY h.id
|
||||
ORDER BY h.id
|
||||
";
|
||||
@@ -1460,7 +1454,7 @@ ORDER BY
|
||||
}
|
||||
|
||||
public static function getPreorderRimoFaultsData(int $campaignId): array {
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
$db = FronkDB::singleton();
|
||||
$fronkDbName = defined('FRONKDB_DBNAME') ? FRONKDB_DBNAME : 'thetool';
|
||||
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
|
||||
$safeCampaignId = (int)$campaignId;
|
||||
|
||||
@@ -398,6 +398,11 @@ class PreorderBillingController extends mfBaseController {
|
||||
return true; // already billed
|
||||
}
|
||||
|
||||
if($price->price_setup <= 0.01 && $price->price_setup >= 0.00000) {
|
||||
$this->log->debug(__METHOD__.": Preorder ".$preorder->id." / ".$preorder->oaid." enduser_setup price is 0 so skipping...");
|
||||
return true;
|
||||
}
|
||||
|
||||
// search for customer
|
||||
$customer_data = [
|
||||
"company" => trim($preorder->company),
|
||||
@@ -441,7 +446,7 @@ class PreorderBillingController extends mfBaseController {
|
||||
die("fibu_revenue code not found for preorder ".$preorder->id);
|
||||
}
|
||||
|
||||
$change_to_active = PreorderHistoryModel::getFirstStatusChangeTo($preorder->id, 500);
|
||||
$change_to_active = PreorderHistoryModel::getLastStatusChangeTo($preorder->id, 500);
|
||||
if($change_to_active) {
|
||||
$status_change_date = new DateTime("@".$change_to_active->changed);
|
||||
$billing_data["start_date"] = $status_change_date->format("Y-m-d");
|
||||
@@ -632,7 +637,14 @@ class PreorderBillingController extends mfBaseController {
|
||||
//var_dump($existing_bill);
|
||||
if(!$existing_bill) {
|
||||
if($netoperator_config["billing-period"] == "quarterly" && $create_date->format("Ymd") > $latest_quarter_bill_date->format("Ymd")) {
|
||||
$this->log->debug(__METHOD__.": Skipping operator_usage ".$create_date->format("m/Y")." for preorder ".$preorder->id." because Billing date ".$create_date->format("Y-m-d")." is after latest_quarter_bill_date ".$latest_quarter_bill_date->format("Y-m-d"));
|
||||
// if this preorder was never billed before and activation date is before latest quarterly billing date, we still need to consider earlier months
|
||||
$any_previous_bill = PreorderBilling::getFirst(["product_id" => $product->id, "preorder_id" => $preorder->id]);
|
||||
if(!$any_previous_bill && $status_change_date->format("Ym") < $latest_quarter_bill_date->format("Ym")) {
|
||||
$create_date->modify("-1 months");
|
||||
continue;
|
||||
}
|
||||
// otherwise if activation was this month, then we need not bill anything now
|
||||
$this->log->debug(__METHOD__.": Skipping operator_usage blubb ".$create_date->format("m/Y")." for preorder ".$preorder->id." because Billing date ".$create_date->format("Y-m-d")." is after latest_quarter_bill_date ".$latest_quarter_bill_date->format("Y-m-d")." (status 500 change date: ".$status_change_date->format("Y-m-d").")");
|
||||
return true;
|
||||
}
|
||||
$new_create_date = clone $create_date;
|
||||
|
||||
@@ -107,12 +107,12 @@ class PreorderCtag extends mfBaseModel {
|
||||
|
||||
// add to interface-list
|
||||
|
||||
$ros->add("/interface list member", ["interface" => $vlan_name, "list" => CITYCOM_OAN_API_NNI_IFLIST_NAME]);
|
||||
$this->log->info(__METHOD__.": done => /interface list member add interface=$vlan_name list=".CITYCOM_OAN_API_NNI_IFLIST_NAME);
|
||||
$ros->add("/interface list member", ["interface" => $vlan_name, "list" => CITYCOM_OAN_NNI_IFLIST_NAME]);
|
||||
$this->log->info(__METHOD__.": done => /interface list member add interface=$vlan_name list=".CITYCOM_OAN_NNI_IFLIST_NAME);
|
||||
|
||||
// add to bridge CITYCOM_OAN_APU_NNI_BRIDGE_NAME
|
||||
$ros->add("/interface bridge port", ["bridge" => CITYCOM_OAN_APU_NNI_BRIDGE_NAME, "interface" => $vlan_name]);
|
||||
$this->log->info(__METHOD__.": done => /bridge port add bridge=".CITYCOM_OAN_APU_NNI_BRIDGE_NAME." interface=$vlan_name");
|
||||
// add to bridge CITYCOM_OAN_NNI_BRIDGE_NAME
|
||||
$ros->add("/interface bridge port", ["bridge" => CITYCOM_OAN_NNI_BRIDGE_NAME, "interface" => $vlan_name]);
|
||||
$this->log->info(__METHOD__.": done => /bridge port add bridge=".CITYCOM_OAN_NNI_BRIDGE_NAME." interface=$vlan_name");
|
||||
|
||||
return true;
|
||||
|
||||
|
||||
@@ -10,14 +10,18 @@ class PreorderIFrameModel extends mfBaseModel
|
||||
public function getClusters($frame_referrer): array
|
||||
{
|
||||
$query = "
|
||||
SELECT n.adb_netzgebiet_id as id, ng.name, pc.id as campaign_id, pc.name as campaign_name
|
||||
FROM thetool.Preordercampaign pc
|
||||
JOIN thetool.Network n ON pc.Network_id = n.id
|
||||
JOIN addressdb.Netzgebiet ng ON n.adb_netzgebiet_id = ng.id
|
||||
WHERE JSON_SEARCH(pc.iframe_origins, 'one', '" . $this->db->escape($frame_referrer) . "') IS NOT NULL
|
||||
GROUP BY n.adb_netzgebiet_id, ng.name
|
||||
ORDER BY ng.name ASC
|
||||
";
|
||||
SELECT
|
||||
n.adb_netzgebiet_id as id,
|
||||
ng.name,
|
||||
GROUP_CONCAT(pc.id SEPARATOR ', ') as campaign_ids,
|
||||
GROUP_CONCAT(pc.name SEPARATOR ', ') as campaign_names
|
||||
FROM thetool.Preordercampaign pc
|
||||
JOIN thetool.Network n ON pc.Network_id = n.id
|
||||
JOIN addressdb.Netzgebiet ng ON n.adb_netzgebiet_id = ng.id
|
||||
WHERE JSON_SEARCH(pc.iframe_origins, 'one', '" . $this->db->escape($frame_referrer) . "') IS NOT NULL
|
||||
GROUP BY n.adb_netzgebiet_id, ng.name
|
||||
ORDER BY ng.name ASC
|
||||
";
|
||||
|
||||
$res = $this->db->query($query);
|
||||
$clusters = $this->db->fetch_all_assoc($res);
|
||||
@@ -32,124 +36,105 @@ class PreorderIFrameModel extends mfBaseModel
|
||||
|
||||
public function findCities(array $params): array
|
||||
{
|
||||
$whereClause = "p.plzstring = " . $this->db->escape($params['zip']);
|
||||
if (!empty($params['gemeindeId'])) {
|
||||
$whereClause .= " AND g.id = " . intval($params['gemeindeId']);
|
||||
} elseif (!empty($params['clusterId'])) {
|
||||
$whereClause .= " AND gn.netzgebiet_id = " . intval($params['clusterId']);
|
||||
} else {
|
||||
return []; // No identifier provided
|
||||
}
|
||||
if (empty($params['gemeindeId']) && empty($params['clusterId'])) return [];
|
||||
|
||||
$query = "
|
||||
SELECT DISTINCT o.name
|
||||
FROM addressdb.Plz p
|
||||
JOIN addressdb.Ortschaft o ON p.gemeinde_id = o.gemeinde_id
|
||||
JOIN addressdb.Gemeinde g ON o.gemeinde_id = g.id
|
||||
LEFT JOIN addressdb.GemeindeNetzgebiet gn ON g.id = gn.gemeinde_id
|
||||
WHERE $whereClause
|
||||
ORDER BY o.name ASC
|
||||
";
|
||||
$sql = "SELECT DISTINCT o.name FROM addressdb.Plz p
|
||||
JOIN addressdb.Ortschaft o ON p.gemeinde_id = o.gemeinde_id
|
||||
JOIN addressdb.Gemeinde g ON o.gemeinde_id = g.id
|
||||
LEFT JOIN addressdb.GemeindeNetzgebiet gn ON g.id = gn.gemeinde_id
|
||||
WHERE p.plzstring = " . $this->db->escape($params['zip']);
|
||||
|
||||
$res = $this->db->query($query);
|
||||
return array_column($this->db->fetch_all_assoc($res), 'name');
|
||||
$cond = !empty($params['gemeindeId'])
|
||||
? " AND g.id = " . intval($params['gemeindeId'])
|
||||
: " AND gn.netzgebiet_id = " . intval($params['clusterId']);
|
||||
|
||||
$rows = $this->db->fetch_all_assoc($this->db->query($sql . $cond));
|
||||
|
||||
if (empty($rows) && empty($params['gemeindeId']))
|
||||
$rows = $this->db->fetch_all_assoc($this->db->query($sql));
|
||||
|
||||
return array_column($rows, 'name');
|
||||
}
|
||||
|
||||
public function findStreets(array $params): array
|
||||
{
|
||||
$whereClauses = [];
|
||||
if (!empty($params['gemeindeId'])) {
|
||||
$whereClauses[] = "g.id = " . intval($params['gemeindeId']);
|
||||
} elseif (!empty($params['clusterId'])) {
|
||||
$whereClauses[] = "gn.netzgebiet_id = " . intval($params['clusterId']);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
if (empty($params['gemeindeId']) && empty($params['clusterId'])) return [];
|
||||
|
||||
$whereClauses[] = "o.name = '" . $this->db->escape($params['city']) . "'";
|
||||
$whereClauses[] = "EXISTS (SELECT 1 FROM addressdb.Plz p WHERE p.plzstring = " . $this->db->escape($params['zip']) . " AND p.gemeinde_id = o.gemeinde_id)";
|
||||
$whereString = implode(" AND ", $whereClauses);
|
||||
$sql = "SELECT DISTINCT s.name
|
||||
FROM addressdb.Strasse s
|
||||
JOIN addressdb.Gemeinde g ON s.gemeinde_id = g.id
|
||||
JOIN addressdb.Ortschaft o ON o.gemeinde_id = g.id
|
||||
LEFT JOIN addressdb.GemeindeNetzgebiet gn ON g.id = gn.gemeinde_id
|
||||
WHERE o.name = '" . $this->db->escape($params['city']) . "'
|
||||
AND EXISTS (SELECT 1 FROM addressdb.Plz p WHERE p.plzstring = " . $this->db->escape($params['zip']) . " AND p.gemeinde_id = o.gemeinde_id)";
|
||||
|
||||
$query = "
|
||||
SELECT DISTINCT s.name
|
||||
FROM addressdb.Strasse s
|
||||
JOIN addressdb.Gemeinde g ON s.gemeinde_id = g.id
|
||||
JOIN addressdb.Ortschaft o ON o.gemeinde_id = g.id
|
||||
LEFT JOIN addressdb.GemeindeNetzgebiet gn ON g.id = gn.gemeinde_id
|
||||
WHERE $whereString
|
||||
ORDER BY s.name ASC
|
||||
";
|
||||
$cond = !empty($params['gemeindeId'])
|
||||
? " AND g.id = " . intval($params['gemeindeId'])
|
||||
: " AND gn.netzgebiet_id = " . intval($params['clusterId']);
|
||||
|
||||
$res = $this->db->query($query);
|
||||
return array_column($this->db->fetch_all_assoc($res), 'name');
|
||||
$rows = $this->db->fetch_all_assoc($this->db->query($sql . $cond));
|
||||
|
||||
// Fallback: If empty result and we were using clusterId, run without the specific ID constraint
|
||||
if (empty($rows) && empty($params['gemeindeId']))
|
||||
$rows = $this->db->fetch_all_assoc($this->db->query($sql));
|
||||
|
||||
return array_column($rows, 'name');
|
||||
}
|
||||
|
||||
|
||||
public function findAddresses(array $params): array
|
||||
{
|
||||
$whereClauses = [
|
||||
"p.plzstring = " . $this->db->escape($params['zip']),
|
||||
"o.name = '" . $this->db->escape($params['city']) . "'",
|
||||
"s.name = '" . $this->db->escape($params['street']) . "'",
|
||||
"h.hausnummer = '" . $this->db->escape($params['housenumber']) . "'",
|
||||
];
|
||||
if (empty($params['gemeinde_id']) && empty($params['cluster_id'])) return [];
|
||||
|
||||
if (!empty($params['gemeinde_id'])) {
|
||||
$whereClauses[] = "h.gemeinde_id = " . intval($params['gemeinde_id']);
|
||||
} elseif (!empty($params['cluster_id'])) {
|
||||
$whereClauses[] = "h.netzgebiet_id = " . intval($params['cluster_id']);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
$sql = "SELECT h.oaid, h.hausnummer, s.name as street, p.plzstring as zip, o.name as city, h.id as hausnummer_id, w.id as wohneinheit_id,
|
||||
h.stiege, h.unit_count as building_unit_count, h.tool_building_type as building_type,
|
||||
w.oaid as unit_oaid, w.stock, w.tuer, w.zusatz
|
||||
FROM addressdb.Hausnummer h
|
||||
JOIN addressdb.Strasse s ON h.strasse_id = s.id
|
||||
JOIN addressdb.Plz p ON h.plz_id = p.id
|
||||
JOIN addressdb.Ortschaft o ON s.gemeinde_id = o.gemeinde_id
|
||||
LEFT JOIN addressdb.Wohneinheit w ON w.hausnummer_id = h.id
|
||||
WHERE p.plzstring = " . $this->db->escape($params['zip']) . "
|
||||
AND o.name = '" . $this->db->escape($params['city']) . "'
|
||||
AND s.name = '" . $this->db->escape($params['street']) . "'
|
||||
AND h.hausnummer = '" . $this->db->escape($params['housenumber']) . "'";
|
||||
|
||||
$whereString = implode(" AND ", $whereClauses);
|
||||
$cond = !empty($params['gemeinde_id'])
|
||||
? " AND h.gemeinde_id = " . intval($params['gemeinde_id'])
|
||||
: " AND h.netzgebiet_id = " . intval($params['cluster_id']);
|
||||
|
||||
$query = "
|
||||
SELECT h.oaid, h.hausnummer, s.name as street, p.plzstring as zip, o.name as city, h.id as hausnummer_id, w.id as wohneinheit_id,
|
||||
h.stiege, h.unit_count as building_unit_count, h.tool_building_type as building_type,
|
||||
w.oaid as unit_oaid, w.stock, w.tuer, w.zusatz
|
||||
FROM addressdb.Hausnummer h
|
||||
JOIN addressdb.Strasse s ON h.strasse_id = s.id
|
||||
JOIN addressdb.Plz p ON h.plz_id = p.id
|
||||
JOIN addressdb.Ortschaft o ON s.gemeinde_id = o.gemeinde_id
|
||||
LEFT JOIN addressdb.Wohneinheit w ON w.hausnummer_id = h.id
|
||||
WHERE $whereString
|
||||
";
|
||||
$results = $this->db->fetch_all_assoc($this->db->query($sql . $cond));
|
||||
|
||||
if (empty($results) && empty($params['gemeinde_id']))
|
||||
$results = $this->db->fetch_all_assoc($this->db->query($sql));
|
||||
|
||||
$results = $this->db->fetch_all_assoc($this->db->query($query));
|
||||
if (empty($results)) return [];
|
||||
|
||||
$orderType = $params['orderType'] ?? 'order';
|
||||
|
||||
// For 'interest' order type, return a single entry for the whole building.
|
||||
if ($orderType === 'interest') {
|
||||
$representativeAddress = $this->formatAddressRow($results[0]);
|
||||
$representativeAddress['wohneinheit_id'] = null; // Critical: No specific unit
|
||||
$representativeAddress['oaid'] = $results[0]['oaid']; // Use building OAID
|
||||
$representativeAddress['showText'] = "Gesamtes Gebäude";
|
||||
$representativeAddress['preorderTypes'] = ['interest'];
|
||||
return [$representativeAddress]; // Return one item, so frontend proceeds directly.
|
||||
if (($params['orderType'] ?? 'order') === 'interest') {
|
||||
$addr = $this->formatAddressRow($results[0]);
|
||||
$addr['wohneinheit_id'] = null;
|
||||
$addr['oaid'] = $results[0]['oaid'];
|
||||
$addr['showText'] = "Gesamtes Gebäude";
|
||||
$addr['preorderTypes'] = ['interest'];
|
||||
return [$addr];
|
||||
}
|
||||
|
||||
// Original logic for 'order' type
|
||||
$addresses = [];
|
||||
$topCounter = 1;
|
||||
|
||||
if (count($results) > 1 && $results[0]['wohneinheit_id'] !== null) {
|
||||
$i = 1;
|
||||
foreach ($results as $row) {
|
||||
$address = $this->formatAddressRow($row);
|
||||
$address['showText'] = $this->buildShowText($row, $topCounter++);
|
||||
$address['preorderTypes'] = ['order'];
|
||||
$addresses[] = $address;
|
||||
$addr = $this->formatAddressRow($row);
|
||||
$addr['showText'] = $this->buildShowText($row, $i++);
|
||||
$addr['preorderTypes'] = ['order'];
|
||||
$addresses[] = $addr;
|
||||
}
|
||||
} else {
|
||||
// Single unit or building without units
|
||||
$address = $this->formatAddressRow($results[0]);
|
||||
$address['preorderTypes'] = ['order'];
|
||||
$addresses[] = $address;
|
||||
$addr = $this->formatAddressRow($results[0]);
|
||||
$addr['preorderTypes'] = ['order'];
|
||||
$addresses[] = $addr;
|
||||
}
|
||||
|
||||
return $addresses;
|
||||
}
|
||||
}
|
||||
|
||||
private function formatAddressRow(array $row): array
|
||||
{
|
||||
|
||||
@@ -192,7 +192,9 @@ class Preordercampaign extends mfBaseModel {
|
||||
if($name == "salesclusters") {
|
||||
$items = PreordercampaignSalesclusterModel::search(["preordercampaign_id" => $this->id]);
|
||||
foreach($items as $pog) {
|
||||
$this->salesclusters[$pog->salescluster_id] = new ADBNetzgebiet($pog->salescluster_id);
|
||||
$sc = new ADBNetzgebiet($pog->salescluster_id);
|
||||
if(!$sc->id) continue;
|
||||
$this->salesclusters[$pog->salescluster_id] = $sc;
|
||||
}
|
||||
return $this->salesclusters;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -219,61 +219,60 @@ class Termination extends mfBaseModel {
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
public function getLineworkportPairs() {
|
||||
$ports = $this->getProperty("workflowitems")["ports"]->value->value_string;
|
||||
$ist_port = $this->getProperty("workflowitems")["ist_ports"]->value->value_string;
|
||||
if(strlen($ist_port)) {
|
||||
$ports = $ist_port;
|
||||
}
|
||||
|
||||
if(!$ports) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$return = [];
|
||||
$return["range"] = [];
|
||||
$return["pairs"] = [];
|
||||
|
||||
$ports = preg_replace('/[^0-9-]+/', "", $ports);
|
||||
if(strpos($ports, "-") !== false) {
|
||||
// port range
|
||||
$this->log->debug("is range");
|
||||
$port_parts = explode("-", $ports);
|
||||
if(is_array($port_parts) && count($port_parts) == 2) {
|
||||
$from = $port_parts[0];
|
||||
$to = $port_parts[1];
|
||||
|
||||
if($port_parts[0] > $port_parts[1]) {
|
||||
$from = $port_parts[1];
|
||||
$to = $port_parts[0];
|
||||
|
||||
public function getLineworkportPairs() {
|
||||
$ports = $this->getProperty("workflowitems")["ports"]->value->value_string;
|
||||
$ist_port = $this->getProperty("workflowitems")["ist_ports"]->value->value_string;
|
||||
if(strlen($ist_port)) {
|
||||
$ports = $ist_port;
|
||||
}
|
||||
|
||||
$range = [];
|
||||
$pairs = [];
|
||||
|
||||
|
||||
|
||||
for($i = $from; $i <= $to; $i++) {
|
||||
$range[] = intval($i);
|
||||
if($i + 1 <= $to) {
|
||||
$pairs[] = $i."-".($i+1);
|
||||
}
|
||||
|
||||
if(!$ports) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$return["range"] = $range;
|
||||
$return["pairs"] = $pairs;
|
||||
}
|
||||
} else {
|
||||
// single port
|
||||
$this->log->debug("not a range");
|
||||
$return["range"][] = $ports;
|
||||
|
||||
$return = [];
|
||||
$return["range"] = [];
|
||||
$return["pairs"] = [];
|
||||
|
||||
$ports = preg_replace('/[^0-9-]+/', "", $ports);
|
||||
|
||||
$port_parts = false;
|
||||
if(preg_match('/^(\d+)-(\d+)$/', $ports, $m)) {
|
||||
$port_parts = [$m[1], $m[2]];
|
||||
}
|
||||
|
||||
if(!is_array($port_parts) || count($port_parts) < 2) {
|
||||
// not a valid port range, treat as single port
|
||||
$return["range"][] = $ports;
|
||||
} else {
|
||||
// valid port range
|
||||
$from = $port_parts[0];
|
||||
$to = $port_parts[1];
|
||||
|
||||
if($port_parts[0] > $port_parts[1]) {
|
||||
$from = $port_parts[1];
|
||||
$to = $port_parts[0];
|
||||
}
|
||||
|
||||
$range = [];
|
||||
$pairs = [];
|
||||
|
||||
for($i = $from; $i <= $to; $i++) {
|
||||
$range[] = intval($i);
|
||||
if($i + 1 <= $to) {
|
||||
$pairs[] = $i."-".($i+1);
|
||||
}
|
||||
}
|
||||
|
||||
$return["range"] = $range;
|
||||
$return["pairs"] = $pairs;
|
||||
}
|
||||
|
||||
//var_dump($return);exit;
|
||||
return $return;
|
||||
|
||||
}
|
||||
|
||||
//var_dump($return);exit;
|
||||
return $return;
|
||||
|
||||
}
|
||||
|
||||
public function resetProperties() {
|
||||
$this->building = null;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
class User extends mfBaseModel {
|
||||
public $permissions;
|
||||
public $flags;
|
||||
public $flags = [];
|
||||
public $address;
|
||||
protected $forcestr = ['mobile','twofactorcode'];
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ class VoiceCallHistoryController extends mfBaseController {
|
||||
"^43317244160",
|
||||
"^4368181877218",
|
||||
"^491744919930",
|
||||
"^4924194559562",
|
||||
];
|
||||
|
||||
$unknown_numbers = [];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
class WarehouseArticleController extends TTCrud {
|
||||
protected string $headerTitle = 'Artikel';
|
||||
protected $createText = 'Artikel erstellen';
|
||||
protected $createText = false;
|
||||
protected string $singleText = 'Artikel';
|
||||
protected bool $reopenOnCreate = true;
|
||||
|
||||
@@ -12,7 +12,7 @@ class WarehouseArticleController extends TTCrud {
|
||||
['key' => 'articleNumber', 'text' => 'Nr.', 'required' => true],
|
||||
['key' => 'description', 'text' => 'Beschreibung', 'required' => true,'modal' => ['type' => 'textarea'], 'table' => ['sortable' => false]],
|
||||
['key' => 'category_id', 'text' => 'Kategorie', 'required' => true, 'modal' => ['type' => 'select', 'items' => []], 'table' => ['filter' => 'select']],
|
||||
['key' => 'unit', 'text' => 'Einheit', 'required' => true,'table' => false],
|
||||
['key' => 'unit', 'text' => 'Einheit', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]], 'table' => ['filter' => 'select', 'filterOptions' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]]],
|
||||
['key' => 'revenueAccount', 'text' => 'Erlöskonto', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 0, 'text' => 'Dienstleistungen'], ['value' => 1, 'text' => 'Handelswaren']]], 'table' => false],
|
||||
['key' => 'cheapestPurchasePrice', 'text' => 'Einkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
|
||||
['key' => 'cheapestSellPrice', 'text' => 'Verkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
|
||||
@@ -32,10 +32,13 @@ class WarehouseArticleController extends TTCrud {
|
||||
protected array $autocompleteColumns = ['articleNumber', 'title', 'description'];
|
||||
protected array $permissionCheck = ['WarehouseUser'];
|
||||
|
||||
protected array $additionalActions = [['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary']];
|
||||
protected array $additionalActions = [
|
||||
['key' => 'printLabel','title' => 'Label drucken','class' => 'fas fa-print text-secondary'],
|
||||
['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary']
|
||||
];
|
||||
// @formatter:on
|
||||
|
||||
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true];
|
||||
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true, 'HIDE_PAGE_TITLE' => true];
|
||||
|
||||
protected function prepareCrudConfig() {
|
||||
$categories = array_map(fn($category) => ['value' => $category->id, 'text' => $category->name], WarehouseCategory::getAll());
|
||||
@@ -50,15 +53,19 @@ class WarehouseArticleController extends TTCrud {
|
||||
$this->additionalJSVariables['WAREHOUSE_ADMIN'] = false;
|
||||
}
|
||||
|
||||
protected function beforeCreate() {
|
||||
if (!in_array($this->user->id, [2, 5, 6, 145]))
|
||||
protected function beforeCreate($postData): bool {
|
||||
if (!in_array($this->user->id, [2, 5, 6, 145, 14]))
|
||||
self::sendError("Sie haben keine Berechtigung, Artikel zu erstellen.");
|
||||
|
||||
$this->validateArticleNumber($postData);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function beforeUpdate($postData): bool {
|
||||
if (!in_array($this->user->id, [2, 5, 6, 145]))
|
||||
if (!in_array($this->user->id, [2, 5, 6, 145, 14]))
|
||||
self::sendError("Sie haben keine Berechtigung, Artikel zu bearbeiten.");
|
||||
|
||||
$this->validateArticleNumber($postData, $postData['id'] ?? null);
|
||||
(new WarehouseHistoryController)->create($postData, $this->mod);
|
||||
return true;
|
||||
}
|
||||
@@ -81,6 +88,38 @@ class WarehouseArticleController extends TTCrud {
|
||||
self::updateSellPrices($postData['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate article number for duplicates and correct category prefix
|
||||
*/
|
||||
private function validateArticleNumber(array $postData, ?int $excludeId = null): void {
|
||||
$articleNumber = $postData['articleNumber'] ?? '';
|
||||
$categoryId = $postData['category_id'] ?? null;
|
||||
|
||||
if (empty($articleNumber)) {
|
||||
self::sendError("Artikelnummer ist erforderlich.");
|
||||
}
|
||||
|
||||
// Check for duplicate article number
|
||||
$existingArticles = WarehouseArticleModel::getAll(['articleNumber' => $articleNumber]);
|
||||
foreach ($existingArticles as $existing) {
|
||||
if ($excludeId === null || $existing->id != $excludeId) {
|
||||
self::sendError("Artikelnummer '{$articleNumber}' existiert bereits (Artikel ID: {$existing->id}).");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate category prefix
|
||||
if ($categoryId) {
|
||||
$category = WarehouseCategory::get($categoryId);
|
||||
if ($category && $category->articleNumberPrefix) {
|
||||
$expectedPrefix = $category->articleNumberPrefix;
|
||||
$articlePrefix = substr($articleNumber, 0, strlen($expectedPrefix));
|
||||
if ($articlePrefix !== $expectedPrefix) {
|
||||
self::sendError("Artikelnummer muss mit dem Kategorie-Prefix '{$expectedPrefix}' beginnen.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function updateSellPrices(int $id): void { // Added return type hint
|
||||
$a = WarehouseArticleModel::get($id);
|
||||
if (!$a instanceof WarehouseArticleModel) throw new Exception("Invalid article type");
|
||||
@@ -131,6 +170,41 @@ class WarehouseArticleController extends TTCrud {
|
||||
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
|
||||
}
|
||||
|
||||
protected function getNextArticleNumberAction() {
|
||||
$categoryId = intval($this->request->categoryId ?? 0);
|
||||
if (!$categoryId) self::sendError("Kategorie nicht angegeben");
|
||||
|
||||
$category = WarehouseCategory::get($categoryId);
|
||||
if (!$category) self::sendError("Kategorie nicht gefunden");
|
||||
if (!$category->articleNumberPrefix) self::sendError("Kategorie hat keinen Artikelnummer-Prefix");
|
||||
|
||||
$prefix = $category->articleNumberPrefix;
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
// Get all existing article numbers with this prefix, sorted
|
||||
$result = $db->query("SELECT CAST(articleNumber AS UNSIGNED) as num FROM WarehouseArticle WHERE articleNumber LIKE '{$prefix}%' ORDER BY num ASC");
|
||||
$existingNumbers = [];
|
||||
while ($row = $db->fetch_array($result)) {
|
||||
$existingNumbers[] = intval($row['num']);
|
||||
}
|
||||
|
||||
// Start from prefix * 10000 + 1 (e.g., 1800 -> 18000001)
|
||||
$startNumber = intval($prefix) * 10000 + 1;
|
||||
$nextNumber = $startNumber;
|
||||
|
||||
// Find first gap
|
||||
foreach ($existingNumbers as $num) {
|
||||
if ($num == $nextNumber) {
|
||||
$nextNumber++;
|
||||
} else if ($num > $nextNumber) {
|
||||
// Found a gap
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'articleNumber' => str_pad($nextNumber, 8, '0', STR_PAD_LEFT)]);
|
||||
}
|
||||
|
||||
protected function autocompleteAction() {
|
||||
$textKey = property_exists($this->model, 'name') ? 'name' : 'title';
|
||||
if (strlen($this->request->searchedID) > 0) {
|
||||
@@ -163,4 +237,29 @@ class WarehouseArticleController extends TTCrud {
|
||||
return ['value' => $item->id, 'text' => $item->$textKey];
|
||||
}, $data));
|
||||
}
|
||||
|
||||
protected function printLabelAction() {
|
||||
$articleId = $this->request->id;
|
||||
|
||||
$article = WarehouseArticleModel::get($articleId);
|
||||
if (!$article) {
|
||||
self::sendError("Artikel nicht gefunden", 404);
|
||||
}
|
||||
|
||||
$pdf_vars = [
|
||||
'articleId' => $article->id,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title
|
||||
];
|
||||
|
||||
$pdf = new PdfForm("WarehouseArticle/LABEL", $pdf_vars);
|
||||
$wkhtmltopdfArgs = "--page-height 25mm --page-width 50mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
|
||||
|
||||
$filename = $pdf->render($wkhtmltopdfArgs);
|
||||
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="label-' . $article->articleNumber . '.pdf"');
|
||||
readfile($filename);
|
||||
die();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,18 @@ class WarehouseArticlePriceTypeController extends TTCrud {
|
||||
$WarehouseArticleController->updatePricesAction();
|
||||
}
|
||||
|
||||
protected function beforeDelete(): bool {
|
||||
$priceTypeId = $this->request->id;
|
||||
$usedByAddresses = AddressPriceTypeModel::getAll(['priceType_id' => $priceTypeId]);
|
||||
|
||||
if (!empty($usedByAddresses)) {
|
||||
$this->infoMessages['delete'] = 'Dieser Preistyp kann nicht gelöscht werden, da er von ' . count($usedByAddresses) . ' Kunde(n) verwendet wird.';
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getHistoryAction() {
|
||||
$history = WarehouseHistoryModel::getByRowId($this->request->id, $this->mod);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ class WarehouseCategory extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public string $name;
|
||||
public string $description;
|
||||
public ?int $articleNumberPrefix;
|
||||
public ?string $articleNumberPrefix;
|
||||
public int $create;
|
||||
public int $create_by;
|
||||
public ?int $edit;
|
||||
|
||||
@@ -9,7 +9,7 @@ class WarehouseCategoryController extends TTCrud {
|
||||
protected array $columns = [
|
||||
['key' => 'name', 'text' => 'Name', 'required' => true,],
|
||||
['key' => 'description', 'text' => 'Beschreibung', 'required' => true],
|
||||
['key' => 'articleNumberPrefix', 'text' => 'Artikelnummerprefix', 'required' => true],
|
||||
['key' => 'articleNumberPrefix', 'text' => 'Artikelnummerprefix', 'required' => false, 'modal' => ['disabled' => true, 'placeholder' => 'Wird automatisch generiert']],
|
||||
['key' => 'create', 'text' => 'Erstellt am', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
|
||||
['key' => 'create_by', 'text' => 'Erstellt von', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
|
||||
['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center', 'priority' => 10]],
|
||||
@@ -18,11 +18,45 @@ class WarehouseCategoryController extends TTCrud {
|
||||
|
||||
protected array $additionalActions = [['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary']];
|
||||
|
||||
protected function beforeCreate(): bool {
|
||||
$this->postData['articleNumberPrefix'] = $this->getNextFreePrefix();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function beforeUpdate($postData): bool {
|
||||
// Preserve existing prefix - don't allow changes
|
||||
$existing = WarehouseCategory::get($postData['id']);
|
||||
if ($existing) {
|
||||
$this->postData['articleNumberPrefix'] = $existing->articleNumberPrefix;
|
||||
}
|
||||
(new WarehouseHistoryController)->create($postData, $this->mod);
|
||||
return true;
|
||||
}
|
||||
|
||||
private function getNextFreePrefix(): string {
|
||||
$db = FronkDB::singleton();
|
||||
$result = $db->query("SELECT articleNumberPrefix FROM WarehouseCategory WHERE articleNumberPrefix IS NOT NULL ORDER BY CAST(articleNumberPrefix AS UNSIGNED) DESC LIMIT 1");
|
||||
$row = $db->fetch_array($result);
|
||||
|
||||
if ($row && $row['articleNumberPrefix']) {
|
||||
$lastPrefix = intval($row['articleNumberPrefix']);
|
||||
// Skip special ranges (9900+)
|
||||
if ($lastPrefix >= 9900) {
|
||||
// Find highest non-special prefix
|
||||
$result = $db->query("SELECT articleNumberPrefix FROM WarehouseCategory WHERE articleNumberPrefix IS NOT NULL AND CAST(articleNumberPrefix AS UNSIGNED) < 9900 ORDER BY CAST(articleNumberPrefix AS UNSIGNED) DESC LIMIT 1");
|
||||
$row = $db->fetch_array($result);
|
||||
$lastPrefix = $row ? intval($row['articleNumberPrefix']) : 1800;
|
||||
}
|
||||
$nextPrefix = $lastPrefix + 100;
|
||||
// Skip 9900+ range
|
||||
if ($nextPrefix >= 9900) $nextPrefix = 9900;
|
||||
} else {
|
||||
$nextPrefix = 1900;
|
||||
}
|
||||
|
||||
return str_pad($nextPrefix, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
protected function getHistoryAction() {
|
||||
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
'deliveryAddressPLZ' => $order->deliveryAddressPLZ,
|
||||
'deliveryAddressCity' => $order->deliveryAddressCity,
|
||||
'deliveryAddressEMail' => '',
|
||||
'note' => 'Erstellung aus Shop Bestellung #' . $id,
|
||||
'note' => 'Erstellung aus Shop Bestellung #' . $id . " | Externe Referenz: " . $order->extRef,
|
||||
'status' => 'new',
|
||||
'positions' => $positions,
|
||||
'textElements' => '[]',
|
||||
@@ -442,11 +442,11 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
if ($_SERVER['HTTP_HOST'] !== 'localhost') {
|
||||
$recipientEmails = ["office@xinon.at", $user->email];
|
||||
// Add shop-specific email if applicable
|
||||
if ($json['addressId'] === 209) {
|
||||
$recipientEmails[] = "ftth-versand@triotronik.com";
|
||||
} elseif ($json['addressId'] === 9633) {
|
||||
$recipientEmails[] = "sbidi-versand@xinon.at"; // Example for SBIDI
|
||||
}
|
||||
//if ($json['addressId'] === 209) {
|
||||
// //$recipientEmails[] = "ftth-versand@triotronik.com";
|
||||
//} elseif ($json['addressId'] === 9633) {
|
||||
// //$recipientEmails[] = "sbidi-versand@xinon.at"; // Example for SBIDI
|
||||
//}
|
||||
$recipientEmails = array_unique($recipientEmails); // Remove duplicates
|
||||
|
||||
foreach ($recipientEmails as $emailAddr) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class WarehouseLocationModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public string $title;
|
||||
public string $description;
|
||||
public ?string $description = null;
|
||||
public int $assignedTo;
|
||||
public int $createBy;
|
||||
public int $create;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<?php
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
class WarehouseOfferController extends TTCrud
|
||||
{
|
||||
protected string $headerTitle = 'Angebote';
|
||||
@@ -53,6 +56,7 @@ class WarehouseOfferController extends TTCrud
|
||||
$this->postData['offerNumber'] = 'AN' . date('Y') . '-' . str_pad($currentCount + 1, 4, '0', STR_PAD_LEFT);
|
||||
$this->postData['status'] = 'new';
|
||||
$this->postData['version'] = 1;
|
||||
$this->postData['validity'] = 14;
|
||||
$this->postData['alternativePositions'] = json_encode([]);
|
||||
return true;
|
||||
}
|
||||
@@ -119,7 +123,7 @@ class WarehouseOfferController extends TTCrud
|
||||
{
|
||||
$id = $this->request->id;
|
||||
if (!$id) self::sendError("ID fehlt");
|
||||
$journalEntries = WarehouseOfferJournalModel::search(['offerId' => $id], ['create' => 'DESC']);
|
||||
$journalEntries = WarehouseOfferJournalModel::searchOfferJournal(['offerId' => $id], ['create' => 'DESC']);
|
||||
self::returnJson($journalEntries);
|
||||
}
|
||||
|
||||
@@ -182,11 +186,16 @@ class WarehouseOfferController extends TTCrud
|
||||
// E-Mail Actions
|
||||
public function sendOfferEmailAction()
|
||||
{
|
||||
//display errors for debugging
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('display_startup_errors', 1);
|
||||
|
||||
$_POST = json_decode(file_get_contents('php://input'), true);
|
||||
$id = $_POST['id'] ?? null;
|
||||
$recipientEmail = $_POST['email'] ?? null;
|
||||
$subject = $_POST['subject'] ?? 'Ihr Angebot von XINON GmbH';
|
||||
$body = $_POST['body'] ?? 'Anbei finden Sie Ihr angefordertes Angebot.';
|
||||
$bodyText = $_POST['body'] ?? 'Anbei finden Sie Ihr angefordertes Angebot.';
|
||||
|
||||
if (!$id || !$recipientEmail) {
|
||||
self::sendError("ID oder E-Mail-Adresse fehlt.");
|
||||
@@ -208,16 +217,66 @@ class WarehouseOfferController extends TTCrud
|
||||
// Generate PDF
|
||||
$pdfContent = $this->createPDFAction(true, $id, true);
|
||||
|
||||
// Send Email
|
||||
$mail = new Mail();
|
||||
$mail->addAddress($recipientEmail, $offer->contactPerson ?? $offer->customerName);
|
||||
$mail->setSubject($subject);
|
||||
$mail->setBody($body);
|
||||
$mail->addStringAttachment($pdfContent, $offer->offerNumber . '_Angebot.pdf', 'base64', 'application/pdf');
|
||||
// --- HTML Email Generation ---
|
||||
$logoToolPath = BASEDIR . '/public/assets/images/the-tool-logo.png';
|
||||
$logoXinonPath = BASEDIR . '/public/assets/images/xinon-full.png';
|
||||
$logoToolExists = file_exists($logoToolPath);
|
||||
$logoXinonExists = file_exists($logoXinonPath);
|
||||
|
||||
// Construct HTML Body
|
||||
$html = '<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Angebot</title><style>body { font-family: Arial, sans-serif; color: #333; }</style></head><body style="margin:0;padding:20px;background-color:#f3f4f6;">';
|
||||
$html .= '<div style="background-color:#fff;padding:20px;border-radius:8px;max-width:600px;margin:0 auto;box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">';
|
||||
|
||||
// Logos
|
||||
$html .= '<div style="text-align:center;margin-bottom:20px;border-bottom: 1px solid #e5e7eb;padding-bottom: 15px;">';
|
||||
if ($logoToolExists) $html .= '<img src="cid:logo_thetool" alt="The Tool" style="height:40px;margin-right:15px;vertical-align:middle;">';
|
||||
if ($logoXinonExists) $html .= '<img src="cid:logo_xinon" alt="Xinon" style="height:40px;vertical-align:middle;">';
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '<h2 style="color:#00558c;text-align:center;font-size:20px;margin-bottom:20px;">' . htmlspecialchars($subject) . '</h2>';
|
||||
$html .= '<div style="font-size:14px;line-height:1.6;color:#333;">';
|
||||
$html .= nl2br(htmlspecialchars($bodyText));
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '<br><div style="border-top:1px solid #eee;padding-top:20px;font-size:12px;color:#999;text-align:center;">';
|
||||
$html .= 'XINON GmbH | <a href="https://www.xinon.at" style="color:#00558c;text-decoration:none;">www.xinon.at</a>';
|
||||
$html .= '</div></div></body></html>';
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
try {
|
||||
// Server settings
|
||||
$mail->isSMTP();
|
||||
$mail->Host = TT_PIPEWORK_SMTP_HOST;
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Username = TT_PIPEWORK_SMTP_USER;
|
||||
$mail->Password = TT_PIPEWORK_SMTP_PASS;
|
||||
$mail->CharSet = PHPMailer::CHARSET_UTF8;
|
||||
$mail->Encoding = 'base64';
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||
$mail->Port = 587;
|
||||
|
||||
// Logos
|
||||
if ($logoToolExists) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool');
|
||||
if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon');
|
||||
|
||||
$mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice');
|
||||
$mail->setFrom('thetool@xinon.at', 'XINON TheTool');
|
||||
// set replyto to backoffice@xinon.at
|
||||
$mail->addAddress($recipientEmail, $offer->contactPerson ?? $offer->customerName);
|
||||
$mail->Subject = ($subject);
|
||||
$mail->isHTML(true);
|
||||
$mail->Body = $html;
|
||||
$mail->AltBody = strip_tags($bodyText);
|
||||
|
||||
$mail->addStringAttachment($pdfContent, $offer->offerNumber . '_Angebot.pdf', 'base64', 'application/pdf');
|
||||
|
||||
$mail->send();
|
||||
|
||||
if ($mail->send()) {
|
||||
// Update offer status and last sent date
|
||||
WarehouseOfferModel::update($id, ['status' => 'sent', 'lastSentDate' => time()]);
|
||||
$WarehouseOffer = (array) WarehouseOfferModel::get($id);
|
||||
$WarehouseOffer['status'] = 'sent';
|
||||
$WarehouseOffer['lastSentDate'] = time();
|
||||
WarehouseOfferModel::update($WarehouseOffer);
|
||||
|
||||
// Add Journal Entry
|
||||
WarehouseOfferJournalModel::create([
|
||||
@@ -228,7 +287,7 @@ class WarehouseOfferController extends TTCrud
|
||||
]);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'E-Mail erfolgreich versendet.']);
|
||||
} else {
|
||||
} catch (Exception $e) {
|
||||
self::sendError('E-Mail konnte nicht gesendet werden. Fehler: ' . $mail->ErrorInfo);
|
||||
}
|
||||
}
|
||||
@@ -377,6 +436,7 @@ class WarehouseOfferController extends TTCrud
|
||||
"includeTax" => true,
|
||||
"vatRate" => 0.20,
|
||||
"offerText" => $offerData->notes ?? '',
|
||||
"validity" => $offerData->validity ?? 14,
|
||||
"closingText" => $offerData->closingText ?? '',
|
||||
"bank_iban" => TT_INVOICE_BANK_IBAN,
|
||||
"bank_bic" => TT_INVOICE_BANK_BIC,
|
||||
|
||||
@@ -24,6 +24,7 @@ class WarehouseOfferModel extends TTCrudBaseModel {
|
||||
public string $closingText;
|
||||
public string $notes;
|
||||
public string $status;
|
||||
public ?int $validity; // New field
|
||||
public ?int $lastSentDate; // New field
|
||||
public float $totalAmount;
|
||||
public int $create;
|
||||
@@ -92,7 +93,7 @@ class WarehouseOfferJournalModel extends TTCrudBaseModel
|
||||
* @param false $count
|
||||
* @return array|int
|
||||
*/
|
||||
public static function search(array $filter = [], array $orderBy = [], $limit = null, $offset = null, $count = false)
|
||||
public static function searchOfferJournal(array $filter = [], array $orderBy = [], $limit = null, $offset = null, $count = false)
|
||||
{
|
||||
$db = self::getDB();
|
||||
$tableName = self::getFullyQualifiedTable();
|
||||
|
||||
@@ -2,46 +2,379 @@
|
||||
|
||||
class WarehouseProjectController extends TTCrud {
|
||||
protected string $headerTitle = 'Projekte';
|
||||
protected string $createText = 'Neues Projekt erstellen';
|
||||
protected string $singleText = 'Projekt';
|
||||
protected bool $createText = false;
|
||||
|
||||
//@formatter:off
|
||||
protected array $columns = [
|
||||
['key' => 'title', 'text' => 'Titel', 'required' => true],
|
||||
['key' => 'description', 'text' => 'Projektbeschreibung', 'modal' => ['type' => 'textarea']],
|
||||
|
||||
['key' => 'startDate', 'text' => 'Startdatum', 'required' => true, 'modal' => ['type' => 'datepicker']],
|
||||
['key' => 'endDate', 'text' => 'Enddatum', 'required' => true, 'modal' => ['type' => 'datepicker']],
|
||||
['key' => 'positions', 'text' => 'Positionen', 'required' => true, 'modal' => ['type' => 'positions-manager', 'config' => [
|
||||
'header' => 'Positionen',
|
||||
'fields' => [
|
||||
'articleId' => ['apiUrl' => '/WarehouseArticle/autoComplete','type' => 'autocomplete','customFieldReference' => 'WarehouseArticle','label' => 'Artikel'],
|
||||
'amount' => ['type' => 'input', 'label' => 'Menge', 'inputType' => 'number'],
|
||||
'purpose' => ['type' => 'input', 'label' => 'Zweck'],
|
||||
],
|
||||
'validateFormOptions' => [
|
||||
['key' => 'articleId', 'message' => 'Bitte füllen Sie den Artikel aus'],
|
||||
['key' => 'amount', 'message' => 'Bitte füllen Sie die Menge aus'],
|
||||
['key' => 'purpose', 'message' => 'Bitte füllen Sie den Zweck aus'],
|
||||
],
|
||||
]], 'table' => false],
|
||||
['key' => 'linkedOrderIds', 'text' => 'Verlinkte Bestellung', 'modal' => false],
|
||||
//
|
||||
['key' => 'assignedPersons', 'text' => 'Zugewiesene Personen', 'modal' => ['type' => 'positions-manager', 'config' => [
|
||||
'header' => 'Zugewiesene Personen',
|
||||
'fields' => [
|
||||
'userId' => ['apiUrl' => '/WarehouseShippingNote/userAutoComplete','type' => 'autocomplete','label' => 'Person','customFieldReference' => 'User']
|
||||
],
|
||||
'validateFormOptions' => [
|
||||
['key' => 'userId', 'message' => 'Bitte füllen Sie die Person aus'],
|
||||
],
|
||||
]], 'table' => false],
|
||||
|
||||
['key' => 'storageLocation', 'text' => 'Lagerort', 'modal' => ['type' => 'input']],
|
||||
['key' => 'note', 'text' => 'Notiz', 'modal' => ['type' => 'textarea']],
|
||||
['key' => 'createBy', 'text' => 'Erstellt von', 'required' => true, 'modal' => ['visible' => false, 'type' => 'select'], 'table' => ['filter' => 'select']],
|
||||
['key' => 'create', 'text' => 'Erstellt', 'required' => true, 'modal' => false],
|
||||
['key' => 'id', 'text' => 'ID', 'table' => false, 'modal' => false],
|
||||
['key' => 'projectNumber', 'text' => 'Projekt-Nr.', 'required' => false, 'modal' => ['disabled' => true, 'placeholder' => 'Wird automatisch generiert']],
|
||||
['key' => 'title', 'text' => 'Bezeichnung', 'required' => true],
|
||||
['key' => 'status', 'text' => 'Status', 'required' => true, 'modal' => ['type' => 'select', 'items' => [
|
||||
['value' => 'new', 'text' => 'Neu'],
|
||||
['value' => 'wip', 'text' => 'In Bearbeitung'],
|
||||
['value' => 'finished', 'text' => 'Abgeschlossen'],
|
||||
['value' => 'cancelled', 'text' => 'Storniert'],
|
||||
]], 'table' => ['filter' => 'select']],
|
||||
['key' => 'startDate', 'text' => 'Startdatum', 'required' => true, 'modal' => ['type' => 'date'], 'table' => ['filter' => 'date']],
|
||||
['key' => 'endDate', 'text' => 'Enddatum', 'required' => true, 'modal' => ['type' => 'date'], 'table' => ['filter' => 'date']],
|
||||
['key' => 'financials', 'text' => 'Gesamtsumme', 'required' => false, 'modal' => ['disabled' => true], 'table' => ['formatter' => 'formatPrice']],
|
||||
['key' => 'storageLocation', 'text' => 'Lagerort', 'required' => false],
|
||||
['key' => 'externalTeam', 'text' => 'Externes Team', 'required' => false, 'modal' => ['type' => 'textarea'], 'table' => false],
|
||||
['key' => 'description', 'text' => 'Beschreibung', 'required' => false, 'modal' => ['type' => 'textarea'], 'table' => false],
|
||||
['key' => 'create', 'text' => 'Erstellt', 'required' => false, 'modal' => false, 'table' => ['formatter' => 'formatDate']],
|
||||
['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
|
||||
];
|
||||
//@formatter:on
|
||||
}
|
||||
|
||||
protected array $permissionCheck = ['WarehouseUser'];
|
||||
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => false];
|
||||
|
||||
protected function prepareCrudConfig(): void {
|
||||
if ($this->user->can('WarehouseAdmin')) {
|
||||
$this->additionalJSVariables['WAREHOUSE_ADMIN'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
private array $tempInternalTeam = [];
|
||||
|
||||
protected function beforeCreate(): bool {
|
||||
$json = json_decode(file_get_contents('php://input'), true);
|
||||
if ($json) {
|
||||
$this->postData = array_merge($this->postData ?? [], $json);
|
||||
}
|
||||
|
||||
if (isset($this->postData['internalTeam'])) {
|
||||
$this->tempInternalTeam = $this->postData['internalTeam'];
|
||||
unset($this->postData['internalTeam']);
|
||||
}
|
||||
|
||||
$this->postData['projectNumber'] = WarehouseProjectModel::getNextProjectNumber();
|
||||
// Ensure defaults if not provided
|
||||
if (!isset($this->postData['status'])) $this->postData['status'] = 'new';
|
||||
if (!isset($this->postData['financials'])) $this->postData['financials'] = 0.00;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function afterCreate($id): void
|
||||
{
|
||||
WarehouseProjectJournalModel::create([
|
||||
'projectId' => $id,
|
||||
'text' => 'Projekt erstellt.',
|
||||
'createBy' => $this->user->id,
|
||||
'create' => time()
|
||||
]);
|
||||
|
||||
// Handle initial Internal Team
|
||||
if (!empty($this->tempInternalTeam) && is_array($this->tempInternalTeam)) {
|
||||
foreach ($this->tempInternalTeam as $userId) {
|
||||
WarehouseProjectMemberModel::create([
|
||||
'projectId' => $id,
|
||||
'userId' => $userId,
|
||||
'create' => time()
|
||||
]);
|
||||
|
||||
$u = UserModel::getOne($userId);
|
||||
$this->logJournal($id, "Teammitglied initial hinzugefügt: " . ($u ? $u->name : $userId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function afterUpdate($postData): void
|
||||
{
|
||||
$id = $postData['id'];
|
||||
// Simple journaling of main record update
|
||||
WarehouseProjectJournalModel::create([
|
||||
'projectId' => $id,
|
||||
'text' => 'Projektstammdaten aktualisiert.',
|
||||
'createBy' => $this->user->id,
|
||||
'create' => time()
|
||||
]);
|
||||
}
|
||||
|
||||
// --- API for Vue ---
|
||||
|
||||
public function getProjectDetailsAction() {
|
||||
$id = $this->request->id;
|
||||
if (!$id) self::sendError("Projekt ID fehlt");
|
||||
|
||||
$project = WarehouseProjectModel::get($id);
|
||||
if (!$project) self::sendError("Projekt nicht gefunden");
|
||||
|
||||
self::returnJson(['project' => $project]);
|
||||
}
|
||||
|
||||
public function getTasksAction() {
|
||||
$projectId = $this->request->id;
|
||||
if (!$projectId) self::sendError("Projekt ID fehlt");
|
||||
|
||||
$tasks = WarehouseProjectTaskModel::getAll(['projectId' => $projectId], null, 0, ['key' => 'order', 'order' => 'ASC']);
|
||||
foreach ($tasks as $task) {
|
||||
if ($task->assignedUserId) {
|
||||
$user = UserModel::getOne($task->assignedUserId);
|
||||
$task->assignedUserName = $user ? $user->name : 'Unbekannt';
|
||||
} else {
|
||||
$task->assignedUserName = null;
|
||||
}
|
||||
}
|
||||
self::returnJson($tasks);
|
||||
}
|
||||
|
||||
public function saveTaskAction() {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$projectId = $data['projectId'] ?? null;
|
||||
|
||||
if (!$projectId) self::sendError("Projekt ID fehlt");
|
||||
|
||||
$taskData = [
|
||||
'projectId' => $projectId,
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? '',
|
||||
'status' => $data['status'] ?? 'todo',
|
||||
'assignedUserId' => !empty($data['assignedUserId']) ? $data['assignedUserId'] : null,
|
||||
'createBy' => $this->user->id,
|
||||
'create' => time()
|
||||
];
|
||||
|
||||
if (!empty($data['id'])) {
|
||||
$existingTask = WarehouseProjectTaskModel::get($data['id']);
|
||||
if (!$existingTask) self::sendError("Aufgabe nicht gefunden");
|
||||
|
||||
// Merge existing data with new data to ensure all required fields are present
|
||||
$updatedData = array_merge((array)$existingTask, $taskData);
|
||||
$updatedData['id'] = $data['id']; // Ensure ID is in the data for update
|
||||
|
||||
// update method expects an array with 'id' key for update.
|
||||
WarehouseProjectTaskModel::update($updatedData);
|
||||
$this->logJournal($projectId, "Aufgabe aktualisiert: {$data['title']}");
|
||||
} else {
|
||||
// Get max order to append
|
||||
$count = WarehouseProjectTaskModel::count(['projectId' => $projectId]);
|
||||
$taskData['order'] = $count + 1;
|
||||
|
||||
WarehouseProjectTaskModel::create($taskData);
|
||||
$this->logJournal($projectId, "Aufgabe erstellt: {$data['title']}");
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true]);
|
||||
}
|
||||
|
||||
public function updateTaskStatusAction() {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($data['id']) || empty($data['status'])) self::sendError("Daten fehlen");
|
||||
|
||||
$task = WarehouseProjectTaskModel::get($data['id']);
|
||||
if ($task) {
|
||||
// Retrieve existing task data to preserve projectId and other required fields
|
||||
$updatedData = (array)$task;
|
||||
$updatedData['status'] = $data['status'];
|
||||
|
||||
// WarehouseProjectTaskModel::update expects an array with 'id' key for update.
|
||||
WarehouseProjectTaskModel::update($updatedData);
|
||||
$this->logJournal($task->projectId, "Aufgabenstatus '{$task->title}' geändert auf {$data['status']}");
|
||||
}
|
||||
self::returnJson(['success' => true]);
|
||||
}
|
||||
|
||||
public function deleteTaskAction() {
|
||||
$id = $this->request->id;
|
||||
if (!$id) self::sendError("ID fehlt");
|
||||
|
||||
$task = WarehouseProjectTaskModel::get($id);
|
||||
if ($task) {
|
||||
WarehouseProjectTaskModel::delete($id);
|
||||
$this->logJournal($task->projectId, "Aufgabe gelöscht: {$task->title}");
|
||||
}
|
||||
self::returnJson(['success' => true]);
|
||||
}
|
||||
|
||||
public function getTeamAction() {
|
||||
$projectId = $this->request->id;
|
||||
if (!$projectId) self::sendError("ID fehlt");
|
||||
|
||||
$members = WarehouseProjectMemberModel::getAll(['projectId' => $projectId]);
|
||||
$users = [];
|
||||
foreach($members as $m) {
|
||||
$u = UserModel::getOne($m->userId);
|
||||
if ($u) {
|
||||
$users[] = [
|
||||
'memberId' => $m->id,
|
||||
'userId' => $u->id,
|
||||
'name' => $u->name,
|
||||
'role' => $m->role
|
||||
];
|
||||
}
|
||||
}
|
||||
self::returnJson($users);
|
||||
}
|
||||
|
||||
public function addTeamMemberAction() {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($data['projectId']) || empty($data['userId'])) self::sendError("Daten fehlen");
|
||||
|
||||
$exists = WarehouseProjectMemberModel::count(['projectId' => $data['projectId'], 'userId' => $data['userId']]);
|
||||
if ($exists > 0) self::sendError("Benutzer bereits im Team");
|
||||
|
||||
WarehouseProjectMemberModel::create([
|
||||
'projectId' => $data['projectId'],
|
||||
'userId' => $data['userId'],
|
||||
'role' => $data['role'] ?? null,
|
||||
'create' => time()
|
||||
]);
|
||||
|
||||
$u = UserModel::getOne($data['userId']);
|
||||
$this->logJournal($data['projectId'], "Teammitglied hinzugefügt: " . ($u ? $u->name : $data['userId']));
|
||||
|
||||
self::returnJson(['success' => true]);
|
||||
}
|
||||
|
||||
public function removeTeamMemberAction() {
|
||||
$id = $this->request->id;
|
||||
$member = WarehouseProjectMemberModel::get($id);
|
||||
if ($member) {
|
||||
$u = UserModel::getOne($member->userId);
|
||||
WarehouseProjectMemberModel::delete($id);
|
||||
$this->logJournal($member->projectId, "Teammitglied entfernt: " . ($u ? $u->name : $member->userId));
|
||||
}
|
||||
self::returnJson(['success' => true]);
|
||||
}
|
||||
|
||||
public function getAvailableOrderRequestsAction() {
|
||||
// Return open requests (not done, not cancelled)
|
||||
// You might want to filter out ones already linked to THIS project, but maybe not strictly necessary.
|
||||
$requests = WarehouseOrderRequest::getAll([], 100, 0, ['key' => 'create', 'order' => 'DESC']);
|
||||
|
||||
$available = [];
|
||||
foreach($requests as $r) {
|
||||
if (!$r->done && !$r->cancelled) {
|
||||
$available[] = [
|
||||
'id' => $r->id,
|
||||
'purpose' => $r->purpose,
|
||||
'create' => $r->create
|
||||
];
|
||||
}
|
||||
}
|
||||
self::returnJson($available);
|
||||
}
|
||||
|
||||
public function getLinkedOrdersAction() {
|
||||
$projectId = $this->request->id;
|
||||
if (!$projectId) self::sendError("ID fehlt");
|
||||
|
||||
$links = WarehouseProjectOrderRequestModel::getAll(['projectId' => $projectId]);
|
||||
$result = [];
|
||||
|
||||
foreach($links as $l) {
|
||||
$req = WarehouseOrderRequest::get($l->orderRequestId);
|
||||
if ($req) {
|
||||
$positions = json_decode($req->positions, true);
|
||||
|
||||
// Resolve actual Orders
|
||||
$orders = [];
|
||||
$ids = [];
|
||||
|
||||
if (!empty($req->linkedOrderIds)) {
|
||||
// Check if it is JSON array
|
||||
$decoded = json_decode($req->linkedOrderIds, true);
|
||||
if (is_array($decoded)) {
|
||||
$ids = $decoded;
|
||||
} else {
|
||||
// Fallback to comma separated
|
||||
$ids = explode(',', $req->linkedOrderIds);
|
||||
}
|
||||
}
|
||||
|
||||
foreach($ids as $oid) {
|
||||
$oid = trim($oid);
|
||||
if (empty($oid)) continue;
|
||||
|
||||
$o = WarehouseOrderModel::get($oid);
|
||||
if ($o) {
|
||||
$orders[] = [
|
||||
'id' => $o->id,
|
||||
'orderNumber' => $o->orderNumber,
|
||||
'status' => $o->status,
|
||||
'distributorId' => $o->distributorId
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'linkId' => $l->id,
|
||||
'requestId' => $req->id,
|
||||
'purpose' => $req->purpose,
|
||||
'create' => $req->create,
|
||||
'status' => $req->done ? 'done' : ($req->cancelled ? 'cancelled' : 'open'),
|
||||
'positionsCount' => is_array($positions) ? count($positions) : 0,
|
||||
'orders' => $orders
|
||||
];
|
||||
}
|
||||
}
|
||||
self::returnJson($result);
|
||||
}
|
||||
|
||||
public function linkOrderAction() {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($data['projectId']) || empty($data['orderId'])) self::sendError("Daten fehlen");
|
||||
|
||||
$order = WarehouseOrderRequest::get($data['orderId']);
|
||||
if (!$order) self::sendError("Bestellwunsch nicht gefunden");
|
||||
|
||||
WarehouseProjectOrderRequestModel::create([
|
||||
'projectId' => $data['projectId'],
|
||||
'orderRequestId' => $data['orderId'],
|
||||
'create' => time()
|
||||
]);
|
||||
|
||||
$this->logJournal($data['projectId'], "Bestellwunsch #{$data['orderId']} verknüpft.");
|
||||
self::returnJson(['success' => true]);
|
||||
}
|
||||
|
||||
public function unlinkOrderAction() {
|
||||
$id = $this->request->id;
|
||||
$link = WarehouseProjectOrderRequestModel::get($id);
|
||||
if ($link) {
|
||||
WarehouseProjectOrderRequestModel::delete($id);
|
||||
$this->logJournal($link->projectId, "Bestellwunsch #{$link->orderRequestId} Verknüpfung aufgehoben.");
|
||||
}
|
||||
self::returnJson(['success' => true]);
|
||||
}
|
||||
|
||||
public function createJournalEntryAction() {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($data['projectId'])) self::sendError("Projekt ID fehlt");
|
||||
|
||||
$this->logJournal($data['projectId'], $data['message'] ?? '');
|
||||
self::returnJson(['success' => true]);
|
||||
}
|
||||
|
||||
public function getJournalAction() {
|
||||
$projectId = $this->request->id;
|
||||
$logs = WarehouseProjectJournalModel::getAll(['projectId' => $projectId], null, 0, ['order' => 'DESC', 'key' => 'create']);
|
||||
|
||||
foreach($logs as $log) {
|
||||
$u = UserModel::getOne($log->createBy);
|
||||
$log->userName = $u ? $u->name : 'System';
|
||||
}
|
||||
|
||||
self::returnJson($logs);
|
||||
}
|
||||
|
||||
private function logJournal($projectId, $text) {
|
||||
WarehouseProjectJournalModel::create([
|
||||
'projectId' => $projectId,
|
||||
'text' => $text,
|
||||
'createBy' => $this->user->id,
|
||||
'create' => time()
|
||||
]);
|
||||
}
|
||||
|
||||
// Users for Team Selection
|
||||
public function getUsersAction() {
|
||||
$users = array_map(function($u) {
|
||||
return ['id' => $u->id, 'name' => $u->name];
|
||||
}, UserModel::search(['employee' => true]));
|
||||
self::returnJson($users);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,35 @@
|
||||
|
||||
class WarehouseProjectModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public string $projectNumber;
|
||||
public string $title;
|
||||
public string $description;
|
||||
public string $startDate;
|
||||
public string $endDate;
|
||||
public ?string $description;
|
||||
public ?int $startDate;
|
||||
public ?int $endDate;
|
||||
public string $status;
|
||||
public string $priority;
|
||||
|
||||
public int $assignedTo;
|
||||
public float $financials;
|
||||
public ?string $storageLocation;
|
||||
public ?string $externalTeam;
|
||||
public ?int $createdFromOrderId;
|
||||
public int $createBy;
|
||||
public int $create;
|
||||
|
||||
public static function getNextProjectNumber(): string {
|
||||
$year = date('Y');
|
||||
$prefix = "XP-$year-";
|
||||
|
||||
$db = self::getDB();
|
||||
$tableName = self::getFullyQualifiedTable();
|
||||
|
||||
$sql = "SELECT projectNumber FROM $tableName WHERE projectNumber LIKE '$prefix%' ORDER BY projectNumber DESC LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
|
||||
$nextNum = 1;
|
||||
if ($row = $result->fetch_assoc()) {
|
||||
$lastNumStr = substr($row['projectNumber'], strrpos($row['projectNumber'], '-') + 1);
|
||||
$nextNum = intval($lastNumStr) + 1;
|
||||
}
|
||||
|
||||
return $prefix . str_pad((string)$nextNum, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
class WarehouseProjectJournalModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public int $projectId;
|
||||
public ?string $text;
|
||||
public ?string $data; // json
|
||||
public int $createBy;
|
||||
public int $create;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
class WarehouseProjectMemberModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public int $projectId;
|
||||
public int $userId;
|
||||
public ?string $role;
|
||||
public int $create;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
class WarehouseProjectOrderRequestModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public int $projectId;
|
||||
public int $orderRequestId;
|
||||
public int $create;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
class WarehouseProjectTaskModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public int $projectId;
|
||||
public ?int $parentTaskId;
|
||||
public string $title;
|
||||
public ?string $description;
|
||||
public string $status; // todo, in_progress, done
|
||||
public ?int $assignedUserId;
|
||||
public int $order;
|
||||
public int $createBy;
|
||||
public int $create;
|
||||
}
|
||||
@@ -7,7 +7,8 @@ class WarehouseShippingNoteController extends TTCrud {
|
||||
//@formatter:off
|
||||
protected array $columns = [
|
||||
['key' => 'id', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap']],
|
||||
['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'type' => 'autocomplete', 'table' => ['class' => 'text-nowrap', 'filter' => 'autocomplete'], 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']],
|
||||
['key' => 'note', 'text' => 'Art der Arbeit', 'required' => true],
|
||||
['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'type' => 'autocomplete', 'table' => false, 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']],
|
||||
['key' => 'status', 'text' => 'Status', 'required' => true, 'table' => ['filter' => 'iconSelect'], 'modal' => ['type' => 'iconSelect', 'items' => [
|
||||
['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'],
|
||||
['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-warning'],
|
||||
@@ -21,7 +22,6 @@ class WarehouseShippingNoteController extends TTCrud {
|
||||
['key' => 'deliveryAddressLine', 'text' => 'L.-Adr.', 'required' => true],
|
||||
['key' => 'deliveryAddressPLZ', 'text' => 'L.-Adr. PLZ', 'required' => true],
|
||||
['key' => 'deliveryAddressEMail', 'text' => 'L.-Adr. EMail', 'required' => false, 'table' => false],
|
||||
['key' => 'note', 'text' => 'Art der Arbeit', 'required' => true, 'table' => false],
|
||||
['key' => 'positions', 'text' => 'Positionen', 'required' => true, 'table' => false, 'modal' => false],
|
||||
['key' => 'create', 'text' => 'Erstellt', 'required' => false, 'modal' => ['visible' => false], 'table' => ['filter' => 'date']],
|
||||
['key' => 'createBy', 'text' => 'Erstellt von', 'required' => false, 'type' => 'autocomplete', 'table' => ['class' => 'text-nowrap', 'filter' => 'select'], 'modal' => ['items' => [], 'type' => 'select',]],
|
||||
@@ -34,6 +34,14 @@ class WarehouseShippingNoteController extends TTCrud {
|
||||
'delete' => 'Lieferschein wurde gelöscht',
|
||||
'noChanges' => 'Keine Änderungen vorgenommen'];
|
||||
protected array $permissionCheck = ['WarehouseUser'];
|
||||
protected array $additionalActions = [
|
||||
[
|
||||
'key' => 'createManualInvoice',
|
||||
'title' => 'Rechnung erstellen',
|
||||
'class' => 'fas fa-file-invoice text-primary',
|
||||
'condition' => ['status' => 'accepted']
|
||||
]
|
||||
];
|
||||
//@formatter:on
|
||||
|
||||
protected function prepareCrudConfig() {
|
||||
@@ -109,6 +117,177 @@ class WarehouseShippingNoteController extends TTCrud {
|
||||
));
|
||||
}
|
||||
|
||||
protected function getShippingNoteForInvoiceAction() {
|
||||
$id = $this->request->id;
|
||||
|
||||
// Get shipping note
|
||||
$shippingNote = WarehouseShippingNoteModel::get($id);
|
||||
if (!$shippingNote) {
|
||||
self::returnJson(['success' => false, 'message' => 'Lieferschein nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get billing address info
|
||||
$billingAddress = null;
|
||||
if ($shippingNote->billingAddressId) {
|
||||
$billingAddress = Address::getOne($shippingNote->billingAddressId);
|
||||
}
|
||||
|
||||
// Determine price type ONCE (not in loop for performance)
|
||||
$priceType = 'Verkauf';
|
||||
if ($shippingNote->billingAddressId) {
|
||||
$addressPriceType = AddressPriceTypeModel::getFirst(['address_id' => $shippingNote->billingAddressId]);
|
||||
if ($addressPriceType) {
|
||||
$warehousePriceType = WarehouseArticlePriceTypeModel::get($addressPriceType->priceType_id);
|
||||
if ($warehousePriceType) {
|
||||
$priceType = $warehousePriceType->title;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decode and enrich positions
|
||||
$positions = json_decode($shippingNote->positions, true);
|
||||
if (!is_array($positions)) {
|
||||
$positions = [];
|
||||
}
|
||||
|
||||
$enrichedPositions = [];
|
||||
|
||||
foreach ($positions as $position) {
|
||||
if (isset($position['article'])) {
|
||||
// Fetch article details
|
||||
$article = WarehouseArticleModel::get($position['article']);
|
||||
if (!$article) continue;
|
||||
|
||||
// Get price for determined price type
|
||||
$prices = json_decode($article->cheapestSellPrice, true) ?: [];
|
||||
$price = 0;
|
||||
foreach ($prices as $p) {
|
||||
if ($p['title'] === $priceType) {
|
||||
$price = $p['price'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$enrichedPositions[] = [
|
||||
'type' => 'article',
|
||||
'articleId' => $article->id,
|
||||
'product_name' => $article->articleNumber . " | " . $article->title,
|
||||
'product_info' => $article->description,
|
||||
'amount' => $position['amount'],
|
||||
'unit' => $article->unit,
|
||||
'price' => $price,
|
||||
'discount' => 0,
|
||||
'vatrate' => 20
|
||||
];
|
||||
|
||||
} elseif (isset($position['articlePacket'])) {
|
||||
// Handle article packets
|
||||
$packet = WarehouseArticlePacketModel::get($position['articlePacket']);
|
||||
if (!$packet) continue;
|
||||
|
||||
$enrichedPositions[] = [
|
||||
'type' => 'packet',
|
||||
'packetId' => $packet->id,
|
||||
'product_name' => $packet->title,
|
||||
'product_info' => $packet->description ?? '',
|
||||
'amount' => $position['amount'],
|
||||
'unit' => 'Pau.',
|
||||
'price' => 0,
|
||||
'discount' => 0,
|
||||
'vatrate' => 20
|
||||
];
|
||||
|
||||
} elseif (isset($position['articleText'])) {
|
||||
// Handle custom text entries
|
||||
$enrichedPositions[] = [
|
||||
'type' => 'text',
|
||||
'product_name' => $position['articleText'],
|
||||
'product_info' => '',
|
||||
'amount' => $position['amount'] ?? 1,
|
||||
'unit' => 'Stk.',
|
||||
'price' => 0,
|
||||
'discount' => 0,
|
||||
'vatrate' => 20
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Add hours entries as positions
|
||||
$hoursEntries = json_decode($shippingNote->hoursEntries, true);
|
||||
if (!is_array($hoursEntries)) {
|
||||
$hoursEntries = [];
|
||||
}
|
||||
|
||||
foreach ($hoursEntries as $hoursEntry) {
|
||||
if (empty($hoursEntry['hourCount']) || floatval(str_replace(",", ".", $hoursEntry['hourCount'])) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$userName = 'Unbekannt';
|
||||
if (!empty($hoursEntry['userId']) && is_numeric($hoursEntry['userId'])) {
|
||||
try {
|
||||
$user = UserModel::getOne($hoursEntry['userId']);
|
||||
$userName = $user ? $user->name : 'Unbekannt';
|
||||
} catch (Exception $e) {
|
||||
$userName = 'Unbekannt';
|
||||
}
|
||||
} elseif (!empty($hoursEntry['userId_text'])) {
|
||||
$userName = $hoursEntry['userId_text'];
|
||||
}
|
||||
|
||||
$enrichedPositions[] = [
|
||||
'type' => 'hours',
|
||||
'product_name' => 'Arbeitsstunden - ' . $userName,
|
||||
'product_info' => 'Datum: ' . (isset($hoursEntry['date']) ? date('d.m.Y', strtotime($hoursEntry['date'])) : ''),
|
||||
'amount' => str_replace(",", ".", $hoursEntry['hourCount']),
|
||||
'unit' => 'h',
|
||||
'price' => 60,
|
||||
'discount' => 0,
|
||||
'vatrate' => 20
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'shippingNoteId' => $shippingNote->id,
|
||||
'billingAddress' => $billingAddress ? [
|
||||
'id' => $billingAddress->id,
|
||||
'customer_number' => $billingAddress->customer_number,
|
||||
'company' => $billingAddress->company,
|
||||
'firstname' => $billingAddress->firstname,
|
||||
'lastname' => $billingAddress->lastname,
|
||||
'street' => $billingAddress->street,
|
||||
'zip' => $billingAddress->zip,
|
||||
'city' => $billingAddress->city,
|
||||
'email' => $billingAddress->email,
|
||||
'uid' => $billingAddress->uid,
|
||||
'fibu_account_number' => $billingAddress->fibu_account_number,
|
||||
'billing_type' => $billingAddress->billing_type,
|
||||
'billing_delivery' => $billingAddress->billing_delivery,
|
||||
'bank_account_bank' => $billingAddress->bank_account_bank,
|
||||
'bank_account_owner' => $billingAddress->bank_account_owner,
|
||||
'bank_account_iban' => $billingAddress->bank_account_iban,
|
||||
'bank_account_bic' => $billingAddress->bank_account_bic,
|
||||
'sepa_date' => $billingAddress->sepa_date,
|
||||
'fibu_payment_due' => $billingAddress->fibu_payment_due,
|
||||
'fibu_payment_skonto' => $billingAddress->fibu_payment_skonto,
|
||||
'fibu_payment_skonto_rate' => $billingAddress->fibu_payment_skonto_rate
|
||||
] : null,
|
||||
'deliveryAddress' => [
|
||||
'name' => $shippingNote->deliveryAddressName,
|
||||
'line' => $shippingNote->deliveryAddressLine,
|
||||
'plz' => $shippingNote->deliveryAddressPLZ,
|
||||
'city' => $shippingNote->deliveryAddressCity,
|
||||
'email' => $shippingNote->deliveryAddressEMail
|
||||
],
|
||||
'note' => $shippingNote->note,
|
||||
'positions' => $enrichedPositions
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getArticleAddressPriceAction() {
|
||||
empty($this->request->articleId) && $this->sendError('Keine Artikel ID gefunden');
|
||||
empty($this->request->addressId) && $this->sendError('Keine Adress ID gefunden');
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
class WarehouseStocktakeController extends TTCrud {
|
||||
protected string $headerTitle = 'Inventur';
|
||||
protected string $createText = 'Inventur erstellen';
|
||||
protected bool $reopenOnCreate = false;
|
||||
|
||||
protected array $columns = [
|
||||
['key' => 'stocktakeNumber', 'text' => 'Inventur-Nr.', 'required' => false,
|
||||
'modal' => false,
|
||||
'table' => ['priority' => 10]],
|
||||
['key' => 'title', 'text' => 'Titel', 'required' => true,
|
||||
'modal' => ['type' => 'text'],
|
||||
'table' => ['priority' => 9]],
|
||||
['key' => 'warehouseLocationId', 'text' => 'Lagerort', 'required' => true,
|
||||
'modal' => ['type' => 'select', 'items' => []],
|
||||
'table' => ['priority' => 8, 'filter' => 'select']],
|
||||
['key' => 'status', 'text' => 'Status', 'required' => false,
|
||||
'modal' => false,
|
||||
'table' => ['priority' => 7, 'filter' => 'iconSelect', 'filterOptions' => [
|
||||
['value' => 'planned', 'text' => 'Geplant', 'icon' => 'fas fa-calendar text-secondary'],
|
||||
['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-primary'],
|
||||
['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-circle text-success'],
|
||||
['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-times-circle text-danger'],
|
||||
]]],
|
||||
['key' => 'progress', 'text' => 'Fortschritt', 'required' => false,
|
||||
'modal' => false,
|
||||
'table' => ['priority' => 6, 'sortable' => false, 'filter' => false]],
|
||||
['key' => 'startedAt', 'text' => 'Gestartet', 'required' => false,
|
||||
'modal' => false,
|
||||
'table' => ['priority' => 5, 'filter' => false]],
|
||||
['key' => 'description', 'text' => 'Beschreibung', 'required' => false,
|
||||
'modal' => ['type' => 'textarea'],
|
||||
'table' => false],
|
||||
['key' => 'actions', 'text' => 'Aktionen', 'required' => false,
|
||||
'modal' => false,
|
||||
'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
|
||||
];
|
||||
|
||||
protected array $additionalActions = [
|
||||
['key' => 'startStocktake', 'title' => 'Inventur starten', 'class' => 'fas fa-play text-success'],
|
||||
['key' => 'viewProgress', 'title' => 'Fortschritt anzeigen', 'class' => 'fas fa-chart-line text-primary'],
|
||||
['key' => 'completeStocktake', 'title' => 'Inventur abschließen', 'class' => 'fas fa-check text-success'],
|
||||
['key' => 'applyToStock', 'title' => 'Auf Lager anwenden', 'class' => 'fas fa-boxes text-warning'],
|
||||
['key' => 'exportReport', 'title' => 'Excel Export', 'class' => 'fas fa-download text-secondary'],
|
||||
['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-secondary'],
|
||||
];
|
||||
|
||||
protected array $additionalJSVariables = [];
|
||||
|
||||
protected array $statusOptions = [
|
||||
['value' => 'planned', 'text' => 'Geplant', 'icon' => 'fas fa-calendar text-secondary', 'color' => 'secondary'],
|
||||
['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-primary', 'color' => 'primary'],
|
||||
['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-circle text-success', 'color' => 'success'],
|
||||
['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-times-circle text-danger', 'color' => 'danger'],
|
||||
];
|
||||
|
||||
protected array $permissionCheck = ['WarehouseUser'];
|
||||
|
||||
protected array $infoMessages = [
|
||||
'create' => 'Inventur wurde erstellt',
|
||||
'update' => 'Inventur wurde aktualisiert',
|
||||
'delete' => 'Inventur wurde gelöscht',
|
||||
'noChanges' => 'Keine Änderungen',
|
||||
];
|
||||
|
||||
public function prepareCrudConfig() {
|
||||
// Populate locations dropdown
|
||||
$locations = array_map(function($location) {
|
||||
return ['value' => $location->id, 'text' => $location->title];
|
||||
}, WarehouseLocationModel::getAll());
|
||||
|
||||
foreach ($this->columns as &$col) {
|
||||
if ($col['key'] === 'warehouseLocationId') {
|
||||
$col['modal']['items'] = $locations;
|
||||
$col['table']['filterOptions'] = $locations;
|
||||
}
|
||||
}
|
||||
|
||||
$this->additionalJSVariables['STATUS_ITEMS'] = $this->statusOptions;
|
||||
}
|
||||
|
||||
protected function beforeCreate(): bool {
|
||||
// Set default values
|
||||
$this->postData['status'] = 'planned';
|
||||
$this->postData['totalItems'] = 0;
|
||||
$this->postData['totalScannedItems'] = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function afterCreate($postData) {
|
||||
// Generate stocktake number
|
||||
$stocktake = WarehouseStocktakeModel::get($postData['id']);
|
||||
if ($stocktake) {
|
||||
$stocktakeNumber = WarehouseStocktakeModel::generateStocktakeNumber();
|
||||
$db = FronkDB::singleton();
|
||||
$db->query("UPDATE WarehouseStocktake SET stocktakeNumber = '{$stocktakeNumber}' WHERE id = {$stocktake->id}");
|
||||
|
||||
// Log creation
|
||||
WarehouseStocktakeLogModel::log($stocktake->id, 'created', null, ['title' => $stocktake->title]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function beforeUpdate($postData): bool {
|
||||
(new WarehouseHistoryController)->create($postData, $this->mod);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function customRowsHandler($rows) {
|
||||
return array_map(fn($row) => $this->formatRow((array)$row), $rows);
|
||||
}
|
||||
|
||||
protected function formatRow($row) {
|
||||
// Keep raw status for frontend conditional logic (don't modify 'status' - table needs raw value for filter)
|
||||
$row['rawStatus'] = $row['status'];
|
||||
|
||||
// Don't modify warehouseLocationId - table uses items to display the text
|
||||
// Don't modify status - table uses filterOptions to display
|
||||
|
||||
// Format progress (no filter on this column)
|
||||
$row['progress'] = "<span class='badge bg-info'>{$row['totalScannedItems']} Artikel gescannt</span>";
|
||||
|
||||
// Format startedAt (no filter on this column)
|
||||
if ($row['startedAt']) {
|
||||
$row['startedAt'] = date('d.m.Y H:i', $row['startedAt']);
|
||||
} else {
|
||||
$row['startedAt'] = '-';
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a stocktake - changes status to in_progress
|
||||
*/
|
||||
protected function startStocktakeAction() {
|
||||
$id = intval($this->postData['id'] ?? 0);
|
||||
if (!$id) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stocktake = WarehouseStocktakeModel::get($id);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($stocktake->status !== 'planned') {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur kann nur im Status "Geplant" gestartet werden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$db->query("UPDATE WarehouseStocktake SET
|
||||
status = 'in_progress',
|
||||
startedAt = " . time() . ",
|
||||
startedBy = {$this->user->id}
|
||||
WHERE id = {$id}");
|
||||
|
||||
WarehouseStocktakeLogModel::log($id, 'started', null, ['startedBy' => $this->user->name]);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Inventur wurde gestartet']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a stocktake - changes status to completed
|
||||
*/
|
||||
protected function completeStocktakeAction() {
|
||||
$id = intval($this->postData['id'] ?? 0);
|
||||
if (!$id) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stocktake = WarehouseStocktakeModel::get($id);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($stocktake->status !== 'in_progress') {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur kann nur im Status "In Bearbeitung" abgeschlossen werden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$db->query("UPDATE WarehouseStocktake SET
|
||||
status = 'completed',
|
||||
completedAt = " . time() . ",
|
||||
completedBy = {$this->user->id}
|
||||
WHERE id = {$id}");
|
||||
|
||||
WarehouseStocktakeLogModel::log($id, 'completed', null, ['completedBy' => $this->user->name]);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Inventur wurde abgeschlossen']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress data for live updates
|
||||
*/
|
||||
protected function getProgressAction() {
|
||||
$id = intval($this->request->id);
|
||||
if (!$id) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stocktake = WarehouseStocktakeModel::get($id);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get items via direct SQL to avoid any ORM issues
|
||||
$db = FronkDB::singleton();
|
||||
$result = $db->query("SELECT si.*, a.articleNumber, a.title as articleTitle, a.cheapestPurchasePrice, w.name as scannedByName,
|
||||
CASE WHEN si.overwrittenById IS NOT NULL THEN 1 ELSE 0 END as isOverwritten
|
||||
FROM WarehouseStocktakeItem si
|
||||
LEFT JOIN WarehouseArticle a ON si.articleId = a.id
|
||||
LEFT JOIN Worker w ON si.scannedBy = w.id
|
||||
WHERE si.stocktakeId = {$id}
|
||||
ORDER BY si.`create` DESC");
|
||||
|
||||
$formattedItems = [];
|
||||
$totalValue = 0;
|
||||
$totalQuantity = 0;
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$unitPrice = $row['cheapestPurchasePrice'] ? (float)$row['cheapestPurchasePrice'] : 0;
|
||||
$quantity = (float)$row['countedQuantity'];
|
||||
$lineTotal = $unitPrice * $quantity;
|
||||
$isOverwritten = (bool)$row['isOverwritten'];
|
||||
|
||||
// Only count non-overwritten items in totals
|
||||
if (!$isOverwritten) {
|
||||
$totalValue += $lineTotal;
|
||||
$totalQuantity += $quantity;
|
||||
}
|
||||
|
||||
$formattedItems[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'articleId' => (int)$row['articleId'],
|
||||
'articleNumber' => $row['articleNumber'] ?? '',
|
||||
'articleTitle' => $row['articleTitle'] ?? 'Unbekannt',
|
||||
'countedQuantity' => $quantity,
|
||||
'unitPrice' => $unitPrice,
|
||||
'lineTotal' => $lineTotal,
|
||||
'rack' => $row['rack'],
|
||||
'shelf' => $row['shelf'],
|
||||
'note' => $row['note'],
|
||||
'scannedAt' => $row['scannedAt'] ? date('d.m.Y H:i:s', $row['scannedAt']) : null,
|
||||
'scannedBy' => $row['scannedByName'],
|
||||
'isOverwritten' => $isOverwritten,
|
||||
];
|
||||
}
|
||||
|
||||
$location = $stocktake->getLocation();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'stocktake' => [
|
||||
'id' => $stocktake->id,
|
||||
'stocktakeNumber' => $stocktake->stocktakeNumber,
|
||||
'title' => $stocktake->title,
|
||||
'status' => $stocktake->status,
|
||||
'locationName' => $location ? $location->title : 'Unbekannt',
|
||||
'totalScannedItems' => $stocktake->totalScannedItems,
|
||||
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
|
||||
],
|
||||
'items' => $formattedItems,
|
||||
'summary' => [
|
||||
'totalValue' => $totalValue,
|
||||
'totalQuantity' => $totalQuantity,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply stocktake results to actual warehouse stock
|
||||
*/
|
||||
protected function applyToStockAction() {
|
||||
$id = intval($this->postData['id'] ?? 0);
|
||||
if (!$id) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stocktake = WarehouseStocktakeModel::get($id);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($stocktake->status !== 'completed') {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur muss abgeschlossen sein, um die Bestände anzupassen']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$items = WarehouseStocktakeItemModel::getAll(['stocktakeId' => $id]);
|
||||
$appliedCount = 0;
|
||||
$createdCount = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
// Check if a WarehouseItem already exists for this article at this location
|
||||
$existingItems = WarehouseItemModel::getAll([
|
||||
'articleId' => $item->articleId,
|
||||
'warehouseLocationId' => $stocktake->warehouseLocationId
|
||||
]);
|
||||
|
||||
if (count($existingItems) > 0) {
|
||||
// Update existing item
|
||||
$existingItem = $existingItems[0];
|
||||
$oldQuantity = $existingItem->quantity;
|
||||
|
||||
$db->query("UPDATE WarehouseItem SET
|
||||
quantity = {$item->countedQuantity},
|
||||
rack = " . ($item->rack ? "'{$db->escape($item->rack)}'" : "NULL") . ",
|
||||
shelf = " . ($item->shelf ? "'{$db->escape($item->shelf)}'" : "NULL") . "
|
||||
WHERE id = {$existingItem->id}");
|
||||
|
||||
// Log history
|
||||
(new WarehouseHistoryController)->create([
|
||||
'id' => $existingItem->id,
|
||||
'quantity' => $item->countedQuantity,
|
||||
'rack' => $item->rack,
|
||||
'shelf' => $item->shelf,
|
||||
], 'WarehouseItem');
|
||||
|
||||
$appliedCount++;
|
||||
} else {
|
||||
// Create new WarehouseItem
|
||||
$db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, rack, shelf, createBy, `create`)
|
||||
VALUES ({$item->articleId}, {$stocktake->warehouseLocationId}, {$item->countedQuantity},
|
||||
" . ($item->rack ? "'{$db->escape($item->rack)}'" : "NULL") . ",
|
||||
" . ($item->shelf ? "'{$db->escape($item->shelf)}'" : "NULL") . ",
|
||||
{$this->user->id}, " . time() . ")");
|
||||
|
||||
$createdCount++;
|
||||
}
|
||||
}
|
||||
|
||||
WarehouseStocktakeLogModel::log($id, 'applied_to_stock', null, [
|
||||
'appliedCount' => $appliedCount,
|
||||
'createdCount' => $createdCount,
|
||||
'appliedBy' => $this->user->name
|
||||
]);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => "Bestände angepasst: {$appliedCount} aktualisiert, {$createdCount} neu erstellt"
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export stocktake report to Excel
|
||||
*/
|
||||
protected function exportReportAction() {
|
||||
$id = intval($this->request->id);
|
||||
if (!$id) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stocktake = WarehouseStocktakeModel::get($id);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get items via direct SQL to include price and overwritten status
|
||||
$db = FronkDB::singleton();
|
||||
$result = $db->query("SELECT si.*, a.articleNumber, a.title as articleTitle, a.cheapestPurchasePrice, w.name as scannedByName
|
||||
FROM WarehouseStocktakeItem si
|
||||
LEFT JOIN WarehouseArticle a ON si.articleId = a.id
|
||||
LEFT JOIN Worker w ON si.scannedBy = w.id
|
||||
WHERE si.stocktakeId = {$id}
|
||||
ORDER BY si.`create` ASC");
|
||||
|
||||
$rows = [];
|
||||
$totalSum = 0;
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$unitPrice = $row['cheapestPurchasePrice'] ? (float)$row['cheapestPurchasePrice'] : 0;
|
||||
$quantity = (float)$row['countedQuantity'];
|
||||
$lineTotal = $unitPrice * $quantity;
|
||||
$isOverwritten = !empty($row['overwrittenById']);
|
||||
|
||||
// Skip overwritten items in calculation but show them
|
||||
if (!$isOverwritten) {
|
||||
$totalSum += $lineTotal;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'Artikel Titel' => $row['articleTitle'] ?? 'Unbekannt',
|
||||
'Artikel Nummer' => $row['articleNumber'] ?? '',
|
||||
'Einzelpreis' => number_format($unitPrice, 2, ',', '.') . ' €',
|
||||
'Anzahl' => $quantity,
|
||||
'Gesamtsumme' => number_format($lineTotal, 2, ',', '.') . ' €',
|
||||
'Gescannt am' => $row['scannedAt'] ? date('d.m.Y H:i', $row['scannedAt']) : '',
|
||||
'Gescannt von' => $row['scannedByName'] ?? '',
|
||||
'Status' => $isOverwritten ? 'Überschrieben' : '',
|
||||
];
|
||||
}
|
||||
|
||||
// Add summary row
|
||||
$rows[] = [
|
||||
'Artikel Titel' => '',
|
||||
'Artikel Nummer' => '',
|
||||
'Einzelpreis' => '',
|
||||
'Anzahl' => 'SUMME:',
|
||||
'Gesamtsumme' => number_format($totalSum, 2, ',', '.') . ' €',
|
||||
'Gescannt am' => '',
|
||||
'Gescannt von' => '',
|
||||
'Status' => '',
|
||||
];
|
||||
|
||||
$filename = "Inventur_{$stocktake->stocktakeNumber}_" . date('Y-m-d') . ".csv";
|
||||
$csv = Helper::arrayToCsv($rows);
|
||||
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
echo "\xEF\xBB\xBF"; // UTF-8 BOM
|
||||
echo $csv;
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history for a stocktake
|
||||
*/
|
||||
protected function getHistoryAction() {
|
||||
$this->prepareCrudConfig();
|
||||
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logs for a stocktake
|
||||
*/
|
||||
protected function getLogsAction() {
|
||||
$id = intval($this->request->id);
|
||||
if (!$id) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$logs = WarehouseStocktakeLogModel::getLogsForStocktake($id);
|
||||
$formattedLogs = [];
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$user = UserModel::get($log->userId);
|
||||
$formattedLogs[] = [
|
||||
'id' => $log->id,
|
||||
'action' => $log->action,
|
||||
'details' => $log->details ? json_decode($log->details, true) : null,
|
||||
'userName' => $user ? $user->name : 'Unbekannt',
|
||||
'create' => date('d.m.Y H:i:s', $log->create),
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'logs' => $formattedLogs]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
class WarehouseStocktakeModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public ?string $stocktakeNumber = null;
|
||||
public string $title;
|
||||
public ?string $description = null;
|
||||
public int $warehouseLocationId;
|
||||
public string $status = 'planned';
|
||||
public ?int $startedAt = null;
|
||||
public ?int $completedAt = null;
|
||||
public ?int $startedBy = null;
|
||||
public ?int $completedBy = null;
|
||||
public int $totalItems = 0;
|
||||
public int $totalScannedItems = 0;
|
||||
public ?string $notes = null;
|
||||
public int $createBy;
|
||||
public int $create;
|
||||
|
||||
/**
|
||||
* Generate next stocktake number (ST-YYYY-NNNN)
|
||||
*/
|
||||
public static function generateStocktakeNumber(): string {
|
||||
$year = date('Y');
|
||||
$prefix = "IN{$year}-X";
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$result = $db->query("SELECT stocktakeNumber FROM WarehouseStocktake
|
||||
WHERE stocktakeNumber LIKE '{$prefix}%'
|
||||
ORDER BY stocktakeNumber DESC LIMIT 1");
|
||||
|
||||
if ($row = $result->fetch_assoc()) {
|
||||
$lastNumber = intval(substr($row['stocktakeNumber'], -6));
|
||||
$nextNumber = $lastNumber + 1;
|
||||
} else {
|
||||
$nextNumber = 1;
|
||||
}
|
||||
|
||||
return $prefix . str_pad((string)$nextNumber, 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get location object
|
||||
*/
|
||||
public function getLocation(): ?WarehouseLocationModel {
|
||||
return WarehouseLocationModel::get($this->warehouseLocationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user who started the stocktake
|
||||
*/
|
||||
public function getStartedByUser(): ?UserModel {
|
||||
if (!$this->startedBy) return null;
|
||||
return UserModel::get($this->startedBy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get items for this stocktake
|
||||
*/
|
||||
public function getItems(): array {
|
||||
return WarehouseStocktakeItemModel::getAll(['stocktakeId' => $this->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress counters
|
||||
*/
|
||||
public function updateProgress(): void {
|
||||
$items = $this->getItems();
|
||||
$this->totalScannedItems = count($items);
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$db->query("UPDATE WarehouseStocktake SET totalScannedItems = {$this->totalScannedItems} WHERE id = {$this->id}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
class WarehouseStocktakeItemController extends TTCrud {
|
||||
protected string $headerTitle = 'Inventur-Artikel';
|
||||
protected string $createText = 'Artikel hinzufügen';
|
||||
|
||||
protected array $columns = [
|
||||
['key' => 'articleId', 'text' => 'Artikel', 'required' => true,
|
||||
'modal' => ['type' => 'autocomplete', 'apiUrl' => '/WarehouseArticle/autocomplete'],
|
||||
'table' => ['priority' => 10]],
|
||||
['key' => 'countedQuantity', 'text' => 'Menge', 'required' => true,
|
||||
'modal' => ['type' => 'number'],
|
||||
'table' => ['priority' => 9]],
|
||||
['key' => 'rack', 'text' => 'Regal', 'required' => false,
|
||||
'modal' => ['type' => 'text'],
|
||||
'table' => ['priority' => 8]],
|
||||
['key' => 'shelf', 'text' => 'Fach', 'required' => false,
|
||||
'modal' => ['type' => 'text'],
|
||||
'table' => ['priority' => 7]],
|
||||
['key' => 'note', 'text' => 'Notiz', 'required' => false,
|
||||
'modal' => ['type' => 'textarea'],
|
||||
'table' => ['priority' => 6]],
|
||||
['key' => 'scannedAt', 'text' => 'Gescannt am', 'required' => false,
|
||||
'modal' => false,
|
||||
'table' => ['priority' => 5]],
|
||||
['key' => 'actions', 'text' => 'Aktionen', 'required' => false,
|
||||
'modal' => false,
|
||||
'table' => ['filter' => false, 'sortable' => false]],
|
||||
];
|
||||
|
||||
protected array $permissionCheck = ['WarehouseUser'];
|
||||
|
||||
protected function formatRow($row) {
|
||||
// Format article
|
||||
if ($row['articleId']) {
|
||||
$article = WarehouseArticleModel::get($row['articleId']);
|
||||
$row['articleId'] = $article ? "[{$article->articleNumber}] {$article->title}" : 'Unbekannt';
|
||||
}
|
||||
|
||||
// Format scannedAt
|
||||
if ($row['scannedAt']) {
|
||||
$row['scannedAt'] = date('d.m.Y H:i', $row['scannedAt']);
|
||||
} else {
|
||||
$row['scannedAt'] = '-';
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add item via scan (used by PWA)
|
||||
*/
|
||||
protected function scanItemAction() {
|
||||
$stocktakeId = intval($this->request->stocktakeId);
|
||||
$articleId = intval($this->request->articleId);
|
||||
$quantity = floatval($this->request->quantity);
|
||||
$rack = $this->request->rack ?? null;
|
||||
$shelf = $this->request->shelf ?? null;
|
||||
$note = $this->request->note ?? null;
|
||||
|
||||
if (!$stocktakeId || !$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify stocktake exists and is in progress
|
||||
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($stocktake->status !== 'in_progress') {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify article exists
|
||||
$article = WarehouseArticleModel::get($articleId);
|
||||
if (!$article) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this article was already scanned in this stocktake
|
||||
$existing = WarehouseStocktakeItemModel::getFirst([
|
||||
'stocktakeId' => $stocktakeId,
|
||||
'articleId' => $articleId
|
||||
]);
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
if ($existing) {
|
||||
// Update existing entry - add to quantity
|
||||
$newQuantity = $existing->countedQuantity + $quantity;
|
||||
$db->query("UPDATE WarehouseStocktakeItem SET
|
||||
countedQuantity = {$newQuantity},
|
||||
rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ",
|
||||
shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ",
|
||||
scannedAt = " . time() . ",
|
||||
scannedBy = {$this->me->id}
|
||||
WHERE id = {$existing->id}");
|
||||
|
||||
$itemId = $existing->id;
|
||||
$message = "Artikel aktualisiert: {$article->title} (Neue Menge: {$newQuantity})";
|
||||
} else {
|
||||
// Create new entry
|
||||
$db->query("INSERT INTO WarehouseStocktakeItem
|
||||
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
|
||||
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
|
||||
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
|
||||
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
|
||||
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
|
||||
" . time() . ", {$this->me->id}, {$this->me->id}, " . time() . ")");
|
||||
|
||||
$itemId = $db->insert_id;
|
||||
$message = "Artikel hinzugefügt: {$article->title} (Menge: {$quantity})";
|
||||
}
|
||||
|
||||
// Update stocktake progress
|
||||
$stocktake->updateProgress();
|
||||
|
||||
// Log the scan
|
||||
WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'quantity' => $quantity,
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
]);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
'item' => [
|
||||
'id' => $itemId,
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'countedQuantity' => $existing ? ($existing->countedQuantity + $quantity) : $quantity,
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
],
|
||||
'totalScanned' => $stocktake->totalScannedItems + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get article info by QR code or article number
|
||||
*/
|
||||
protected function getArticleByCodeAction() {
|
||||
$code = $this->request->code;
|
||||
|
||||
if (!$code) {
|
||||
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to parse QR code format: WA:articleId:articleNumber (Warehouse Article)
|
||||
// Also accept WH: for backwards compatibility
|
||||
$articleId = null;
|
||||
if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
|
||||
$articleId = intval($matches[1]);
|
||||
} else {
|
||||
// Try to find by article number
|
||||
$article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
|
||||
if ($article) {
|
||||
$articleId = $article->id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$article = WarehouseArticleModel::get($articleId);
|
||||
if (!$article) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'article' => [
|
||||
'id' => $article->id,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'title' => $article->title,
|
||||
'description' => $article->description ?? '',
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
class WarehouseStocktakeItemModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public int $stocktakeId;
|
||||
public int $articleId;
|
||||
public ?int $warehouseItemId;
|
||||
public float $countedQuantity;
|
||||
public ?string $rack;
|
||||
public ?string $shelf;
|
||||
public ?string $note;
|
||||
public ?int $scannedAt;
|
||||
public ?int $scannedBy;
|
||||
public ?int $overwrittenById;
|
||||
public int $createBy;
|
||||
public int $create;
|
||||
|
||||
/**
|
||||
* Get the article object
|
||||
*/
|
||||
public function getArticle(): ?WarehouseArticleModel {
|
||||
return WarehouseArticleModel::get($this->articleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stocktake object
|
||||
*/
|
||||
public function getStocktake(): ?WarehouseStocktakeModel {
|
||||
return WarehouseStocktakeModel::get($this->stocktakeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user who scanned this item
|
||||
*/
|
||||
public function getScannedByUser(): ?User {
|
||||
if (!$this->scannedBy) return null;
|
||||
return UserModel::getOne($this->scannedBy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
class WarehouseStocktakeLogModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public int $stocktakeId;
|
||||
public ?int $stocktakeItemId;
|
||||
public string $action;
|
||||
public ?string $details;
|
||||
public int $userId;
|
||||
public int $create;
|
||||
|
||||
/**
|
||||
* Create a log entry
|
||||
*/
|
||||
public static function log(int $stocktakeId, string $action, ?int $stocktakeItemId = null, ?array $details = null, ?int $userId = null): self {
|
||||
$me = mfValuecache::singleton()->get("me");
|
||||
$logUserId = $userId ?? ($me ? $me->id : 0);
|
||||
|
||||
$log = new self();
|
||||
$log->stocktakeId = $stocktakeId;
|
||||
$log->stocktakeItemId = $stocktakeItemId;
|
||||
$log->action = $action;
|
||||
$log->details = $details ? json_encode($details) : null;
|
||||
$log->userId = $logUserId;
|
||||
$log->create = time();
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$db->query("INSERT INTO WarehouseStocktakeLog (stocktakeId, stocktakeItemId, action, details, userId, `create`)
|
||||
VALUES ({$log->stocktakeId}, " . ($log->stocktakeItemId ? $log->stocktakeItemId : "NULL") . ",
|
||||
'{$db->escape($log->action)}', " . ($log->details ? "'{$db->escape($log->details)}'" : "NULL") . ",
|
||||
{$log->userId}, {$log->create})");
|
||||
|
||||
$log->id = $db->insert_id;
|
||||
return $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logs for a stocktake
|
||||
*/
|
||||
public static function getLogsForStocktake(int $stocktakeId): array {
|
||||
return self::getAll(['stocktakeId' => $stocktakeId], 0, 0, ['create' => 'DESC']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
<?php
|
||||
|
||||
class WarehouseStocktakePWAController extends mfBaseController {
|
||||
|
||||
protected $user;
|
||||
|
||||
protected function init() {
|
||||
$this->needlogin = true;
|
||||
|
||||
$me = mfValuecache::singleton()->get("me");
|
||||
if (!$me) {
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
mfValuecache::singleton()->set("me", $me);
|
||||
}
|
||||
$this->me = $me;
|
||||
$this->user = $me;
|
||||
$this->layout()->set("me", $me);
|
||||
|
||||
// Check permission
|
||||
if (!$me->can('WarehouseUser')) {
|
||||
$this->redirect("Dashboard");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main PWA View
|
||||
*/
|
||||
public function indexAction() {
|
||||
$this->layout()->setTemplate("VueViews/WarehouseStocktakePWA");
|
||||
$this->layout()->set("JSGlobals", [
|
||||
'BASE_PATH' => '/WarehouseStocktakePWA',
|
||||
'USER_ID' => $this->user->id,
|
||||
'USER_NAME' => $this->user->name,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
protected function logoutAction() {
|
||||
mfLoginController::staticLogout();
|
||||
$this->redirect('/WarehouseStocktakePWA');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active stocktakes that user can participate in
|
||||
*/
|
||||
protected function getActiveStocktakesAction() {
|
||||
$stocktakes = WarehouseStocktakeModel::getAll(['status' => 'in_progress']);
|
||||
|
||||
$result = [];
|
||||
foreach ($stocktakes as $stocktake) {
|
||||
$location = $stocktake->getLocation();
|
||||
$result[] = [
|
||||
'id' => $stocktake->id,
|
||||
'stocktakeNumber' => $stocktake->stocktakeNumber,
|
||||
'title' => $stocktake->title,
|
||||
'locationName' => $location ? $location->title : 'Unbekannt',
|
||||
'totalScannedItems' => $stocktake->totalScannedItems,
|
||||
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'stocktakes' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stocktake details
|
||||
*/
|
||||
protected function getStocktakeAction() {
|
||||
$id = intval($this->request->id);
|
||||
if (!$id) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stocktake = WarehouseStocktakeModel::get($id);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$location = $stocktake->getLocation();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'stocktake' => [
|
||||
'id' => $stocktake->id,
|
||||
'stocktakeNumber' => $stocktake->stocktakeNumber,
|
||||
'title' => $stocktake->title,
|
||||
'status' => $stocktake->status,
|
||||
'locationId' => $stocktake->warehouseLocationId,
|
||||
'locationName' => $location ? $location->title : 'Unbekannt',
|
||||
'totalScannedItems' => $stocktake->totalScannedItems,
|
||||
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get article by QR code or article number
|
||||
*/
|
||||
protected function getArticleAction() {
|
||||
$code = $this->request->code;
|
||||
|
||||
if (!$code) {
|
||||
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$articleId = null;
|
||||
|
||||
// Try to parse QR code format: WA:articleId:articleNumber (Warehouse Article)
|
||||
// Also accept WH: for backwards compatibility
|
||||
if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
|
||||
$articleId = intval($matches[1]);
|
||||
} else {
|
||||
// Try to find by article number
|
||||
$article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
|
||||
if ($article) {
|
||||
$articleId = $article->id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$article = WarehouseArticleModel::get($articleId);
|
||||
if (!$article) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get category name
|
||||
$category = WarehouseCategory::get($article->category_id);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'article' => [
|
||||
'id' => $article->id,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'title' => $article->title,
|
||||
'description' => $article->description ?? '',
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
'categoryName' => $category ? $category->name : '',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search articles by text with optional category filter
|
||||
*/
|
||||
protected function searchArticlesAction() {
|
||||
$query = $this->request->query ?? '';
|
||||
$categoryId = intval($this->request->categoryId ?? 0);
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$conditions = ["(isEndOfLife IS NULL OR isEndOfLife = 0)"];
|
||||
|
||||
if ($query && strlen($query) >= 2) {
|
||||
$escapedQuery = $db->escape($query);
|
||||
$conditions[] = "(articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%' OR description LIKE '%{$escapedQuery}%')";
|
||||
}
|
||||
|
||||
if ($categoryId > 0) {
|
||||
$conditions[] = "category_id = {$categoryId}";
|
||||
}
|
||||
|
||||
if (count($conditions) === 1 && !$categoryId) {
|
||||
self::returnJson(['success' => true, 'articles' => []]);
|
||||
return;
|
||||
}
|
||||
|
||||
$whereClause = implode(' AND ', $conditions);
|
||||
$result = $db->query("SELECT id, articleNumber, title, unit, category_id
|
||||
FROM WarehouseArticle
|
||||
WHERE {$whereClause}
|
||||
ORDER BY title ASC
|
||||
LIMIT 50");
|
||||
|
||||
$articles = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$articles[] = [
|
||||
'id' => intval($row['id']),
|
||||
'articleNumber' => $row['articleNumber'],
|
||||
'title' => $row['title'],
|
||||
'unit' => $row['unit'] ?? 'Stk.',
|
||||
'categoryId' => intval($row['category_id'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'articles' => $articles]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories for browsing
|
||||
*/
|
||||
protected function getCategoriesAction() {
|
||||
$db = FronkDB::singleton();
|
||||
$res = $db->query("SELECT id, name FROM WarehouseCategory ORDER BY name ASC");
|
||||
|
||||
$categories = [];
|
||||
while ($row = $res->fetch_assoc()) {
|
||||
$categories[] = [
|
||||
'id' => intval($row['id']),
|
||||
'name' => $row['name'],
|
||||
];
|
||||
}
|
||||
self::returnJson(['success' => true, 'categories' => $categories]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if article is already scanned in stocktake
|
||||
*/
|
||||
protected function checkAlreadyScannedAction() {
|
||||
$stocktakeId = intval($this->request->stocktakeId);
|
||||
$articleId = intval($this->request->articleId);
|
||||
|
||||
if (!$stocktakeId || !$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = WarehouseStocktakeItemModel::getFirst([
|
||||
'stocktakeId' => $stocktakeId,
|
||||
'articleId' => $articleId,
|
||||
'overwrittenById' => null
|
||||
]);
|
||||
|
||||
if ($existing) {
|
||||
$db = FronkDB::singleton();
|
||||
$scannedByResult = $db->query("SELECT name FROM Worker WHERE id = {$existing->scannedBy}");
|
||||
$scannedByRow = $scannedByResult->fetch_assoc();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'alreadyScanned' => true,
|
||||
'existingItem' => [
|
||||
'id' => $existing->id,
|
||||
'countedQuantity' => $existing->countedQuantity,
|
||||
'scannedAt' => $existing->scannedAt ? date('d.m.Y H:i', $existing->scannedAt) : null,
|
||||
'scannedBy' => $scannedByRow ? $scannedByRow['name'] : 'Unbekannt',
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
self::returnJson(['success' => true, 'alreadyScanned' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a scanned item
|
||||
*/
|
||||
protected function submitScanAction() {
|
||||
$postData = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
|
||||
$stocktakeId = intval($postData['stocktakeId'] ?? 0);
|
||||
$articleId = intval($postData['articleId'] ?? 0);
|
||||
$quantity = floatval($postData['quantity'] ?? 0);
|
||||
$rack = $postData['rack'] ?? null;
|
||||
$shelf = $postData['shelf'] ?? null;
|
||||
$note = $postData['note'] ?? null;
|
||||
$overwrite = boolval($postData['overwrite'] ?? false);
|
||||
$overwriteItemId = intval($postData['overwriteItemId'] ?? 0);
|
||||
|
||||
if (!$stocktakeId || !$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($quantity <= 0) {
|
||||
self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify stocktake exists and is in progress
|
||||
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($stocktake->status !== 'in_progress') {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify article exists
|
||||
$article = WarehouseArticleModel::get($articleId);
|
||||
if (!$article) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
// If overwrite mode is enabled, mark existing item as overwritten
|
||||
if ($overwrite && $overwriteItemId) {
|
||||
// Create new entry
|
||||
$db->query("INSERT INTO WarehouseStocktakeItem
|
||||
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
|
||||
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
|
||||
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
|
||||
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
|
||||
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
|
||||
" . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
|
||||
|
||||
$itemId = $db->insert_id;
|
||||
|
||||
// Mark old item as overwritten by new item
|
||||
$db->query("UPDATE WarehouseStocktakeItem SET overwrittenById = {$itemId} WHERE id = {$overwriteItemId}");
|
||||
|
||||
$finalQuantity = $quantity;
|
||||
$isOverwrite = true;
|
||||
|
||||
// Log the overwrite
|
||||
WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'quantity' => $quantity,
|
||||
'overwrittenItemId' => $overwriteItemId,
|
||||
]);
|
||||
|
||||
// Update stocktake progress (don't increase count since we're replacing)
|
||||
$stocktake->updateProgress();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => "'{$article->title}' überschrieben ({$quantity} {$article->unit})",
|
||||
'item' => [
|
||||
'id' => $itemId,
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'countedQuantity' => $finalQuantity,
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
'isOverwrite' => true,
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this article was already scanned in this stocktake (non-overwritten)
|
||||
$existing = WarehouseStocktakeItemModel::getFirst([
|
||||
'stocktakeId' => $stocktakeId,
|
||||
'articleId' => $articleId,
|
||||
'overwrittenById' => null
|
||||
]);
|
||||
|
||||
if ($existing) {
|
||||
// Update existing entry - add to quantity
|
||||
$newQuantity = $existing->countedQuantity + $quantity;
|
||||
$db->query("UPDATE WarehouseStocktakeItem SET
|
||||
countedQuantity = {$newQuantity},
|
||||
rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ",
|
||||
shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ",
|
||||
scannedAt = " . time() . ",
|
||||
scannedBy = {$this->user->id}
|
||||
WHERE id = {$existing->id}");
|
||||
|
||||
$itemId = $existing->id;
|
||||
$finalQuantity = $newQuantity;
|
||||
$isUpdate = true;
|
||||
} else {
|
||||
// Create new entry
|
||||
$db->query("INSERT INTO WarehouseStocktakeItem
|
||||
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
|
||||
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
|
||||
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
|
||||
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
|
||||
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
|
||||
" . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
|
||||
|
||||
$itemId = $db->insert_id;
|
||||
$finalQuantity = $quantity;
|
||||
$isUpdate = false;
|
||||
}
|
||||
|
||||
// Update stocktake progress
|
||||
$stocktake->updateProgress();
|
||||
|
||||
// Log the scan
|
||||
WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'quantity' => $quantity,
|
||||
'totalQuantity' => $finalQuantity,
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
'isUpdate' => $isUpdate,
|
||||
]);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => $isUpdate
|
||||
? "Menge für '{$article->title}' erhöht auf {$finalQuantity}"
|
||||
: "'{$article->title}' hinzugefügt ({$quantity} {$article->unit})",
|
||||
'item' => [
|
||||
'id' => $itemId,
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'countedQuantity' => $finalQuantity,
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
'isUpdate' => $isUpdate,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent scans for current user in a stocktake
|
||||
*/
|
||||
protected function getMyScansAction() {
|
||||
$stocktakeId = intval($this->request->stocktakeId);
|
||||
|
||||
if (!$stocktakeId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$result = $db->query("SELECT si.*, wa.articleNumber, wa.title as articleTitle, wa.unit
|
||||
FROM WarehouseStocktakeItem si
|
||||
JOIN WarehouseArticle wa ON wa.id = si.articleId
|
||||
WHERE si.stocktakeId = {$stocktakeId}
|
||||
AND si.scannedBy = {$this->user->id}
|
||||
ORDER BY si.scannedAt DESC
|
||||
LIMIT 50");
|
||||
|
||||
$items = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$items[] = [
|
||||
'id' => intval($row['id']),
|
||||
'articleId' => intval($row['articleId']),
|
||||
'articleNumber' => $row['articleNumber'],
|
||||
'articleTitle' => $row['articleTitle'],
|
||||
'countedQuantity' => floatval($row['countedQuantity']),
|
||||
'unit' => $row['unit'] ?? 'Stk.',
|
||||
'rack' => $row['rack'],
|
||||
'shelf' => $row['shelf'],
|
||||
'scannedAt' => $row['scannedAt'] ? date('H:i', $row['scannedAt']) : null,
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'items' => $items]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress stats
|
||||
*/
|
||||
protected function getProgressAction() {
|
||||
$stocktakeId = intval($this->request->stocktakeId);
|
||||
|
||||
if (!$stocktakeId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
// Total scanned items
|
||||
$totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}");
|
||||
$totalRow = $totalResult->fetch_assoc();
|
||||
$totalScanned = intval($totalRow['count']);
|
||||
|
||||
// My scanned items
|
||||
$myResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId} AND scannedBy = {$this->user->id}");
|
||||
$myRow = $myResult->fetch_assoc();
|
||||
$myScanned = intval($myRow['count']);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'progress' => [
|
||||
'totalScanned' => $totalScanned,
|
||||
'myScanned' => $myScanned,
|
||||
'status' => $stocktake->status,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -161,17 +161,44 @@ class WorkorderBaseController extends TTCrud
|
||||
$networks = NetworkModel::search(['owner_id' => $config->addressId]);
|
||||
if (empty($networks)) continue;
|
||||
|
||||
$tenantCampaigns = array_map(fn($n) => $n->id, PreordercampaignModel::getAll(['network_id' => array_map(fn($n) => $n->id, $networks)]));
|
||||
$networkIds = array_map(fn($n) => $n->id, $networks);
|
||||
$tenantCampaigns = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => $networkIds]));
|
||||
if (empty($tenantCampaigns)) continue;
|
||||
|
||||
$filters['preordercampaign_id'] = $tenantCampaigns;
|
||||
$newPreorders = PreorderModel::searchActive($filters);
|
||||
|
||||
foreach ($newPreorders as $preorder) {
|
||||
if (!WorkorderModel::getFirst(['preorderId' => $preorder->id])) {
|
||||
$existingWorkorder = (array) WorkorderModel::getFirst(['preorderId' => $preorder->id]);
|
||||
|
||||
if ($existingWorkorder) {
|
||||
if ($existingWorkorder['status'] === 'archived') {
|
||||
$oldStatus = $existingWorkorder['status'];
|
||||
$new = (array) $existingWorkorder;
|
||||
|
||||
$new['status'] = 'new';
|
||||
$new['companyId'] = null;
|
||||
$new['civilEngineeringCompanyId'] = null;
|
||||
$new['deadlineDate'] = null;
|
||||
$new['appointmentDate'] = null;
|
||||
$new['clusterId'] = $preorder->preordercampaign_id;
|
||||
WorkorderModel::update($new);
|
||||
|
||||
WorkorderJournalModel::create([
|
||||
'workorderId' => $existingWorkorder['id'],
|
||||
'text' => 'Arbeitsauftrag wurde automatisch reaktiviert, da die zugehörige Vorbestellung wieder den Kriterien entspricht.',
|
||||
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('new'),
|
||||
'create' => time(),
|
||||
'createBy' => 1,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
WorkorderModel::create([
|
||||
'preorderId' => $preorder->id, 'clusterId' => $preorder->preordercampaign_id,
|
||||
'status' => 'new', 'create' => time(), 'createBy' => 0 // System User
|
||||
'preorderId' => $preorder->id,
|
||||
'clusterId' => $preorder->preordercampaign_id,
|
||||
'status' => 'new',
|
||||
'create' => time(),
|
||||
'createBy' => 1
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -202,22 +229,25 @@ class WorkorderBaseController extends TTCrud
|
||||
continue;
|
||||
}
|
||||
|
||||
$tenantCampaignIds = array_column(PreordercampaignModel::getAll(['network_id' => array_column($networks, 'id')]), 'id');
|
||||
$networkIds = array_map(fn($n) => $n->id, $networks);
|
||||
$tenantCampaignIds = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => $networkIds]));
|
||||
if (empty($tenantCampaignIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$activeFilters['preordercampaign_id'] = $tenantCampaignIds;
|
||||
|
||||
$activePreorderIds = array_column(PreorderModel::searchActive($activeFilters), 'id');
|
||||
$activePreorderIds = array_map(fn($p) => $p->id, PreorderModel::searchActive($activeFilters));
|
||||
$activePreorderIdsSet = array_flip($activePreorderIds);
|
||||
|
||||
$statusesToCheck = ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested', 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed', 'problem_solved'];
|
||||
|
||||
$allTenantPreorders = PreorderModel::getAll(['preordercampaign_id' => $tenantCampaignIds]);
|
||||
// Get ALL preorders for tenant (including deleted/cancelled) to ensure their workorders get archived
|
||||
// Note: Not passing 'deleted' filter means all preorders are returned regardless of deleted status
|
||||
$allTenantPreorders = PreorderModel::search(['preordercampaign_id' => $tenantCampaignIds]);
|
||||
if(empty($allTenantPreorders)) continue;
|
||||
|
||||
$allTenantPreorderIds = array_column($allTenantPreorders, 'id');
|
||||
$allTenantPreorderIds = array_map(fn($p) => $p->id, $allTenantPreorders);
|
||||
|
||||
$workordersToCheck = WorkorderModel::getAll([
|
||||
'status' => $statusesToCheck,
|
||||
@@ -243,4 +273,4 @@ class WorkorderBaseController extends TTCrud
|
||||
file_put_contents($lockFile, time());
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
class WorkorderMphModel extends TTCrudBaseModel
|
||||
{
|
||||
public int $id;
|
||||
public int $hausnummerId;
|
||||
public ?int $companyId;
|
||||
public string $status;
|
||||
public ?int $assignmentDate;
|
||||
public ?int $deadlineDate;
|
||||
public ?int $appointmentDate;
|
||||
public ?string $additionalInfo;
|
||||
public ?int $easement;
|
||||
public ?int $btb;
|
||||
public ?int $fttxLocationSupplied;
|
||||
public ?int $conduitToHuepLaid;
|
||||
public ?int $huepMounted;
|
||||
public ?int $dropCableAvailable;
|
||||
public ?int $spliceCompleted;
|
||||
public int $create;
|
||||
public int $createBy;
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
|
||||
class WorkorderMphAdminController extends WorkorderMphBaseController
|
||||
{
|
||||
protected string $headerTitle = 'MPH Arbeitsaufträge Verwaltung';
|
||||
protected bool $createText = false;
|
||||
protected array $permissionCheck = ['WorkorderMphAdmin'];
|
||||
|
||||
protected array $columns = [
|
||||
['key' => 'id', 'text' => 'Auftrags-Nr.', 'table' => ['sortable' => true, 'filter' => 'numberRange']],
|
||||
['key' => 'netOwnerId', 'text' => 'Netzeigentümer', 'modal' => false, 'table' => ['filter' => 'select', 'sortable' => false], 'required' => false],
|
||||
['key' => 'hausnummerInfo', 'text' => 'Adresse', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]],
|
||||
['key' => 'netzgebietName', 'text' => 'Netzgebiet', 'modal' => false, 'table' => ['filter' => 'select', 'sortable' => false]],
|
||||
['key' => 'rimoFcpName', 'text' => 'FCP', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]],
|
||||
['key' => 'companyName', 'text' => 'Firma', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]],
|
||||
['key' => 'wohneinheitCount', 'text' => 'WE', 'modal' => false, 'table' => ['sortable' => true, 'filter' => 'numberRange']],
|
||||
['key' => 'additionalInfo', 'text' => 'Notiz', 'modal' => false, 'table' => ['sortable' => false]],
|
||||
['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]],
|
||||
['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]],
|
||||
];
|
||||
|
||||
protected function prepareCrudConfig()
|
||||
{
|
||||
$hausnummerInfoColIdx = array_search('hausnummerInfo', array_column($this->columns, 'key'));
|
||||
array_splice($this->columns, $hausnummerInfoColIdx + 1, 0, [$this->statusColumn]);
|
||||
|
||||
// Handle netOwnerId column - only visible for admins
|
||||
$netOwnerColIdx = array_search('netOwnerId', array_column($this->columns, 'key'));
|
||||
if ($netOwnerColIdx !== false) {
|
||||
if ($this->user->isAdmin()) {
|
||||
$netOwners = Helper::getMphNetworkOwners();
|
||||
$this->columns[$netOwnerColIdx]['table']['filterOptions'] = array_map(fn($o) => ['value' => $o->id, 'text' => $o->company], $netOwners);
|
||||
} else {
|
||||
$this->columns[$netOwnerColIdx]['table'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Populate netzgebiet filter options
|
||||
$netzgebietColIdx = array_search('netzgebietName', array_column($this->columns, 'key'));
|
||||
if ($netzgebietColIdx !== false) {
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
|
||||
// Apply network ownership filtering
|
||||
$netzgebietFilter = "";
|
||||
if (!$this->user->isAdmin()) {
|
||||
$allowedNetzgebietIds = Helper::getADBNetworksFromUser($this->user);
|
||||
if (!empty($allowedNetzgebietIds)) {
|
||||
$escapedIds = array_map(fn($id) => $db->escape($id), $allowedNetzgebietIds);
|
||||
$netzgebietFilter = " AND ng.id IN (" . implode(',', $escapedIds) . ")";
|
||||
}
|
||||
}
|
||||
|
||||
$fronkDbName = FRONKDB_DBNAME;
|
||||
$sql = "SELECT DISTINCT ng.id, ng.name FROM Netzgebiet ng
|
||||
INNER JOIN Hausnummer hn ON ng.id = hn.netzgebiet_id
|
||||
INNER JOIN `$fronkDbName`.`WorkorderMph` wm ON wm.hausnummerId = hn.id
|
||||
WHERE ng.name IS NOT NULL AND ng.name != ''
|
||||
$netzgebietFilter
|
||||
ORDER BY ng.name ASC";
|
||||
$result = $db->query($sql);
|
||||
$netzgebiete = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
$this->columns[$netzgebietColIdx]['table']['filterOptions'] = array_map(fn($ng) => ['value' => $ng['id'], 'text' => $ng['name']], $netzgebiete);
|
||||
}
|
||||
}
|
||||
|
||||
public function indexAction()
|
||||
{
|
||||
// Note: Workorder creation is now handled by cronjob script: scripts/workorder-mph-create-from-hausnummer.php
|
||||
parent::indexAction();
|
||||
}
|
||||
|
||||
protected function getAction()
|
||||
{
|
||||
$pagination = $this->postData['pagination'] ?? ['page' => 1, 'per_page' => 10];
|
||||
$filters = $this->postData['filters'] ?? [];
|
||||
$order = $this->postData['order'] ?? [];
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$fronkDbName = FRONKDB_DBNAME;
|
||||
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
|
||||
|
||||
$whereClauses = "WHERE 1=1";
|
||||
|
||||
// Apply network ownership filtering (similar to WorkorderAdmin)
|
||||
if (!$this->user->isAdmin()) {
|
||||
$allowedNetzgebietIds = Helper::getADBNetworksFromUser($this->user);
|
||||
if (!empty($allowedNetzgebietIds)) {
|
||||
$escapedIds = array_map(fn($id) => $db->escape($id), $allowedNetzgebietIds);
|
||||
$whereClauses .= " AND hn.netzgebiet_id IN (" . implode(',', $escapedIds) . ")";
|
||||
} else {
|
||||
// User has no networks assigned, show no results
|
||||
$whereClauses .= " AND 1=0";
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($filters['status'])) {
|
||||
$whereClauses .= " AND w.status NOT IN ('completed', 'cancelled', 'archived')";
|
||||
} else {
|
||||
$whereClauses .= Helper::generateFilterCondition($filters['status'], 'w.status', true);
|
||||
}
|
||||
|
||||
if (!empty($filters['id'])) $whereClauses .= Helper::generateFilterCondition($filters['id'], 'w.id', true);
|
||||
if (!empty($filters['netOwnerId'])) $whereClauses .= Helper::generateFilterCondition($filters['netOwnerId'], 'n.owner_id');
|
||||
if (!empty($filters['hausnummerInfo'])) {
|
||||
$searchColumns = "str.name|hn.hausnummer|hn.stiege|plz.plz|ort.name|w.additionalInfo";
|
||||
$whereClauses .= Helper::generateFilterCondition($filters['hausnummerInfo'], $searchColumns);
|
||||
}
|
||||
if (!empty($filters['netzgebietName'])) $whereClauses .= Helper::generateFilterCondition($filters['netzgebietName'], 'ng.id');
|
||||
if (!empty($filters['rimoFcpName'])) $whereClauses .= Helper::generateFilterCondition($filters['rimoFcpName'], 'hn.rimo_fcp_name');
|
||||
if (!empty($filters['companyName'])) $whereClauses .= Helper::generateFilterCondition($filters['companyName'], 'c.name');
|
||||
if (!empty($filters['wohneinheitCount'])) $whereClauses .= Helper::generateFilterCondition($filters['wohneinheitCount'], '(SELECT COUNT(*) FROM `' . $addressDbName . '`.`Wohneinheit` we WHERE we.hausnummer_id = hn.id)', true);
|
||||
if (!empty($filters['deadlineDate'])) $whereClauses .= Helper::generateFilterCondition($filters['deadlineDate'], 'w.deadlineDate');
|
||||
if (!empty($filters['additionalInfo'])) $whereClauses .= Helper::generateFilterCondition($filters['additionalInfo'], 'w.additionalInfo');
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
w.id, w.status, w.deadlineDate, w.appointmentDate, w.companyId, w.additionalInfo,
|
||||
IFNULL(c.name, 'Nicht zugewiesen') as companyName,
|
||||
CONCAT_WS(' ', str.name, hn.hausnummer, hn.stiege) as hausnummerInfo,
|
||||
str.name as street, hn.hausnummer, hn.stiege, plz.plz, ort.name as city,
|
||||
ng.id as netzgebietName,
|
||||
n.owner_id as netOwnerId,
|
||||
hn.rimo_fcp_name as rimoFcpName,
|
||||
(SELECT COUNT(*) FROM `$addressDbName`.`Wohneinheit` we WHERE we.hausnummer_id = hn.id) as wohneinheitCount
|
||||
FROM `$fronkDbName`.`WorkorderMph` w
|
||||
LEFT JOIN `$fronkDbName`.`WorkorderCompany` c ON w.companyId = c.id
|
||||
LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = hn.id
|
||||
LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id
|
||||
LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id
|
||||
LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
|
||||
LEFT JOIN `$addressDbName`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id
|
||||
LEFT JOIN `$fronkDbName`.`Network` n ON n.adb_netzgebiet_id = ng.id
|
||||
$whereClauses
|
||||
";
|
||||
|
||||
$orderBy = "";
|
||||
if (!empty($order['key'])) {
|
||||
$sortableColumns = ['id', 'status', 'deadlineDate', 'appointmentDate', 'wohneinheitCount'];
|
||||
if (in_array($order['key'], $sortableColumns)) {
|
||||
$sortOrder = (strtoupper($order['order']) === 'DESC') ? 'DESC' : 'ASC';
|
||||
$orderBy = " ORDER BY " . $db->escape($order['key']) . " " . $sortOrder;
|
||||
}
|
||||
}
|
||||
if (empty($orderBy)) $orderBy = " ORDER BY CASE WHEN w.deadlineDate IS NULL THEN 1 ELSE 0 END, w.deadlineDate ASC";
|
||||
|
||||
$sql .= $orderBy;
|
||||
|
||||
// Get total count
|
||||
$countSql = "SELECT COUNT(*) as count FROM `$fronkDbName`.`WorkorderMph` w
|
||||
LEFT JOIN `$fronkDbName`.`WorkorderCompany` c ON w.companyId = c.id
|
||||
LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = hn.id
|
||||
LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id
|
||||
LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id
|
||||
LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
|
||||
LEFT JOIN `$addressDbName`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id
|
||||
LEFT JOIN `$fronkDbName`.`Network` n ON n.adb_netzgebiet_id = ng.id
|
||||
$whereClauses";
|
||||
$totalCount = (int)$db->query($countSql)->fetch_assoc()['count'];
|
||||
|
||||
// Add pagination
|
||||
if ($pagination['per_page'] !== null) {
|
||||
$sql .= " LIMIT " . intval($pagination['per_page']) . " OFFSET " . intval(($pagination['page'] - 1) * $pagination['per_page']);
|
||||
}
|
||||
|
||||
$result = $db->query($sql);
|
||||
$rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
|
||||
self::returnJson([
|
||||
'rows' => $rows,
|
||||
'pagination' => [
|
||||
'page' => (int)$pagination['page'],
|
||||
'per_page' => (int)$pagination['per_page'],
|
||||
'total_rows' => $totalCount,
|
||||
'total_pages' => (int)ceil($totalCount / $pagination['per_page']),
|
||||
'filtered_available' => $totalCount
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getWorkorderByIdAction()
|
||||
{
|
||||
if (empty($this->request->id)) self::sendError("ID fehlt.");
|
||||
$workorder = WorkorderMphModel::get($this->request->id);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
self::returnJson((array)$workorder);
|
||||
}
|
||||
|
||||
protected function getCompaniesAction()
|
||||
{
|
||||
$companies = WorkorderCompanyModel::getAll();
|
||||
self::returnJson(array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $companies));
|
||||
}
|
||||
|
||||
protected function assignWorkorderAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId']) || empty($this->postData['companyId'])) self::sendError("Erforderliche Felder fehlen.");
|
||||
$deadline = !empty($this->postData['deadlineDate']) ? $this->postData['deadlineDate'] : strtotime('+6 weeks');
|
||||
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
$oldStatus = $workorder->status;
|
||||
$oldCompanyId = $workorder->companyId;
|
||||
|
||||
$workorder->companyId = $this->postData['companyId'];
|
||||
$workorder->status = 'assigned';
|
||||
$workorder->assignmentDate = time();
|
||||
$workorder->deadlineDate = $deadline;
|
||||
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
$company = WorkorderCompanyModel::get($this->postData['companyId']);
|
||||
$statusChange = $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('assigned');
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => "Arbeitsauftrag zugewiesen an: " . ($company ? $company->name : "Firma ID " . $this->postData['companyId']),
|
||||
'statusChange' => $statusChange,
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag erfolgreich zugewiesen.']);
|
||||
}
|
||||
|
||||
protected function updateDeadlineAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId']) || empty($this->postData['deadlineDate'])) self::sendError("Erforderliche Felder fehlen.");
|
||||
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
$workorder->deadlineDate = $this->postData['deadlineDate'];
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => 'Deadline geändert auf ' . date('d.m.Y', $this->postData['deadlineDate']) . '.',
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id
|
||||
]);
|
||||
self::returnJson(['success' => true, 'message' => 'Deadline erfolgreich aktualisiert.']);
|
||||
}
|
||||
|
||||
protected function acceptDocumentationAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
if ($workorder->status !== 'documented') self::sendError("Die Dokumentation muss zuerst von der Firma als fertig markiert werden.");
|
||||
|
||||
$oldStatus = $workorder->status;
|
||||
$workorder->status = 'completed';
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => 'Dokumentation akzeptiert und Arbeitsauftrag abgeschlossen.',
|
||||
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('completed'),
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
self::returnJson(['success' => true, 'message' => 'Dokumentation akzeptiert und Arbeitsauftrag abgeschlossen.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Background task: Creates WorkorderMph from Hausnummer with >2 Wohneinheiten
|
||||
* and RIMO state not in grossplaning/not2connect
|
||||
*/
|
||||
private function createWorkordersFromHausnummer()
|
||||
{
|
||||
$lockFile = TEMP_DIR . "/task_create_workorder_mph.lock";
|
||||
if (file_exists($lockFile) && (time() - intval(file_get_contents($lockFile))) < 300) {
|
||||
return; // Run only every 5 minutes
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
|
||||
// Build netzgebiet filter
|
||||
$netzgebietIds = defined('TT_WORKORDER_MPH_NETZGEBIET_IDS') ? TT_WORKORDER_MPH_NETZGEBIET_IDS : [];
|
||||
$netzgebietFilter = '';
|
||||
if (!empty($netzgebietIds)) {
|
||||
$escapedIds = array_map(fn($id) => $db->escape($id), $netzgebietIds);
|
||||
$netzgebietFilter = " AND hn.netzgebiet_id IN (" . implode(',', $escapedIds) . ")";
|
||||
}
|
||||
|
||||
// Find Hausnummer with >2 Wohneinheiten and state not in grossplaning/not2connect
|
||||
$sql = "
|
||||
SELECT hn.id, hn.netzgebiet_id, COUNT(we.id) as we_count
|
||||
FROM Hausnummer hn
|
||||
LEFT JOIN Wohneinheit we ON hn.id = we.hausnummer_id
|
||||
WHERE hn.rimo_ex_state NOT IN ('grossplaning', 'not2connect')
|
||||
AND hn.rimo_op_state NOT IN ('grossplaning', 'not2connect')
|
||||
$netzgebietFilter
|
||||
GROUP BY hn.id
|
||||
HAVING we_count > 2
|
||||
";
|
||||
|
||||
$result = $db->query($sql);
|
||||
$hausnummern = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
|
||||
// Get valid hausnummer IDs
|
||||
$validHausnummerIds = array_column($hausnummern, 'id');
|
||||
|
||||
foreach ($hausnummern as $hn) {
|
||||
// Check if WorkorderMph already exists
|
||||
$existing = WorkorderMphModel::getFirst(['hausnummerId' => $hn['id']]);
|
||||
|
||||
if (!$existing) {
|
||||
// Create new WorkorderMph
|
||||
WorkorderMphModel::create([
|
||||
'hausnummerId' => $hn['id'],
|
||||
'status' => 'new',
|
||||
'create' => time(),
|
||||
'createBy' => 1 // System user
|
||||
]);
|
||||
} elseif ($existing->status === 'archived') {
|
||||
// Reactivate archived workorder
|
||||
$existing->status = 'new';
|
||||
$existing->companyId = null;
|
||||
$existing->deadlineDate = null;
|
||||
$existing->appointmentDate = null;
|
||||
WorkorderMphModel::update((array)$existing);
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $existing->id,
|
||||
'text' => 'Arbeitsauftrag wurde automatisch reaktiviert.',
|
||||
'statusChange' => $this->getStatusText('archived') . " -> " . $this->getStatusText('new'),
|
||||
'create' => time(),
|
||||
'createBy' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Archive workorders for Hausnummer that are no longer in allowed netzgebiete or don't meet criteria
|
||||
if (!empty($netzgebietIds)) {
|
||||
$allWorkorders = WorkorderMphModel::getAll(['status' => ['new', 'assigned', 'scheduled', 'in_progress']]);
|
||||
foreach ($allWorkorders as $workorder) {
|
||||
if (!in_array($workorder->hausnummerId, $validHausnummerIds)) {
|
||||
$workorder->status = 'archived';
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => 'Arbeitsauftrag automatisch archiviert (Netzgebiet deaktiviert oder Kriterien nicht mehr erfüllt).',
|
||||
'statusChange' => 'active -> archived',
|
||||
'create' => time(),
|
||||
'createBy' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents($lockFile, time());
|
||||
}
|
||||
|
||||
protected function cancelWorkorderAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
$oldStatus = $workorder->status;
|
||||
$workorder->status = 'cancelled';
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
$reason = !empty($this->postData['reason']) ? $this->postData['reason'] : 'Kein Grund angegeben';
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => "Arbeitsauftrag storniert. Grund: " . $reason,
|
||||
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('cancelled'),
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag wurde storniert.']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
<?php
|
||||
|
||||
class WorkorderMphBaseController extends TTCrud
|
||||
{
|
||||
protected array $statusColumn = [
|
||||
'key' => 'status', 'text' => 'Status', 'modal' => false, 'table' => ['filter' => 'iconSelect', 'sortable' => false, 'filterOptions' => [
|
||||
['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'],
|
||||
['value' => 'assigned', 'text' => 'Zugewiesen', 'icon' => 'fas fa-user-check text-info'],
|
||||
['value' => 'scheduled', 'text' => 'Geplant', 'icon' => 'fas fa-calendar-check text-warning'],
|
||||
['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-warning'],
|
||||
['value' => 'documented', 'text' => 'Dokumentiert', 'icon' => 'fas fa-file-alt text-success'],
|
||||
['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-double text-secondary'],
|
||||
['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-ban text-danger'],
|
||||
['value' => 'archived', 'text' => 'Archiviert', 'icon' => 'fas fa-archive text-muted'],
|
||||
]]
|
||||
];
|
||||
|
||||
protected array $additionalJS = ["js/pages/WorkorderMphBase/WorkorderMphBase.js"];
|
||||
protected array $additionalHead = ["<link rel='stylesheet' href='/js/pages/WorkorderMphBase/WorkorderMphBase.css'>"];
|
||||
|
||||
protected function getStatusText(string $statusKey): string
|
||||
{
|
||||
$statusMap = array_column($this->statusColumn['table']['filterOptions'] ?? [], 'text', 'value');
|
||||
return $statusMap[$statusKey] ?? ucfirst(str_replace('_', ' ', $statusKey));
|
||||
}
|
||||
|
||||
//region SHARED ACTIONS
|
||||
/**
|
||||
* Fetches documentation and journal entries for a given workorder.
|
||||
*/
|
||||
protected function getDocumentationAction()
|
||||
{
|
||||
if (empty($this->request->workorderMphId)) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
|
||||
$docs = WorkorderMphDocumentationModel::getAll(['workorderMphId' => intval($this->request->workorderMphId)], null, 0, ['key' => 'create', 'order' => 'ASC']);
|
||||
$journals = WorkorderMphJournalModel::getAll(['workorderMphId' => intval($this->request->workorderMphId)], null, 0, ['key' => 'create', 'order' => 'DESC']);
|
||||
|
||||
$responseDocs = [];
|
||||
$typeCounts = [];
|
||||
|
||||
foreach ($docs as $doc) {
|
||||
$file = new File($doc->fileId);
|
||||
$documentTypeKey = $doc->documentType;
|
||||
$typeCounts[$documentTypeKey] = ($typeCounts[$documentTypeKey] ?? 0) + 1;
|
||||
$originalFilename = $file->orig_filename ?? $file->filename;
|
||||
$extension = pathinfo($originalFilename, PATHINFO_EXTENSION);
|
||||
$newFilename = "{$documentTypeKey}_{$typeCounts[$documentTypeKey]}." . strtolower($extension);
|
||||
|
||||
$responseDocs[] = [
|
||||
'id' => $doc->id,
|
||||
'fileId' => $doc->fileId,
|
||||
'fileName' => $newFilename,
|
||||
'description' => $doc->description,
|
||||
'documentType' => $documentTypeKey,
|
||||
'userName' => UserModel::getOne($doc->createBy)->name ?? 'Unbekannt',
|
||||
'mimetype' => $file->mimetype ?? 'application/octet-stream',
|
||||
'create' => $doc->create
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($journals as $journal) {
|
||||
$journal->createByName = UserModel::getOne($journal->createBy)->name ?? 'Unbekannt';
|
||||
}
|
||||
|
||||
self::returnJson(['docs' => $responseDocs, 'journals' => $journals]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload documentation for the Workorder itself (not Wohneinheit).
|
||||
*/
|
||||
protected function uploadDocumentationAction()
|
||||
{
|
||||
if (empty($_FILES['files']) && empty($_FILES['file'])) self::sendError('Erforderliche Daten fehlen.');
|
||||
if (empty($_POST['workorderMphId'])) self::sendError('Workorder ID fehlt.');
|
||||
|
||||
$workorderMphId = intval($_POST['workorderMphId']);
|
||||
$uploadedCount = 0;
|
||||
|
||||
// Handle multiple files (files[])
|
||||
if (!empty($_FILES['files'])) {
|
||||
foreach ($_FILES['files']['name'] as $index => $name) {
|
||||
if ($_FILES['files']['error'][$index] === UPLOAD_ERR_OK) {
|
||||
// Mock the $_FILES entry for handleFormUpload
|
||||
$_FILES['single_upload_file'] = [
|
||||
'name' => $name,
|
||||
'type' => $_FILES['files']['type'][$index],
|
||||
'tmp_name' => $_FILES['files']['tmp_name'][$index],
|
||||
'error' => $_FILES['files']['error'][$index],
|
||||
'size' => $_FILES['files']['size'][$index]
|
||||
];
|
||||
try {
|
||||
$uploaded = mfUpload::handleFormUpload("single_upload_file", false, "/WorkorderMph");
|
||||
WorkorderMphDocumentationModel::create([
|
||||
'workorderMphId' => $workorderMphId,
|
||||
'fileId' => $uploaded->id,
|
||||
'description' => $_POST['description'] ?? '',
|
||||
'documentType' => $_POST['documentType'] ?? 'other',
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id
|
||||
]);
|
||||
$uploadedCount++;
|
||||
} catch (Exception $e) {
|
||||
// Log error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle single file (file) - fallback or primary if JS sends single
|
||||
elseif (!empty($_FILES['file'])) {
|
||||
try {
|
||||
$uploaded = mfUpload::handleFormUpload("file", false, "/WorkorderMph");
|
||||
WorkorderMphDocumentationModel::create([
|
||||
'workorderMphId' => $workorderMphId,
|
||||
'fileId' => $uploaded->id,
|
||||
'description' => $_POST['description'] ?? '',
|
||||
'documentType' => $_POST['documentType'] ?? 'other',
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id
|
||||
]);
|
||||
$uploadedCount++;
|
||||
} catch (Exception $e) {
|
||||
self::sendError("Upload fehlgeschlagen: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if ($uploadedCount > 0) {
|
||||
self::returnJson(['success' => true, 'message' => "$uploadedCount Datei(en) erfolgreich hochgeladen."]);
|
||||
} else {
|
||||
self::sendError("Keine Dateien wurden hochgeladen.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Workorder documentation
|
||||
*/
|
||||
protected function deleteDocumentationAction()
|
||||
{
|
||||
if (empty($this->postData['documentationId'])) self::sendError("Dokumentations-ID fehlt.");
|
||||
WorkorderMphDocumentationModel::delete($this->postData['documentationId']);
|
||||
self::returnJson(['success' => true, 'message' => 'Dokument gelöscht.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new entry to a workorder's journal.
|
||||
*/
|
||||
protected function addJournalAction()
|
||||
{
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($post['workorderMphId']) || empty(trim($post['text']))) self::sendError("Arbeitsauftrags-ID und Text sind erforderlich.");
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $post['workorderMphId'],
|
||||
'text' => $post['text'],
|
||||
'createBy' => $this->user->id,
|
||||
'create' => time()
|
||||
]);
|
||||
|
||||
$journals = WorkorderMphJournalModel::getAll(['workorderMphId' => intval($post['workorderMphId'])], null, 0, ['key' => 'create', 'order' => 'DESC']);
|
||||
foreach ($journals as $journal) {
|
||||
$journal->createByName = UserModel::getOne($journal->createBy)->name ?? 'Unbekannt';
|
||||
}
|
||||
self::returnJson(['success' => true, 'message' => 'Journaleintrag hinzugefügt.', 'journals' => $journals]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the additional info field for a workorder.
|
||||
*/
|
||||
protected function updateAdditionalInfoAction()
|
||||
{
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($post['workorderMphId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
$workorder = WorkorderMphModel::get($post['workorderMphId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
$oldInfo = $workorder->additionalInfo;
|
||||
$newInfo = $post['additionalInfo'] ?? null;
|
||||
$workorder->additionalInfo = $newInfo;
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => "Zusatzinfo geändert.\nAlt: '{$oldInfo}'\nNeu: '{$newInfo}'",
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
self::returnJson(['success' => true, 'message' => 'Zusatzinfo aktualisiert.', 'newInfo' => $newInfo]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Wohneinheiten for a specific workorder with their statuses and notes
|
||||
*/
|
||||
protected function getWohneinheitenAction()
|
||||
{
|
||||
if (empty($this->request->workorderMphId)) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
|
||||
$workorderMphId = intval($this->request->workorderMphId);
|
||||
$workorder = WorkorderMphModel::get($workorderMphId);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
// Get all Wohneinheiten for this Hausnummer from addressdb
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
$hausnummerId = $db->escape($workorder->hausnummerId);
|
||||
|
||||
// Fetch statuses from addressdb
|
||||
$statusSql = "SELECT id, code, name FROM Status WHERE type = 'wohneinheit' ORDER BY code ASC";
|
||||
$statusResult = $db->query($statusSql);
|
||||
$statuses = $statusResult ? $statusResult->fetch_all(MYSQLI_ASSOC) : [];
|
||||
|
||||
$statusOptions = array_map(function($s) {
|
||||
return ['value' => intval($s['id']), 'text' => $s['code'] . ' - ' . $s['name'], 'code' => intval($s['code'])];
|
||||
}, $statuses);
|
||||
|
||||
// Fetch Wohneinheiten directly
|
||||
$sql = "SELECT w.id, w.zusatz, w.tuer, w.contact, w.oaid, w.note, w.status_id, w.splice_hak_completed
|
||||
FROM Wohneinheit w
|
||||
WHERE w.hausnummer_id = $hausnummerId
|
||||
ORDER BY w.oaid ASC";
|
||||
$result = $db->query($sql);
|
||||
$wohneinheiten = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
|
||||
// Get Preorders for this Hausnummer to fallback contact info
|
||||
$preorders = [];
|
||||
if (class_exists('PreorderModel')) {
|
||||
// Use searchActive to filter out canceled preorders (status_code = 20)
|
||||
$preorderList = PreorderModel::searchActive(['adb_hausnummer_id' => $workorder->hausnummerId]);
|
||||
foreach ($preorderList as $preorder) {
|
||||
if ($preorder->adb_wohneinheit_id) {
|
||||
$preorders[$preorder->adb_wohneinheit_id] = $preorder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge data
|
||||
$response = [];
|
||||
foreach ($wohneinheiten as $we) {
|
||||
// Contact info logic
|
||||
$contact = $we['contact'];
|
||||
$preorderContact = null;
|
||||
$preorderUcode = null;
|
||||
|
||||
if (isset($preorders[$we['id']])) {
|
||||
$p = $preorders[$we['id']];
|
||||
$preorderUcode = $p->ucode;
|
||||
$pContact = trim($p->firstname . ' ' . $p->lastname);
|
||||
if ($p->phone) $pContact .= ' (' . $p->phone . ')';
|
||||
|
||||
$preorderContact = $pContact;
|
||||
|
||||
// If address contact is empty, use preorder contact
|
||||
if (empty($contact)) {
|
||||
$contact = $pContact;
|
||||
}
|
||||
}
|
||||
|
||||
// Get document count for this Wohneinheit
|
||||
$docCountSql = "SELECT COUNT(*) as cnt FROM WohneinheitDocumentation WHERE wohneinheit_id = " . $db->escape($we['id']);
|
||||
$docCountResult = $db->query($docCountSql);
|
||||
$documentCount = 0;
|
||||
if ($docCountResult) {
|
||||
$docCountRow = $docCountResult->fetch_assoc();
|
||||
$documentCount = intval($docCountRow['cnt']);
|
||||
}
|
||||
|
||||
$response[] = [
|
||||
'wohneinheitId' => intval($we['id']),
|
||||
'zusatz' => $we['zusatz'],
|
||||
'tuer' => $we['tuer'],
|
||||
'contact' => $contact,
|
||||
'preorderContact' => $preorderContact,
|
||||
'preorderUcode' => $preorderUcode,
|
||||
'oaid' => $we['oaid'],
|
||||
'status' => intval($we['status_id']),
|
||||
'spliceCompleted' => intval($we['splice_hak_completed'] ?? 0),
|
||||
'note' => $we['note'],
|
||||
'documentCount' => $documentCount,
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson([
|
||||
'wohneinheiten' => $response,
|
||||
'statusOptions' => $statusOptions,
|
||||
'hausnummerId' => $workorder->hausnummerId
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status and note for a specific Wohneinheit
|
||||
*/
|
||||
protected function updateWohneinheitAction()
|
||||
{
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($post['workorderMphId']) || empty($post['wohneinheitId'])) {
|
||||
self::sendError("Arbeitsauftrags-ID und Wohneinheit-ID sind erforderlich.");
|
||||
}
|
||||
|
||||
$workorderMphId = intval($post['workorderMphId']);
|
||||
$wohneinheitId = intval($post['wohneinheitId']);
|
||||
$newStatusId = intval($post['status'] ?? 1);
|
||||
$spliceCompleted = isset($post['spliceCompleted']) ? intval($post['spliceCompleted']) : 0;
|
||||
$tuer = $post['tuer'] ?? null;
|
||||
$zusatz = $post['zusatz'] ?? null;
|
||||
|
||||
// Validate that "Tür" field is not empty if it's being set
|
||||
if ($tuer !== null && trim($tuer) === '') {
|
||||
self::sendError("Das Feld 'Tür' darf nicht leer sein.");
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
$escapedWohneinheitId = $db->escape($wohneinheitId);
|
||||
|
||||
// Fetch current state
|
||||
$currentSql = "SELECT status_id, tuer, zusatz, splice_hak_completed FROM Wohneinheit WHERE id = $escapedWohneinheitId";
|
||||
$result = $db->query($currentSql);
|
||||
$current = $result ? $result->fetch_assoc() : null;
|
||||
|
||||
if (!$current) self::sendError("Wohneinheit nicht gefunden.");
|
||||
|
||||
$oldStatusId = intval($current['status_id']);
|
||||
$oldTuer = $current['tuer'];
|
||||
$oldZusatz = $current['zusatz'];
|
||||
$oldSplice = intval($current['splice_hak_completed'] ?? 0);
|
||||
|
||||
// Update Wohneinheit
|
||||
$escapedTuer = $tuer !== null ? "'" . $db->escape($tuer) . "'" : "NULL";
|
||||
$escapedZusatz = $zusatz !== null ? "'" . $db->escape($zusatz) . "'" : "NULL";
|
||||
$escapedStatusId = $db->escape($newStatusId);
|
||||
$escapedSplice = $db->escape($spliceCompleted);
|
||||
|
||||
$updateSql = "UPDATE Wohneinheit SET
|
||||
status_id = $escapedStatusId,
|
||||
tuer = $escapedTuer,
|
||||
zusatz = $escapedZusatz,
|
||||
splice_hak_completed = $escapedSplice
|
||||
WHERE id = $escapedWohneinheitId";
|
||||
|
||||
$db->query($updateSql);
|
||||
|
||||
// Journaling
|
||||
$changes = [];
|
||||
if ($oldStatusId !== $newStatusId) {
|
||||
// Fetch status names for better logging
|
||||
$statusNamesSql = "SELECT id, code, name FROM Status WHERE id IN ($oldStatusId, $newStatusId)";
|
||||
$statusRes = $db->query($statusNamesSql);
|
||||
$statusMap = [];
|
||||
if ($statusRes) {
|
||||
while($row = $statusRes->fetch_assoc()) {
|
||||
$statusMap[$row['id']] = $row['code'] . ' - ' . $row['name'];
|
||||
}
|
||||
}
|
||||
$oldText = $statusMap[$oldStatusId] ?? "ID $oldStatusId";
|
||||
$newText = $statusMap[$newStatusId] ?? "ID $newStatusId";
|
||||
$changes[] = "Status: $oldText → $newText";
|
||||
}
|
||||
|
||||
if ($oldSplice !== $spliceCompleted) {
|
||||
$changes[] = "Spleiß: " . ($spliceCompleted ? 'Erledigt' : 'Nicht erledigt');
|
||||
}
|
||||
|
||||
if ($oldTuer !== $tuer) {
|
||||
$changes[] = "Tür aktualisiert: '$oldTuer' -> '$tuer'";
|
||||
}
|
||||
if ($oldZusatz !== $zusatz) {
|
||||
$changes[] = "Zusatz aktualisiert: '$oldZusatz' -> '$zusatz'";
|
||||
}
|
||||
|
||||
if (!empty($changes)) {
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorderMphId,
|
||||
'text' => "Wohneinheit $wohneinheitId: " . implode(', ', $changes),
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
// Status flag logic for BEP MD (241) and ONT (300). Need to check codes for these IDs.
|
||||
// Since we only have IDs, we need to check the code of the newStatusId.
|
||||
$newStatusCodeSql = "SELECT code FROM Status WHERE id = $escapedStatusId";
|
||||
$resCode = $db->query($newStatusCodeSql);
|
||||
$newStatusCode = $resCode ? intval($resCode->fetch_assoc()['code']) : 0;
|
||||
|
||||
if (in_array($newStatusCode, [241, 300])) { // 241=BEP MD, 300=ONT
|
||||
$this->setWohneinheitStatusflag($wohneinheitId, 200);
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Wohneinheit aktualisiert.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set statusflag on Wohneinheit in addressdb
|
||||
*/
|
||||
private function setWohneinheitStatusflag(int $wohneinheitId, int $statusflagId)
|
||||
{
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
$weId = $db->escape($wohneinheitId);
|
||||
$sfId = $db->escape($statusflagId);
|
||||
|
||||
// Check if statusflag already exists
|
||||
$checkSql = "SELECT COUNT(*) as count FROM WohneinheitStatusflagValue WHERE wohneinheit_id = $weId AND statusflag_id = $sfId";
|
||||
$result = $db->query($checkSql);
|
||||
$exists = $result->fetch_assoc()['count'] > 0;
|
||||
|
||||
if (!$exists) {
|
||||
$insertSql = "INSERT INTO WohneinheitStatusflagValue (wohneinheit_id, statusflag_id, create, createBy)
|
||||
VALUES ($weId, $sfId, " . time() . ", " . $this->user->id . ")";
|
||||
$db->query($insertSql);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get documents for a specific Wohneinheit
|
||||
*/
|
||||
protected function getWohneinheitDocumentsAction()
|
||||
{
|
||||
if (empty($this->request->wohneinheitId)) self::sendError("Wohneinheit-ID fehlt.");
|
||||
|
||||
$wohneinheitId = intval($this->request->wohneinheitId);
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
|
||||
$sql = "SELECT * FROM WohneinheitDocumentation WHERE wohneinheit_id = " . $db->escape($wohneinheitId) . " ORDER BY `create` ASC";
|
||||
$result = $db->query($sql);
|
||||
$docs = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
|
||||
$responseDocs = [];
|
||||
foreach ($docs as $doc) {
|
||||
$file = new File($doc['fileId']);
|
||||
$responseDocs[] = [
|
||||
'id' => $doc['id'],
|
||||
'fileId' => $doc['fileId'],
|
||||
'fileName' => $file->orig_filename ?? $file->filename,
|
||||
'description' => $doc['description'],
|
||||
'documentType' => $doc['documentType'],
|
||||
'userName' => UserModel::getOne($doc['createBy'])->name ?? 'Unbekannt',
|
||||
'mimetype' => $file->mimetype ?? 'application/octet-stream',
|
||||
'create' => $doc['create']
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson(['docs' => $responseDocs]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload document for a specific Wohneinheit
|
||||
*/
|
||||
protected function uploadWohneinheitDocumentAction()
|
||||
{
|
||||
if (empty($_FILES['file']) || empty($_POST['wohneinheitId'])) {
|
||||
self::sendError("Datei und Wohneinheit-ID sind erforderlich.");
|
||||
}
|
||||
|
||||
$wohneinheitId = intval($_POST['wohneinheitId']);
|
||||
$documentType = $_POST['documentType'] ?? 'photo';
|
||||
$description = $_POST['description'] ?? null;
|
||||
|
||||
// Upload file using mfUpload handleFormUpload for proper handling
|
||||
try {
|
||||
$upload = mfUpload::handleFormUpload("file", false, "/WorkorderMph/Wohneinheit");
|
||||
$file = $upload; // handleFormUpload returns the File object
|
||||
} catch (Exception $e) {
|
||||
self::sendError("Datei-Upload fehlgeschlagen: " . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert into WohneinheitDocumentation table in addressdb
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
$escapedWohneinheitId = $db->escape($wohneinheitId);
|
||||
$escapedFileId = $db->escape($file->id);
|
||||
$escapedDescription = $description ? "'" . $db->escape($description) . "'" : "NULL";
|
||||
$escapedDocumentType = "'" . $db->escape($documentType) . "'";
|
||||
$escapedCreateBy = $db->escape($this->user->id);
|
||||
$escapedCreate = time();
|
||||
|
||||
$sql = "INSERT INTO WohneinheitDocumentation (wohneinheit_id, fileId, description, documentType, `create`, createBy)
|
||||
VALUES ($escapedWohneinheitId, $escapedFileId, $escapedDescription, $escapedDocumentType, $escapedCreate, $escapedCreateBy)";
|
||||
$db->query($sql);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Dokument erfolgreich hochgeladen.', 'fileId' => $file->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete document for a specific Wohneinheit
|
||||
*/
|
||||
protected function deleteWohneinheitDocumentAction()
|
||||
{
|
||||
if (empty($this->postData['documentationId'])) self::sendError("Dokumentations-ID fehlt.");
|
||||
|
||||
$documentationId = intval($this->postData['documentationId']);
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
|
||||
$escapedId = $db->escape($documentationId);
|
||||
$sql = "DELETE FROM WohneinheitDocumentation WHERE id = $escapedId";
|
||||
$db->query($sql);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Dokument gelöscht.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update checkbox documentation fields
|
||||
*/
|
||||
protected function updateCheckboxesAction()
|
||||
{
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($post['workorderMphId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
|
||||
$workorder = WorkorderMphModel::get($post['workorderMphId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
$changes = [];
|
||||
$checkboxFields = [
|
||||
'easement' => 'Leitungsrecht',
|
||||
'btb' => 'Bautechnische Begehung',
|
||||
'fttxLocationSupplied' => 'FTTx Location mit Leerrohr versorgt',
|
||||
'conduitToHuepLaid' => 'Leerrohr bis HÜP/HAK verlegt',
|
||||
'huepMounted' => 'HÜP/HAK montiert',
|
||||
'dropCableAvailable' => 'Dropkabel vorhanden',
|
||||
'spliceCompleted' => 'Spleiß abgeschlossen'
|
||||
];
|
||||
|
||||
$updateHausnummerStatus = false;
|
||||
|
||||
foreach ($checkboxFields as $field => $fieldLabel) {
|
||||
if (array_key_exists($field, $post)) {
|
||||
$oldValue = $workorder->$field;
|
||||
$newValue = $post[$field] ? 1 : 0;
|
||||
if ($oldValue !== $newValue) {
|
||||
$workorder->$field = $newValue;
|
||||
// Only log changes where newValue is 'ja' or oldValue was 'ja' (changing from yes to no)
|
||||
if ($newValue === 1 || $oldValue === 1) {
|
||||
$changes[] = "$fieldLabel: " . ($newValue ? 'ja' : 'nein');
|
||||
}
|
||||
|
||||
// Check for FTTx Location mit Leerrohr versorgt
|
||||
if ($field === 'fttxLocationSupplied' && $newValue === 1) {
|
||||
$updateHausnummerStatus = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($changes)) {
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
if ($updateHausnummerStatus) {
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
// Find status ID for code 200
|
||||
$statusSql = "SELECT id FROM Status WHERE code = 200 AND type = 'hausnummer' LIMIT 1";
|
||||
$statusResult = $db->query($statusSql);
|
||||
if ($statusResult && $row = $statusResult->fetch_assoc()) {
|
||||
$statusId = $row['id'];
|
||||
$hnId = $db->escape($workorder->hausnummerId);
|
||||
$updateHnSql = "UPDATE Hausnummer SET status_id = $statusId WHERE id = $hnId";
|
||||
$db->query($updateHnSql);
|
||||
}
|
||||
}
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => "Dokumentation aktualisiert:\n" . implode("\n", $changes),
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Dokumentation aktualisiert.']);
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
class WorkorderMphCompanyController extends WorkorderMphBaseController
|
||||
{
|
||||
protected string $headerTitle = 'Meine MPH Arbeitsaufträge';
|
||||
protected bool $createText = false;
|
||||
protected array $permissionCheck = ['RMLCompany'];
|
||||
|
||||
protected array $columns = [
|
||||
['key' => 'id', 'text' => 'Auftrags-Nr.', 'table' => ['sortable' => true, 'filter' => 'numberRange']],
|
||||
['key' => 'hausnummerInfo', 'text' => 'Adresse', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]],
|
||||
['key' => 'netzgebietName', 'text' => 'Netzgebiet', 'modal' => false, 'table' => ['filter' => 'select', 'sortable' => false]],
|
||||
['key' => 'rimoFcpName', 'text' => 'FCP', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]],
|
||||
['key' => 'additionalInfo', 'text' => 'Notiz', 'modal' => false, 'table' => ['sortable' => false]],
|
||||
['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]],
|
||||
['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]],
|
||||
];
|
||||
protected array $additionalJSVariables = ['COMPANY_ID' => '0', 'IS_COMPANY_VIEW' => true];
|
||||
|
||||
protected function prepareCrudConfig()
|
||||
{
|
||||
$hausnummerInfoColIdx = array_search('hausnummerInfo', array_column($this->columns, 'key'));
|
||||
array_splice($this->columns, $hausnummerInfoColIdx + 1, 0, [$this->statusColumn]);
|
||||
|
||||
$company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
|
||||
$this->additionalJSVariables['COMPANY_ID'] = $company ? $company->id : 0;
|
||||
|
||||
// Populate netzgebiet filter options for this company's workorders
|
||||
$netzgebietColIdx = array_search('netzgebietName', array_column($this->columns, 'key'));
|
||||
if ($netzgebietColIdx !== false && $company) {
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
$fronkDbName = FRONKDB_DBNAME;
|
||||
|
||||
$sql = "SELECT DISTINCT ng.id, ng.name FROM Netzgebiet ng
|
||||
INNER JOIN Hausnummer hn ON ng.id = hn.netzgebiet_id
|
||||
INNER JOIN `$fronkDbName`.`WorkorderMph` wm ON wm.hausnummerId = hn.id
|
||||
WHERE ng.name IS NOT NULL AND ng.name != '' AND wm.companyId = " . intval($company->id) . "
|
||||
ORDER BY ng.name ASC";
|
||||
$result = $db->query($sql);
|
||||
$netzgebiete = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
$this->columns[$netzgebietColIdx]['table']['filterOptions'] = array_map(fn($ng) => ['value' => $ng['id'], 'text' => $ng['name']], $netzgebiete);
|
||||
}
|
||||
}
|
||||
|
||||
protected function getAction()
|
||||
{
|
||||
$pagination = $this->postData['pagination'] ?? ['page' => 1, 'per_page' => 10];
|
||||
$filters = $this->postData['filters'] ?? [];
|
||||
$order = $this->postData['order'] ?? [];
|
||||
|
||||
$company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
|
||||
if (!$company) {
|
||||
self::returnJson(['rows' => [], 'pagination' => array_merge($pagination, ['total_rows' => 0, 'total_pages' => 0, 'filtered_available' => 0])]);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$fronkDbName = FRONKDB_DBNAME;
|
||||
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
|
||||
|
||||
$whereClauses = "WHERE w.companyId = " . intval($company->id);
|
||||
|
||||
if (empty($filters['status'])) {
|
||||
$whereClauses .= " AND w.status NOT IN ('completed', 'cancelled', 'archived')";
|
||||
} else {
|
||||
$whereClauses .= Helper::generateFilterCondition($filters['status'], 'w.status', true);
|
||||
}
|
||||
|
||||
if (!empty($filters['id'])) $whereClauses .= Helper::generateFilterCondition($filters['id'], 'w.id', true);
|
||||
if (!empty($filters['hausnummerInfo'])) {
|
||||
$searchColumns = "str.name|hn.hausnummer|hn.stiege|plz.plz|ort.name|w.additionalInfo";
|
||||
$whereClauses .= Helper::generateFilterCondition($filters['hausnummerInfo'], $searchColumns);
|
||||
}
|
||||
if (!empty($filters['netzgebietName'])) $whereClauses .= Helper::generateFilterCondition($filters['netzgebietName'], 'ng.id');
|
||||
if (!empty($filters['rimoFcpName'])) $whereClauses .= Helper::generateFilterCondition($filters['rimoFcpName'], 'hn.rimo_fcp_name');
|
||||
if (!empty($filters['deadlineDate'])) $whereClauses .= Helper::generateFilterCondition($filters['deadlineDate'], 'w.deadlineDate');
|
||||
if (!empty($filters['appointmentDate'])) $whereClauses .= Helper::generateFilterCondition($filters['appointmentDate'], 'w.appointmentDate');
|
||||
if (!empty($filters['additionalInfo'])) $whereClauses .= Helper::generateFilterCondition($filters['additionalInfo'], 'w.additionalInfo');
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
w.id, w.status, w.deadlineDate, w.appointmentDate, w.additionalInfo,
|
||||
CONCAT_WS(' ', str.name, hn.hausnummer, hn.stiege) as hausnummerInfo,
|
||||
str.name as street, hn.hausnummer, hn.stiege, plz.plz, ort.name as city,
|
||||
ng.id as netzgebietName,
|
||||
hn.rimo_fcp_name as rimoFcpName,
|
||||
(SELECT COUNT(*) FROM `$addressDbName`.`Wohneinheit` we WHERE we.hausnummer_id = hn.id) as wohneinheitCount
|
||||
FROM `$fronkDbName`.`WorkorderMph` w
|
||||
LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = hn.id
|
||||
LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id
|
||||
LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id
|
||||
LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
|
||||
LEFT JOIN `$addressDbName`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id
|
||||
$whereClauses
|
||||
";
|
||||
|
||||
$orderBy = "";
|
||||
if (!empty($order['key'])) {
|
||||
$sortableColumns = ['id', 'status', 'deadlineDate', 'appointmentDate'];
|
||||
if (in_array($order['key'], $sortableColumns)) {
|
||||
$sortOrder = (strtoupper($order['order']) === 'DESC') ? 'DESC' : 'ASC';
|
||||
$orderBy = " ORDER BY " . $db->escape($order['key']) . " " . $sortOrder;
|
||||
}
|
||||
}
|
||||
if (empty($orderBy)) $orderBy = " ORDER BY CASE WHEN w.deadlineDate IS NULL THEN 1 ELSE 0 END, w.deadlineDate ASC";
|
||||
|
||||
$sql .= $orderBy;
|
||||
|
||||
// Get total count
|
||||
$countSql = "SELECT COUNT(*) as count FROM `$fronkDbName`.`WorkorderMph` w
|
||||
LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = hn.id
|
||||
LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id
|
||||
LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id
|
||||
LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
|
||||
LEFT JOIN `$addressDbName`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id
|
||||
$whereClauses";
|
||||
$totalCount = (int)$db->query($countSql)->fetch_assoc()['count'];
|
||||
|
||||
// Add pagination
|
||||
if ($pagination['per_page'] !== null) {
|
||||
$sql .= " LIMIT " . intval($pagination['per_page']) . " OFFSET " . intval(($pagination['page'] - 1) * $pagination['per_page']);
|
||||
}
|
||||
|
||||
$result = $db->query($sql);
|
||||
$rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
|
||||
self::returnJson([
|
||||
'rows' => $rows,
|
||||
'pagination' => [
|
||||
'page' => (int)$pagination['page'],
|
||||
'per_page' => (int)$pagination['per_page'],
|
||||
'total_rows' => $totalCount,
|
||||
'total_pages' => (int)ceil($totalCount / $pagination['per_page']),
|
||||
'filtered_available' => $totalCount
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function getWorkorderByIdAction()
|
||||
{
|
||||
if (empty($this->request->id)) self::sendError("ID fehlt");
|
||||
$workorder = WorkorderMphModel::get($this->request->id);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden");
|
||||
self::returnJson((array)$workorder);
|
||||
}
|
||||
|
||||
protected function scheduleAppointmentAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId']) || empty($this->postData['appointmentDate'])) self::sendError("Erforderliche Felder fehlen.");
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden");
|
||||
if ((int)date('H', $this->postData['appointmentDate']) >= 23 || (int)date('H', $this->postData['appointmentDate']) < 1) self::sendError("Bitte geben Sie eine Uhrzeit an!");
|
||||
|
||||
$oldStatus = $workorder->status;
|
||||
$workorder->appointmentDate = $this->postData['appointmentDate'];
|
||||
$workorder->status = 'scheduled';
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => 'Termin festgelegt auf: ' . date('d.m.Y H:i', $this->postData['appointmentDate']),
|
||||
'statusChange' => $oldStatus !== 'scheduled' ? $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('scheduled') : null,
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gespeichert.']);
|
||||
}
|
||||
|
||||
protected function rescheduleAppointmentAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId']) || empty($this->postData['appointmentDate']) || empty($this->postData['reason'])) self::sendError("Erforderliche Felder fehlen.");
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
if ((int)date('H', $this->postData['appointmentDate']) >= 23 || (int)date('H', $this->postData['appointmentDate']) < 1) self::sendError("Bitte geben Sie eine Uhrzeit an!");
|
||||
|
||||
$oldDateFormatted = $workorder->appointmentDate ? date('d.m.Y H:i', $workorder->appointmentDate) : 'N/A';
|
||||
$newDateFormatted = date('d.m.Y H:i', $this->postData['appointmentDate']);
|
||||
$workorder->appointmentDate = $this->postData['appointmentDate'];
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => "Termin verschoben von {$oldDateFormatted} auf {$newDateFormatted}. Grund: " . $this->postData['reason'],
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich verschoben.']);
|
||||
}
|
||||
|
||||
protected function startWorkAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
$oldStatus = $workorder->status;
|
||||
$workorder->status = 'in_progress';
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => 'Arbeit begonnen.',
|
||||
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('in_progress'),
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
self::returnJson(['success' => true, 'message' => 'Arbeit wurde gestartet.']);
|
||||
}
|
||||
|
||||
protected function completeWorkorderAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
$oldStatus = $workorder->status;
|
||||
$workorder->status = 'documented';
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => 'Arbeitsauftrag abgeschlossen und dokumentiert.',
|
||||
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('documented'),
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag erfolgreich abgeschlossen.']);
|
||||
}
|
||||
|
||||
protected function uploadDocumentationAction()
|
||||
{
|
||||
if (empty($_FILES['file']) || empty($_POST['workorderMphId'])) self::sendError("Datei und Arbeitsauftrags-ID sind erforderlich.");
|
||||
|
||||
$workorderMphId = intval($_POST['workorderMphId']);
|
||||
$workorder = WorkorderMphModel::get($workorderMphId);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
$documentType = $_POST['documentType'] ?? 'photo';
|
||||
$description = $_POST['description'] ?? null;
|
||||
|
||||
// Upload file using mfUpload
|
||||
$upload = new mfUpload($_FILES['file']);
|
||||
if (!$upload->upload()) {
|
||||
self::sendError("Datei-Upload fehlgeschlagen.");
|
||||
}
|
||||
|
||||
$file = $upload->getFile();
|
||||
|
||||
WorkorderMphDocumentationModel::create([
|
||||
'workorderMphId' => $workorderMphId,
|
||||
'fileId' => $file->id,
|
||||
'description' => $description,
|
||||
'documentType' => $documentType,
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id
|
||||
]);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Dokument erfolgreich hochgeladen.', 'fileId' => $file->id]);
|
||||
}
|
||||
|
||||
protected function deleteDocumentationAction()
|
||||
{
|
||||
if (empty($this->postData['documentationId'])) self::sendError("Dokumentations-ID fehlt.");
|
||||
|
||||
$doc = WorkorderMphDocumentationModel::get($this->postData['documentationId']);
|
||||
if (!$doc) self::sendError("Dokumentation nicht gefunden.");
|
||||
|
||||
WorkorderMphDocumentationModel::delete($doc->id);
|
||||
self::returnJson(['success' => true, 'message' => 'Dokumentation gelöscht.']);
|
||||
}
|
||||
|
||||
protected function updateAdditionalInfoAction()
|
||||
{
|
||||
if (empty($this->postData['workorderMphId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderMphId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
// Verify company access
|
||||
$company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
|
||||
if (!$company || $workorder->companyId != $company->id) {
|
||||
self::sendError("Keine Berechtigung für diesen Arbeitsauftrag.");
|
||||
}
|
||||
|
||||
$oldInfo = $workorder->additionalInfo;
|
||||
$newInfo = $this->postData['additionalInfo'] ?? '';
|
||||
$workorder->additionalInfo = $newInfo;
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
if ($oldInfo !== $newInfo) {
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => "Notiz geändert: " . ($newInfo ?: '(leer)'),
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Notiz aktualisiert.', 'newInfo' => $newInfo]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
class WorkorderMphDocumentationModel extends TTCrudBaseModel
|
||||
{
|
||||
public int $id;
|
||||
public int $workorderMphId;
|
||||
public int $fileId;
|
||||
public ?string $description;
|
||||
public string $documentType;
|
||||
public int $create;
|
||||
public int $createBy;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
class WorkorderMphJournalModel extends TTCrudBaseModel
|
||||
{
|
||||
public int $id;
|
||||
public int $workorderMphId;
|
||||
public ?string $text;
|
||||
public ?string $fileIds;
|
||||
public ?string $statusChange;
|
||||
public int $create;
|
||||
public int $createBy;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ class WorkorderTenantConfigModel extends TTCrudBaseModel {
|
||||
public int $civilEngineeringDocsRequired;
|
||||
public int $requireCableLength;
|
||||
public int $requireCableType;
|
||||
public int $enableWorkorder;
|
||||
public int $enableWorkorderMph;
|
||||
public int $create;
|
||||
public int $createBy;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user