diff --git a/addons/laundry_management/wizard/laundry_order_unlock_wizard.py b/addons/laundry_management/wizard/laundry_order_unlock_wizard.py
new file mode 100644
index 0000000..9801970
--- /dev/null
+++ b/addons/laundry_management/wizard/laundry_order_unlock_wizard.py
@@ -0,0 +1,126 @@
+from datetime import timedelta
+
+from odoo import models, fields, api, _
+from odoo.exceptions import UserError, AccessError
+
+
+class LaundryOrderUnlockWizard(models.TransientModel):
+ """Manager-only wizard that grants a TIMED unlock window on a
+ locked laundry.order.
+
+ The wizard does not unlock the order permanently — it sets
+ `manager_unlocked_until` to a future timestamp. The lock compute
+ on `laundry.order.locked` re-evaluates against `now()` on every
+ read, so once the window passes the order auto-relocks. No manual
+ re-lock action is needed; nothing can drift.
+
+ Audit trail:
+ - reason is logged as a chatter note via mail.message
+ - manager_unlocked_by stores the actor
+ - manager_unlock_reason stores the rationale on the order itself
+ """
+ _name = 'laundry.order.unlock.wizard'
+ _description = 'Unlock Laundry Order for Editing'
+
+ DEFAULT_DURATION = 30 # minutes
+ MAX_DURATION = 240 # minutes (4 hours)
+
+ order_id = fields.Many2one(
+ 'laundry.order', string='Order',
+ required=True, readonly=True, ondelete='cascade',
+ )
+ order_name = fields.Char(
+ related='order_id.name', readonly=True,
+ )
+ order_source_type = fields.Selection(
+ related='order_id.source_type', readonly=True,
+ )
+ order_state = fields.Selection(
+ related='order_id.state', readonly=True,
+ )
+ duration_minutes = fields.Integer(
+ string='Unlock For (minutes)',
+ default=DEFAULT_DURATION,
+ required=True,
+ help='How long the order will accept edits before re-locking '
+ 'automatically. Capped at %s minutes.' % MAX_DURATION,
+ )
+ reason = fields.Char(
+ string='Reason',
+ required=True,
+ help='Logged in the chatter for audit. Required.',
+ )
+
+ @api.constrains('duration_minutes')
+ def _check_duration(self):
+ for wiz in self:
+ if wiz.duration_minutes < 1:
+ raise UserError(_('Unlock duration must be at least 1 minute.'))
+ if wiz.duration_minutes > self.MAX_DURATION:
+ raise UserError(_(
+ 'Unlock duration is capped at %s minutes.',
+ self.MAX_DURATION,
+ ))
+
+ def action_unlock(self):
+ self.ensure_one()
+ if not self.env.user.has_group(
+ 'laundry_management.group_laundry_manager_override'
+ ):
+ raise AccessError(_(
+ 'Only users with the "Laundry / Manager Override" '
+ 'privilege can unlock locked orders.'
+ ))
+ if not self.reason or not self.reason.strip():
+ raise UserError(_('A reason is required to unlock an order.'))
+
+ unlock_until = (
+ fields.Datetime.now() + timedelta(minutes=self.duration_minutes)
+ )
+ # The wizard writes only manager_unlocked_* fields, which are NOT
+ # in LOCKED_HEADER_FIELDS — the lock guard is a no-op for this
+ # write, so we don't need a context bypass.
+ self.order_id.sudo().write({
+ 'manager_unlocked_until': unlock_until,
+ 'manager_unlocked_by': self.env.user.id,
+ 'manager_unlock_reason': self.reason.strip(),
+ })
+ self.order_id.message_post(
+ body=_(
+ 'Order unlocked for %(minutes)s minutes by '
+ '%(user)s. Re-locks automatically at '
+ '%(until)s.
Reason: %(reason)s',
+ minutes=self.duration_minutes,
+ user=self.env.user.name,
+ until=fields.Datetime.to_string(unlock_until),
+ reason=self.reason.strip(),
+ ),
+ subtype_xmlid='mail.mt_note',
+ )
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'laundry.order',
+ 'res_id': self.order_id.id,
+ 'view_mode': 'form',
+ 'target': 'current',
+ }
+
+ def action_relock_now(self):
+ """Optional: managers can also re-lock immediately (clears the
+ unlock window early). Same access control as unlock."""
+ self.ensure_one()
+ if not self.env.user.has_group(
+ 'laundry_management.group_laundry_manager_override'
+ ):
+ raise AccessError(_('Only override managers can re-lock.'))
+ self.order_id.sudo().write({
+ 'manager_unlocked_until': False,
+ })
+ self.order_id.message_post(
+ body=_(
+ 'Order re-locked early by %(user)s.',
+ user=self.env.user.name,
+ ),
+ subtype_xmlid='mail.mt_note',
+ )
+ return {'type': 'ir.actions.act_window_close'}