• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

foodcoops / foodsoft / 18256748003

05 Oct 2025 09:13AM UTC coverage: 42.833% (-24.2%) from 67.053%
18256748003

push

github

web-flow
Fixes #1221 (#1222)

1. Renamed the new `price-unit-wrapper` class to just `unit-wrapper` as it affects non-price units as well.
2. Added the class to the erroneous fields.

3081 of 7193 relevant lines covered (42.83%)

11.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

14.29
/app/controllers/articles_controller.rb
1
class ArticlesController < ApplicationController
1✔
2
  before_action :authenticate_article_meta, :find_supplier
1✔
3

4
  before_action :load_article, only: %i[edit update]
1✔
5
  before_action :load_article_units, only: %i[edit update new create]
1✔
6
  before_action :load_article_categories, only: %i[edit new edit_all copy migrate_units update_all]
1✔
7
  before_action :new_empty_article_ratio,
1✔
8
                only: %i[edit edit_all migrate_units update new create parse_upload sync update_synchronized]
9

10
  def index
1✔
11
    sort = if params['sort']
×
12
             case params['sort']
×
13
             when 'name' then 'article_versions.name'
×
14
             when 'unit' then 'article_versions.unit'
×
15
             when 'article_category' then 'article_categories.name'
×
16
             when 'note' then 'article_versions.note'
×
17
             when 'availability' then 'article_versions.availability'
×
18
             when 'name_reverse' then 'article_versions.name DESC'
×
19
             when 'unit_reverse' then 'article_versions.unit DESC'
×
20
             when 'article_category_reverse' then 'article_categories.name DESC'
×
21
             when 'note_reverse' then 'article_versions.note DESC'
×
22
             when 'availability_reverse' then 'article_versions.availability DESC'
×
23
             end
24
           else
25
             'article_categories.name, article_versions.name'
×
26
           end
27

28
    @articles = Article.with_latest_versions_and_categories.order(sort).undeleted.where(supplier_id: @supplier,
×
29
                                                                                        type: nil)
30

31
    if request.format.csv?
×
32
      send_data ArticlesCsv.new(@articles, encoding: 'utf-8', foodsoft_url: root_url).to_csv, filename: 'articles.csv', type: 'text/csv'
×
33
      return
×
34
    end
35

36
    @articles = @articles.where('article_versions.name LIKE ?', "%#{params[:query]}%") unless params[:query].nil?
×
37

38
    @articles = @articles.page(params[:page]).per(@per_page)
×
39

40
    respond_to do |format|
×
41
      format.html
×
42
      format.js { render layout: false }
×
43
    end
44
  end
45

46
  def new
1✔
47
    @article = @supplier.articles.build
×
48
    @article.latest_article_version = @article.article_versions.build(tax: FoodsoftConfig[:tax_default])
×
49
    render layout: false
×
50
  end
51

52
  def copy
1✔
53
    article = @supplier.articles.find(params[:article_id])
×
54
    @article = article.duplicate_including_latest_version_and_ratios
×
55
    load_article_units(@article.current_article_units)
×
56
    render layout: false
×
57
  end
58

59
  def edit
1✔
60
    render action: 'new', layout: false
×
61
  end
62

63
  def create
1✔
64
    valid = false
×
65
    Article.transaction do
×
66
      @article = Article.create(supplier_id: @supplier.id)
×
67
      @article.attributes = { latest_article_version_attributes: params[:article_version] }
×
68
      raise ActiveRecord::Rollback unless @article.valid?
×
69

70
      valid = @article.save
×
71
    end
72

73
    if valid
×
74
      render layout: false
×
75
    else
76
      load_article_units(@article.current_article_units)
×
77
      render action: 'new', layout: false
×
78
    end
79
  end
80

81
  # Updates one Article and highlights the line if succeded
82
  def update
1✔
83
    Article.transaction do
×
84
      if @article.update(latest_article_version_attributes: params[:article_version])
×
85
        render layout: false
×
86
      else
87
        Rails.logger.info @article.errors.to_yaml.to_s
×
88
        render action: 'new', layout: false
×
89
      end
90
    end
91
  end
92

93
  # Deletes article from database. send error msg, if article is used in a current order
94
  def destroy
1✔
95
    @article = Article.find(params[:id])
×
96
    @article.mark_as_deleted unless @order = @article.in_open_order # If article is in an active Order, the Order will be returned
×
97
    render layout: false
×
98
  end
99

100
  # Renders a form for editing all articles from a supplier
101
  def edit_all
1✔
102
    @articles = @supplier.articles.undeleted
×
103

104
    load_article_units
×
105
  end
106

107
  def prepare_units_migration; end
1✔
108

109
  def migrate_units
1✔
110
    build_article_migration_samples
×
111
  end
112

113
  def complete_units_migration
