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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +272 -0
- data/Rakefile +12 -0
- data/app/assets/javascripts/nti_receipt_builder/index.js +16 -0
- data/app/assets/javascripts/nti_receipt_builder/receipt_builder_controller.js +297 -0
- data/app/assets/javascripts/nti_receipt_builder/receipt_canvas_controller.js +279 -0
- data/app/assets/javascripts/nti_receipt_builder/receipt_draggable_controller.js +154 -0
- data/app/assets/javascripts/nti_receipt_builder/receipt_print_controller.js +24 -0
- data/app/assets/javascripts/nti_receipt_builder/receipt_properties_controller.js +297 -0
- data/app/assets/stylesheets/nti_receipt_builder.css +238 -0
- data/app/controllers/concerns/nti_receipt_builder/templates_controller.rb +170 -0
- data/app/helpers/nti_receipt_builder/render_helper.rb +21 -0
- data/app/models/nti_receipt_builder/template.rb +79 -0
- data/app/views/nti_receipt_builder/render/_order_lines_element.html.erb +36 -0
- data/app/views/nti_receipt_builder/render/_overflow_banner.html.erb +5 -0
- data/app/views/nti_receipt_builder/render/_paper.html.erb +12 -0
- data/app/views/nti_receipt_builder/render/_text_element.html.erb +1 -0
- data/app/views/nti_receipt_builder/render/_variable_element.html.erb +1 -0
- data/app/views/nti_receipt_builder/templates/_canvas.html.erb +33 -0
- data/app/views/nti_receipt_builder/templates/_elements_panel.html.erb +49 -0
- data/app/views/nti_receipt_builder/templates/_errors.html.erb +21 -0
- data/app/views/nti_receipt_builder/templates/_lock_version_field.html.erb +2 -0
- data/app/views/nti_receipt_builder/templates/_print_assets.html.erb +15 -0
- data/app/views/nti_receipt_builder/templates/_properties_panel.html.erb +8 -0
- data/app/views/nti_receipt_builder/templates/_settings_panel.html.erb +130 -0
- data/app/views/nti_receipt_builder/templates/create.turbo_stream.erb +3 -0
- data/app/views/nti_receipt_builder/templates/edit.html.erb +54 -0
- data/app/views/nti_receipt_builder/templates/new.html.erb +115 -0
- data/app/views/nti_receipt_builder/templates/preview.html.erb +24 -0
- data/app/views/nti_receipt_builder/templates/print.html.erb +31 -0
- data/app/views/nti_receipt_builder/templates/render_preview.turbo_stream.erb +26 -0
- data/app/views/nti_receipt_builder/templates/update.turbo_stream.erb +7 -0
- data/config/importmap.rb +4 -0
- data/lib/generators/nti_receipt_builder/install_generator.rb +49 -0
- data/lib/generators/nti_receipt_builder/templates/create_nti_receipt_templates.rb +28 -0
- data/lib/generators/nti_receipt_builder/templates/initializer.rb +16 -0
- data/lib/generators/nti_receipt_builder/templates/turbo_stream_tags.rb +36 -0
- data/lib/nti_receipt_builder/configuration.rb +59 -0
- data/lib/nti_receipt_builder/elements.rb +46 -0
- data/lib/nti_receipt_builder/engine.rb +32 -0
- data/lib/nti_receipt_builder/errors.rb +13 -0
- data/lib/nti_receipt_builder/layout_validator.rb +285 -0
- data/lib/nti_receipt_builder/presenters/collection.rb +126 -0
- data/lib/nti_receipt_builder/presenters/element.rb +68 -0
- data/lib/nti_receipt_builder/render_result.rb +18 -0
- data/lib/nti_receipt_builder/renderer.rb +78 -0
- data/lib/nti_receipt_builder/set_default.rb +24 -0
- data/lib/nti_receipt_builder/template_form.rb +60 -0
- data/lib/nti_receipt_builder/type_inference.rb +26 -0
- data/lib/nti_receipt_builder/variable_definition.rb +74 -0
- data/lib/nti_receipt_builder/variable_resolver.rb +55 -0
- data/lib/nti_receipt_builder/variables.rb +159 -0
- data/lib/nti_receipt_builder/version.rb +5 -0
- data/lib/nti_receipt_builder.rb +50 -0
- 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
|