Compare commits

...

18 Commits

Author SHA1 Message Date
559901e528 Update chill bundles version to v3.5.0 2024-12-09 11:35:16 +01:00
05bc69fd33 Fix display of gender label
Label already localized in serialization so not to be accessed as label.fr within vue component
2024-12-09 11:32:28 +01:00
49da62d364 Merge branch 'signature-state-change-wrapped-into-transaction' into 'master'
Refactor transaction handling for signature state changes, to wrap them into transactions

See merge request Chill-Projet/chill-bundles!771
2024-12-06 11:43:08 +00:00
3af7824d01 Refactor transaction handling for signature state changes, to wrap them into transactions
Wrap signature state changes in transactions to prevent race conditions and ensure data integrity. Update controller and test class names to reflect broader state change capabilities. Enhance documentation with comments to clarify transaction requirements and procedure details for signature operations.
2024-12-06 12:37:17 +01:00
033053c437 Restrict the version of amqp-messenger 2024-12-06 12:37:16 +01:00
633bb00154 fix typing of Datetime 2024-12-06 12:37:16 +01:00
f5c1b5cf8a Merge branch '318-signature-vue-app-show-full-doc' into 'master'
Resolve "[App de signature] Pouvoir voir le document en continu (toutes les pages ensemble)"

Closes #318

See merge request Chill-Projet/chill-bundles!761
2024-12-05 16:31:04 +00:00
ccd71da4e4 Add changie 2024-12-05 17:25:08 +01:00
1eadb3bbdb add missing configuration for chill_workflow_signature_documents.yaml 2024-12-05 17:16:06 +01:00
nobohan
0bb5a79cae FEATURE signature: show full pages - show next signature button even if one signature 2024-12-05 17:16:06 +01:00
nobohan
bd3198e42b FEATURE signature: show full pages - UI and turn pages 2024-12-05 17:16:05 +01:00
nobohan
96dfddc55f FEATURE signature: show full pages - can add zone + fix hitSignature is canvas-aware 2024-12-05 17:16:05 +01:00
nobohan
da37a3db5f FEATURE signature: show full pages - can select zone 2024-12-05 17:16:04 +01:00
nobohan
c2882b1079 FEATURE signature: show full pages - WIP 2024-12-05 17:16:01 +01:00
b9e515f4e6 Merge branch '317-add-zone-bug-with-zoom' into 'master'
Resolve "Lorsqu'on place manuellement une zone de signature avec le zoom actif, la zone n'est pas placée correctement sur la page"

Closes #317

See merge request Chill-Projet/chill-bundles!757
2024-12-05 15:49:38 +00:00
nobohan
df2ea7e1ba FIXED adding zone in signature vue app was not placed correctly when zooming 2024-12-05 16:41:51 +01:00
nobohan
d59cda9cc4 DX format signature vue app 2024-12-05 16:41:51 +01:00
7a98bb5a06 release v3.4.3 2024-12-05 15:44:51 +01:00
14 changed files with 345 additions and 127 deletions

4
.changes/v3.4.3.md Normal file
View File

@@ -0,0 +1,4 @@
## v3.4.3 - 2024-12-05
### Fixed
* Remove the "not null" constraint on person supplementary phones
* Remove doctrine annotation that prevent from adding documents to activities

6
.changes/v3.5.0.md Normal file
View File

@@ -0,0 +1,6 @@
## v3.5.0 - 2024-12-09
### Feature
* ([#318](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/318)) Show all the pages of the documents in the signature app
### Fixed
* Wrap the signature's change state into a transaction, to avoid race conditions
* Fix display of gender label

View File

@@ -6,9 +6,17 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v3.5.0 - 2024-12-09
### Feature
* ([#318](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/318)) Show all the pages of the documents in the signature app
### Fixed
* Wrap the signature's change state into a transaction, to avoid race conditions
* Fix display of gender label
## v3.4.3 - 2024-12-05
### Fixed
* Remove the "not null" constraint on person supplementary phones
* Remove the "not null" constraint on person supplementary phones
* Remove doctrine annotation that prevent from adding documents to activities
## v3.4.2 - 2024-12-05
### Fixed

View File

@@ -95,6 +95,7 @@
"phpstan/phpstan-strict-rules": "^1.0",
"phpunit/phpunit": "^10.5.24",
"rector/rector": "^1.1.0",
"symfony/amqp-messenger": "^5.4.45",
"symfony/debug-bundle": "^5.4",
"symfony/dotenv": "^5.4",
"symfony/flex": "^2.4",

View File

@@ -0,0 +1,11 @@
chill_main:
workflow_signature:
base_signer:
document_kinds:
- { key: id_card, labels: [ { lang: fr, label: "Carte d'identité" } ] }
- { key: passport, labels: [ { lang: fr, label: "Passeport" } ] }
- { key: drivers_license, labels: [ { lang: fr, label: "Permis de conduire" } ] }
- { key: visa_long_stay, labels: [ { lang: fr, label: "Visa de long séjour" } ] }
- { key: resident_permit, labels: [ { lang: fr, label: "Carte de séjour" } ] }
- { key: residency_card, labels: [ { lang: fr, label: "Carte de résident" } ] }
- { key: provisionary_residency_permit, labels: [ { lang: fr, label: "Autorisation provisoire de séjour" } ] }

View File

@@ -18,8 +18,6 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use Monolog\DateTimeImmutable;
/**
* * Lundi => Envoi des rdv du mardi et mercredi.
* * Mardi => Envoi des rdv du jeudi.
@@ -31,7 +29,7 @@ class DefaultRangeGenerator implements RangeGeneratorInterface
{
public function generateRange(\DateTimeImmutable $date): ?array
{
$onMidnight = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date->format('Y-m-d').' 00:00:00');
$onMidnight = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date->format('Y-m-d').' 00:00:00');
switch ($dow = (int) $onMidnight->format('w')) {
case 6: // Saturday

View File

@@ -26,9 +26,9 @@
</template>
</modal>
</teleport>
<div class="col-12 m-auto">
<div class="col-12 m-auto sticky-top">
<div class="row justify-content-center border-bottom pdf-tools d-md-none">
<div class="col text-center turn-page">
<div class="col-5 text-center turn-page">
<select
class="form-select form-select-sm"
id="zoomSelect"
@@ -40,26 +40,52 @@
{{ z.label.fr }}
</option>
</select>
<template v-if="pageCount > 1">
<button
class="btn btn-light btn-xs p-1"
:disabled="page <= 1"
@click="turnPage(-1)"
>
</button>
<span>{{ page }}/{{ pageCount }}</span>
<button
class="btn btn-light btn-xs p-1"
:disabled="page >= pageCount"
@click="turnPage(1)"
>
</button>
</template>
<template v-if="pageCount > 1">
<button
class="btn btn-light btn-xs p-1"
:disabled="page <= 1"
@click="turnPage(-1)"
>
</button>
<span>{{ page }}/{{ pageCount }}</span>
<button
class="btn btn-light btn-xs p-1"
:disabled="page >= pageCount"
@click="turnPage(1)"
>
</button>
</template>
<template v-if="pageCount > 1">
<button
class="btn btn-light btn-xs p-1"
:disabled="page <= 1"
@click="turnPage(-1)"
>
</button>
<span>{{ page }}/{{ pageCount }}</span>
<button
class="btn btn-light btn-xs p-1"
:disabled="page >= pageCount"
@click="turnPage(1)"
>
</button>
<input
type="checkbox"
id="checkboxMulti"
v-model="multiPage"
@change="toggleMultiPage"
/>
<label class="form-check-label" for="checkboxMulti">
{{ $t("all_pages") }}
</label>
</template>
</div>
<div
v-if="signature.zones.length > 1"
v-if="signature.zones.length > 0"
class="col-5 p-0 text-center turnSignature"
>
<button
@@ -96,16 +122,23 @@
>
{{ $t("cancel") }}
</button>
<button v-if="userSignatureZone === null"
:class="{ btn: true, 'btn-sm': true, 'btn-create': canvasEvent !== 'add', 'btn-chill-green': canvasEvent === 'add', active: canvasEvent === 'add' }"
<button
v-if="userSignatureZone === null"
:class="{
btn: true,
'btn-sm': true,
'btn-create': canvasEvent !== 'add',
'btn-chill-green': canvasEvent === 'add',
active: canvasEvent === 'add',
}"
@click="toggleAddZone()"
:title="$t('add_sign_zone')"
>
<template v-if="canvasEvent === 'add'">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</template>
<template v-if="canvasEvent === 'add'">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</template>
</button>
</div>
</div>
@@ -113,7 +146,7 @@
<div
class="row justify-content-center border-bottom pdf-tools d-none d-md-flex"
>
<div class="col-3 text-center turn-page ps-3">
<div class="col-5 text-center turn-page ps-3">
<select
class="form-select form-select-sm"
id="zoomSelect"
@@ -125,26 +158,35 @@
{{ z.label.fr }}
</option>
</select>
<template v-if="pageCount > 1">
<button
class="btn btn-light btn-xs p-1"
:disabled="page <= 1"
@click="turnPage(-1)"
>
</button>
<span>{{ page }} / {{ pageCount }}</span>
<button
class="btn btn-light btn-xs p-1"
:disabled="page >= pageCount"
@click="turnPage(1)"
>
</button>
</template>
<template v-if="pageCount > 1">
<button
class="btn btn-light btn-xs p-1"
:disabled="page <= 1"
@click="turnPage(-1)"
>
</button>
<span>{{ page }} / {{ pageCount }}</span>
<button
class="btn btn-light btn-xs p-1"
:disabled="page >= pageCount"
@click="turnPage(1)"
>
</button>
<input
type="checkbox"
id="checkboxMulti"
v-model="multiPage"
@change="toggleMultiPage"
/>
<label class="form-check-label" for="checkboxMulti">
{{ $t("see_all_pages") }}
</label>
</template>
</div>
<div
v-if="signature.zones.length > 1 && signedState !== 'signed'"
v-if="signature.zones.length > 0 && signedState !== 'signed'"
class="col-4 d-xl-none text-center turnSignature p-0"
>
<button
@@ -164,7 +206,7 @@
</button>
</div>
<div
v-if="signature.zones.length > 1 && signedState !== 'signed'"
v-if="signature.zones.length > 0 && signedState !== 'signed'"
class="col-4 d-none d-xl-flex p-0 text-center turnSignature"
>
<button
@@ -200,28 +242,45 @@
>
{{ $t("cancel") }}
</button>
<button v-if="userSignatureZone === null"
:class="{ btn: true, 'btn-sm': true, 'btn-create': canvasEvent !== 'add', 'btn-chill-green': canvasEvent === 'add', active: canvasEvent === 'add' }"
<button
v-if="userSignatureZone === null"
:class="{
btn: true,
'btn-sm': true,
'btn-create': canvasEvent !== 'add',
'btn-chill-green': canvasEvent === 'add',
active: canvasEvent === 'add',
}"
@click="toggleAddZone()"
:title="$t('add_sign_zone')"
>
<template v-if="canvasEvent !== 'add'">
{{ $t("add_zone") }}
</template>
<template v-else>
{{ $t("click_on_document")}}
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</template>
<template v-if="canvasEvent !== 'add'">
{{ $t("add_zone") }}
</template>
<template v-else>
{{ $t("click_on_document") }}
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</template>
</button>
</div>
</div>
</div>
<div class="col-xs-12 col-md-12 col-lg-9 m-auto my-5 text-center" :class="{onAddZone: canvasEvent === 'add'}">
<canvas class="m-auto" id="canvas" ></canvas>
<div
v-if="multiPage"
class="col-xs-12 col-md-12 col-lg-9 m-auto my-5 text-center d-flex flex-column"
:class="{ onAddZone: canvasEvent === 'add' }"
>
<canvas v-for="p in pageCount" :key="p" :id="`canvas-${p}`"></canvas>
</div>
<div
v-else
class="col-xs-12 col-md-12 col-lg-9 m-auto my-5 text-center"
:class="{ onAddZone: canvasEvent === 'add' }"
>
<canvas class="m-auto" id="canvas"></canvas>
</div>
<div class="col-xs-12 col-md-12 col-lg-9 m-auto p-4" id="action-buttons">
<div class="row">
<div class="col d-flex">
@@ -281,6 +340,7 @@ import {download_and_decrypt_doc, download_doc_as_pdf} from "../StoredObjectButt
pdfjsLib.GlobalWorkerOptions.workerSrc = "pdfjs-dist/build/pdf.worker.mjs";
const multiPage: Ref<boolean> = ref(true);
const modalOpen: Ref<boolean> = ref(false);
const loading: Ref<boolean> = ref(false);
const adding: Ref<boolean> = ref(false);
@@ -347,23 +407,37 @@ const $toast = useToast();
const signature = window.signature;
const setZoomLevel = (zoomLevel: string) => {
const setZoomLevel = async (zoomLevel: string) => {
zoom.value = Number.parseFloat(zoomLevel);
setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200);
await resetPages();
setTimeout(drawAllZones, 200);
};
const mountPdf = async (doc: ArrayBuffer) => {
const loadingTask = pdfjsLib.getDocument(doc);
pdf = await loadingTask.promise;
pageCount.value = pdf.numPages;
await setPage(page.value);
if (multiPage.value) {
await setAllPages();
} else {
await setPage(1);
}
};
const getCanvas = (page: number) =>
multiPage.value
? (document.getElementById(`canvas-${page}`) as HTMLCanvasElement)
: (document.querySelectorAll("canvas")[0] as HTMLCanvasElement);
const getCanvasId = (canvas: HTMLCanvasElement) => {
const number = canvas.id.split("-").pop();
return number ? parseInt(number) : 0;
};
const getRenderContext = (pdfPage: PDFPageProxy) => {
const scale = 1 * zoom.value;
const viewport = pdfPage.getViewport({ scale });
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
const canvas = getCanvas(pdfPage.pageNumber);
const context = canvas.getContext("2d") as CanvasRenderingContext2D;
canvas.height = viewport.height;
canvas.width = viewport.width;
@@ -374,6 +448,9 @@ const getRenderContext = (pdfPage: PDFPageProxy) => {
};
};
const setAllPages = async () =>
Array.from(Array(pageCount.value).keys()).map((p) => setPage(p + 1));
const setPage = async (page: number) => {
const pdfPage = await pdf.getPage(page);
const renderContext = getRenderContext(pdfPage);
@@ -395,10 +472,34 @@ async function downloadAndOpen(): Promise<Blob> {
return raw;
}
const addCanvasEvents = () => {
if (multiPage.value) {
Array.from(Array(pageCount.value).keys()).map((p) => {
const canvas = getCanvas(p + 1);
canvas.addEventListener(
"pointerup",
(e) => canvasClick(e, canvas),
false
);
});
} else {
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
canvas.addEventListener("pointerup", (e) => canvasClick(e, canvas), false);
}
};
const initPdf = () => {
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
canvas.addEventListener("pointerup", canvasClick, false);
setTimeout(() => drawAllZones(page.value), 800);
addCanvasEvents();
setTimeout(drawAllZones, 800);
};
const resetPages = () =>
multiPage.value ? setAllPages() : setPage(page.value);
const toggleMultiPage = async () => {
await resetPages();
setTimeout(drawAllZones, 200);
addCanvasEvents();
};
const scaleXToCanvas = (x: number, canvasWidth: number, PDFWidth: number) =>
@@ -410,35 +511,36 @@ const scaleYToCanvas = (h: number, canvasHeight: number, PDFHeight: number) =>
const hitSignature = (
zone: SignatureZone,
xy: number[],
canvasWidth: number,
canvasHeight: number
canvas: HTMLCanvasElement
) =>
scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width) < xy[0] &&
scaleXToCanvas(zone.x, canvas.width, zone.PDFPage.width) < xy[0] &&
xy[0] <
scaleXToCanvas(zone.x + zone.width, canvasWidth, zone.PDFPage.width) &&
scaleXToCanvas(zone.x + zone.width, canvas.width, zone.PDFPage.width) &&
zone.PDFPage.height * zoom.value -
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) <
scaleYToCanvas(zone.y, canvas.height, zone.PDFPage.height) <
xy[1] &&
xy[1] <
scaleYToCanvas(zone.height - zone.y, canvasHeight, zone.PDFPage.height) +
scaleYToCanvas(zone.height - zone.y, canvas.height, zone.PDFPage.height) +
zone.PDFPage.height * zoom.value;
const selectZone = (z: SignatureZone, canvas: HTMLCanvasElement) => {
const selectZone = async (z: SignatureZone, canvas: HTMLCanvasElement) => {
userSignatureZone.value = z;
const ctx = canvas.getContext("2d");
if (ctx) {
setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200);
await resetPages();
setTimeout(drawAllZones, 200);
}
};
const selectZoneEvent = (e: PointerEvent, canvas: HTMLCanvasElement) =>
signature.zones
.filter((z) => z.PDFPage.index + 1 === page.value)
.filter(
(z) =>
(z.PDFPage.index + 1 === getCanvasId(canvas) && multiPage.value) ||
(z.PDFPage.index + 1 === page.value && !multiPage.value)
)
.map((z) => {
if (
hitSignature(z, [e.offsetX, e.offsetY], canvas.width, canvas.height)
) {
if (hitSignature(z, [e.offsetX, e.offsetY], canvas)) {
if (userSignatureZone.value === null) {
selectZone(z, canvas);
} else {
@@ -449,18 +551,20 @@ const selectZoneEvent = (e: PointerEvent, canvas: HTMLCanvasElement) =>
}
});
const canvasClick = (e: PointerEvent) => {
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
const canvasClick = (e: PointerEvent, canvas: HTMLCanvasElement) =>
canvasEvent.value === "select"
? selectZoneEvent(e, canvas)
: addZoneEvent(e, canvas);
};
const turnPage = async (upOrDown: number) => {
//userSignatureZone.value = null; // desactivate the reset of the zone when turning page
page.value = page.value + upOrDown;
await setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200);
if (multiPage.value) {
const canvas = getCanvas(page.value);
canvas.scrollIntoView();
} else {
await setPage(page.value);
setTimeout(drawAllZones, 200);
}
};
const turnSignature = async (upOrDown: number) => {
@@ -476,9 +580,9 @@ const turnSignature = async (upOrDown: number) => {
let currentZone = signature.zones[zoneIndex];
if (currentZone) {
page.value = currentZone.PDFPage.index + 1;
userSignatureZone.value = currentZone;
const canvas = document.querySelectorAll("canvas")[0];
const canvas = getCanvas(currentZone.PDFPage.index + 1);
selectZone(currentZone, canvas);
canvas.scrollIntoView();
}
};
@@ -523,19 +627,25 @@ const drawZone = (
}
};
const drawAllZones = (page: number) => {
const canvas = document.querySelectorAll("canvas")[0];
const ctx = canvas.getContext("2d");
if (ctx && signedState.value !== "signed") {
const drawAllZones = () => {
if (signedState.value !== "signed") {
signature.zones
.filter((z) => z.PDFPage.index + 1 === page)
.filter(
(z) =>
multiPage.value ||
(z.PDFPage.index + 1 === page.value && !multiPage.value)
)
.map((z) => {
if (userSignatureZone.value) {
if (userSignatureZone.value?.index === z.index) {
const canvas = getCanvas(z.PDFPage.index + 1);
const ctx = canvas.getContext("2d");
if (ctx) {
if (userSignatureZone.value) {
if (userSignatureZone.value?.index === z.index) {
drawZone(z, ctx, canvas.width, canvas.height);
}
} else {
drawZone(z, ctx, canvas.width, canvas.height);
}
} else {
drawZone(z, ctx, canvas.width, canvas.height);
}
});
}
@@ -621,8 +731,8 @@ const confirmSign = () => {
const undoSign = async () => {
signature.zones = signature.zones.filter((z) => z.index !== null);
await setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200);
await resetPages();
setTimeout(drawAllZones, 200);
userSignatureZone.value = null;
adding.value = false;
canvasEvent.value = "select";
@@ -648,13 +758,13 @@ const addZoneEvent = async (e: PointerEvent, canvas: HTMLCanvasElement) => {
scaleXToCanvas(x, canvas.width, PDFPageWidth) -
scaleXToCanvas(BOX_WIDTH / 2, canvas.width, PDFPageWidth),
y:
PDFPageHeight -
PDFPageHeight * zoom.value -
scaleYToCanvas(y, canvas.height, PDFPageHeight) +
scaleYToCanvas(BOX_HEIGHT / 2, canvas.height, PDFPageHeight),
width: BOX_WIDTH,
height: BOX_HEIGHT,
width: BOX_WIDTH * zoom.value,
height: BOX_HEIGHT * zoom.value,
PDFPage: {
index: page.value - 1,
index: multiPage.value ? getCanvasId(canvas) - 1 : page.value - 1,
width: PDFPageWidth,
height: PDFPageHeight,
},
@@ -662,8 +772,8 @@ const addZoneEvent = async (e: PointerEvent, canvas: HTMLCanvasElement) => {
signature.zones.push(newZone);
userSignatureZone.value = newZone;
await setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200);
await resetPages();
setTimeout(drawAllZones, 200);
canvasEvent.value = "select";
adding.value = true;
};
@@ -678,16 +788,17 @@ init();
</script>
<style scoped lang="scss">
#canvas {
canvas {
box-shadow: 0 20px 20px rgba(0, 0, 0, 0.2);
margin: 1rem auto;
}
.onAddZone {
cursor: not-allowed;
cursor: not-allowed;
#canvas {
cursor: copy;
}
canvas {
cursor: copy;
}
}
div#action-buttons {
@@ -699,6 +810,10 @@ div#action-buttons {
div.pdf-tools {
background-color: #f3f3f3;
font-size: 0.6rem;
label {
font-size: 0.75rem !important;
margin: auto 0 auto 0.3rem;
}
button {
font-size: 0.75rem !important;
}

View File

@@ -24,6 +24,8 @@ const appMessages = {
loading: 'Chargement...',
remove_sign_zone: 'Enlever la zone',
return: 'Retour',
see_all_pages: 'Voir toutes les pages',
all_pages: 'Toutes les pages',
}
}

View File

@@ -26,7 +26,7 @@ use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Twig\Environment;
final readonly class WorkflowSignatureCancelController
final readonly class WorkflowSignatureStateChangeController
{
public function __construct(
private EntityManagerInterface $entityManager,
@@ -79,8 +79,9 @@ final readonly class WorkflowSignatureCancelController
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$markSignature($signature);
$this->entityManager->flush();
$this->entityManager->wrapInTransaction(function () use ($signature, $markSignature) {
$markSignature($signature);
});
return new RedirectResponse(
$this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $signature->getStep()->getEntityWorkflow()->getId()])

View File

@@ -14,10 +14,13 @@ namespace Chill\MainBundle\Tests\Workflow;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Chill\MainBundle\Workflow\SignatureStepStateChanger;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Entity\Person;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Clock\MockClock;
@@ -47,7 +50,12 @@ class SignatureStepStateChangerTest extends TestCase
$user = new User();
$messengerBus = new MessageBus([]);
$changer = new SignatureStepStateChanger($registry, $clock, new NullLogger(), $messengerBus);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects($this->exactly(4))->method('refresh')->with(
$this->isInstanceOf(EntityWorkflowStepSignature::class),
$this->logicalOr(LockMode::PESSIMISTIC_WRITE, LockMode::PESSIMISTIC_READ)
);
$changer = new SignatureStepStateChanger($registry, $clock, new NullLogger(), $messengerBus, $entityManager);
// move it to signature
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
@@ -94,7 +102,12 @@ class SignatureStepStateChangerTest extends TestCase
$user = new User();
$messengerBus = new MessageBus([]);
$changer = new SignatureStepStateChanger($registry, $clock, new NullLogger(), $messengerBus);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects($this->exactly(2))->method('refresh')->with(
$this->isInstanceOf(EntityWorkflowStepSignature::class),
$this->logicalOr(LockMode::PESSIMISTIC_WRITE, LockMode::PESSIMISTIC_READ)
);
$changer = new SignatureStepStateChanger($registry, $clock, new NullLogger(), $messengerBus, $entityManager);
// move it to signature
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
@@ -126,7 +139,12 @@ class SignatureStepStateChangerTest extends TestCase
$workflow = $registry->get($entityWorkflow, 'dummy');
$clock = new MockClock();
$user = new User();
$changer = new SignatureStepStateChanger($registry, $clock, new NullLogger(), new MessageBus([]));
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects($this->exactly(2))->method('refresh')->with(
$this->isInstanceOf(EntityWorkflowStepSignature::class),
$this->logicalOr(LockMode::PESSIMISTIC_WRITE, LockMode::PESSIMISTIC_READ)
);
$changer = new SignatureStepStateChanger($registry, $clock, new NullLogger(), new MessageBus([]), $entityManager);
// move it to signature
$dto = new WorkflowTransitionContextDTO($entityWorkflow);

View File

@@ -33,9 +33,12 @@ final readonly class PostSignatureStateChangeHandler implements MessageHandlerIn
throw new UnrecoverableMessageHandlingException('signature not found');
}
$this->signatureStepStateChanger->onPostMark($signature);
$this->entityManager->wrapInTransaction(function () use ($signature) {
$this->signatureStepStateChanger->onPostMark($signature);
$this->entityManager->flush();
});
$this->entityManager->flush();
$this->entityManager->clear();
}
}

View File

@@ -16,11 +16,16 @@ use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Workflow\Messenger\PostSignatureStateChangeMessage;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Workflow\Registry;
/**
* Handles state changes for signature steps within a workflow.
*/
class SignatureStepStateChanger
{
private const LOG_PREFIX = '[SignatureStepStateChanger] ';
@@ -30,10 +35,26 @@ class SignatureStepStateChanger
private readonly ClockInterface $clock,
private readonly LoggerInterface $logger,
private readonly MessageBusInterface $messageBus,
private readonly EntityManagerInterface $entityManager,
) {}
/**
* Marks a signature as signed.
*
* This method will acquire a lock on the database side, so it must be wrapped into an explicit
* transaction.
*
* This method updates the state of the provided signature entity, sets the signature index,
* and logs the action. Additionally, it dispatches a message indicating that the signature
* state has changed.
*
* @param EntityWorkflowStepSignature $signature the signature entity to be marked as signed
* @param int|null $atIndex optional index position for the signature within the zone
*/
public function markSignatureAsSigned(EntityWorkflowStepSignature $signature, ?int $atIndex): void
{
$this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE);
$signature
->setState(EntityWorkflowSignatureStateEnum::SIGNED)
->setZoneSignatureIndex($atIndex)
@@ -42,8 +63,19 @@ class SignatureStepStateChanger
$this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId()));
}
/**
* Marks a signature as canceled.
*
* This method will acquire a lock on the database side, so it must be wrapped into an explicit
* transaction.
*
* This method updates the signature state to 'canceled' and logs the action.
* It also dispatches a message to notify about the state change.
*/
public function markSignatureAsCanceled(EntityWorkflowStepSignature $signature): void
{
$this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE);
$signature
->setState(EntityWorkflowSignatureStateEnum::CANCELED)
->setStateDate($this->clock->now());
@@ -51,8 +83,21 @@ class SignatureStepStateChanger
$this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId()));
}
/**
* Marks the given signature as rejected and updates its state and state date accordingly.
*
* This method will acquire a lock on the database side, so it must be wrapped into an explicit
* transaction.
*
* This method logs the rejection of the signature and dispatches a message indicating
* a state change has occurred.
*
* @param EntityWorkflowStepSignature $signature the signature entity to be marked as rejected
*/
public function markSignatureAsRejected(EntityWorkflowStepSignature $signature): void
{
$this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE);
$signature
->setState(EntityWorkflowSignatureStateEnum::REJECTED)
->setStateDate($this->clock->now());
@@ -63,10 +108,15 @@ class SignatureStepStateChanger
/**
* Executed after a signature has a new state.
*
* This method will acquire a lock on the database side, so it must be wrapped into an explicit
* transaction.
*
* This should be executed only by a system user (without any user registered)
*/
public function onPostMark(EntityWorkflowStepSignature $signature): void
{
$this->entityManager->refresh($signature, LockMode::PESSIMISTIC_READ);
if (!EntityWorkflowStepSignature::isAllSignatureNotPendingForStep($signature->getStep())) {
$this->logger->info(self::LOG_PREFIX.'This is not the last signature, skipping transition to another place', ['signatureId' => $signature->getId()]);

View File

@@ -80,7 +80,7 @@
>
<option selected disabled >{{ $t('person.gender.placeholder') }}</option>
<option v-for="g in config.genders" :value="g.id" :key="g.id">
{{ g.label.fr }}
{{ g.label }}
</option>
</select>
<label>{{ $t('person.gender.title') }}</label>
@@ -337,6 +337,7 @@ export default {
getGenders()
.then(genders => {
if ('results' in genders) {
console.log('genders', genders.results)
this.config.genders = genders.results;
}
});

View File

@@ -11,7 +11,7 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Tests\Controller;
use Chill\MainBundle\Controller\WorkflowSignatureCancelController;
use Chill\MainBundle\Controller\WorkflowSignatureStateChangeController;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Routing\ChillUrlGeneratorInterface;
@@ -72,7 +72,7 @@ class WorkflowSignatureCancelControllerStepTest extends WebTestCase
$twig->expects($this->once())->method('render')->withAnyParameters()
->willReturn('template');
$controller = new WorkflowSignatureCancelController($entityManager, $security, $this->formFactory, $twig, $this->signatureStepStateChanger, $this->chillUrlGenerator);
$controller = new WorkflowSignatureStateChangeController($entityManager, $security, $this->formFactory, $twig, $this->signatureStepStateChanger, $this->chillUrlGenerator);
$request = new Request();
$request->setMethod('GET');