1✔
114
    @invalid_articles = []
×
115
    @samples = []
×
116

117
    Article.transaction do
×
118
      params[:samples]&.values&.each do |sample|
×
119
        next unless sample[:apply_migration] == '1'
×
120

121
        original_unit = nil
×
122
        articles = Article.with_latest_versions_and_categories
×
123
                          .includes(latest_article_version: [:article_unit_ratios])
124
                          .find(sample[:article_ids])
125
        articles.each do |article|
×
126
          latest_article_version = article.latest_article_version
×
127
          original_unit = latest_article_version.unit
×
128
          next if latest_article_version.article_unit_ratios.length > 1 ||
×
129
                  latest_article_version.billing_unit != latest_article_version.group_order_unit ||
130
                  latest_article_version.price_unit != latest_article_version.group_order_unit
131

132
          article_version_params = sample.slice(:supplier_order_unit, :group_order_granularity, :group_order_unit)
×
133
          article_version_params[:unit] = nil
×
134
          article_version_params[:billing_unit] = article_version_params[:group_order_unit]
×
135
          article_version_params[:price_unit] = article_version_params[:group_order_unit]
×
136
          article_version_params[:article_unit_ratios_attributes] = {}
×
137
          if sample[:first_ratio_unit].present?
×
138
            article_version_params[:article_unit_ratios_attributes]['1'] = {
×
139
              id: latest_article_version.article_unit_ratios.first&.id,
140
              sort: 1,
141
              quantity: sample[:first_ratio_quantity],
142
              unit: sample[:first_ratio_unit]
143
            }
144
          end
145
          article_version_params[:id] = latest_article_version.id
×
146
          @invalid_articles << article unless article.update(latest_article_version_attributes: article_version_params)
×
147
        end
148

149
        errors = articles.find { |a| !a.errors.nil? }&.errors
×
150
        @samples << {
×
151
          unit: original_unit,
152
          conversion_result: sample
153
                    .except(:article_ids, :first_ratio_quantity, :first_ratio_unit)
154
                    .merge(
155
                      first_ratio: {
156
                        quantity: sample[:first_ratio_quantity],
157
                        unit: sample[:first_ratio_unit]
158
                      }
159
                    ),
160
          articles: articles,
161
          errors: errors,
162
          error: errors.present?
163
        }
164
      end
165
      @supplier.update_attribute(:unit_migration_completed, Time.now)
×
166
      raise ActiveRecord::Rollback unless @invalid_articles.empty?
×
167
    end
168

169
    if @invalid_articles.empty?
×
170
      redirect_to supplier_articles_path(@supplier),
×
171
                  notice: I18n.t('articles.controller.complete_units_migration.notice')
172
    else
173
      additional_units = @samples.map do |sample|
×
174
        [sample[:conversion_result][:supplier_order_unit], sample[:conversion_result][:group_order_unit],
×
175
         sample[:conversion_result][:first_ratio]&.dig(:unit)]
176
      end.flatten.uniq.compact
177
      load_article_units(additional_units)
×
178

179
      flash.now.alert = I18n.t('articles.controller.error_invalid')
×
180
      render :migrate_units
×
181
    end
182
  end
183

184
  # Updates all article of specific supplier
185
  def update_all
1✔
186
    invalid_articles = false
×
187

188
    Article.transaction do
×
189
      if params[:articles].present?
×
190
        # Update other article attributes...
191
        @articles = Article.with_latest_versions_and_categories
×
192
                           .includes(latest_article_version: [:article_unit_ratios])
193
                           .find(params[:articles].keys)
194
        @articles.each do |article|
×
195
          article_version_params = params[:articles][article.id.to_s]
×
196
          article_version_params['id'] = article.latest_article_version.id
×
197
          unless article.update(latest_article_version_attributes: article_version_params)
×
198
            invalid_articles ||= true # Remember that there are validation errors
×
199
          end
200
        end
201

202
        @supplier.update_attribute(:unit_migration_completed, Time.now) if params[:complete_migration]
×
203

204
        raise ActiveRecord::Rollback if invalid_articles # Rollback all changes
×
205
      end
206
    end
207

208
    if invalid_articles
×
209
      # An error has occurred, transaction has been rolled back.
210
      flash.now.alert = I18n.t('articles.controller.error_invalid')
×
211
      render :edit_all
×
212
    else
213
      # Successfully done.
214
      redirect_to supplier_articles_path(@supplier), notice: I18n.t('articles.controller.update_all.notice')
×
215
    end
216
  end
217

218
  # makes different actions on selected articles
219
  def update_selected
1✔
220
    raise I18n.t('articles.controller.error_nosel') if params[:selected_articles].nil?
×
221

222
    articles = Article.with_latest_versions_and_categories
×
223
                      .includes(latest_article_version: [:article_unit_ratios])
