diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ecb51f --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Sign Sequence Field + +Add a Sequence field type to Odoo Sign templates to automatically generate numbers upon document completion. + +## Features + +- **New Field Type**: Adds a "Sequence" field to the Sign template editor toolbar. +- **Auto-Generation**: Automatically generates the next sequence number from a linked `ir.sequence` when all signers have completed the document. +- **Live Editing**: Edit Sequence Prefix, Suffix, Padding, and Next Number directly from the template editor sidebar. +- **Dynamic Positioning**: Place the sequence number anywhere on your document just like any other sign field. + +## Usage + +1. **Edit a Template**: Go to Sign -> Templates and open a template for editing. +2. **Drag & Drop**: Drag the "Sequence" field from the left toolbar onto your document. +3. **Configure**: Click on the sequence field to open its configuration popover: + - Select an existing **Sequence** record. + - (Optional) Modify the **Prefix**, **Suffix**, **Padding**, or **Next Number**. +4. **Save**: Save the template. +5. **Sign**: When a document created from this template is fully signed, the sequence number will be generated and stamped onto the final PDF. + +## Technical Details + +- **Module Name**: `sign_sequence_field` +- **Dependency**: `sign` (Enterprise) +- **Odoo Version**: 19.0 +- **Authors**: Suherdy Yacob diff --git a/models/sign_document.py b/models/sign_document.py index 5d535f0..d047230 100644 --- a/models/sign_document.py +++ b/models/sign_document.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import io +import logging from odoo import models from odoo.exceptions import ValidationError from odoo.tools.pdf import PdfFileReader, PdfFileWriter, PdfReadError, reshape_text @@ -10,6 +11,8 @@ try: except ImportError: stringWidth = None +_logger = logging.getLogger(__name__) + class SignDocument(models.Model): _inherit = 'sign.document' @@ -26,7 +29,9 @@ class SignDocument(models.Model): for item in page_items ) + _logger.info("SignSequenceField: render_document_with_items started") if not has_sequence_items: + _logger.info("SignSequenceField: No sequence items found in document, skipping custom render") return base_output try: @@ -91,7 +96,9 @@ class SignDocument(models.Model): can.showPage() can.save() + _logger.info("SignSequenceField: Custom canvas saved to packet") + packet.seek(0) item_pdf = PdfFileReader(packet) new_pdf = PdfFileWriter() @@ -105,6 +112,9 @@ class SignDocument(models.Model): try: new_pdf.write(output) except PdfReadError: + _logger.error("SignSequenceField: PdfReadError during final PDF write") raise ValidationError(self.env._("There was an issue generating the document.")) + _logger.info("SignSequenceField: Custom rendering completed successfully") + output.seek(0) return output diff --git a/models/sign_request.py b/models/sign_request.py index 6d93c4d..9227021 100755 --- a/models/sign_request.py +++ b/models/sign_request.py @@ -43,18 +43,26 @@ class SignRequest(models.Model): # We only generate if we are about to complete the signing process. # The logic in _post_fill_request_item calls _sign only when all items are completed. + _logger.info("SignSequenceField: Starting sequence generation for sign.request %s", self.ids) for request in self: # Generate sequence numbers for sequence items - for sign_item in request.template_id.sign_item_ids: + # Use template_id.document_ids.sign_item_ids to be safe + signer_items = request.template_id.document_ids.sign_item_ids + _logger.info("SignSequenceField: Found %s total sign items on template documents for request %s", len(signer_items), request.id) + for sign_item in signer_items: + _logger.debug("SignSequenceField: Checking item %s: type=%s, sequence_id=%s", sign_item.id, sign_item.type_id.item_type, sign_item.sequence_id.id if sign_item.sequence_id else 'None') if sign_item.type_id.item_type == 'sequence' and sign_item.sequence_id: + _logger.info("SignSequenceField: Processing sequence item %s (ID: %s) for request %s", sign_item.name, sign_item.id, request.id) # Find the responsible request item (signer) # Use role_id to match. # Fallback to the first signer if no specific matching signer is found (e.g. role is "Anyone") request_items = request.request_item_ids.filtered(lambda r: r.role_id == sign_item.responsible_id) if not request_items: + _logger.info("SignSequenceField: No matching request items for role %s, falling back to first signer", sign_item.responsible_id.name) request_items = request.request_item_ids[:1] if not request_items: + _logger.warning("SignSequenceField: No request items found at all for request %s", request.id) continue # We only generate if it hasn't been generated yet for this sign_item across the whole request @@ -64,16 +72,24 @@ class SignRequest(models.Model): ('sign_item_id', '=', sign_item.id) ], limit=1) - # If it exists but value is empty/false OR it equals the placeholder "Sequence Number" + placeholder = (sign_item.type_id.placeholder or "Sequence Number").strip().lower() + current_val = str(existing_value.value or "").strip().lower() + _logger.debug("SignSequenceField: existing_value check for item %s: '%s' (placeholder: '%s')", sign_item.id, current_val, placeholder) + + # If it exists but value is empty/false OR it equals the placeholder # we must regenerate it. - if not existing_value or not existing_value.value or existing_value.value == "Sequence Number": + if not existing_value or not current_val or current_val == placeholder: + _logger.info("SignSequenceField: Generating sequence for item %s", sign_item.id) new_seq = sign_item.sequence_id.next_by_id() + _logger.info("SignSequenceField: Generated new sequence: %s", new_seq) if new_seq: if existing_value: # Update existing empty record + _logger.debug("SignSequenceField: Updating existing value record %s", existing_value.id) existing_value.write({'value': new_seq}) else: # Create value for the first matching signer + _logger.debug("SignSequenceField: Creating new value record for signer %s", request_items[0].id) request.env['sign.request.item.value'].create({ 'sign_request_item_id': request_items[0].id, 'sign_item_id': sign_item.id, @@ -85,7 +101,12 @@ class SignRequest(models.Model): # request.reference is the document name. # Check if already renamed to avoid double prefixing if not request.reference.startswith(f"{new_seq} - "): + _logger.info("SignSequenceField: Prefixing document name with sequence: %s", new_seq) request.write({'reference': f"{new_seq} - {request.reference}"}) + else: + _logger.error("SignSequenceField: next_by_id() returned None for sequence %s", sign_item.sequence_id.id) + else: + _logger.info("SignSequenceField: Sequence already exists for item %s: %s", sign_item.id, existing_value.value) return super()._sign() diff --git a/static/src/js/sign_backend_patch.js b/static/src/js/sign_backend_patch.js index e7ba4c9..82a3948 100755 --- a/static/src/js/sign_backend_patch.js +++ b/static/src/js/sign_backend_patch.js @@ -8,6 +8,10 @@ import { patch } from "@web/core/utils/patch"; patch(SignTemplateIframe.prototype, { async openSignItemPopup(signItem) { + // Save the item to the backend if it has a negative ID, as it indicates the item is new and not yet linked to the template. + if (signItem.data.id < 0) { + await this.saveChangesOnBackend(); + } const shouldOpenNewPopover = !(signItem.data.id in this.closePopoverFns); this.closePopover(); if (shouldOpenNewPopover) { @@ -20,20 +24,20 @@ patch(SignTemplateIframe.prototype, { SignItemCustomPopover, { debug: this.env.debug, - responsible: signItem.data.responsible, - roles: this.signRolesById, alignment: signItem.data.alignment, required: signItem.data.required, + constant: signItem.data.constant, header_title: header_title, placeholder: signItem.data.placeholder, id: signItem.data.id, type: signItem.data.type, option_ids: signItem.data.option_ids, - num_options: this.getSignItemById(signItem.data.id).data.num_options, + num_options: this.getSignItemById(signItem.data.id)?.data.num_options, radio_set_id: signItem.data.radio_set_id, // PATCH START sequence_id: signItem.data.sequence_id, sequence_prefix: signItem.data.sequence_prefix, + sequence_suffix: signItem.data.sequence_suffix, sequence_padding: signItem.data.sequence_padding, sequence_number_next: signItem.data.sequence_number_next, // PATCH END @@ -45,18 +49,26 @@ patch(SignTemplateIframe.prototype, { this.closePopover(); this.deleteSignItem(signItem); }, + onDuplicate: () => { + this.closePopover(); + this.duplicateSignItem(signItem); + }, onClose: () => { this.closePopover(); }, - updateSelectionOptions: (ids) => this.updateSelectionOptions(ids), - updateRoles: (id) => this.updateRoles(id), + onCopyItem: (id) => this.onCopyItem(id), }, { position: "right", onClose: () => { this.closePopoverFns = {}; }, - closeOnClickAway: (target) => !target.closest(".modal"), + closeOnClickAway: (target) => { + if (!target.closest(".popover")) { + this.closePopover(); + } + return !target.closest(".popover"); + }, popoverClass: "sign-popover", } ); @@ -69,7 +81,7 @@ patch(SignTemplateIframe.prototype, { updateSignItem(signItem, data) { // Ensure sequence fields are not filtered out if they were missing from initial load - const sequenceFields = ['sequence_id', 'sequence_prefix', 'sequence_padding', 'sequence_number_next']; + const sequenceFields = ['sequence_id', 'sequence_prefix', 'sequence_suffix', 'sequence_padding', 'sequence_number_next']; for (const field of sequenceFields) { if (field in data && !(field in signItem.data)) { signItem.data[field] = false; // Initialize with dummy value to pass the check @@ -82,21 +94,24 @@ patch(SignTemplateIframe.prototype, { patch(SignTemplateBody.prototype, { prepareTemplateData() { const [updatedSignItems, Id2UpdatedItem] = super.prepareTemplateData(); - const items = this.iframe?.signItems ?? {}; + const items = this.props.iframe?.signItems ?? {}; for (const page in items) { for (const id in items[page]) { const signItem = items[page][id].data; // updatedSignItems is keyed by ID. Check if this item is in the updated list. if (updatedSignItems[id]) { - if (signItem.sequence_id) { - // specific handling for Many2one: take ID if it's an array - updatedSignItems[id].sequence_id = Array.isArray(signItem.sequence_id) ? signItem.sequence_id[0] : signItem.sequence_id; + if (signItem.type === 'sequence') { + if (signItem.sequence_id) { + // specific handling for Many2one: take ID if it's an array + updatedSignItems[id].sequence_id = Array.isArray(signItem.sequence_id) ? signItem.sequence_id[0] : signItem.sequence_id; + } + // For related fields, we want to save them if they changed. + if (signItem.sequence_prefix !== undefined) updatedSignItems[id].sequence_prefix = signItem.sequence_prefix; + if (signItem.sequence_suffix !== undefined) updatedSignItems[id].sequence_suffix = signItem.sequence_suffix; + if (signItem.sequence_padding !== undefined) updatedSignItems[id].sequence_padding = signItem.sequence_padding; + if (signItem.sequence_number_next !== undefined) updatedSignItems[id].sequence_number_next = signItem.sequence_number_next; } - // For related fields, we want to save them if they changed. - if (signItem.sequence_prefix !== undefined) updatedSignItems[id].sequence_prefix = signItem.sequence_prefix; - if (signItem.sequence_padding !== undefined) updatedSignItems[id].sequence_padding = signItem.sequence_padding; - if (signItem.sequence_number_next !== undefined) updatedSignItems[id].sequence_number_next = signItem.sequence_number_next; } } } diff --git a/static/src/js/sign_item_custom_popover_patch.js b/static/src/js/sign_item_custom_popover_patch.js index 29694bc..e9e7161 100755 --- a/static/src/js/sign_item_custom_popover_patch.js +++ b/static/src/js/sign_item_custom_popover_patch.js @@ -9,6 +9,7 @@ patch(SignItemCustomPopover.prototype, { super.setup(); this.state.sequence_id = this.props.sequence_id || false; this.state.sequence_prefix = this.props.sequence_prefix || ""; + this.state.sequence_suffix = this.props.sequence_suffix || ""; this.state.sequence_padding = this.props.sequence_padding || 0; this.state.sequence_number_next = this.props.sequence_number_next || 1; @@ -17,7 +18,8 @@ patch(SignItemCustomPopover.prototype, { if (this.props.sequence_id) { this.state.sequence_value = "Loading..."; - this.orm.read("ir.sequence", [this.props.sequence_id], ["display_name"]).then((res) => { + const sequenceId = Array.isArray(this.props.sequence_id) ? this.props.sequence_id[0] : this.props.sequence_id; + this.orm.read("ir.sequence", [sequenceId], ["display_name"]).then((res) => { if (res && res.length) { this.state.sequence_value = res[0].display_name; } else { @@ -36,14 +38,16 @@ patch(SignItemCustomPopover.prototype, { this.state.sequence_id = sequenceId; if (sequenceId) { - const result = await this.orm.read("ir.sequence", [sequenceId], ["prefix", "padding", "number_next"]); + const result = await this.orm.read("ir.sequence", [sequenceId], ["prefix", "suffix", "padding", "number_next"]); if (result && result.length) { this.state.sequence_prefix = result[0].prefix || ""; + this.state.sequence_suffix = result[0].suffix || ""; this.state.sequence_padding = result[0].padding || 0; this.state.sequence_number_next = result[0].number_next || 1; } } else { this.state.sequence_prefix = ""; + this.state.sequence_suffix = ""; this.state.sequence_padding = 0; this.state.sequence_number_next = 1; } diff --git a/static/src/xml/sign_item_custom_popover_patch.xml b/static/src/xml/sign_item_custom_popover_patch.xml index 76aeff3..1b4b841 100755 --- a/static/src/xml/sign_item_custom_popover_patch.xml +++ b/static/src/xml/sign_item_custom_popover_patch.xml @@ -24,6 +24,10 @@ +
+ + +