mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-09-13 10:14:57 +00:00
Compare commits
6 Commits
389-displa
...
refactor_a
Author | SHA1 | Date | |
---|---|---|---|
724e6c7365 | |||
a8f8c23027 | |||
cb7e2d752e | |||
1019a7bcd4 | |||
9b0ae7198a | |||
30e7009178 |
6
.changes/unreleased/Feature-20250211-142243.yaml
Normal file
6
.changes/unreleased/Feature-20250211-142243.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
kind: Feature
|
||||||
|
body: Allow the merge of two accompanying period works
|
||||||
|
time: 2025-02-11T14:22:43.134106669+01:00
|
||||||
|
custom:
|
||||||
|
Issue: "359"
|
||||||
|
SchemaChange: No schema change
|
@@ -203,3 +203,5 @@ export interface WorkflowAttachment {
|
|||||||
updatedBy: User | null;
|
updatedBy: User | null;
|
||||||
genericDoc: null | GenericDoc;
|
genericDoc: null | GenericDoc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PrivateCommentEmbeddable = any;
|
||||||
|
@@ -7,6 +7,7 @@ import {
|
|||||||
WorkflowAvailable,
|
WorkflowAvailable,
|
||||||
} from "../../../ChillMainBundle/Resources/public/types";
|
} from "../../../ChillMainBundle/Resources/public/types";
|
||||||
import { StoredObject } from "../../../ChillDocStoreBundle/Resources/public/types";
|
import { StoredObject } from "../../../ChillDocStoreBundle/Resources/public/types";
|
||||||
|
import {Thirdparty} from "../../../ChillThirdPartyBundle/Resources/public/types";
|
||||||
|
|
||||||
export interface Person {
|
export interface Person {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -41,3 +42,51 @@ export interface AccompanyingPeriodWorkEvaluationDocument {
|
|||||||
workflows_availables: WorkflowAvailable[];
|
workflows_availables: WorkflowAvailable[];
|
||||||
workflows: object[];
|
workflows: object[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AccompanyingPeriodWork {
|
||||||
|
id?: number;
|
||||||
|
accompanyingPeriod?: AccompanyingPeriod;
|
||||||
|
accompanyingPeriodWorkEvaluations: AccompanyingPeriodWorkEvaluation[];
|
||||||
|
createdAt?: string;
|
||||||
|
createdAutomatically: boolean;
|
||||||
|
createdAutomaticallyReason: string;
|
||||||
|
createdBy: User;
|
||||||
|
endDate?: string;
|
||||||
|
goals: AccompanyingPeriodWorkGoal[];
|
||||||
|
handlingThierParty?: Thirdparty;
|
||||||
|
note: string;
|
||||||
|
persons: Person[];
|
||||||
|
privateComment: PrivateCommentEmbeddable;
|
||||||
|
referrersHistory: AccompanyingPeriodWorkReferrerHistory[];
|
||||||
|
results: Result[];
|
||||||
|
socialAction?: SocialAction;
|
||||||
|
startDate?: string;
|
||||||
|
thirdParties: Thirdparty[];
|
||||||
|
updatedAt?: string;
|
||||||
|
updatedBy: User;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SocialAction {
|
||||||
|
id?: number;
|
||||||
|
parent?: SocialAction | null;
|
||||||
|
children: SocialAction[];
|
||||||
|
issue?: SocialIssue | null;
|
||||||
|
ordering: number;
|
||||||
|
title: Record<string, string>;
|
||||||
|
defaultNotificationDelay?: string | null;
|
||||||
|
desactivationDate?: string | null;
|
||||||
|
evaluations: Evaluation[];
|
||||||
|
goals: Goal[];
|
||||||
|
results: Result[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type SocialIssue = any;
|
||||||
|
type Goal = any;
|
||||||
|
type Result = any;
|
||||||
|
type Evaluation = any;
|
||||||
|
type AccompanyingPeriod = any;
|
||||||
|
type AccompanyingPeriodWorkEvaluation = any;
|
||||||
|
type AccompanyingPeriodWorkGoal = any;
|
||||||
|
type PrivateCommentEmbeddable = any;
|
||||||
|
type AccompanyingPeriodWorkReferrerHistory = any;
|
||||||
|
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2>Add {{ entityType }}</h2>
|
||||||
|
|
||||||
|
<label for="entityType">Select type:</label>
|
||||||
|
<select v-model="entityType">
|
||||||
|
<option value="person">Person</option>
|
||||||
|
<option value="thirdparty">Thirdparty</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<SearchEntity :entityType="entityType" @entity-selected="handleEntitySelection" />
|
||||||
|
|
||||||
|
<PersonDetails v-if="entityType === 'person' && selectedEntity" :person="selectedEntity" />
|
||||||
|
<ThirdpartyDetails v-if="entityType === 'thirdparty' && selectedEntity" :thirdparty="selectedEntity" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapState, mapMutations } from 'vuex';
|
||||||
|
import SearchEntity from '@/components/SearchEntity.vue';
|
||||||
|
import PersonDetails from '@/components/PersonDetails.vue';
|
||||||
|
import ThirdpartyDetails from '@/components/ThirdpartyDetails.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
SearchEntity,
|
||||||
|
PersonDetails,
|
||||||
|
ThirdpartyDetails
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
entityType: 'person'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState('entities', ['selectedEntity'])
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapMutations('entities', ['SET_SELECTED_ENTITY']),
|
||||||
|
handleEntitySelection(entity) {
|
||||||
|
this.SET_SELECTED_ENTITY(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
select {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
@@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="person">
|
||||||
|
<h2>{{ person.name }}</h2>
|
||||||
|
<p><strong>Email:</strong> {{ person.email }}</p>
|
||||||
|
<p><strong>Phone:</strong> {{ person.phone }}</p>
|
||||||
|
<div v-if="person.household">
|
||||||
|
<h3>Household</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="member in person.household" :key="member.id">{{ member.name }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const person = computed(() => store.state.selectedEntity);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
h2 { margin-bottom: 8px; }
|
||||||
|
</style>
|
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label for="search">Search {{ entityType }}:</label>
|
||||||
|
<input
|
||||||
|
id="search"
|
||||||
|
v-model="query"
|
||||||
|
@input="debouncedSearch"
|
||||||
|
placeholder="Type to search..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ul v-if="results.length">
|
||||||
|
<li v-for="result in results" :key="result.id" @click="selectEntity(result)">
|
||||||
|
{{ result.name }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import debounce from 'lodash.debounce';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
entityType: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
validator: (value) => ['Person', 'Thirdparty'].includes(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const query = ref('');
|
||||||
|
const results = computed(() => store.state.entity.searchResults);
|
||||||
|
|
||||||
|
const searchEntities = () => {
|
||||||
|
if (query.value.trim().length > 2) {
|
||||||
|
store.dispatch('entity/searchEntities', { type: props.entityType, query: query.value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedSearch = debounce(searchEntities, 300);
|
||||||
|
|
||||||
|
const selectEntity = (entity) => {
|
||||||
|
store.dispatch('entity/selectEntity', entity);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
li:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
</style>
|
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import store from './store/entities';
|
||||||
|
import AddEntity from './components/AddEntity.vue';
|
||||||
|
|
||||||
|
const app = createApp(AddEntity);
|
||||||
|
app.use(store);
|
||||||
|
app.mount('#add-entity-app');
|
@@ -0,0 +1,37 @@
|
|||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state: {
|
||||||
|
selectedEntity: null,
|
||||||
|
persons: [],
|
||||||
|
thirdparties: [],
|
||||||
|
accompanyingPeriodWorks: []
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
SET_SELECTED_ENTITY(state, entity) {
|
||||||
|
state.selectedEntity = entity;
|
||||||
|
},
|
||||||
|
SET_PERSONS(state, persons) {
|
||||||
|
state.persons = persons;
|
||||||
|
},
|
||||||
|
SET_THIRDPARTIES(state, thirdparties) {
|
||||||
|
state.thirdparties = thirdparties;
|
||||||
|
},
|
||||||
|
SET_ACCOMPANYING_PERIOD_WORKS(state, works) {
|
||||||
|
state.accompanyingPeriodWorks = works;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
async fetchPersons({ commit }, query) {
|
||||||
|
const response = await api.get(`/persons?search=${query}`);
|
||||||
|
commit('SET_PERSONS', response.data);
|
||||||
|
},
|
||||||
|
async fetchThirdparties({ commit }, query) {
|
||||||
|
const response = await api.get(`/thirdparties?search=${query}`);
|
||||||
|
commit('SET_THIRDPARTIES', response.data);
|
||||||
|
},
|
||||||
|
async fetchAccompanyingPeriodWorks({ commit }, personId) {
|
||||||
|
const response = await api.get(`/persons/${personId}/accompanying-period-works`);
|
||||||
|
commit('SET_ACCOMPANYING_PERIOD_WORKS', response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\PersonBundle\Service\AccompanyingPeriodWork;
|
||||||
|
|
||||||
|
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork;
|
||||||
|
use Doctrine\DBAL\Exception;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||||
|
|
||||||
|
class AccompanyingPeriodWorkMergeService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly EntityManagerInterface $em) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function merge(AccompanyingPeriodWork $toKeep, AccompanyingPeriodWork $toDelete): void
|
||||||
|
{
|
||||||
|
// Transfer non-duplicate data
|
||||||
|
$this->transferData($toKeep, $toDelete);
|
||||||
|
|
||||||
|
// Update linked entities
|
||||||
|
$this->updateReferences($toKeep, $toDelete);
|
||||||
|
|
||||||
|
$this->em->remove($toDelete);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function transferData(AccompanyingPeriodWork $toKeep, AccompanyingPeriodWork $toDelete): void
|
||||||
|
{
|
||||||
|
$conn = $this->em->getConnection();
|
||||||
|
|
||||||
|
$sqlStatements = [
|
||||||
|
$this->generateStartDateSQL(),
|
||||||
|
$this->generateEndDateSQL(),
|
||||||
|
$this->generateCommentSQL(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$conn->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach ($sqlStatements as $sql) {
|
||||||
|
$conn->executeQuery($sql, ['toDelete' => $toDelete->getId(), 'toKeep' => $toKeep->getId()]);
|
||||||
|
}
|
||||||
|
$conn->commit();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$conn->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateStartDateSQL(): string
|
||||||
|
{
|
||||||
|
return '
|
||||||
|
UPDATE chill_person_accompanying_period_work
|
||||||
|
SET startdate = LEAST(
|
||||||
|
COALESCE((SELECT startdate FROM chill_person_accompanying_period_work WHERE id = :toDelete), startdate),
|
||||||
|
startdate
|
||||||
|
)
|
||||||
|
WHERE id = :toKeep';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateEndDateSQL(): string
|
||||||
|
{
|
||||||
|
return '
|
||||||
|
UPDATE chill_person_accompanying_period_work
|
||||||
|
SET enddate =
|
||||||
|
CASE
|
||||||
|
WHEN (SELECT enddate FROM chill_person_accompanying_period_work WHERE id = :toDelete) IS NULL
|
||||||
|
OR enddate IS NULL
|
||||||
|
THEN NULL
|
||||||
|
ELSE GREATEST(
|
||||||
|
COALESCE((SELECT enddate FROM chill_person_accompanying_period_work WHERE id = :toDelete), enddate),
|
||||||
|
enddate
|
||||||
|
)
|
||||||
|
END
|
||||||
|
WHERE id = :toKeep';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateCommentSQL(): string
|
||||||
|
{
|
||||||
|
return "
|
||||||
|
UPDATE chill_person_accompanying_period_work
|
||||||
|
SET note = CONCAT_WS(
|
||||||
|
'\n',
|
||||||
|
NULLIF(TRIM(note), ''),
|
||||||
|
NULLIF(TRIM((SELECT note FROM chill_person_accompanying_period_work WHERE id = :toDelete)), '')
|
||||||
|
)
|
||||||
|
WHERE id = :toKeep";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function updateReferences(AccompanyingPeriodWork $toKeep, AccompanyingPeriodWork $toDelete): void
|
||||||
|
{
|
||||||
|
$allMeta = $this->em->getMetadataFactory()->getAllMetadata();
|
||||||
|
$conn = $this->em->getConnection();
|
||||||
|
$sqlStatements = [];
|
||||||
|
|
||||||
|
foreach ($allMeta as $meta) {
|
||||||
|
if ($meta->isMappedSuperclass) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($meta->getAssociationMappings() as $assoc) {
|
||||||
|
if (AccompanyingPeriodWork::class !== $assoc['targetEntity']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($assoc['type'] & ClassMetadata::TO_ONE) {
|
||||||
|
if ('handlingThirdparty' === $assoc['fieldName']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$sqlStatements[] = $this->generateToOneUpdateQuery($meta, $assoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($assoc['type'] & ClassMetadata::TO_MANY) {
|
||||||
|
if (!isset($assoc['joinTable'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$sqlStatements = array_merge($sqlStatements, $this->generateToManyUpdateQueries($assoc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn->beginTransaction();
|
||||||
|
try {
|
||||||
|
foreach ($sqlStatements as $sql) {
|
||||||
|
$conn->executeStatement($sql, ['toDelete' => $toDelete->getId(), 'toKeep' => $toKeep->getId()]);
|
||||||
|
}
|
||||||
|
$conn->commit();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$conn->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateToOneUpdateQuery(ClassMetadata $meta, array $assoc): string
|
||||||
|
{
|
||||||
|
$tableName = $meta->getTableName();
|
||||||
|
$joinColumn = $meta->getSingleAssociationJoinColumnName($assoc['fieldName']);
|
||||||
|
|
||||||
|
return "UPDATE {$tableName} SET {$joinColumn} = :toKeep WHERE {$joinColumn} = :toDelete";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateToManyUpdateQueries(array $assoc): array
|
||||||
|
{
|
||||||
|
$sqls = [];
|
||||||
|
$joinTable = $assoc['joinTable']['name'];
|
||||||
|
$owningColumn = $assoc['joinTable']['joinColumns'][0]['name'];
|
||||||
|
$inverseColumn = $assoc['joinTable']['inverseJoinColumns'][0]['name'];
|
||||||
|
|
||||||
|
// Insert relations, skip already existing ones
|
||||||
|
$sqls[] = "INSERT IGNORE INTO {$joinTable} ({$owningColumn}, {$inverseColumn})
|
||||||
|
SELECT :toKeep, {$inverseColumn} FROM {$joinTable} WHERE {$owningColumn} = :toDelete";
|
||||||
|
// Delete old references
|
||||||
|
$sqls[] = "DELETE FROM {$joinTable} WHERE {$owningColumn} = :toDelete";
|
||||||
|
|
||||||
|
return $sqls;
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1 @@
|
|||||||
|
export type Thirdparty = any;
|
@@ -0,0 +1 @@
|
|||||||
|
export type ThirdParty = any;
|
Reference in New Issue
Block a user