224
                      .find(params[:selected_articles])
225
    Article.transaction do
×
226
      case params[:selected_action]
×
227
      when 'destroy'
228
        articles.each(&:mark_as_deleted)
×
229
        flash[:notice] = I18n.t('articles.controller.update_sel.notice_destroy')
×
230
      when 'setNotAvailable'
231
        articles.each { |a| a.update_attribute(:availability, false) }
×
232
        flash[:notice] = I18n.t('articles.controller.update_sel.notice_unavail')
×
233
      when 'setAvailable'
234
        articles.each { |a| a.update_attribute(:availability, true) }
×
235
        flash[:notice] = I18n.t('articles.controller.update_sel.notice_avail')
×
236
      else
237
        flash[:alert] = I18n.t('articles.controller.update_sel.notice_noaction')
×
238
      end
239
    end
240
    # action succeded
241
    redirect_to supplier_articles_url(@supplier, per_page: params[:per_page])
×
242
  rescue StandardError => e
243
    redirect_to supplier_articles_url(@supplier, per_page: params[:per_page]),
×
244
                alert: I18n.t('errors.general_msg', msg: e)
245
  end
246

247
  # lets start with parsing articles from uploaded file, yeah
248
  # Renders the upload form
249
  def upload; end
1✔
250

251
  # Update articles from a spreadsheet
252
  def parse_upload
1✔
253
    uploaded_file = params[:articles]['file'] or raise I18n.t('articles.controller.parse_upload.no_file')
×
254
    options = { filename: uploaded_file.original_filename, foodsoft_url: root_url }
×
255
    options[:delete_unavailable] = (params[:articles]['delete_unavailable'] == '1')
×
256
    options[:outlist_absent] = (params[:articles]['outlist_absent'] == '1')
×
257
    options[:convert_units] = (params[:articles]['convert_units'] == '1')
×
258
    @enable_unit_migration = (params[:articles]['activate_unit_migration'] == '1')
×
259
    @updated_article_pairs, @outlisted_articles, @new_articles, import_data = @supplier.sync_from_file(uploaded_file.tempfile,
×
260
                                                                                                       options)
261

262
    @articles = @updated_article_pairs.pluck(0) + @new_articles
×
263
    load_article_units
×
264

265
    if @updated_article_pairs.empty? && @outlisted_articles.empty? && @new_articles.empty?
×
266
      redirect_to supplier_articles_path(@supplier),
×
267
                  notice: I18n.t('articles.controller.parse_upload.notice', count: import_data.length)
268
    end
269
    @ignored_article_count = 0
×
270
  rescue StandardError => e
271
    redirect_to upload_supplier_articles_path(@supplier), alert: I18n.t('errors.general_msg', msg: e.message)
×
272
  end
273

274
  # sync all articles with the external database
275
  # renders a form with articles, which should be updated
276
  def sync
1✔
277
    @updated_article_pairs, @outlisted_articles, @new_articles, import_data = @supplier.sync_from_remote
×
278
    redirect_to(supplier_articles_path(@supplier), notice: I18n.t('articles.controller.parse_upload.notice', count: import_data[:articles].length)) if @updated_article_pairs.empty? && @outlisted_articles.empty? && @new_articles.empty?
×
279
    @ignored_article_count = 0
×
280
    load_article_units((@new_articles + @updated_article_pairs.map(&:first)).map(&:current_article_units).flatten.uniq)
×
281
  rescue StandardError => e
282
    redirect_to upload_supplier_articles_path(@supplier), alert: I18n.t('errors.general_msg', msg: e.message)
×
283
  end
284

285
  # Updates, deletes articles when upload or sync form is submitted
286
  def update_synchronized
1✔
287
    @enable_unit_migration = (params[:enable_unit_migration] == '1')
×
288
    @outlisted_articles = Article.includes(:latest_article_version).where(article_versions: { id: params[:outlisted_articles]&.values || [] })
×
289
    @updated_articles = Article.includes(:latest_article_version).where(article_versions: { id: params[:articles]&.values&.map do |v|
×
290
                                                                                                  v[:id]
×
291
                                                                                                end || [] })
292
    @new_articles = build_articles_from_params_array(params[:new_articles]&.values || [])
×
293

294
    # Prepare updated articles with their parameters
295
    updated_article_params = []
×
296
    @updated_articles.each do |a|
×
297
      current_params = params[:articles].values.detect { |p| p[:id] == a.latest_article_version.id.to_s }
×
298
      current_params.delete(:id)
×
299
      updated_article_params << [a, current_params]
×
300
    end
301

302
    # Use the SupplierSyncService to persist changes
303
    service = SupplierSyncService.new(@supplier)
×
304
    success = service.persist(@new_articles, @outlisted_articles, updated_article_params, enable_unit_migration: @enable_unit_migration)
