Skip to content

Commit e4d5c1e

Browse files
author
Florian Wunderlich
committed
fix: keep queued async extraction bound to one attachment
1 parent 00c4dc5 commit e4d5c1e

3 files changed

Lines changed: 61 additions & 2 deletions

File tree

models/account_move.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ class AccountMove(models.Model):
9090
ondelete='set null',
9191
help='Attachment used for the cached extraction.',
9292
)
93+
ai_queued_attachment_id = fields.Many2one(
94+
'ir.attachment',
95+
string='Queued Attachment',
96+
copy=False,
97+
ondelete='set null',
98+
help='Attachment selected for the next background extraction run.',
99+
)
93100
ai_extraction_queued_at = fields.Datetime(
94101
string='Queued At',
95102
copy=False,
@@ -386,6 +393,7 @@ def action_ai_extract(self):
386393

387394
# --- Async mode: queue for background processing -------------------
388395
if self._ai_get_bool_param('ai_async_extraction'):
396+
self.ai_queued_attachment_id = attachment.id
389397
self.ai_extraction_status = 'processing'
390398
self.ai_extraction_queued_at = fields.Datetime.now()
391399
return self._ai_notify(

models/ai_cron_processor.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ def _ai_cron_process_queue(self):
2929
])
3030
if stale:
3131
_logger.warning('AI cron: marking %d stale extractions as failed', len(stale))
32-
stale.write({'ai_extraction_status': 'failed', 'ai_extraction_queued_at': False})
32+
stale.write({
33+
'ai_extraction_status': 'failed',
34+
'ai_extraction_queued_at': False,
35+
'ai_queued_attachment_id': False,
36+
})
3337

3438
# Claim the batch with a row-level lock so a concurrent transaction
3539
# (a manual extraction, or a second trigger) cannot grab the same
@@ -65,15 +69,21 @@ def _ai_cron_process_queue(self):
6569
_logger.exception('AI cron: extraction failed for move %s', move.id)
6670
move.ai_extraction_status = 'failed'
6771
move.ai_extraction_queued_at = False
72+
move.ai_queued_attachment_id = False
6873
# Commit after each invoice to avoid losing work on error
6974
self.env.cr.commit() # noqa: B010
7075

7176
def _ai_cron_extract_one(self, move, api_key):
7277
"""Process a single queued extraction."""
73-
attachment = move._ai_get_invoice_attachment()
78+
attachment = (
79+
move.ai_queued_attachment_id
80+
or move.ai_last_extraction_attachment_id
81+
or move._ai_get_invoice_attachment()
82+
)
7483
if not attachment:
7584
move.ai_extraction_status = 'failed'
7685
move.ai_extraction_queued_at = False
86+
move.ai_queued_attachment_id = False
7787
return
7888

7989
data = move._ai_trigger_extraction(api_key, attachment, preview=True)
@@ -96,3 +106,4 @@ def _ai_cron_extract_one(self, move, api_key):
96106
)
97107
move.ai_extraction_status = 'done'
98108
move.ai_extraction_queued_at = False
109+
move.ai_queued_attachment_id = False

tests/test_async_extraction.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,14 @@ def test_button_queues_extraction(self):
6262
"""Button should set status='processing' + queued_at when async enabled."""
6363
self.ICP.set_param(self._p + 'ai_async_extraction', 'True')
6464
move = self._create_invoice_with_attachment()
65+
attachment = move.attachment_ids.filtered(lambda att: att.mimetype == 'application/pdf')[:1]
6566

6667
result = move.action_ai_extract()
6768

6869
self.assertEqual(move.ai_extraction_status, 'processing')
6970
self.assertTrue(move.ai_extraction_queued_at)
71+
self.assertEqual(move.ai_queued_attachment_id, attachment)
72+
self.assertFalse(move.ai_last_extraction_attachment_id)
7073
self.assertEqual(result['tag'], 'display_notification')
7174

7275
def test_cron_processes_queue(self):
@@ -85,6 +88,39 @@ def test_cron_processes_queue(self):
8588
self.assertEqual(move.ai_extraction_status, 'done')
8689
self.assertTrue(move.ai_last_extraction_data)
8790
self.assertFalse(move.ai_extraction_queued_at)
91+
self.assertFalse(move.ai_queued_attachment_id)
92+
93+
def test_cron_uses_scheduled_attachment(self):
94+
"""Cron should process the attachment that was queued, not rediscover a newer one."""
95+
self.ICP.set_param(self._p + 'ai_async_extraction', 'True')
96+
move = self._create_invoice_with_attachment()
97+
queued_attachment = move.attachment_ids.filtered(lambda att: att.mimetype == 'application/pdf')[:1]
98+
self.env['ir.attachment'].create(
99+
{
100+
'name': 'later.png',
101+
'datas': base64.b64encode(b'fake-image'),
102+
'res_model': 'account.move',
103+
'res_id': move.id,
104+
'mimetype': 'image/png',
105+
}
106+
)
107+
move.ai_extraction_status = 'processing'
108+
move.ai_extraction_queued_at = fields.Datetime.now()
109+
move.ai_queued_attachment_id = queued_attachment.id
110+
111+
def _mock_extract(api_key, attachment, preview=False):
112+
self.assertEqual(attachment, queued_attachment)
113+
return self.mock_data
114+
115+
with patch.object(
116+
type(move),
117+
'_ai_trigger_extraction',
118+
side_effect=_mock_extract,
119+
), patch.object(self.env.cr, 'commit'):
120+
self.env['account.move']._ai_cron_process_queue()
121+
122+
self.assertEqual(move.ai_last_extraction_attachment_id, queued_attachment)
123+
self.assertFalse(move.ai_queued_attachment_id)
88124

89125
def test_cron_handles_failure(self):
90126
"""Cron should set status='failed' when extraction returns None."""
@@ -101,6 +137,7 @@ def test_cron_handles_failure(self):
101137

102138
self.assertEqual(move.ai_extraction_status, 'failed')
103139
self.assertFalse(move.ai_extraction_queued_at)
140+
self.assertFalse(move.ai_queued_attachment_id)
104141

105142
def test_sync_fallback(self):
106143
"""When async is disabled, extraction should happen synchronously."""
@@ -144,14 +181,17 @@ def test_cron_marks_stale_as_failed(self):
144181
"""Items queued > 10 minutes ago should be marked as failed."""
145182
self.ICP.set_param(self._p + 'ai_async_extraction', 'True')
146183
move = self._create_invoice_with_attachment()
184+
queued_attachment = move.attachment_ids.filtered(lambda att: att.mimetype == 'application/pdf')[:1]
147185
move.ai_extraction_status = 'processing'
148186
move.ai_extraction_queued_at = fields.Datetime.now() - timedelta(minutes=15)
187+
move.ai_queued_attachment_id = queued_attachment.id
149188

150189
with patch.object(self.env.cr, 'commit'):
151190
self.env['account.move']._ai_cron_process_queue()
152191

153192
self.assertEqual(move.ai_extraction_status, 'failed')
154193
self.assertFalse(move.ai_extraction_queued_at)
194+
self.assertFalse(move.ai_queued_attachment_id)
155195

156196
def test_cron_batch_size_limit(self):
157197
"""Cron should only process up to 5 items per run."""

0 commit comments

Comments
 (0)