nti_receipt_builder 0.1.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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +272 -0
  6. data/Rakefile +12 -0
  7. data/app/assets/javascripts/nti_receipt_builder/index.js +16 -0
  8. data/app/assets/javascripts/nti_receipt_builder/receipt_builder_controller.js +297 -0
  9. data/app/assets/javascripts/nti_receipt_builder/receipt_canvas_controller.js +279 -0
  10. data/app/assets/javascripts/nti_receipt_builder/receipt_draggable_controller.js +154 -0
  11. data/app/assets/javascripts/nti_receipt_builder/receipt_print_controller.js +24 -0
  12. data/app/assets/javascripts/nti_receipt_builder/receipt_properties_controller.js +297 -0
  13. data/app/assets/stylesheets/nti_receipt_builder.css +238 -0
  14. data/app/controllers/concerns/nti_receipt_builder/templates_controller.rb +170 -0
  15. data/app/helpers/nti_receipt_builder/render_helper.rb +21 -0
  16. data/app/models/nti_receipt_builder/template.rb +79 -0
  17. data/app/views/nti_receipt_builder/render/_order_lines_element.html.erb +36 -0
  18. data/app/views/nti_receipt_builder/render/_overflow_banner.html.erb +5 -0
  19. data/app/views/nti_receipt_builder/render/_paper.html.erb +12 -0
  20. data/app/views/nti_receipt_builder/render/_text_element.html.erb +1 -0
  21. data/app/views/nti_receipt_builder/render/_variable_element.html.erb +1 -0
  22. data/app/views/nti_receipt_builder/templates/_canvas.html.erb +33 -0
  23. data/app/views/nti_receipt_builder/templates/_elements_panel.html.erb +49 -0
  24. data/app/views/nti_receipt_builder/templates/_errors.html.erb +21 -0
  25. data/app/views/nti_receipt_builder/templates/_lock_version_field.html.erb +2 -0
  26. data/app/views/nti_receipt_builder/templates/_print_assets.html.erb +15 -0
  27. data/app/views/nti_receipt_builder/templates/_properties_panel.html.erb +8 -0
  28. data/app/views/nti_receipt_builder/templates/_settings_panel.html.erb +130 -0
  29. data/app/views/nti_receipt_builder/templates/create.turbo_stream.erb +3 -0
  30. data/app/views/nti_receipt_builder/templates/edit.html.erb +54 -0
  31. data/app/views/nti_receipt_builder/templates/new.html.erb +115 -0
  32. data/app/views/nti_receipt_builder/templates/preview.html.erb +24 -0
  33. data/app/views/nti_receipt_builder/templates/print.html.erb +31 -0
  34. data/app/views/nti_receipt_builder/templates/render_preview.turbo_stream.erb +26 -0
  35. data/app/views/nti_receipt_builder/templates/update.turbo_stream.erb +7 -0
  36. data/config/importmap.rb +4 -0
  37. data/lib/generators/nti_receipt_builder/install_generator.rb +49 -0
  38. data/lib/generators/nti_receipt_builder/templates/create_nti_receipt_templates.rb +28 -0
  39. data/lib/generators/nti_receipt_builder/templates/initializer.rb +16 -0
  40. data/lib/generators/nti_receipt_builder/templates/turbo_stream_tags.rb +36 -0
  41. data/lib/nti_receipt_builder/configuration.rb +59 -0
  42. data/lib/nti_receipt_builder/elements.rb +46 -0
  43. data/lib/nti_receipt_builder/engine.rb +32 -0
  44. data/lib/nti_receipt_builder/errors.rb +13 -0
  45. data/lib/nti_receipt_builder/layout_validator.rb +285 -0
  46. data/lib/nti_receipt_builder/presenters/collection.rb +126 -0
  47. data/lib/nti_receipt_builder/presenters/element.rb +68 -0
  48. data/lib/nti_receipt_builder/render_result.rb +18 -0
  49. data/lib/nti_receipt_builder/renderer.rb +78 -0
  50. data/lib/nti_receipt_builder/set_default.rb +24 -0
  51. data/lib/nti_receipt_builder/template_form.rb +60 -0
  52. data/lib/nti_receipt_builder/type_inference.rb +26 -0
  53. data/lib/nti_receipt_builder/variable_definition.rb +74 -0
  54. data/lib/nti_receipt_builder/variable_resolver.rb +55 -0
  55. data/lib/nti_receipt_builder/variables.rb +159 -0
  56. data/lib/nti_receipt_builder/version.rb +5 -0
  57. data/lib/nti_receipt_builder.rb +50 -0
  58. metadata +175 -0
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NtiReceiptBuilder
4
+ # Structural + whitelist validation of a template's jsonb `layout`. This is the single
5
+ # place that decides whether a submitted layout is safe to persist and render — it never
6
+ # trusts client-side snap/clamp/whitelist checks, since those are UX conveniences only.
7
+ #
8
+ # Allowed variable keys and order-line column keys come from the host's variables class,
9
+ # so this validator holds no domain vocabulary of its own.
10
+ class LayoutValidator
11
+ GEOMETRY_KEYS = %w[x_mm y_mm width_mm height_mm].freeze
12
+
13
+ def initialize(layout:, paper_width_mm:, paper_height_mm:, margin_top_mm:, margin_right_mm:,
14
+ margin_bottom_mm:, margin_left_mm:, height_mode:,
15
+ variables_class: NtiReceiptBuilder.variables_class)
16
+ @layout = layout
17
+ @paper_width_mm = paper_width_mm
18
+ @paper_height_mm = paper_height_mm
19
+ @margin_top_mm = margin_top_mm
20
+ @margin_right_mm = margin_right_mm
21
+ @margin_bottom_mm = margin_bottom_mm
22
+ @margin_left_mm = margin_left_mm
23
+ @height_mode = height_mode
24
+ @variables_class = variables_class
25
+ @errors = []
26
+ end
27
+
28
+ def call
29
+ unless @layout.is_a?(Array)
30
+ @errors << 'must be an array of elements'
31
+ return @errors
32
+ end
33
+
34
+ if @layout.size > Elements::MAX_ELEMENTS
35
+ @errors << "may contain at most #{Elements::MAX_ELEMENTS} elements (has #{@layout.size})"
36
+ end
37
+
38
+ seen_ids = Set.new
39
+ @layout.each_with_index { |element, index| validate_element(element, index, seen_ids) }
40
+
41
+ @errors
42
+ end
43
+
44
+ private
45
+
46
+ def validate_element(element, index, seen_ids)
47
+ tag = "element ##{index + 1}"
48
+
49
+ unless element.is_a?(Hash)
50
+ @errors << "#{tag} must be an object"
51
+ return
52
+ end
53
+
54
+ validate_id(element, tag, seen_ids)
55
+
56
+ type = element['type']
57
+ unless Elements::TYPES.include?(type)
58
+ @errors << "#{tag} has an unknown type (#{type.inspect})"
59
+ return
60
+ end
61
+
62
+ validate_geometry(element, tag)
63
+ validate_z_index(element, tag)
64
+ validate_visibility(element, tag)
65
+ validate_styles(element, tag)
66
+
67
+ case type
68
+ when 'variable' then validate_variable(element, tag)
69
+ when 'text' then validate_text(element, tag)
70
+ when 'order_lines' then validate_order_lines(element, tag)
71
+ end
72
+ end
73
+
74
+ def validate_id(element, tag, seen_ids)
75
+ id = element['id']
76
+ if !id.is_a?(String) || id.blank?
77
+ @errors << "#{tag} is missing a valid id"
78
+ elsif seen_ids.include?(id)
79
+ @errors << "#{tag} has a duplicate id (#{id})"
80
+ else
81
+ seen_ids << id
82
+ end
83
+ end
84
+
85
+ def validate_geometry(element, tag)
86
+ return unless numeric_geometry?(element, tag)
87
+
88
+ validate_position(element, tag)
89
+ validate_dimensions(element, tag)
90
+ validate_paper_bounds(element, tag)
91
+ end
92
+
93
+ # A single non-numeric coordinate stops geometry validation — the remaining checks would
94
+ # all report on coerced zeros and bury the real problem.
95
+ def numeric_geometry?(element, tag)
96
+ offending = GEOMETRY_KEYS.find { |key| !element[key].is_a?(Numeric) }
97
+ return true if offending.nil?
98
+
99
+ @errors << "#{tag} has a non-numeric #{offending}"
100
+ false
101
+ end
102
+
103
+ def validate_position(element, tag)
104
+ return unless element['x_mm'].to_f.negative? || element['y_mm'].to_f.negative?
105
+
106
+ @errors << "#{tag} has a negative position"
107
+ end
108
+
109
+ def validate_dimensions(element, tag)
110
+ too_small = element['width_mm'].to_f < Elements::MIN_ELEMENT_DIMENSION_MM ||
111
+ element['height_mm'].to_f < Elements::MIN_ELEMENT_DIMENSION_MM
112
+ return unless too_small
113
+
114
+ @errors << "#{tag} is smaller than the minimum size of #{Elements::MIN_ELEMENT_DIMENSION_MM}mm"
115
+ end
116
+
117
+ def validate_paper_bounds(element, tag)
118
+ x_mm = element['x_mm'].to_f
119
+ y_mm = element['y_mm'].to_f
120
+
121
+ @errors << "#{tag} extends beyond the paper width" if x_mm + element['width_mm'].to_f > @paper_width_mm
122
+
123
+ return if @height_mode == 'content'
124
+
125
+ @errors << "#{tag} extends beyond the paper height" if y_mm + element['height_mm'].to_f > @paper_height_mm
126
+ end
127
+
128
+ def validate_z_index(element, tag)
129
+ return unless element.key?('z_index')
130
+
131
+ value = element['z_index']
132
+ return if value.is_a?(Integer) && value.between?(Elements::Z_INDEX_MIN, Elements::Z_INDEX_MAX)
133
+
134
+ @errors << "#{tag} has an invalid z_index"
135
+ end
136
+
137
+ def validate_visibility(element, tag)
138
+ return unless element.key?('visible')
139
+
140
+ @errors << "#{tag} has an invalid visible flag" unless [true, false].include?(element['visible'])
141
+ end
142
+
143
+ def validate_styles(element, tag)
144
+ styles = element['styles']
145
+ return if styles.nil?
146
+
147
+ unless styles.is_a?(Hash)
148
+ @errors << "#{tag} has invalid styles"
149
+ return
150
+ end
151
+
152
+ styles.each { |key, value| validate_style_property(key, value, tag) }
153
+ end
154
+
155
+ def validate_style_property(key, value, tag)
156
+ rule = Elements::STYLE_WHITELIST[key]
157
+ unless rule
158
+ @errors << "#{tag} has a disallowed style property (#{key})"
159
+ return
160
+ end
161
+
162
+ case rule[:type]
163
+ when :numeric
164
+ unless value.is_a?(Numeric) && value.between?(rule[:min], rule[:max])
165
+ @errors << "#{tag} has an out-of-range #{key}"
166
+ end
167
+ when :enum
168
+ @errors << "#{tag} has an invalid #{key}" unless rule[:values].include?(value)
169
+ end
170
+ end
171
+
172
+ def validate_variable(element, tag)
173
+ key = element['variable_key']
174
+ unless key.is_a?(String) && @variables_class.scalar_keys.include?(key)
175
+ @errors << "#{tag} has an unknown variable_key (#{key.inspect})"
176
+ end
177
+
178
+ validate_optional_string(element, 'fallback', Elements::FALLBACK_MAX_LENGTH, tag)
179
+ validate_optional_string(element, 'prefix', Elements::PREFIX_MAX_LENGTH, tag)
180
+ validate_optional_string(element, 'suffix', Elements::SUFFIX_MAX_LENGTH, tag)
181
+ end
182
+
183
+ def validate_optional_string(element, key, max_length, tag)
184
+ return unless element.key?(key)
185
+
186
+ value = element[key]
187
+ @errors << "#{tag} has an invalid #{key}" unless value.is_a?(String) && value.length <= max_length
188
+ end
189
+
190
+ def validate_text(element, tag)
191
+ text = element['text']
192
+ return if text.is_a?(String) && text.present? && text.length <= Elements::TEXT_MAX_LENGTH
193
+
194
+ @errors << "#{tag} has a missing or too-long text value"
195
+ end
196
+
197
+ def validate_order_lines(element, tag)
198
+ config = element['config']
199
+ unless config.is_a?(Hash)
200
+ @errors << "#{tag} is missing its order_lines config"
201
+ return
202
+ end
203
+
204
+ if config.key?('show_header') && ![true, false].include?(config['show_header'])
205
+ @errors << "#{tag} has an invalid show_header"
206
+ end
207
+
208
+ validate_row_spacing(config, tag)
209
+ validate_order_lines_font_size(config, tag)
210
+ validate_order_line_columns(config['columns'], tag)
211
+ end
212
+
213
+ def validate_row_spacing(config, tag)
214
+ return unless config.key?('row_spacing_mm')
215
+
216
+ value = config['row_spacing_mm']
217
+ in_range = value.is_a?(Numeric) &&
218
+ value.between?(Elements::ROW_SPACING_MIN_MM, Elements::ROW_SPACING_MAX_MM)
219
+ @errors << "#{tag} has an out-of-range row_spacing_mm" unless in_range
220
+ end
221
+
222
+ def validate_order_lines_font_size(config, tag)
223
+ return unless config.key?('font_size_pt')
224
+
225
+ rule = Elements::STYLE_WHITELIST['font_size_pt']
226
+ value = config['font_size_pt']
227
+ return if value.is_a?(Numeric) && value.between?(rule[:min], rule[:max])
228
+
229
+ @errors << "#{tag} has an out-of-range font_size_pt"
230
+ end
231
+
232
+ # Only a lower bound is checked. Every key must be one of the host's declared collection
233
+ # columns and duplicates are rejected below, so the column count cannot exceed the host's
234
+ # own vocabulary — capping it here would reject a legitimately wider host.
235
+ def validate_order_line_columns(columns, tag)
236
+ unless columns.is_a?(Array) && columns.size >= Elements::MIN_ORDER_LINE_COLUMNS
237
+ @errors << "#{tag} must have at least #{Elements::MIN_ORDER_LINE_COLUMNS} order_lines column"
238
+ return
239
+ end
240
+
241
+ seen_keys = Set.new
242
+ columns.each_with_index { |column, index| validate_order_line_column(column, index, tag, seen_keys) }
243
+ end
244
+
245
+ def validate_order_line_column(column, index, tag, seen_keys)
246
+ col_tag = "#{tag} column ##{index + 1}"
247
+
248
+ unless column.is_a?(Hash)
249
+ @errors << "#{col_tag} must be an object"
250
+ return
251
+ end
252
+
253
+ validate_order_line_column_key(column, col_tag, seen_keys)
254
+ validate_order_line_column_label(column, col_tag)
255
+ validate_order_line_column_geometry(column, col_tag)
256
+ end
257
+
258
+ def validate_order_line_column_label(column, col_tag)
259
+ label = column['label']
260
+ return if label.is_a?(String) && label.present? && label.length <= Elements::COLUMN_LABEL_MAX_LENGTH
261
+
262
+ @errors << "#{col_tag} has an invalid label"
263
+ end
264
+
265
+ def validate_order_line_column_geometry(column, col_tag)
266
+ width_mm = column['width_mm']
267
+ @errors << "#{col_tag} has an invalid width_mm" unless width_mm.is_a?(Numeric) && width_mm.positive?
268
+
269
+ @errors << "#{col_tag} has an invalid align" unless Elements::TEXT_ALIGNS.include?(column['align'])
270
+ end
271
+
272
+ def validate_order_line_column_key(column, col_tag, seen_keys)
273
+ key = column['key']
274
+ allowed = @variables_class.collection_definition&.column_keys || []
275
+
276
+ if !key.is_a?(String) || !allowed.include?(key)
277
+ @errors << "#{col_tag} has an unknown key (#{key.inspect})"
278
+ elsif seen_keys.include?(key)
279
+ @errors << "#{col_tag} duplicates column key #{key}"
280
+ else
281
+ seen_keys << key
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_view'
4
+
5
+ module NtiReceiptBuilder
6
+ module Presenters
7
+ # Presents one `order_lines` layout element: whitelisted columns, per-row formatted cells,
8
+ # and the dynamically computed content height that drives overflow detection. An empty
9
+ # collection renders a single muted "no line items" row rather than collapsing to nothing.
10
+ #
11
+ # `columns`, `rows` and `total_column_width_mm` are memoised per instance so cell rendering
12
+ # stays O(rows x columns) rather than recomputing the width sum once per cell. Instances are
13
+ # short-lived — one per render — so nothing here outlives the response.
14
+ class Collection
15
+ include ActionView::Helpers::NumberHelper
16
+
17
+ DEFAULT_ROW_SPACING_MM = 1.0
18
+ DEFAULT_FONT_SIZE_PT = 9
19
+ MM_PER_PT = 0.3528
20
+ # Tight leading — receipt rows sit directly under one another. Anything near 1.6
21
+ # double-spaces the table and pushes the line items apart.
22
+ LINE_HEIGHT_FACTOR = 1.25
23
+
24
+ def initialize(element, rows, definition: nil)
25
+ @element = element
26
+ @collection_rows = rows || []
27
+ @definition = definition
28
+ end
29
+
30
+ def id = element['id']
31
+ def type = 'order_lines'
32
+ def x_mm = element['x_mm'].to_f
33
+ def y_mm = element['y_mm'].to_f
34
+ def width_mm = element['width_mm'].to_f
35
+ def z_index = element['z_index'] || 0
36
+
37
+ def columns
38
+ @columns ||= (config['columns'] || []).map { |column| build_column(column) }
39
+ end
40
+
41
+ # Configured column widths are treated as ratios, not absolutes: normalising them to
42
+ # percentages lets `table-layout: fixed` fill the row edge-to-edge whatever the mm happen
43
+ # to sum to. Falls back to even columns when the template configures no widths at all.
44
+ def column_width_pct(column)
45
+ return (100.0 / columns.size).round(4) unless total_column_width_mm.positive?
46
+
47
+ (column['width_mm'] / total_column_width_mm * 100).round(4)
48
+ end
49
+
50
+ def show_header? = config.fetch('show_header', true)
51
+ def row_spacing_mm = config['row_spacing_mm'] || DEFAULT_ROW_SPACING_MM
52
+ def font_size_pt = config['font_size_pt'] || DEFAULT_FONT_SIZE_PT
53
+
54
+ # The line box for one row of text. Cells render at exactly this line-height with
55
+ # row_spacing_mm as padding below, so a row's drawn height always equals row_height_mm
56
+ # and content_height_mm stays honest for overflow detection.
57
+ def text_line_height_mm
58
+ (font_size_pt * MM_PER_PT * LINE_HEIGHT_FACTOR).round(4)
59
+ end
60
+
61
+ def row_height_mm = text_line_height_mm + row_spacing_mm
62
+
63
+ # The header is just a bold row — same typography, same height.
64
+ def header_height_mm = show_header? ? row_height_mm : 0.0
65
+
66
+ def empty? = collection_rows.blank?
67
+
68
+ def rows
69
+ @rows ||= build_rows
70
+ end
71
+
72
+ def content_height_mm
73
+ header_height_mm + (rows.size * row_height_mm)
74
+ end
75
+
76
+ private
77
+
78
+ attr_reader :element, :collection_rows, :definition
79
+
80
+ def config
81
+ @config ||= element['config'].is_a?(Hash) ? element['config'] : {}
82
+ end
83
+
84
+ def total_column_width_mm
85
+ @total_column_width_mm ||= columns.sum { |column| column['width_mm'] }
86
+ end
87
+
88
+ def build_column(column)
89
+ {
90
+ 'key' => column['key'],
91
+ 'label' => column['label'],
92
+ 'width_mm' => column['width_mm'].to_f,
93
+ 'align' => column['align']
94
+ }
95
+ end
96
+
97
+ def build_rows
98
+ return [] if empty?
99
+
100
+ collection_rows.map do |row|
101
+ columns.each_with_object({}) do |column, cells|
102
+ key = column['key']
103
+ cells[key] = format_cell(key, row[key])
104
+ end
105
+ end
106
+ end
107
+
108
+ # The declared column type wins; otherwise the value's class decides. The gem does not
109
+ # know what "unit_price" means — the host's variables class does.
110
+ def format_cell(key, value)
111
+ case definition&.column_type(key) || TypeInference.call(value)
112
+ when :currency then format_currency(value)
113
+ else value.to_s
114
+ end
115
+ end
116
+
117
+ def format_currency(value)
118
+ return '' if value.nil?
119
+
120
+ number_to_currency(value, unit: NtiReceiptBuilder.config.currency_unit)
121
+ rescue StandardError
122
+ value.to_s
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NtiReceiptBuilder
4
+ module Presenters
5
+ # Presents one `variable` or `text` layout element: a whitelisted inline style string and
6
+ # a plain (un-escaped-by-us) display string. ERB's default auto-escaping in the render
7
+ # partials is what actually escapes the text — this presenter never marks anything
8
+ # html_safe.
9
+ class Element
10
+ def initialize(element, resolved_variables)
11
+ @element = element
12
+ @resolved_variables = resolved_variables
13
+ end
14
+
15
+ def id = element['id']
16
+ def type = element['type']
17
+ def x_mm = element['x_mm'].to_f
18
+ def y_mm = element['y_mm'].to_f
19
+ def width_mm = element['width_mm'].to_f
20
+ def height_mm = element['height_mm'].to_f
21
+ def z_index = element['z_index'] || 0
22
+
23
+ def css_style
24
+ declarations = [
25
+ 'position: absolute',
26
+ "left: #{x_mm}mm",
27
+ "top: #{y_mm}mm",
28
+ "width: #{width_mm}mm",
29
+ "height: #{height_mm}mm",
30
+ "z-index: #{z_index}"
31
+ ]
32
+ declarations.concat(style_declarations).join('; ')
33
+ end
34
+
35
+ def display_text
36
+ case type
37
+ when 'variable' then variable_display_text
38
+ when 'text' then element['text'].to_s
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :element, :resolved_variables
45
+
46
+ def style_declarations
47
+ styles = element['styles']
48
+ return [] unless styles.is_a?(Hash)
49
+
50
+ styles.filter_map do |key, value|
51
+ case key
52
+ when 'font_size_pt' then "font-size: #{value}pt"
53
+ when 'font_weight' then "font-weight: #{value}"
54
+ when 'text_align' then "text-align: #{value}"
55
+ when 'line_height' then "line-height: #{value}"
56
+ end
57
+ end
58
+ end
59
+
60
+ def variable_display_text
61
+ value = resolved_variables[element['variable_key']]
62
+ value = element['fallback'] if value.blank?
63
+
64
+ "#{element['prefix']}#{value}#{element['suffix']}"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NtiReceiptBuilder
4
+ # The output of one render: presenters in paint order, whether the content overflows the
5
+ # printable area, and how tall it actually is. Not cached — one per render, holding no
6
+ # references after the response.
7
+ class RenderResult
8
+ attr_reader :elements, :content_height_mm
9
+
10
+ def initialize(elements:, overflow:, content_height_mm:)
11
+ @elements = elements
12
+ @overflow = overflow
13
+ @content_height_mm = content_height_mm
14
+ end
15
+
16
+ def overflow? = @overflow
17
+ end
18
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NtiReceiptBuilder
4
+ # The single entry point that assembles a template's visible layout elements into
5
+ # presenters. The designer preview and the printed receipt both call this — never two
6
+ # divergent render code paths.
7
+ #
8
+ # Pass a receipt_object for real data; omit it and the configured variables class supplies
9
+ # samples, without calling any mapping method.
10
+ class Renderer
11
+ def initialize(template:, receipt_object: nil, variables_class: NtiReceiptBuilder.variables_class)
12
+ @template = template
13
+ @receipt_object = receipt_object
14
+ @variables_class = variables_class
15
+ end
16
+
17
+ def call
18
+ data = resolve_data
19
+ resolved_variables = VariableResolver.new(data, definitions: variables_class.definitions).call
20
+ collection_rows = collection_key ? data[collection_key] || [] : []
21
+
22
+ presenters = visible_elements
23
+ .map { |element| build_presenter(element, resolved_variables, collection_rows) }
24
+ .sort_by(&:z_index)
25
+
26
+ RenderResult.new(
27
+ elements: presenters,
28
+ overflow: overflow?(presenters),
29
+ content_height_mm: content_height_mm(presenters)
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :template, :receipt_object, :variables_class
36
+
37
+ def resolve_data
38
+ return variables_class.sample_data if receipt_object.nil?
39
+
40
+ variables_class.new(receipt_object).resolve
41
+ end
42
+
43
+ def collection_key = variables_class.collection_key
44
+
45
+ def visible_elements
46
+ (template.layout || []).select { |element| element.fetch('visible', true) }
47
+ end
48
+
49
+ def build_presenter(element, resolved_variables, collection_rows)
50
+ if element['type'] == 'order_lines'
51
+ Presenters::Collection.new(element, collection_rows, definition: variables_class.collection_definition)
52
+ else
53
+ Presenters::Element.new(element, resolved_variables)
54
+ end
55
+ end
56
+
57
+ def bottom_edge_mm(presenter)
58
+ if presenter.is_a?(Presenters::Collection)
59
+ presenter.y_mm + presenter.content_height_mm
60
+ else
61
+ presenter.y_mm + presenter.height_mm
62
+ end
63
+ end
64
+
65
+ def content_height_mm(presenters)
66
+ return 0.0 if presenters.empty?
67
+
68
+ presenters.map { |presenter| bottom_edge_mm(presenter) }.max
69
+ end
70
+
71
+ def overflow?(presenters)
72
+ return false if template.content_layout?
73
+
74
+ available_height_mm = template.paper_height_mm.to_f - template.margin_bottom_mm.to_f
75
+ content_height_mm(presenters) > available_height_mm
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NtiReceiptBuilder
4
+ # Makes one template the owner's default, clearing any previous default in a single UPDATE
5
+ # rather than loading the collection.
6
+ class SetDefault
7
+ def initialize(template:)
8
+ @template = template
9
+ end
10
+
11
+ def call
12
+ template.class
13
+ .where(owner_type: template.owner_type, owner_id: template.owner_id, is_default: true)
14
+ .where.not(id: template.id)
15
+ .update_all(is_default: false, updated_at: Time.current)
16
+
17
+ template.update!(is_default: true) unless template.is_default?
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :template
23
+ end
24
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+ require 'json'
5
+
6
+ module NtiReceiptBuilder
7
+ # Validates and coerces submitted template params.
8
+ #
9
+ # `layout` arrives as a JSON string from the builder's hidden field. Unparseable JSON is
10
+ # rejected rather than silently dropped — a designer whose layout failed to serialise must
11
+ # see an error, not a blank receipt.
12
+ class TemplateForm
13
+ include ActiveModel::Model
14
+
15
+ ATTRIBUTES = %i[name paper_size_key paper_width_mm paper_height_mm orientation height_mode
16
+ margin_top_mm margin_right_mm margin_bottom_mm margin_left_mm layout
17
+ lock_version].freeze
18
+
19
+ attr_accessor(*ATTRIBUTES)
20
+
21
+ validates :name, :paper_size_key, :orientation, :height_mode, presence: true
22
+ validates :paper_width_mm, :paper_height_mm, presence: true
23
+ validate :layout_must_be_valid_json
24
+
25
+ def attributes
26
+ {
27
+ name: name,
28
+ paper_size_key: paper_size_key,
29
+ paper_width_mm: paper_width_mm,
30
+ paper_height_mm: paper_height_mm,
31
+ orientation: orientation,
32
+ height_mode: height_mode,
33
+ margin_top_mm: margin_top_mm.presence || 0,
34
+ margin_right_mm: margin_right_mm.presence || 0,
35
+ margin_bottom_mm: margin_bottom_mm.presence || 0,
36
+ margin_left_mm: margin_left_mm.presence || 0,
37
+ layout: parsed_layout
38
+ }.tap { |attrs| attrs[:lock_version] = lock_version if lock_version.present? }
39
+ end
40
+
41
+ def parsed_layout
42
+ return [] if layout.blank?
43
+ return layout if layout.is_a?(Array)
44
+
45
+ JSON.parse(layout)
46
+ rescue JSON::ParserError
47
+ []
48
+ end
49
+
50
+ private
51
+
52
+ def layout_must_be_valid_json
53
+ return if layout.blank? || layout.is_a?(Array)
54
+
55
+ JSON.parse(layout)
56
+ rescue JSON::ParserError
57
+ errors.add(:layout, 'must be valid JSON')
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module NtiReceiptBuilder
6
+ # Maps a resolved value to the formatting type used by VariableResolver and the collection
7
+ # presenter. Inference is the default so a host declares nothing for the common cases; a
8
+ # Variables subclass overrides it per key with `variable KEY, type: :currency`.
9
+ #
10
+ # Deciding at format time rather than declaration time means real data and preview samples
11
+ # travel the same path.
12
+ module TypeInference
13
+ module_function
14
+
15
+ def call(value)
16
+ case value
17
+ when nil then nil
18
+ when Array then :collection
19
+ when BigDecimal then :currency
20
+ when Numeric then :number
21
+ else
22
+ value.respond_to?(:strftime) ? :datetime : :string
23
+ end
24
+ end
25
+ end
26
+ end