×
305

306
    if success
×
307
      redirect_to supplier_articles_path(@supplier), notice: I18n.t('articles.controller.update_sync.notice')
×
308
    else
309
      load_article_units((@new_articles + @updated_articles).map(&:current_article_units).flatten.uniq)
×
310
      @updated_article_pairs = @updated_articles.map do |article|
×
311
        orig_article = Article.find(article.id)
×
312
        [article, orig_article.unequal_attributes(article)]
×
313
      end
314
      flash.now.alert = I18n.t('articles.controller.error_invalid')
×
315
      render params[:from_action] == 'sync' ? :sync : :parse_upload
×
316
    end
317
  end
318

319
  private
1✔
320

321
  def build_articles_from_params_array(params)
1✔
322
    params.map do |a|
×
323
      article = @supplier.articles.build
×
324
      article_version = article.article_versions.build(a)
×
325
      article.article_versions << article_version
×
326
      article.latest_article_version = article_version
×
327
      article_version.article = article
×
328
      article
×
329
    end
330
  end
331

332
  def build_article_migration_samples
1✔
333
    articles = @supplier.articles.with_latest_versions_and_categories.undeleted.includes(latest_article_version: [:article_unit_ratios])
×
334
    samples_hash = {}
×
335
    articles.each do |article|
×
336
      article_version = article.latest_article_version
×
337
      quantity = 1
×
338
      ratios = article_version.article_unit_ratios
×
339

340
      next if ratios.length > 1 ||
×
341
              article_version.billing_unit != article_version.group_order_unit ||
342
              article_version.price_unit != article_version.group_order_unit
343

344
      quantity = ratios[0].quantity if ratios.length == 1 && ratios[0].quantity != 1 && ratios[0].unit == 'XPP'
×
345

346
      samples_hash[article_version.unit] = {} if samples_hash[article_version.unit].nil?
×
347
      samples_hash[article_version.unit][quantity] = [] if samples_hash[article_version.unit][quantity].nil?
×
348
      samples_hash[article_version.unit][quantity] << article
×
349
    end
350
    @samples = samples_hash.map do |unit, quantities_hash|
×
351
      quantities_hash.map do |quantity, sample_articles|
×
352
        conversion_result = ArticleUnitsLib.convert_old_unit(unit, quantity)
×
353
        { unit: unit, quantity: quantity, articles: sample_articles, conversion_result: conversion_result }
×
354
      end
355
    end
356
    @samples = @samples.flatten
×
357
                       .reject { |sample| sample[:conversion_result].nil? }
×
358

359
    additional_units = @samples.map do |sample|
×
360
      [sample[:conversion_result][:supplier_order_unit], sample[:conversion_result][:group_order_unit],
×
361
       sample[:conversion_result][:first_ratio]&.dig(:unit)]
362
    end.flatten.uniq.compact
363
    load_article_units(additional_units)
×
364
  end
365

366
  def load_article
1✔
367
    @article = Article
×
368
               .with_latest_versions_and_categories
369
               .includes(latest_article_version: [:article_unit_ratios])
370
               .find(params[:id])
371
  end
372

373
  def load_article_units(additional_units = [])
1✔
374
    additional_units = if !@article.nil?
×
375
                         @article.current_article_units
×
376
                       elsif !@articles.nil?
×
377
                         @articles.map(&:current_article_units)
×
378
                                  .flatten
379
                                  .uniq
380
                       else
381
                         additional_units
×
382
                       end
383

384
    @article_units = ArticleUnit.as_options(additional_units: additional_units)
×
385
    @all_units = ArticleUnit.as_hash(additional_units: additional_units)
×
386
  end
387

388
  def load_article_categories
1✔
389
    @article_categories = ArticleCategory.undeleted.order(:name)
×
390

391
    current_category_id = @article&.latest_article_version&.article_category_id
×
392
    @article_categories = @article_categories.or(ArticleCategory.where(id: current_category_id)) unless current_category_id.nil?
×
393
  end
394

395
  def new_empty_article_ratio
1✔
396
    @empty_article_unit_ratio = ArticleUnitRatio.new
×
397
    @empty_article_unit_ratio.article_version = @article.latest_article_version unless @article.nil?
×
398
    @empty_article_unit_ratio.sort = -1
×
399
  end
400

401
  # @return [Number] Number of articles not taken into account when syncing (having no number)
402
  def ignored_article_count
1✔
403
    if action_name == 'sync' || params[:from_action] == 'sync'
×
404
      @ignored_article_count ||= @supplier.articles.includes(:latest_article_version).undeleted.where(article_versions: { order_number: [
×
405
                                                                                                        nil, ''
406
                                                                                                      ] }).count
407
    else
408
      0
×
409
    end
410
  end
411
  helper_method :ignored_article_count
1✔
412
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc