qube_rails 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +161 -0
- data/LICENSE.txt +21 -0
- data/README.md +967 -0
- data/app/assets/javascripts/qube_rails/question.js +457 -0
- data/app/assets/stylesheets/qube_rails.css +1115 -0
- data/app/controllers/concerns/qube_rails/run_authorization.rb +37 -0
- data/app/controllers/qube_rails/connections_controller.rb +141 -0
- data/app/controllers/qube_rails/questions_controller.rb +132 -0
- data/app/controllers/qube_rails/runs_controller.rb +54 -0
- data/app/controllers/qube_rails/storyboard_controller.rb +50 -0
- data/app/controllers/qube_rails/webhooks_controller.rb +99 -0
- data/app/jobs/qube_rails/prune_runs_job.rb +18 -0
- data/app/jobs/qube_rails/refresh_job.rb +18 -0
- data/app/jobs/qube_rails/webhook_job.rb +43 -0
- data/app/models/qube_rails/application_record.rb +17 -0
- data/app/models/qube_rails/connection.rb +303 -0
- data/app/models/qube_rails/notification.rb +12 -0
- data/app/models/qube_rails/queued_request.rb +66 -0
- data/app/models/qube_rails/quickbooks_record.rb +36 -0
- data/app/models/qube_rails/request_delivery.rb +12 -0
- data/app/models/qube_rails/run.rb +187 -0
- data/app/models/qube_rails/sync_mark.rb +21 -0
- data/app/views/qube_rails/_error.html.erb +13 -0
- data/app/views/qube_rails/_question.html.erb +21 -0
- data/app/views/qube_rails/_run_status.html.erb +22 -0
- data/app/views/qube_rails/question/_choice.html.erb +20 -0
- data/app/views/qube_rails/question/_details.html.erb +6 -0
- data/app/views/qube_rails/question/_field.html.erb +15 -0
- data/app/views/qube_rails/question/_footer.html.erb +7 -0
- data/app/views/qube_rails/question/_message.html.erb +2 -0
- data/app/views/qube_rails/question/_pick.html.erb +19 -0
- data/app/views/qube_rails/question/_picks.html.erb +6 -0
- data/app/views/qube_rails/question/_question.html.erb +24 -0
- data/app/views/qube_rails/question/fields/_boolean.html.erb +7 -0
- data/app/views/qube_rails/question/fields/_number.html.erb +7 -0
- data/app/views/qube_rails/question/fields/_rows.html.erb +23 -0
- data/app/views/qube_rails/question/fields/_search.html.erb +23 -0
- data/app/views/qube_rails/question/fields/_select.html.erb +8 -0
- data/app/views/qube_rails/question/fields/_text.html.erb +7 -0
- data/app/views/qube_rails/question/rows/_cell.html.erb +24 -0
- data/app/views/qube_rails/question/rows/_row.html.erb +22 -0
- data/app/views/qube_rails/question/rows/_segmented.html.erb +14 -0
- data/app/views/qube_rails/run_status/_detail.html.erb +7 -0
- data/app/views/qube_rails/run_status/_redeliver.html.erb +5 -0
- data/app/views/qube_rails/run_status/_retry.html.erb +4 -0
- data/app/views/qube_rails/run_status/_stall.html.erb +3 -0
- data/app/views/qube_rails/run_status/_status.html.erb +21 -0
- data/app/views/qube_rails/run_status/_summary.html.erb +2 -0
- data/app/views/qube_rails/storyboard/show.html.erb +86 -0
- data/config/locales/en.yml +46 -0
- data/config/routes.rb +35 -0
- data/db/migrate/20261025000000_create_qube_tables.rb +83 -0
- data/db/migrate/20261026230000_add_qube_sync_status_indexes.rb +13 -0
- data/db/migrate/20261027000000_create_qube_sync_marks.rb +17 -0
- data/db/migrate/20261028000000_create_qube_requests.rb +38 -0
- data/lib/generators/qube_rails/install_generator.rb +47 -0
- data/lib/generators/qube_rails/templates/connection_events.rb.tt +38 -0
- data/lib/generators/qube_rails/templates/initializer.rb.tt +82 -0
- data/lib/generators/qube_rails/workflows_generator.rb +77 -0
- data/lib/qube_rails/backend.rb +70 -0
- data/lib/qube_rails/cache_dedupe.rb +34 -0
- data/lib/qube_rails/configuration.rb +319 -0
- data/lib/qube_rails/connection_events.rb +167 -0
- data/lib/qube_rails/database_dedupe.rb +28 -0
- data/lib/qube_rails/engine.rb +33 -0
- data/lib/qube_rails/json_coder.rb +43 -0
- data/lib/qube_rails/operation.rb +464 -0
- data/lib/qube_rails/question_view.rb +219 -0
- data/lib/qube_rails/request.rb +289 -0
- data/lib/qube_rails/rows_view.rb +146 -0
- data/lib/qube_rails/run_status_view.rb +71 -0
- data/lib/qube_rails/storyboard/ask_about_add.json +60 -0
- data/lib/qube_rails/storyboard/choose.json +51 -0
- data/lib/qube_rails/storyboard/enum_select.json +56 -0
- data/lib/qube_rails/storyboard/missing_record.json +141 -0
- data/lib/qube_rails/storyboard/name_clash.json +59 -0
- data/lib/qube_rails/storyboard/near_match.json +77 -0
- data/lib/qube_rails/storyboard/number_boolean.json +54 -0
- data/lib/qube_rails/storyboard/record_search.json +90 -0
- data/lib/qube_rails/storyboard/refusal.json +53 -0
- data/lib/qube_rails/storyboard/rows.json +359 -0
- data/lib/qube_rails/storyboard.rb +165 -0
- data/lib/qube_rails/sync_status.rb +124 -0
- data/lib/qube_rails/version.rb +5 -0
- data/lib/qube_rails/view_helpers.rb +29 -0
- data/lib/qube_rails.rb +201 -0
- metadata +180 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Records not in QuickBooks yet",
|
|
3
|
+
"note": "add_transaction's ask_missing: a block per missing record, its fields stacked, each shown while its \"when\" holds. Hardware Kit's add says it's an inventory part, so its type is shown, not asked. \"Set all to:\" sets every record's choice; \"Use for all\" copies a picked value to the others.",
|
|
4
|
+
"prompt": {
|
|
5
|
+
"id": "add_transaction / ask_missing",
|
|
6
|
+
"kind": "decision",
|
|
7
|
+
"token": "Yj7sW9xCu5RkD7fF",
|
|
8
|
+
"message": "Some records this transaction uses aren't in QuickBooks yet. For each one, create it or pick one QuickBooks already has.",
|
|
9
|
+
"data": {
|
|
10
|
+
"operation": "invoice",
|
|
11
|
+
"missing": [
|
|
12
|
+
{
|
|
13
|
+
"kind": "customer",
|
|
14
|
+
"full_name": "Lone Star Supply",
|
|
15
|
+
"paths": [
|
|
16
|
+
"customer_ref"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"kind": "item",
|
|
21
|
+
"full_name": "Vinyl Banner 3x6",
|
|
22
|
+
"paths": [
|
|
23
|
+
"invoice_line_add.0.item_ref"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"kind": "item",
|
|
28
|
+
"full_name": "Hardware Kit",
|
|
29
|
+
"paths": [
|
|
30
|
+
"invoice_line_add.1.item_ref"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"details": [
|
|
36
|
+
{
|
|
37
|
+
"label": "Adding",
|
|
38
|
+
"value": "Invoice"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"label": "Number",
|
|
42
|
+
"value": "1042"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"label": "For",
|
|
46
|
+
"value": "Lone Star Supply"
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
"options": [],
|
|
50
|
+
"choices": [
|
|
51
|
+
{
|
|
52
|
+
"id": "proceed",
|
|
53
|
+
"label": "Add the transaction",
|
|
54
|
+
"description": null,
|
|
55
|
+
"detail": null,
|
|
56
|
+
"fields": [
|
|
57
|
+
{
|
|
58
|
+
"name": "records",
|
|
59
|
+
"type": "rows",
|
|
60
|
+
"label": "Not in QuickBooks yet",
|
|
61
|
+
"required": true,
|
|
62
|
+
"columns": [
|
|
63
|
+
{
|
|
64
|
+
"name": "action",
|
|
65
|
+
"type": "string",
|
|
66
|
+
"label": "What to do",
|
|
67
|
+
"required": true,
|
|
68
|
+
"kind": null,
|
|
69
|
+
"enum": [
|
|
70
|
+
{
|
|
71
|
+
"value": "create",
|
|
72
|
+
"label": "Create it"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"value": "use",
|
|
76
|
+
"label": "Use an existing one"
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
"when": null
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"name": "list_id",
|
|
83
|
+
"type": "record",
|
|
84
|
+
"label": "Existing record",
|
|
85
|
+
"required": true,
|
|
86
|
+
"kind": null,
|
|
87
|
+
"enum": null,
|
|
88
|
+
"when": {
|
|
89
|
+
"action": [
|
|
90
|
+
"use"
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"name": "item_type",
|
|
96
|
+
"type": "string",
|
|
97
|
+
"label": "Item type",
|
|
98
|
+
"required": true,
|
|
99
|
+
"kind": null,
|
|
100
|
+
"enum": [
|
|
101
|
+
{
|
|
102
|
+
"value": "item_service",
|
|
103
|
+
"label": "Service"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"value": "item_non_inventory",
|
|
107
|
+
"label": "Non-inventory part"
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"value": "item_inventory",
|
|
111
|
+
"label": "Inventory part"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"value": "item_other_charge",
|
|
115
|
+
"label": "Other charge"
|
|
116
|
+
}
|
|
117
|
+
],
|
|
118
|
+
"when": {
|
|
119
|
+
"action": [
|
|
120
|
+
"create"
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"name": "entity_type",
|
|
126
|
+
"type": "string",
|
|
127
|
+
"label": "Add it as",
|
|
128
|
+
"required": true,
|
|
129
|
+
"kind": null,
|
|
130
|
+
"enum": [
|
|
131
|
+
{
|
|
132
|
+
"value": "customer",
|
|
133
|
+
"label": "Customer"
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"value": "vendor",
|
|
137
|
+
"label": "Vendor"
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"value": "other_name",
|
|
141
|
+
"label": "Other name"
|
|
142
|
+
}
|
|
143
|
+
],
|
|
144
|
+
"when": {
|
|
145
|
+
"action": [
|
|
146
|
+
"create"
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"name": "income_account",
|
|
152
|
+
"type": "record",
|
|
153
|
+
"label": "Income account",
|
|
154
|
+
"required": true,
|
|
155
|
+
"kind": "account",
|
|
156
|
+
"enum": null,
|
|
157
|
+
"when": {
|
|
158
|
+
"action": [
|
|
159
|
+
"create"
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"name": "asset_account",
|
|
165
|
+
"type": "record",
|
|
166
|
+
"label": "Inventory asset account",
|
|
167
|
+
"required": true,
|
|
168
|
+
"kind": "account",
|
|
169
|
+
"enum": null,
|
|
170
|
+
"when": {
|
|
171
|
+
"action": [
|
|
172
|
+
"create"
|
|
173
|
+
],
|
|
174
|
+
"item_type": [
|
|
175
|
+
"item_inventory"
|
|
176
|
+
]
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"name": "cogs_account",
|
|
181
|
+
"type": "record",
|
|
182
|
+
"label": "Cost of goods sold account",
|
|
183
|
+
"required": true,
|
|
184
|
+
"kind": "account",
|
|
185
|
+
"enum": null,
|
|
186
|
+
"when": {
|
|
187
|
+
"action": [
|
|
188
|
+
"create"
|
|
189
|
+
],
|
|
190
|
+
"item_type": [
|
|
191
|
+
"item_inventory"
|
|
192
|
+
]
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
],
|
|
196
|
+
"rows": [
|
|
197
|
+
{
|
|
198
|
+
"key": "customer_ref",
|
|
199
|
+
"label": "Lone Star Supply",
|
|
200
|
+
"detail": "Customer",
|
|
201
|
+
"columns": [
|
|
202
|
+
"action",
|
|
203
|
+
"list_id"
|
|
204
|
+
],
|
|
205
|
+
"values": {},
|
|
206
|
+
"kinds": {
|
|
207
|
+
"list_id": "customer"
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"key": "invoice_line_add.0.item_ref",
|
|
212
|
+
"label": "Vinyl Banner 3x6",
|
|
213
|
+
"detail": "Item",
|
|
214
|
+
"columns": [
|
|
215
|
+
"action",
|
|
216
|
+
"list_id",
|
|
217
|
+
"item_type",
|
|
218
|
+
"income_account",
|
|
219
|
+
"asset_account",
|
|
220
|
+
"cogs_account"
|
|
221
|
+
],
|
|
222
|
+
"values": {},
|
|
223
|
+
"kinds": {
|
|
224
|
+
"list_id": "item",
|
|
225
|
+
"income_account": "account",
|
|
226
|
+
"asset_account": "account",
|
|
227
|
+
"cogs_account": "account"
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"key": "invoice_line_add.1.item_ref",
|
|
232
|
+
"label": "Hardware Kit",
|
|
233
|
+
"detail": "Item",
|
|
234
|
+
"columns": [
|
|
235
|
+
"action",
|
|
236
|
+
"list_id",
|
|
237
|
+
"income_account",
|
|
238
|
+
"asset_account",
|
|
239
|
+
"cogs_account"
|
|
240
|
+
],
|
|
241
|
+
"values": {
|
|
242
|
+
"item_type": "item_inventory"
|
|
243
|
+
},
|
|
244
|
+
"kinds": {
|
|
245
|
+
"list_id": "item",
|
|
246
|
+
"income_account": "account",
|
|
247
|
+
"asset_account": "account",
|
|
248
|
+
"cogs_account": "account"
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
]
|
|
252
|
+
}
|
|
253
|
+
]
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"id": "cancel",
|
|
257
|
+
"label": "Cancel",
|
|
258
|
+
"description": "Stop here. Nothing is created.",
|
|
259
|
+
"detail": null,
|
|
260
|
+
"fields": []
|
|
261
|
+
}
|
|
262
|
+
]
|
|
263
|
+
},
|
|
264
|
+
"search": {
|
|
265
|
+
"records.list_id": [
|
|
266
|
+
{
|
|
267
|
+
"value": "80000001-1700000000",
|
|
268
|
+
"label": "Riverbend Electric",
|
|
269
|
+
"detail": "Customer"
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
"value": "80000002-1700000000",
|
|
273
|
+
"label": "Riverbend Supply Co.",
|
|
274
|
+
"detail": "Customer"
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
"value": "80000003-1700000000",
|
|
278
|
+
"label": "Lone Star Supply",
|
|
279
|
+
"detail": "Customer"
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
"value": "80000011-1700000000",
|
|
283
|
+
"label": "Vinyl Banner 3x6",
|
|
284
|
+
"detail": "Inventory part"
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
"value": "80000012-1700000000",
|
|
288
|
+
"label": "Hardware Kit",
|
|
289
|
+
"detail": "Non-inventory part"
|
|
290
|
+
}
|
|
291
|
+
],
|
|
292
|
+
"records.income_account": [
|
|
293
|
+
{
|
|
294
|
+
"value": "8000000A-1700000000",
|
|
295
|
+
"label": "Sales",
|
|
296
|
+
"detail": "Income"
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
"value": "8000000B-1700000000",
|
|
300
|
+
"label": "Sales:Signs",
|
|
301
|
+
"detail": "Income"
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
"value": "80000005-1700000000",
|
|
305
|
+
"label": "Inventory Asset",
|
|
306
|
+
"detail": "Other current asset"
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
"value": "80000006-1700000000",
|
|
310
|
+
"label": "Cost of Goods Sold",
|
|
311
|
+
"detail": "Cost of goods sold"
|
|
312
|
+
}
|
|
313
|
+
],
|
|
314
|
+
"records.asset_account": [
|
|
315
|
+
{
|
|
316
|
+
"value": "8000000A-1700000000",
|
|
317
|
+
"label": "Sales",
|
|
318
|
+
"detail": "Income"
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
"value": "8000000B-1700000000",
|
|
322
|
+
"label": "Sales:Signs",
|
|
323
|
+
"detail": "Income"
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
"value": "80000005-1700000000",
|
|
327
|
+
"label": "Inventory Asset",
|
|
328
|
+
"detail": "Other current asset"
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
"value": "80000006-1700000000",
|
|
332
|
+
"label": "Cost of Goods Sold",
|
|
333
|
+
"detail": "Cost of goods sold"
|
|
334
|
+
}
|
|
335
|
+
],
|
|
336
|
+
"records.cogs_account": [
|
|
337
|
+
{
|
|
338
|
+
"value": "8000000A-1700000000",
|
|
339
|
+
"label": "Sales",
|
|
340
|
+
"detail": "Income"
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
"value": "8000000B-1700000000",
|
|
344
|
+
"label": "Sales:Signs",
|
|
345
|
+
"detail": "Income"
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
"value": "80000005-1700000000",
|
|
349
|
+
"label": "Inventory Asset",
|
|
350
|
+
"detail": "Other current asset"
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
"value": "80000006-1700000000",
|
|
354
|
+
"label": "Cost of Goods Sold",
|
|
355
|
+
"detail": "Cost of goods sold"
|
|
356
|
+
}
|
|
357
|
+
]
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module QubeRails
|
|
6
|
+
# Every screen the engine's partials show, from sample data, for GET qube/storyboard: the run
|
|
7
|
+
# status in each state, the one-shot error, and a question of each kind (lib/qube_rails/
|
|
8
|
+
# storyboard/*.json, prompts shaped as QuBe Sync sends them). Nothing is read from or written
|
|
9
|
+
# to QuBe Sync or the database: runs are built, never saved.
|
|
10
|
+
module Storyboard
|
|
11
|
+
DIR = File.expand_path("storyboard", __dir__)
|
|
12
|
+
|
|
13
|
+
Screen = Struct.new(:key, :group, :title, :note, :partials, :kind, :sample)
|
|
14
|
+
|
|
15
|
+
QUESTIONS = %w[near_match record_search choose enum_select number_boolean name_clash ask_about_add missing_record rows
|
|
16
|
+
refusal].freeze
|
|
17
|
+
|
|
18
|
+
# key, title, SyncStatus status, the run's attributes (ages in seconds), extra pieces, note.
|
|
19
|
+
RUN_STATUSES = [
|
|
20
|
+
["not_sent", "Not sent", :not_sent, nil, [],
|
|
21
|
+
"A record with no run: the run status partial renders nothing."],
|
|
22
|
+
["starting", "Starting", :sending, { state: "starting", age: 5 }, [],
|
|
23
|
+
"A start QuBe Sync is confirming."],
|
|
24
|
+
["unconfirmed", "A start not confirmed", :sending, { state: "starting", age: 120 }, ["run_status/retry"],
|
|
25
|
+
"QuBe Sync hasn't confirmed the start for a minute: the retry button sends it again, same reference."],
|
|
26
|
+
["running", "Running", :sending, { state: "running", qube_run_id: "run-sample", age: 30 }, [],
|
|
27
|
+
"QuBe Sync confirmed it; the Web Connector is working through it."],
|
|
28
|
+
["stalled_not_checking_in", "Waiting: QuickBooks isn't checking in", :sending,
|
|
29
|
+
{ state: "running", qube_run_id: "run-sample", age: 30 * 60, stall: "not_checking_in" }, ["run_status/stall"],
|
|
30
|
+
"A run going on a connection QuBe Sync says stalled (not_checking_in): the Web Connector hasn't called in. " \
|
|
31
|
+
"No redeliver button: the cause is known."],
|
|
32
|
+
["stalled_quickbooks_unavailable", "Waiting: QuickBooks is closed", :sending,
|
|
33
|
+
{ state: "running", qube_run_id: "run-sample", age: 30 * 60, stall: "quickbooks_unavailable" },
|
|
34
|
+
["run_status/stall"],
|
|
35
|
+
"The same, stalled as quickbooks_unavailable: the Web Connector calls in but can't open the company file."],
|
|
36
|
+
["needs_answer", "Waiting for an answer", :needs_answer,
|
|
37
|
+
{ state: "awaiting_input", qube_run_id: "run-sample", age: 30, question: { "message" => "?" } }, [],
|
|
38
|
+
"The run asks a question: the question partial shows it (below)."],
|
|
39
|
+
["looks_stuck", "Looks stuck", :sending, { state: "running", qube_run_id: "run-sample", age: 15 * 60 },
|
|
40
|
+
["run_status/redeliver"],
|
|
41
|
+
"No news for 10 minutes: the redeliver button asks QuBe Sync to send its latest event again."],
|
|
42
|
+
["in_quickbooks", "In QuickBooks", :in_quickbooks, { state: "completed", outcome: "added", ended: true }, [],
|
|
43
|
+
"The record is linked to its QuickBooks id."],
|
|
44
|
+
["done", "Done", :done, { state: "completed", outcome: "completed", ended: true }, [],
|
|
45
|
+
"Completed, linking nothing (an operation that doesn't link its record)."],
|
|
46
|
+
["failed", "Failed", :failed,
|
|
47
|
+
{ state: "failed", ended: true,
|
|
48
|
+
error: { "message" => "QuickBooks refused the invoice because Riverbend Electric is inactive",
|
|
49
|
+
"detail" => "Make Riverbend Electric active in QuickBooks, then send it again." } },
|
|
50
|
+
["run_status/detail"], "A failure: the pill says it couldn't sync; below it, why (the error's message) and what to do (its detail)."],
|
|
51
|
+
["cancelled", "Cancelled", :cancelled,
|
|
52
|
+
{ state: "cancelled", ended: true, cancel_reason: "The order was cancelled in the app." }, [],
|
|
53
|
+
"Cancelled by your app, with its reason."]
|
|
54
|
+
].freeze
|
|
55
|
+
|
|
56
|
+
# Stands in for the engine's routes: every form posts to the storyboard's no-op answer.
|
|
57
|
+
class Urls
|
|
58
|
+
def initialize(answer_path)
|
|
59
|
+
@answer_path = answer_path
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def question_path(_run)
|
|
63
|
+
@answer_path
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def retry_start_path(_run)
|
|
67
|
+
@answer_path
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def redeliver_path(_run)
|
|
71
|
+
@answer_path
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class << self
|
|
76
|
+
def screens
|
|
77
|
+
run_status_screens + [error_screen] + QUESTIONS.map { |key| question_screen(key) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def find(key)
|
|
81
|
+
screens.find { |screen| screen.key == key.to_s }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def run_status_view(screen, urls)
|
|
85
|
+
RunStatusView.from_sync(sync_status(screen.sample), urls)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def question_view(screen, answer_path, search_path)
|
|
89
|
+
QuestionView.new(screen.sample["prompt"], action_url: answer_path, search_url: search_path,
|
|
90
|
+
error: screen.sample["error"])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The sample's matches for a search: at most 20 whose label holds q.
|
|
94
|
+
def matches(screen, field, q)
|
|
95
|
+
list = screen && screen.kind == :question ? Array(screen.sample["search"][field.to_s]) : []
|
|
96
|
+
q = q.to_s.strip.downcase
|
|
97
|
+
list.select { |m| m["label"].to_s.downcase.include?(q) }.first(20)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The file an app puts at that path to override a piece.
|
|
101
|
+
def override_path(partial)
|
|
102
|
+
dir, _, name = partial.rpartition("/")
|
|
103
|
+
"app/views/qube_rails/#{dir.empty? ? "" : "#{dir}/"}_#{name}.html.erb"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def run_status_screens
|
|
109
|
+
RUN_STATUSES.map do |key, title, status, run, pieces, note|
|
|
110
|
+
partials = %w[run_status/status run_status/summary] + pieces
|
|
111
|
+
partials = [] if status == :not_sent
|
|
112
|
+
Screen.new(key, "Run status", title, note, partials, :run_status, { status: status, run: run })
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def error_screen
|
|
117
|
+
Screen.new("error", "Run status", "A one-shot error",
|
|
118
|
+
"A route's message (flash[:qube_rails_error]), shown once by whichever partial comes first, " \
|
|
119
|
+
"or by render \"qube_rails/error\" on a page without them.", ["error"], :error,
|
|
120
|
+
{ message: QubeRails.t("errors.question_changed") })
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def question_screen(key)
|
|
124
|
+
sample = JSON.parse(File.read(File.join(DIR, "#{key}.json")))
|
|
125
|
+
Screen.new(key, "Questions", sample["title"], sample["note"], question_partials(sample), :question, sample)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def question_partials(sample)
|
|
129
|
+
prompt = sample["prompt"]
|
|
130
|
+
list = %w[question/question question/message]
|
|
131
|
+
list << "question/details" if Array(prompt["details"]).any?
|
|
132
|
+
view = QuestionView.new(prompt, action_url: "", search_url: "")
|
|
133
|
+
list.concat(%w[question/picks question/pick]) if view.picks.any?
|
|
134
|
+
list << "question/choice" if view.forms.any? || view.exits.any?
|
|
135
|
+
list << "question/footer" if view.exits.any?
|
|
136
|
+
list << "error" if sample["error"]
|
|
137
|
+
fields = Array(prompt["choices"]).flat_map { |choice| Array(choice["fields"]) }
|
|
138
|
+
fields.each do |field|
|
|
139
|
+
type = FieldView.type_of(field, record_type: "search")
|
|
140
|
+
list << "question/field" unless type == "rows"
|
|
141
|
+
list << "question/fields/#{type}"
|
|
142
|
+
next unless type == "rows"
|
|
143
|
+
|
|
144
|
+
list.concat(%w[question/rows/row question/rows/cell question/rows/segmented question/field])
|
|
145
|
+
Array(field["columns"]).each { |c| list << "question/fields/#{FieldView.type_of(c, record_type: "search")}" }
|
|
146
|
+
end
|
|
147
|
+
list.uniq
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def sync_status(sample)
|
|
151
|
+
attrs = sample[:run]
|
|
152
|
+
return SyncStatus.new(:not_sent, nil, nil) unless attrs
|
|
153
|
+
|
|
154
|
+
now = Time.now
|
|
155
|
+
created = now - attrs[:age].to_i
|
|
156
|
+
run = Run.new(state: attrs[:state], outcome: attrs[:outcome], error: attrs[:error],
|
|
157
|
+
cancel_reason: attrs[:cancel_reason], qube_run_id: attrs[:qube_run_id],
|
|
158
|
+
question: attrs[:question], operation: "storyboard", reference: "storyboard",
|
|
159
|
+
created_at: created, updated_at: created, ended_at: attrs[:ended] ? now : nil)
|
|
160
|
+
run.connection = Connection.new(status: attrs[:stall] ? "stalled" : "connected", stall_reason: attrs[:stall])
|
|
161
|
+
SyncStatus.new(sample[:status], run, nil)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QubeRails
|
|
4
|
+
# Where one of your records stands with QuickBooks on one connection, for one operation,
|
|
5
|
+
# worked out from what the engine already keeps: the record's runs (qube_runs) and its
|
|
6
|
+
# QuickBooks link (qube_quickbooks_records). Nothing is stored for it, so it can't go stale,
|
|
7
|
+
# and your tables need no status column.
|
|
8
|
+
#
|
|
9
|
+
# status = connection.sync_status(invoice, operation: "create_invoice")
|
|
10
|
+
# status.status # => :not_sent, :sending, :needs_answer, :in_quickbooks, :done, :failed or
|
|
11
|
+
# # :cancelled
|
|
12
|
+
# status.qb_id # the record's QuickBooks id on this connection, when it's linked
|
|
13
|
+
# status.error # a failed run's error ({ "message" => ..., ... }), else nil
|
|
14
|
+
# status.retry? # whether starting it again makes sense
|
|
15
|
+
#
|
|
16
|
+
# Per connection: an invoice can be in one company file and not another.
|
|
17
|
+
#
|
|
18
|
+
# The run and the link can disagree (a record linked earlier, with a new run since), so the
|
|
19
|
+
# newest word wins, in this order:
|
|
20
|
+
# 1. the latest run hasn't ended: :sending (starting or running, an unconfirmed start too:
|
|
21
|
+
# unconfirmed? says so) or :needs_answer (awaiting input), whatever the link says;
|
|
22
|
+
# 2. the latest run failed or was cancelled after the link was made (or there's no link):
|
|
23
|
+
# :failed or :cancelled;
|
|
24
|
+
# 3. the record is linked: :in_quickbooks, always with its qb_id;
|
|
25
|
+
# 4. the latest run completed and linked nothing (an operation that doesn't link its record):
|
|
26
|
+
# :done, which a start may follow, since nothing in QuickBooks stands for the record;
|
|
27
|
+
# 5. neither: :not_sent.
|
|
28
|
+
class SyncStatus
|
|
29
|
+
STATUSES = %i[not_sent sending needs_answer in_quickbooks done failed cancelled].freeze
|
|
30
|
+
|
|
31
|
+
attr_reader :status, :run, :link
|
|
32
|
+
|
|
33
|
+
# record's status on connection for operation (its SLUG).
|
|
34
|
+
def self.for(connection, record, operation:)
|
|
35
|
+
run = QubeRails::Run.where(connection: connection).latest_for(record, operation)
|
|
36
|
+
type, id = QubeRails::ApplicationRecord.key_for(record)
|
|
37
|
+
link = connection.quickbooks_records.where(record_type: type, record_id: id)
|
|
38
|
+
.order(updated_at: :desc, id: :desc).first
|
|
39
|
+
new(status_of(run, link), run, link)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# record's status on the connection its latest run of operation went out on: what a record's
|
|
43
|
+
# page shows when it doesn't say which connection. :not_sent when it has no run.
|
|
44
|
+
def self.latest(record, operation:)
|
|
45
|
+
run = QubeRails::Run.latest_for(record, operation)
|
|
46
|
+
return new(:not_sent, nil, nil) unless run
|
|
47
|
+
|
|
48
|
+
self.for(run.connection, record, operation: operation)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.status_of(run, link)
|
|
52
|
+
return in_flight(run) if run && !run.ended?
|
|
53
|
+
return stopped(run) if stopped_since_link?(run, link)
|
|
54
|
+
return :in_quickbooks if link
|
|
55
|
+
return :done if run&.state == "completed"
|
|
56
|
+
|
|
57
|
+
:not_sent
|
|
58
|
+
end
|
|
59
|
+
private_class_method :status_of
|
|
60
|
+
|
|
61
|
+
def self.in_flight(run)
|
|
62
|
+
run.state == "awaiting_input" ? :needs_answer : :sending
|
|
63
|
+
end
|
|
64
|
+
private_class_method :in_flight
|
|
65
|
+
|
|
66
|
+
def self.stopped(run)
|
|
67
|
+
run.cancelled? ? :cancelled : :failed
|
|
68
|
+
end
|
|
69
|
+
private_class_method :stopped
|
|
70
|
+
|
|
71
|
+
# The latest run failed or was cancelled, and the link (if any) is older than its end.
|
|
72
|
+
def self.stopped_since_link?(run, link)
|
|
73
|
+
return false unless run && (run.failed? || run.cancelled?)
|
|
74
|
+
|
|
75
|
+
link.nil? || link.updated_at <= (run.ended_at || run.updated_at)
|
|
76
|
+
end
|
|
77
|
+
private_class_method :stopped_since_link?
|
|
78
|
+
|
|
79
|
+
def initialize(status, run, link)
|
|
80
|
+
@status = status
|
|
81
|
+
@run = run
|
|
82
|
+
@link = link
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
STATUSES.each do |name|
|
|
86
|
+
define_method(:"#{name}?") { status == name }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The record's QuickBooks id on this connection, when it's linked.
|
|
90
|
+
def qb_id
|
|
91
|
+
link&.qb_id
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A failed run's error, as its event carried it.
|
|
95
|
+
def error
|
|
96
|
+
run&.error if failed?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A start QuBe Sync hasn't confirmed for a minute: run.retry_start! sends it again.
|
|
100
|
+
def unconfirmed?
|
|
101
|
+
sending? && run.unconfirmed?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Whether starting the operation again makes sense: after a failure or a cancellation, unless
|
|
105
|
+
# the record is in QuickBooks on this connection anyway (an add sent again would add it twice).
|
|
106
|
+
# Not while a run is going: a start then returns that run.
|
|
107
|
+
def retry?
|
|
108
|
+
(failed? || cancelled?) && link.nil?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Where it stands, in one plain sentence for a person (the run's, QubeRails::Run#summary_text,
|
|
112
|
+
# when a run says it), or nil when it hasn't been sent.
|
|
113
|
+
def summary_text
|
|
114
|
+
return nil if not_sent?
|
|
115
|
+
return QubeRails.t("status.in_quickbooks") if in_quickbooks? && run&.state != "completed"
|
|
116
|
+
|
|
117
|
+
run.summary_text
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def to_s
|
|
121
|
+
status.to_s
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QubeRails
|
|
4
|
+
# Helpers in every view of your app (the engine includes them into ActionView).
|
|
5
|
+
module ViewHelpers
|
|
6
|
+
# The question's behaviour script, and with stylesheet: true the engine's opt-in stylesheet,
|
|
7
|
+
# for your layout's <head>:
|
|
8
|
+
# <%= qube_rails_assets stylesheet: true %>
|
|
9
|
+
# Served by your asset pipeline (Propshaft or Sprockets) as qube_rails/question.js and
|
|
10
|
+
# qube_rails.css.
|
|
11
|
+
def qube_rails_assets(stylesheet: false)
|
|
12
|
+
tags = []
|
|
13
|
+
tags << stylesheet_link_tag("qube_rails", media: "all") if stylesheet
|
|
14
|
+
tags << javascript_include_tag("qube_rails/question", defer: true, nonce: true)
|
|
15
|
+
safe_join(tags, "\n")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The classes for a piece of the engine's screens: its qube- classes, then the app's
|
|
19
|
+
# config.ui_classes for that piece.
|
|
20
|
+
def qube_classes(piece, *base)
|
|
21
|
+
extra = QubeRails.config.ui_classes[piece.to_sym]
|
|
22
|
+
unless QubeRails::Configuration::UI_PIECES.include?(piece.to_sym)
|
|
23
|
+
raise ArgumentError, "QuBe Sync: no ui_classes piece #{piece.inspect}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
(base.flatten.compact + [extra]).reject { |c| c.nil? || c.empty? }.join(" ")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|