Skip to content

Commit e7b0ff1

Browse files
committed
Add API endpoint for basket CSV export
Adds GET /api/v1/deliveries/:id/baskets.csv endpoint that supports 'current', 'next', or numeric delivery IDs. This allows automation tools like Google Apps Script to fetch basket data for label printing. Extracts CSV generation to Basket::CSVExporter PORO, now used by both the API and ActiveAdmin. The exporter supports single delivery mode (includes member details for labels) and fiscal year mode (for bulk exports). Includes performance optimizations: memoized queries, cached feature checks, and cursor-based batching.
1 parent 7241d97 commit e7b0ff1

File tree

7 files changed

+468
-93
lines changed

7 files changed

+468
-93
lines changed

app/admin/basket.rb

Lines changed: 15 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -25,89 +25,6 @@
2525
collection: -> { fiscal_years_collection }
2626

2727
includes :delivery, :basket_size, :depot, baskets_basket_complements: :basket_complement, membership: :member
28-
csv do
29-
basket_complements_exist = BasketComplement.kept.any?
30-
single_delivery = params[:q][:delivery_id_eq].present?
31-
deliveries = []
32-
deliveries << Delivery.find(params[:q][:delivery_id_eq]) if single_delivery
33-
deliveries += Delivery.during_year(params[:q][:during_year]) if params[:q][:during_year].present?
34-
deliveries.compact!
35-
shop_orders =
36-
if feature?("shop")
37-
Shop::Order.where(delivery: deliveries).includes(items: { product: :basket_complement })
38-
else
39-
Shop::Order.none
40-
end
41-
shop_orders_index = shop_orders.index_by { |o| [ o.member_id, o.delivery_id ] }
42-
shop_products = shop_orders.products_displayed_in_delivery_sheets
43-
44-
if deliveries.many?
45-
column(:delivery_id) { |b| b.delivery.display_number }
46-
column(:delivery_date) { |b| b.delivery.date }
47-
end
48-
49-
column(:basket_id) { |b| b.id }
50-
column(:membership_id) { |b| b.membership_id }
51-
column(:member_id) { |b| b.membership.member_id }
52-
if single_delivery
53-
column(:name) { |b| b.membership.member.name }
54-
column(:emails) { |b| b.membership.member.emails_array.join(", ") }
55-
column(:phones) { |b| b.membership.member.phones_array.map(&:phony_formatted).join(", ") }
56-
column(:street) { |b| b.membership.member.street }
57-
column(:zip) { |b| b.membership.member.zip }
58-
column(:city) { |b| b.membership.member.city }
59-
column(:food_note) { |b| b.membership.member.food_note }
60-
column(:delivery_note) { |b| b.membership.member.delivery_note }
61-
end
62-
column(:depot_id)
63-
column(:depot) { |b| b.depot&.public_name }
64-
column(:depot_price) { |b| cur(b.depot_price) }
65-
column(:basket_size_id)
66-
column(I18n.t("attributes.basket_size")) { |b| b.basket_size.name }
67-
column(:quantity) { |b| b.quantity }
68-
column(:basket_size_price) { |b| cur(b.basket_size_price) }
69-
column(:state) { |b| b.state }
70-
column(:absence_id)
71-
column(:provisionally_absent) { |b| b.provisionally_absent? }
72-
if feature?("basket_price_extra")
73-
column(:price_extra) { |b| cur(b.calculated_price_extra) }
74-
end
75-
column(:description) { |b| b.basket_description(public_name: true) }
76-
if basket_complements_exist
77-
shop_orders ||= Shop::Order.none
78-
BasketComplement.for(collection, shop_orders).each do |c|
79-
column(c.name) { |b|
80-
basket_qty = b.baskets_basket_complements.select { |bc| bc.basket_complement_id == c.id }.sum(&:quantity)
81-
shop_order = shop_orders_index[[ b.membership.member_id, b.delivery_id ]]
82-
shop_qty = shop_order&.items&.select { |i| i.product.basket_complement_id == c.id }&.sum(&:quantity) || 0
83-
basket_qty + shop_qty
84-
}
85-
end
86-
column("#{Basket.human_attribute_name(:complement_ids)} (#{Basket.human_attribute_name(:description)})") { |b|
87-
b.complements_description(public_name: true)
88-
}
89-
column(:complements_price) { |b| cur(b.complements_price) }
90-
end
91-
if feature?("shop")
92-
column(I18n.t("shop.title_orders", count: 2)) { |b|
93-
shop_orders_index[[ b.membership.member_id, b.delivery_id ]] ? "X" : nil
94-
}
95-
if basket_complements_exist
96-
column("#{Basket.human_attribute_name(:complement_ids)} (#{Shop::Order.model_name.human(count: 1)})") { |b|
97-
shop_orders_index[[ b.membership.member_id, b.delivery_id ]]&.complements_description
98-
}
99-
end
100-
if shop_products.any?
101-
column("#{::Shop::Product.model_name.human(count: 2)} (#{I18n.t('shop.title')})") { |b|
102-
shop_order = shop_orders_index[[ b.membership.member_id, b.delivery_id ]]
103-
shop_products.filter_map { |p|
104-
quantity = shop_order&.items&.find { |i| i.product_id == p.id }&.quantity
105-
"#{quantity}x #{p.name_with_single_variant}" if quantity
106-
}.join(", ").presence
107-
}
108-
end
109-
end
110-
end
11128

