mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-11-03 18:58:24 +00:00 
			
		
		
		
	Compare commits
	
		
			6 Commits
		
	
	
		
			move-docum
			...
			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;
 | 
			
		||||
    genericDoc: null | GenericDoc;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type PrivateCommentEmbeddable = any;
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ import {
 | 
			
		||||
  WorkflowAvailable,
 | 
			
		||||
} from "../../../ChillMainBundle/Resources/public/types";
 | 
			
		||||
import { StoredObject } from "../../../ChillDocStoreBundle/Resources/public/types";
 | 
			
		||||
import {Thirdparty} from "../../../ChillThirdPartyBundle/Resources/public/types";
 | 
			
		||||
 | 
			
		||||
export interface Person {
 | 
			
		||||
  id: number;
 | 
			
		||||
@@ -41,3 +42,51 @@ export interface AccompanyingPeriodWorkEvaluationDocument {
 | 
			
		||||
  workflows_availables: WorkflowAvailable[];
 | 
			
		||||
  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