11229
form do |f|
11330
deliveries_collection = basket_deliveries_collection(f.object)
@@ -232,6 +149,16 @@
232149
]
233150

234151
controller do
152+
def index
153+
if request.format.csv?
154+
exporter = build_csv_exporter
155+
return send_data exporter.generate,
156+
filename: exporter.filename,
157+
type: "text/csv; charset=utf-8"
158+
end
159+
super
160+
end
161+
235162
def update
236163
update! do |success, failure|
237164
success.html { redirect_to resource.membership }
@@ -246,20 +173,15 @@ def scoped_collection
246173
end
247174
end
248175

249-
def csv_filename
176+
private
177+
178+
def build_csv_exporter
250179
if params[:q][:delivery_id_eq].present?
251180
delivery = Delivery.find(params[:q][:delivery_id_eq])
252-
[
253-
Delivery.model_name.human.downcase,
254-
delivery.display_number,
255-
delivery.date.strftime("%Y%m%d")
256-
].join("-") + ".csv"
181+
Basket::CSVExporter.new(delivery: delivery)
257182
elsif params[:q][:during_year].present?
258183
fiscal_year = Current.org.fiscal_year_for(params[:q][:during_year])
259-
[
260-
Delivery.model_name.human(count: 2).downcase,
261-
fiscal_year.to_s
262-
].join("-") + ".csv"
184+
Basket::CSVExporter.new(fiscal_year: fiscal_year)
263185
end
264186
end
265187
end
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# frozen_string_literal: true
2+
3+
module API
4+
# Provides delivery lookup for API endpoints that operate on a specific delivery.
5+
# Supports numeric IDs as well as "current" and "next" keywords.
6+
#
7+
# Usage:
8+
# class BasketsController < BaseController
9+
# include DeliveryScoped
10+
#
11+
# def index
12+
# @baskets = @delivery.baskets
13+
# end
14+
# end
15+
#
16+
# Routes:
17+
# GET /api/v1/deliveries/123/baskets
18+
# GET /api/v1/deliveries/current/baskets
19+
# GET /api/v1/deliveries/next/baskets
20+
module DeliveryScoped
21+
extend ActiveSupport::Concern
22+
23+
included do
24+
before_action :set_delivery
25+
end
26+
27+
private
28+
29+
def set_delivery
30+
@delivery =
31+
case params[:delivery_id]
32+
when "current"
33+
Delivery.current
34+
when "next"
35+
Delivery.next
36+
else
37+
Delivery.find_by(id: params[:delivery_id])
38+
end
39+
40+
head :not_found unless @delivery
41+
end
42+
end
43+
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
module API
4+
module V1
5+
class BasketsController < BaseController
6+
include DeliveryScoped
7+
8+
# GET /api/v1/deliveries/:delivery_id/baskets.csv
9+
#
10+
# Returns basket CSV for the specified delivery.
11+
# Supports "current", "next", or a numeric delivery ID.
12+
def index
13+
exporter = Basket::CSVExporter.new(delivery: @delivery)
14+
15+
send_data exporter.generate,
16+
filename: exporter.filename,
17+
type: "text/csv; charset=utf-8"
18+
end
19+
end
20+
end
21+
end

0 commit comments

Comments
 (0)