mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-01-30 19:52:20 +01:00
(bug) unable to generate statistics
This commit is contained in:
parent
a49b702bd1
commit
b9c02742a1
@ -21,6 +21,7 @@ Metrics/BlockLength:
|
||||
- 'config/routes.rb'
|
||||
- 'app/pdfs/pdf/*.rb'
|
||||
- 'test/**/*.rb'
|
||||
- '**/*_concern.rb'
|
||||
Metrics/ParameterLists:
|
||||
CountKeywordArgs: false
|
||||
Style/RegexpLiteral:
|
||||
|
@ -1,5 +1,12 @@
|
||||
# Changelog Fab-manager
|
||||
|
||||
- Improved automated test on statistics generation
|
||||
- Refactored statistics generation
|
||||
- Refactored test helpers
|
||||
- Fix a bug: unable to generate statistics
|
||||
- Fix a bug: the automated test on statistics generation was not running
|
||||
- [TODO DEPLOY] `rails fablab:maintenance:regenerate_statistics[2022,07]`
|
||||
|
||||
## v5.4.16 2022 August 24
|
||||
|
||||
- Updated user's manual for v5.4 (fr)
|
||||
|
@ -1,9 +1,10 @@
|
||||
module Stats
|
||||
class Machine
|
||||
# frozen_string_literal: true
|
||||
|
||||
# This is a statistical data saved in ElasticSearch, about a machine reservation
|
||||
class Stats::Machine
|
||||
include Elasticsearch::Persistence::Model
|
||||
include StatConcern
|
||||
include StatReservationConcern
|
||||
|
||||
attribute :machineId, Integer
|
||||
end
|
||||
end
|
||||
|
@ -1,504 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# This will generate statistics indicators for ElasticSearch database
|
||||
class StatisticService
|
||||
def generate_statistic(options = default_options)
|
||||
# remove data exists
|
||||
clean_stat(options)
|
||||
|
||||
# subscription month/year list
|
||||
subscriptions_list(options).each do |s|
|
||||
Stats::Subscription.create({
|
||||
date: format_date(s.date),
|
||||
type: s.duration,
|
||||
subType: s.slug,
|
||||
stat: 1,
|
||||
ca: s.ca,
|
||||
planId: s.plan_id,
|
||||
subscriptionId: s.subscription_id,
|
||||
invoiceItemId: s.invoice_item_id,
|
||||
groupName: s.plan_group_name
|
||||
}.merge(user_info_stat(s)))
|
||||
end
|
||||
|
||||
# machine list
|
||||
reservations_machine_list(options).each do |r|
|
||||
%w[booking hour].each do |type|
|
||||
stat = Stats::Machine.new({
|
||||
date: format_date(r.date),
|
||||
type: type,
|
||||
subType: r.machine_type,
|
||||
ca: r.ca,
|
||||
machineId: r.machine_id,
|
||||
name: r.machine_name,
|
||||
reservationId: r.reservation_id
|
||||
}.merge(user_info_stat(r)))
|
||||
stat.stat = (type == 'booking' ? 1 : r.nb_hours)
|
||||
stat.save
|
||||
end
|
||||
end
|
||||
|
||||
# space list
|
||||
reservations_space_list(options).each do |r|
|
||||
%w[booking hour].each do |type|
|
||||
stat = Stats::Space.new({
|
||||
date: format_date(r.date),
|
||||
type: type,
|
||||
subType: r.space_type,
|
||||
ca: r.ca,
|
||||
spaceId: r.space_id,
|
||||
name: r.space_name,
|
||||
reservationId: r.reservation_id
|
||||
}.merge(user_info_stat(r)))
|
||||
stat.stat = (type == 'booking' ? 1 : r.nb_hours)
|
||||
stat.save
|
||||
end
|
||||
end
|
||||
|
||||
# training list
|
||||
reservations_training_list(options).each do |r|
|
||||
%w[booking hour].each do |type|
|
||||
stat = Stats::Training.new({
|
||||
date: format_date(r.date),
|
||||
type: type,
|
||||
subType: r.training_type,
|
||||
ca: r.ca,
|
||||
trainingId: r.training_id,
|
||||
name: r.training_name,
|
||||
trainingDate: r.training_date,
|
||||
reservationId: r.reservation_id
|
||||
}.merge(user_info_stat(r)))
|
||||
stat.stat = (type == 'booking' ? 1 : r.nb_hours)
|
||||
stat.save
|
||||
end
|
||||
end
|
||||
|
||||
# event list
|
||||
reservations_event_list(options).each do |r|
|
||||
%w[booking hour].each do |type|
|
||||
stat = Stats::Event.new({
|
||||
date: format_date(r.date),
|
||||
type: type,
|
||||
subType: r.event_type,
|
||||
ca: r.ca,
|
||||
eventId: r.event_id,
|
||||
name: r.event_name,
|
||||
eventDate: r.event_date,
|
||||
reservationId: r.reservation_id,
|
||||
eventTheme: r.event_theme,
|
||||
ageRange: r.age_range
|
||||
}.merge(user_info_stat(r)))
|
||||
stat.stat = (type == 'booking' ? r.nb_places : r.nb_hours)
|
||||
stat.save
|
||||
end
|
||||
end
|
||||
|
||||
# account list
|
||||
members_list(options).each do |m|
|
||||
Stats::Account.create({
|
||||
date: format_date(m.date),
|
||||
type: 'member',
|
||||
subType: 'created',
|
||||
stat: 1
|
||||
}.merge(user_info_stat(m)))
|
||||
end
|
||||
|
||||
# project list
|
||||
projects_list(options).each do |p|
|
||||
Stats::Project.create({
|
||||
date: format_date(p.date),
|
||||
type: 'project',
|
||||
subType: 'published',
|
||||
stat: 1
|
||||
}.merge(user_info_stat(p)).merge(project_info_stat(p)))
|
||||
end
|
||||
|
||||
# member ca list
|
||||
members_ca_list(options).each do |m|
|
||||
Stats::User.create({
|
||||
date: format_date(m.date),
|
||||
type: 'revenue',
|
||||
subType: m.group,
|
||||
stat: m.ca
|
||||
}.merge(user_info_stat(m)))
|
||||
end
|
||||
end
|
||||
|
||||
def subscriptions_list(options = default_options)
|
||||
result = []
|
||||
InvoiceItem.where("object_type = '#{Subscription.name}' AND invoice_items.created_at >= :start_date AND invoice_items.created_at <= :end_date", options)
|
||||
.eager_load(invoice: [:coupon]).each do |i|
|
||||
next if i.invoice.is_a?(Avoir)
|
||||
|
||||
sub = i.object
|
||||
|
||||
ca = i.amount.to_i
|
||||
cs = CouponService.new
|
||||
ca = cs.ventilate(cs.invoice_total_no_coupon(i.invoice), ca, i.invoice.coupon) unless i.invoice.coupon_id.nil?
|
||||
ca /= 100.00
|
||||
profile = sub.statistic_profile
|
||||
p = sub.plan
|
||||
result.push OpenStruct.new({
|
||||
date: options[:start_date].to_date,
|
||||
plan: p.group.slug,
|
||||
plan_id: p.id,
|
||||
plan_interval: p.interval,
|
||||
plan_interval_count: p.interval_count,
|
||||
plan_group_name: p.group.name,
|
||||
slug: p.slug,
|
||||
duration: p.find_statistic_type.key,
|
||||
subscription_id: sub.id,
|
||||
invoice_item_id: i.id,
|
||||
ca: ca
|
||||
}.merge(user_info(profile)))
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def reservations_machine_list(options = default_options)
|
||||
result = []
|
||||
Reservation
|
||||
.where("reservable_type = 'Machine' AND slots.canceled_at IS NULL AND " \
|
||||
'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options)
|
||||
.eager_load(:slots, :invoice_items, statistic_profile: [:group])
|
||||
.each do |r|
|
||||
next unless r.reservable
|
||||
|
||||
profile = r.statistic_profile
|
||||
result.push OpenStruct.new({
|
||||
date: options[:start_date].to_date,
|
||||
reservation_id: r.id,
|
||||
machine_id: r.reservable.id,
|
||||
machine_type: r.reservable.friendly_id,
|
||||
machine_name: r.reservable.name,
|
||||
nb_hours: r.slots.size,
|
||||
ca: calcul_ca(r.original_invoice)
|
||||
}.merge(user_info(profile)))
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def reservations_space_list(options = default_options)
|
||||
result = []
|
||||
Reservation
|
||||
.where("reservable_type = 'Space' AND slots.canceled_at IS NULL AND " \
|
||||
'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options)
|
||||
.eager_load(:slots, :invoice_items, statistic_profile: [:group])
|
||||
.each do |r|
|
||||
next unless r.reservable
|
||||
|
||||
profile = r.statistic_profile
|
||||
result.push OpenStruct.new({
|
||||
date: options[:start_date].to_date,
|
||||
reservation_id: r.id,
|
||||
space_id: r.reservable.id,
|
||||
space_name: r.reservable.name,
|
||||
space_type: r.reservable.slug,
|
||||
nb_hours: r.slots.size,
|
||||
ca: calcul_ca(r.original_invoice)
|
||||
}.merge(user_info(profile)))
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def reservations_training_list(options = default_options)
|
||||
result = []
|
||||
Reservation
|
||||
.where("reservable_type = 'Training' AND slots.canceled_at IS NULL AND " \
|
||||
'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options)
|
||||
.eager_load(:slots, :invoice_items, statistic_profile: [:group])
|
||||
.each do |r|
|
||||
next unless r.reservable
|
||||
|
||||
profile = r.statistic_profile
|
||||
slot = r.slots.first
|
||||
result.push OpenStruct.new({
|
||||
date: options[:start_date].to_date,
|
||||
reservation_id: r.id,
|
||||
training_id: r.reservable.id,
|
||||
training_type: r.reservable.friendly_id,
|
||||
training_name: r.reservable.name,
|
||||
training_date: slot.start_at.to_date,
|
||||
nb_hours: difference_in_hours(slot.start_at, slot.end_at),
|
||||
ca: calcul_ca(r.original_invoice)
|
||||
}.merge(user_info(profile)))
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def reservations_event_list(options = default_options)
|
||||
result = []
|
||||
Reservation
|
||||
.where("reservable_type = 'Event' AND slots.canceled_at IS NULL AND " \
|
||||
'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options)
|
||||
.eager_load(:slots, :invoice_items, statistic_profile: [:group])
|
||||
.each do |r|
|
||||
next unless r.reservable
|
||||
|
||||
profile = r.statistic_profile
|
||||
slot = r.slots.first
|
||||
result.push OpenStruct.new({
|
||||
date: options[:start_date].to_date,
|
||||
reservation_id: r.id,
|
||||
event_id: r.reservable.id,
|
||||
event_type: r.reservable.category.slug,
|
||||
event_name: r.reservable.name,
|
||||
event_date: slot.start_at.to_date,
|
||||
event_theme: (r.reservable.event_themes.first ? r.reservable.event_themes.first.name : ''),
|
||||
age_range: (r.reservable.age_range_id ? r.reservable.age_range.name : ''),
|
||||
nb_places: r.total_booked_seats,
|
||||
nb_hours: difference_in_hours(slot.start_at, slot.end_at),
|
||||
ca: calcul_ca(r.original_invoice)
|
||||
}.merge(user_info(profile)))
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def members_ca_list(options = default_options)
|
||||
subscriptions_ca_list = subscriptions_list(options)
|
||||
reservations_ca_list = []
|
||||
avoirs_ca_list = []
|
||||
result = []
|
||||
Reservation.where('reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options)
|
||||
.eager_load(:slots, :invoice_items, statistic_profile: [:group])
|
||||
.each do |r|
|
||||
next unless r.reservable
|
||||
|
||||
reservations_ca_list.push OpenStruct.new({
|
||||
date: options[:start_date].to_date,
|
||||
ca: calcul_ca(r.original_invoice)
|
||||
}.merge(user_info(r.statistic_profile)))
|
||||
end
|
||||
Avoir.where('invoices.created_at >= :start_date AND invoices.created_at <= :end_date', options)
|
||||
.eager_load(:invoice_items, statistic_profile: [:group])
|
||||
.each do |i|
|
||||
# the following line is a workaround for issue #196
|
||||
profile = i.statistic_profile || i.main_item.object&.wallet&.user&.statistic_profile
|
||||
avoirs_ca_list.push OpenStruct.new({
|
||||
date: options[:start_date].to_date,
|
||||
ca: calcul_avoir_ca(i)
|
||||
}.merge(user_info(profile)))
|
||||
end
|
||||
reservations_ca_list.concat(subscriptions_ca_list).concat(avoirs_ca_list).each do |e|
|
||||
profile = StatisticProfile.find(e.statistic_profile_id)
|
||||
u = find_or_create_user_info_info_list(profile, result)
|
||||
u.date = options[:start_date].to_date
|
||||
e.ca = 0 unless e.ca
|
||||
if u.ca
|
||||
u.ca = u.ca + e.ca
|
||||
else
|
||||
u.ca = 0
|
||||
u.ca = u.ca + e.ca
|
||||
result.push u
|
||||
end
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def members_list(options = default_options)
|
||||
result = []
|
||||
member = Role.find_by(name: 'member')
|
||||
StatisticProfile.where('role_id = :member AND created_at >= :start_date AND created_at <= :end_date', options.merge(member: member.id))
|
||||
.each do |sp|
|
||||
next if sp.user&.need_completion?
|
||||
|
||||
result.push OpenStruct.new({
|
||||
date: options[:start_date].to_date
|
||||
}.merge(user_info(sp)))
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def projects_list(options = default_options)
|
||||
result = []
|
||||
Project.where('projects.published_at >= :start_date AND projects.published_at <= :end_date', options)
|
||||
.eager_load(:licence, :themes, :components, :machines, :project_users, author: [:group])
|
||||
.each do |p|
|
||||
result.push OpenStruct.new({
|
||||
date: options[:start_date].to_date
|
||||
}.merge(user_info(p.author)).merge(project_info(p)))
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
# return always yesterday's sum of comment of each project
|
||||
# def projects_comment_nb_list
|
||||
# result = []
|
||||
# Project.where(state: 'published')
|
||||
# .eager_load(:licence, :themes, :components, :machines, :project_users, author: %i[profile group])
|
||||
# .each do |p|
|
||||
# result.push OpenStruct.new({
|
||||
# date: 1.day.ago.to_date,
|
||||
# project_comments: get_project_comment_nb(p)
|
||||
# }.merge(user_info(p.author)).merge(project_info(p)))
|
||||
# end
|
||||
# result
|
||||
# end
|
||||
|
||||
def clean_stat(options = default_options)
|
||||
client = Elasticsearch::Model.client
|
||||
%w[Account Event Machine Project Subscription Training User Space].each do |o|
|
||||
model = "Stats::#{o}".constantize
|
||||
client.delete_by_query(
|
||||
index: model.index_name,
|
||||
type: model.document_type,
|
||||
body: { query: { match: { date: format_date(options[:start_date]) } } }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_options
|
||||
yesterday = 1.day.ago
|
||||
{
|
||||
start_date: yesterday.beginning_of_day,
|
||||
end_date: yesterday.end_of_day
|
||||
}
|
||||
end
|
||||
|
||||
def format_date(date)
|
||||
if date.is_a?(String)
|
||||
Date.strptime(date, '%Y%m%d').strftime('%Y-%m-%d')
|
||||
else
|
||||
date.strftime('%Y-%m-%d')
|
||||
end
|
||||
end
|
||||
|
||||
def user_info(statistic_profile)
|
||||
return {} unless statistic_profile
|
||||
|
||||
{
|
||||
statistic_profile_id: statistic_profile.id,
|
||||
user_id: statistic_profile.user_id,
|
||||
gender: statistic_profile.str_gender,
|
||||
age: statistic_profile.age,
|
||||
group: statistic_profile.group ? statistic_profile.group.slug : nil
|
||||
}
|
||||
end
|
||||
|
||||
def user_info_stat(s)
|
||||
{
|
||||
userId: s.user_id,
|
||||
gender: s.gender,
|
||||
age: s.age,
|
||||
group: s.group
|
||||
}
|
||||
end
|
||||
|
||||
def calcul_ca(invoice)
|
||||
return nil unless invoice
|
||||
|
||||
ca = 0
|
||||
# sum each items in the invoice (+ for invoices/- for refunds)
|
||||
invoice.invoice_items.each do |ii|
|
||||
next if ii.object_type == 'Subscription'
|
||||
|
||||
ca = if invoice.is_a?(Avoir)
|
||||
ca - ii.amount.to_i
|
||||
else
|
||||
ca + ii.amount.to_i
|
||||
end
|
||||
end
|
||||
# subtract coupon discount from invoices and refunds
|
||||
cs = CouponService.new
|
||||
ca = cs.ventilate(cs.invoice_total_no_coupon(invoice), ca, invoice.coupon) unless invoice.coupon_id.nil?
|
||||
# divide the result by 100 to convert from centimes to monetary unit
|
||||
ca.zero? ? ca : ca / 100.0
|
||||
end
|
||||
|
||||
def calcul_avoir_ca(invoice)
|
||||
ca = 0
|
||||
invoice.invoice_items.each do |ii|
|
||||
ca -= ii.amount.to_i
|
||||
end
|
||||
# subtract coupon discount from the refund
|
||||
cs = CouponService.new
|
||||
ca = cs.ventilate(cs.invoice_total_no_coupon(invoice), ca, invoice.coupon) unless invoice.coupon_id.nil?
|
||||
ca.zero? ? ca : ca / 100.0
|
||||
end
|
||||
|
||||
def difference_in_hours(start_at, end_at)
|
||||
if start_at.to_date == end_at.to_date
|
||||
((end_at - start_at) / 60 / 60).to_i
|
||||
else
|
||||
end_at_to_start_date = end_at.change(year: start_at.year, month: start_at.month, day: start_at.day)
|
||||
hours = ((end_at_to_start_date - start_at) / 60 / 60).to_i
|
||||
hours = ((end_at.to_date - start_at.to_date).to_i + 1) * hours if end_at.to_date > start_at.to_date
|
||||
hours
|
||||
end
|
||||
end
|
||||
|
||||
def get_project_themes(project)
|
||||
project.themes.map do |t|
|
||||
{ id: t.id, name: t.name }
|
||||
end
|
||||
end
|
||||
|
||||
def get_projects_components(project)
|
||||
project.components.map do |c|
|
||||
{ id: c.id, name: c.name }
|
||||
end
|
||||
end
|
||||
|
||||
def get_projects_machines(project)
|
||||
project.machines.map do |m|
|
||||
{ id: m.id, name: m.name }
|
||||
end
|
||||
end
|
||||
|
||||
def get_project_users(project)
|
||||
sum = 0
|
||||
project.project_users.each do |pu|
|
||||
sum += 1 if pu.is_valid
|
||||
end
|
||||
sum
|
||||
end
|
||||
|
||||
# def get_project_comment_nb(project)
|
||||
# project_comment_info = @projects_comment_info.select do |p|
|
||||
# p['identifiers'].first == "project_#{project.id}"
|
||||
# end.first
|
||||
# project_comment_info ? project_comment_info['posts'] : 0
|
||||
# end
|
||||
|
||||
def project_info(project)
|
||||
{
|
||||
project_id: project.id,
|
||||
project_name: project.name,
|
||||
project_created_at: project.created_at,
|
||||
project_published_at: project.published_at,
|
||||
project_licence: {},
|
||||
project_themes: get_project_themes(project),
|
||||
project_components: get_projects_components(project),
|
||||
project_machines: get_projects_machines(project),
|
||||
project_users: get_project_users(project)
|
||||
}
|
||||
end
|
||||
|
||||
def project_info_stat(project)
|
||||
{
|
||||
projectId: project.project_id,
|
||||
name: project.project_name,
|
||||
licence: project.project_licence,
|
||||
themes: project.project_themes,
|
||||
components: project.project_components,
|
||||
machines: project.project_machines,
|
||||
users: project.project_users
|
||||
}
|
||||
end
|
||||
|
||||
# def get_user_subscription_ca(user, subscriptions_ca_list)
|
||||
# user_subscription_ca = subscriptions_ca_list.select do |ca|
|
||||
# ca.user_id == user.id
|
||||
# end
|
||||
# user_subscription_ca.inject {|sum,x| sum.ca + x.ca } || 0
|
||||
# end
|
||||
|
||||
def find_or_create_user_info_info_list(profile, list)
|
||||
found = list.select do |l|
|
||||
l.statistic_profile_id == profile.id
|
||||
end.first
|
||||
found || OpenStruct.new(user_info(profile))
|
||||
end
|
||||
end
|
16
app/services/statistics/builder_service.rb
Normal file
16
app/services/statistics/builder_service.rb
Normal file
@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# This will generate statistics indicators. Those will be saved in the ElasticSearch database
|
||||
class Statistics::BuilderService
|
||||
class << self
|
||||
def generate_statistic(options = default_options)
|
||||
# remove data exists
|
||||
Statistics::CleanerService.clean_stat(options)
|
||||
|
||||
Statistics::Builders::SubscriptionsBuilderService.build(options)
|
||||
Statistics::Builders::ReservationsBuilderService.build(options)
|
||||
Statistics::Builders::MembersBuilderService.build(options)
|
||||
Statistics::Builders::ProjectsBuilderService.build(options)
|
||||
end
|
||||
end
|
||||
end
|
26
app/services/statistics/builders/members_builder_service.rb
Normal file
26
app/services/statistics/builders/members_builder_service.rb
Normal file
@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Generate statistics indicators about members
|
||||
class Statistics::Builders::MembersBuilderService
|
||||
include Statistics::Concerns::HelpersConcern
|
||||
|
||||
class << self
|
||||
def build(options = default_options)
|
||||
# account list
|
||||
Statistics::FetcherService.members_list(options).each do |m|
|
||||
Stats::Account.create({ date: format_date(m[:date]),
|
||||
type: 'member',
|
||||
subType: 'created',
|
||||
stat: 1 }.merge(user_info_stat(m)))
|
||||
end
|
||||
|
||||
# member ca list
|
||||
Statistics::FetcherService.members_ca_list(options).each do |m|
|
||||
Stats::User.create({ date: format_date(m[:date]),
|
||||
type: 'revenue',
|
||||
subType: m[:group],
|
||||
stat: m[:ca] }.merge(user_info_stat(m)))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
19
app/services/statistics/builders/projects_builder_service.rb
Normal file
19
app/services/statistics/builders/projects_builder_service.rb
Normal file
@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Generate statistics indicators about projects
|
||||
class Statistics::Builders::ProjectsBuilderService
|
||||
include Statistics::Concerns::HelpersConcern
|
||||
include Statistics::Concerns::ProjectsConcern
|
||||
|
||||
class << self
|
||||
def build(options = default_options)
|
||||
# project list
|
||||
Statistics::FetcherService.projects_list(options).each do |p|
|
||||
Stats::Project.create({ date: format_date(p.date),
|
||||
type: 'project',
|
||||
subType: 'published',
|
||||
stat: 1 }.merge(user_info_stat(p)).merge(project_info_stat(p)))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,45 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Generate statistics indicators about reservations
|
||||
class Statistics::Builders::ReservationsBuilderService
|
||||
include Statistics::Concerns::HelpersConcern
|
||||
|
||||
class << self
|
||||
def build(options = default_options)
|
||||
# machine/space/training list
|
||||
%w[machine space training].each do |category|
|
||||
Statistics::FetcherService.send("reservations_#{category}_list", options).each do |r|
|
||||
%w[booking hour].each do |type|
|
||||
stat = Stats::Machine.new({ date: format_date(r[:date]),
|
||||
type: type,
|
||||
subType: r["#{category}_type".to_sym],
|
||||
ca: r[:ca],
|
||||
machineId: r["#{category}_id".to_sym],
|
||||
name: r["#{category}_name".to_sym],
|
||||
reservationId: r[:reservation_id] }.merge(user_info_stat(r)))
|
||||
stat.stat = (type == 'booking' ? 1 : r[:nb_hours])
|
||||
stat.save
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# event list
|
||||
Statistics::FetcherService.reservations_event_list(options).each do |r|
|
||||
%w[booking hour].each do |type|
|
||||
stat = Stats::Event.new({ date: format_date(r[:date]),
|
||||
type: type,
|
||||
subType: r[:event_type],
|
||||
ca: r[:ca],
|
||||
eventId: r[:event_id],
|
||||
name: r[:event_name],
|
||||
eventDate: r[:event_date],
|
||||
reservationId: r[:reservation_id],
|
||||
eventTheme: r[:event_theme],
|
||||
ageRange: r[:age_range] }.merge(user_info_stat(r)))
|
||||
stat.stat = (type == 'booking' ? r[:nb_places] : r[:nb_hours])
|
||||
stat.save
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Generate statistics indicators about subscriptions
|
||||
class Statistics::Builders::SubscriptionsBuilderService
|
||||
include Statistics::Concerns::HelpersConcern
|
||||
|
||||
class << self
|
||||
def build(options = default_options)
|
||||
# subscription list
|
||||
Statistics::FetcherService.subscriptions_list(options).each do |s|
|
||||
Stats::Subscription.create({ date: format_date(s[:date]),
|
||||
type: s[:duration],
|
||||
subType: s[:slug],
|
||||
stat: 1,
|
||||
ca: s[:ca],
|
||||
planId: s[:plan_id],
|
||||
subscriptionId: s[:subscription_id],
|
||||
invoiceItemId: s[:invoice_item_id],
|
||||
groupName: s[:plan_group_name] }.merge(user_info_stat(s)))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
20
app/services/statistics/cleaner_service.rb
Normal file
20
app/services/statistics/cleaner_service.rb
Normal file
@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Clean the existing statistics
|
||||
class Statistics::CleanerService
|
||||
include Statistics::Concerns::HelpersConcern
|
||||
|
||||
class << self
|
||||
def clean_stat(options = default_options)
|
||||
client = Elasticsearch::Model.client
|
||||
%w[Account Event Machine Project Subscription Training User Space].each do |o|
|
||||
model = "Stats::#{o}".constantize
|
||||
client.delete_by_query(
|
||||
index: model.index_name,
|
||||
type: model.document_type,
|
||||
body: { query: { match: { date: format_date(options[:start_date]) } } }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
40
app/services/statistics/concerns/compute_concern.rb
Normal file
40
app/services/statistics/concerns/compute_concern.rb
Normal file
@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Provides methods to compute totals in statistics
|
||||
module Statistics::Concerns::ComputeConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def calcul_ca(invoice)
|
||||
return nil unless invoice
|
||||
|
||||
ca = 0
|
||||
# sum each items in the invoice (+ for invoices/- for refunds)
|
||||
invoice.invoice_items.each do |ii|
|
||||
next if ii.object_type == 'Subscription'
|
||||
|
||||
ca = if invoice.is_a?(Avoir)
|
||||
ca - ii.amount.to_i
|
||||
else
|
||||
ca + ii.amount.to_i
|
||||
end
|
||||
end
|
||||
# subtract coupon discount from invoices and refunds
|
||||
cs = CouponService.new
|
||||
ca = cs.ventilate(cs.invoice_total_no_coupon(invoice), ca, invoice.coupon) unless invoice.coupon_id.nil?
|
||||
# divide the result by 100 to convert from centimes to monetary unit
|
||||
ca.zero? ? ca : ca / 100.0
|
||||
end
|
||||
|
||||
def calcul_avoir_ca(invoice)
|
||||
ca = 0
|
||||
invoice.invoice_items.each do |ii|
|
||||
ca -= ii.amount.to_i
|
||||
end
|
||||
# subtract coupon discount from the refund
|
||||
cs = CouponService.new
|
||||
ca = cs.ventilate(cs.invoice_total_no_coupon(invoice), ca, invoice.coupon) unless invoice.coupon_id.nil?
|
||||
ca.zero? ? ca : ca / 100.0
|
||||
end
|
||||
end
|
||||
end
|
47
app/services/statistics/concerns/helpers_concern.rb
Normal file
47
app/services/statistics/concerns/helpers_concern.rb
Normal file
@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# module grouping all statistics concerns
|
||||
module Statistics::Concerns; end
|
||||
|
||||
# Provides various helpers for services dealing with statistics generation
|
||||
module Statistics::Concerns::HelpersConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def default_options
|
||||
yesterday = 1.day.ago
|
||||
{
|
||||
start_date: yesterday.beginning_of_day,
|
||||
end_date: yesterday.end_of_day
|
||||
}
|
||||
end
|
||||
|
||||
def format_date(date)
|
||||
if date.is_a?(String)
|
||||
Date.strptime(date, '%Y%m%d').strftime('%Y-%m-%d')
|
||||
else
|
||||
date.strftime('%Y-%m-%d')
|
||||
end
|
||||
end
|
||||
|
||||
def user_info_stat(stat)
|
||||
{
|
||||
userId: stat[:user_id],
|
||||
gender: stat[:gender],
|
||||
age: stat[:age],
|
||||
group: stat[:group]
|
||||
}
|
||||
end
|
||||
|
||||
def difference_in_hours(start_at, end_at)
|
||||
if start_at.to_date == end_at.to_date
|
||||
((end_at - start_at) / 60 / 60).to_i
|
||||
else
|
||||
end_at_to_start_date = end_at.change(year: start_at.year, month: start_at.month, day: start_at.day)
|
||||
hours = ((end_at_to_start_date - start_at) / 60 / 60).to_i
|
||||
hours = ((end_at.to_date - start_at.to_date).to_i + 1) * hours if end_at.to_date > start_at.to_date
|
||||
hours
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
60
app/services/statistics/concerns/projects_concern.rb
Normal file
60
app/services/statistics/concerns/projects_concern.rb
Normal file
@ -0,0 +1,60 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Provides methods to consolidate data from Projects to use in statistics
|
||||
module Statistics::Concerns::ProjectsConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def get_project_themes(project)
|
||||
project.themes.map do |t|
|
||||
{ id: t.id, name: t.name }
|
||||
end
|
||||
end
|
||||
|
||||
def get_projects_components(project)
|
||||
project.components.map do |c|
|
||||
{ id: c.id, name: c.name }
|
||||
end
|
||||
end
|
||||
|
||||
def get_projects_machines(project)
|
||||
project.machines.map do |m|
|
||||
{ id: m.id, name: m.name }
|
||||
end
|
||||
end
|
||||
|
||||
def get_project_users(project)
|
||||
sum = 0
|
||||
project.project_users.each do |pu|
|
||||
sum += 1 if pu.is_valid
|
||||
end
|
||||
sum
|
||||
end
|
||||
|
||||
def project_info(project)
|
||||
{
|
||||
project_id: project.id,
|
||||
project_name: project.name,
|
||||
project_created_at: project.created_at,
|
||||
project_published_at: project.published_at,
|
||||
project_licence: {},
|
||||
project_themes: get_project_themes(project),
|
||||
project_components: get_projects_components(project),
|
||||
project_machines: get_projects_machines(project),
|
||||
project_users: get_project_users(project)
|
||||
}
|
||||
end
|
||||
|
||||
def project_info_stat(project)
|
||||
{
|
||||
projectId: project.project_id,
|
||||
name: project.project_name,
|
||||
licence: project.project_licence,
|
||||
themes: project.project_themes,
|
||||
components: project.project_components,
|
||||
machines: project.project_machines,
|
||||
users: project.project_users
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
214
app/services/statistics/fetcher_service.rb
Normal file
214
app/services/statistics/fetcher_service.rb
Normal file
@ -0,0 +1,214 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Fetch data from the PostgreSQL database and prepare them
|
||||
# to be used in the statistics generation
|
||||
class Statistics::FetcherService
|
||||
include Statistics::Concerns::HelpersConcern
|
||||
include Statistics::Concerns::ComputeConcern
|
||||
|
||||
class << self
|
||||
def subscriptions_list(options = default_options)
|
||||
result = []
|
||||
InvoiceItem.where("object_type = '#{Subscription.name}' AND invoice_items.created_at >= :start_date " \
|
||||
'AND invoice_items.created_at <= :end_date', options)
|
||||
.eager_load(invoice: [:coupon]).each do |i|
|
||||
next if i.invoice.is_a?(Avoir)
|
||||
|
||||
sub = i.object
|
||||
|
||||
ca = i.amount.to_i
|
||||
cs = CouponService.new
|
||||
ca = cs.ventilate(cs.invoice_total_no_coupon(i.invoice), ca, i.invoice.coupon) unless i.invoice.coupon_id.nil?
|
||||
ca /= 100.00
|
||||
profile = sub.statistic_profile
|
||||
p = sub.plan
|
||||
result.push({ date: options[:start_date].to_date,
|
||||
plan: p.group.slug,
|
||||
plan_id: p.id,
|
||||
plan_interval: p.interval,
|
||||
plan_interval_count: p.interval_count,
|
||||
plan_group_name: p.group.name,
|
||||
slug: p.slug,
|
||||
duration: p.find_statistic_type.key,
|
||||
subscription_id: sub.id,
|
||||
invoice_item_id: i.id,
|
||||
ca: ca }.merge(user_info(profile)))
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def reservations_machine_list(options = default_options)
|
||||
result = []
|
||||
Reservation
|
||||
.where("reservable_type = 'Machine' AND slots_reservations.canceled_at IS NULL AND " \
|
||||
'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options)
|
||||
.eager_load(:slots, :slots_reservations, :invoice_items, statistic_profile: [:group])
|
||||
.each do |r|
|
||||
next unless r.reservable
|
||||
|
||||
profile = r.statistic_profile
|
||||
result.push({ date: options[:start_date].to_date,
|
||||
reservation_id: r.id,
|
||||
machine_id: r.reservable.id,
|
||||
machine_type: r.reservable.friendly_id,
|
||||
machine_name: r.reservable.name,
|
||||
nb_hours: r.slots.size,
|
||||
ca: calcul_ca(r.original_invoice) }.merge(user_info(profile)))
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def reservations_space_list(options = default_options)
|
||||
result = []
|
||||
Reservation
|
||||
.where("reservable_type = 'Space' AND slots_reservations.canceled_at IS NULL AND " \
|
||||
'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options)
|
||||
.eager_load(:slots, :slots_reservations, :invoice_items, statistic_profile: [:group])
|
||||
.each do |r|
|
||||
next unless r.reservable
|
||||
|
||||
profile = r.statistic_profile
|
||||
result.push({ date: options[:start_date].to_date,
|
||||
reservation_id: r.id,
|
||||
space_id: r.reservable.id,
|
||||
space_name: r.reservable.name,
|
||||
space_type: r.reservable.slug,
|
||||
nb_hours: r.slots.size,
|
||||
ca: calcul_ca(r.original_invoice) }.merge(user_info(profile)))
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def reservations_training_list(options = default_options)
|
||||
result = []
|
||||
Reservation
|
||||
.where("reservable_type = 'Training' AND slots_reservations.canceled_at IS NULL AND " \
|
||||
'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options)
|
||||
.eager_load(:slots, :slots_reservations, :invoice_items, statistic_profile: [:group])
|
||||
.each do |r|
|
||||
next unless r.reservable
|
||||
|
||||
profile = r.statistic_profile
|
||||
slot = r.slots.first
|
||||
result.push({ date: options[:start_date].to_date,
|
||||
reservation_id: r.id,
|
||||
training_id: r.reservable.id,
|
||||
training_type: r.reservable.friendly_id,
|
||||
training_name: r.reservable.name,
|
||||
training_date: slot.start_at.to_date,
|
||||
nb_hours: difference_in_hours(slot.start_at, slot.end_at),
|
||||
ca: calcul_ca(r.original_invoice) }.merge(user_info(profile)))
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def reservations_event_list(options = default_options)
|
||||
result = []
|
||||
Reservation
|
||||
.where("reservable_type = 'Event' AND slots_reservations.canceled_at IS NULL AND " \
|
||||
'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options)
|
||||
.eager_load(:slots, :slots_reservations, :invoice_items, statistic_profile: [:group])
|
||||
.each do |r|
|
||||
next unless r.reservable
|
||||
|
||||
profile = r.statistic_profile
|
||||
slot = r.slots.first
|
||||
result.push({ date: options[:start_date].to_date,
|
||||
reservation_id: r.id,
|
||||
event_id: r.reservable.id,
|
||||
event_type: r.reservable.category.slug,
|
||||
event_name: r.reservable.name,
|
||||
event_date: slot.start_at.to_date,
|
||||
event_theme: (r.reservable.event_themes.first ? r.reservable.event_themes.first.name : ''),
|
||||
age_range: (r.reservable.age_range_id ? r.reservable.age_range.name : ''),
|
||||
nb_places: r.total_booked_seats,
|
||||
nb_hours: difference_in_hours(slot.start_at, slot.end_at),
|
||||
ca: calcul_ca(r.original_invoice) }.merge(user_info(profile)))
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def members_ca_list(options = default_options)
|
||||
subscriptions_ca_list = subscriptions_list(options)
|
||||
reservations_ca_list = []
|
||||
avoirs_ca_list = []
|
||||
users_list = []
|
||||
Reservation.where('reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options)
|
||||
.eager_load(:slots, :invoice_items, statistic_profile: [:group])
|
||||
.each do |r|
|
||||
next unless r.reservable
|
||||
|
||||
reservations_ca_list.push(
|
||||
{ date: options[:start_date].to_date, ca: calcul_ca(r.original_invoice) || 0 }.merge(user_info(r.statistic_profile))
|
||||
)
|
||||
end
|
||||
Avoir.where('invoices.created_at >= :start_date AND invoices.created_at <= :end_date', options)
|
||||
.eager_load(:invoice_items, statistic_profile: [:group])
|
||||
.each do |i|
|
||||
# the following line is a workaround for issue #196
|
||||
profile = i.statistic_profile || i.main_item.object&.wallet&.user&.statistic_profile
|
||||
avoirs_ca_list.push({ date: options[:start_date].to_date, ca: calcul_avoir_ca(i) || 0 }.merge(user_info(profile)))
|
||||
end
|
||||
reservations_ca_list.concat(subscriptions_ca_list).concat(avoirs_ca_list).each do |e|
|
||||
profile = StatisticProfile.find(e[:statistic_profile_id])
|
||||
u = find_or_create_user_info(profile, users_list)
|
||||
u[:date] = options[:start_date].to_date
|
||||
add_ca(u, e[:ca], users_list)
|
||||
end
|
||||
users_list
|
||||
end
|
||||
|
||||
def members_list(options = default_options)
|
||||
result = []
|
||||
member = Role.find_by(name: 'member')
|
||||
StatisticProfile.where('role_id = :member AND created_at >= :start_date AND created_at <= :end_date',
|
||||
options.merge(member: member.id))
|
||||
.each do |sp|
|
||||
next if sp.user&.need_completion?
|
||||
|
||||
result.push({ date: options[:start_date].to_date }.merge(user_info(sp)))
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def projects_list(options = default_options)
|
||||
result = []
|
||||
Project.where('projects.published_at >= :start_date AND projects.published_at <= :end_date', options)
|
||||
.eager_load(:licence, :themes, :components, :machines, :project_users, author: [:group])
|
||||
.each do |p|
|
||||
result.push({ date: options[:start_date].to_date }.merge(user_info(p.author)).merge(project_info(p)))
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def add_ca(profile, new_ca, users_list)
|
||||
if profile[:ca]
|
||||
profile[:ca] += new_ca || 0
|
||||
else
|
||||
profile[:ca] = new_ca || 0
|
||||
users_list.push profile
|
||||
end
|
||||
end
|
||||
|
||||
def find_or_create_user_info(profile, list)
|
||||
found = list.find do |l|
|
||||
l[:statistic_profile_id] == profile.id
|
||||
end
|
||||
found || user_info(profile)
|
||||
end
|
||||
|
||||
def user_info(statistic_profile)
|
||||
return {} unless statistic_profile
|
||||
|
||||
{
|
||||
statistic_profile_id: statistic_profile.id,
|
||||
user_id: statistic_profile.user_id,
|
||||
gender: statistic_profile.str_gender,
|
||||
age: statistic_profile.age,
|
||||
group: statistic_profile.group ? statistic_profile.group.slug : nil
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
@ -10,10 +10,10 @@ class PeriodStatisticsWorker
|
||||
days = date_to_days(period)
|
||||
Rails.logger.info "\n==> generating statistics for the last #{days} days <==\n"
|
||||
if days.zero?
|
||||
StatisticService.new.generate_statistic(start_date: DateTime.current.beginning_of_day, end_date: DateTime.current.end_of_day)
|
||||
Statistics::BuilderService.generate_statistic(start_date: DateTime.current.beginning_of_day, end_date: DateTime.current.end_of_day)
|
||||
else
|
||||
days.times.each do |i|
|
||||
StatisticService.new.generate_statistic(start_date: i.day.ago.beginning_of_day, end_date: i.day.ago.end_of_day)
|
||||
Statistics::BuilderService.generate_statistic(start_date: i.day.ago.beginning_of_day, end_date: i.day.ago.end_of_day)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -8,6 +8,6 @@ class StatisticWorker
|
||||
def perform
|
||||
return unless Setting.get('statistics_module')
|
||||
|
||||
StatisticService.new.generate_statistic
|
||||
Statistics::BuilderService.generate_statistic
|
||||
end
|
||||
end
|
||||
|
@ -33,9 +33,7 @@ namespace :fablab do
|
||||
desc 'recreate every versions of images'
|
||||
task build_images_versions: :environment do
|
||||
Project.find_each do |project|
|
||||
if project.project_image.present? && project.project_image.attachment.present?
|
||||
project.project_image.attachment.recreate_versions!
|
||||
end
|
||||
project.project_image.attachment.recreate_versions! if project.project_image.present? && project.project_image.attachment.present?
|
||||
end
|
||||
ProjectStepImage.find_each do |project_step_image|
|
||||
project_step_image.attachment.recreate_versions! if project_step_image.present? && project_step_image.attachment.present?
|
||||
@ -59,7 +57,7 @@ namespace :fablab do
|
||||
count = User.where(is_active: false).count
|
||||
if count.positive?
|
||||
print "WARNING: You are about to delete #{count} users. Are you sure? (y/n) "
|
||||
confirm = STDIN.gets.chomp
|
||||
confirm = $stdin.gets.chomp
|
||||
next unless confirm == 'y'
|
||||
|
||||
User.where(is_active: false).map(&:destroy!)
|
||||
@ -89,7 +87,6 @@ namespace :fablab do
|
||||
|
||||
desc 'clean the cron workers'
|
||||
task clean_workers: :environment do
|
||||
|
||||
Sidekiq::Cron::Job.destroy_all!
|
||||
Sidekiq::Queue.new('system').clear
|
||||
Sidekiq::Queue.new('default').clear
|
||||
@ -120,7 +117,7 @@ namespace :fablab do
|
||||
end_date = yesterday.end_of_day
|
||||
puts "-> Start regenerate statistics between #{I18n.l start_date, format: :long} and " \
|
||||
"#{I18n.l end_date, format: :long}"
|
||||
StatisticService.new.generate_statistic(
|
||||
Statistics::BuilderService.generate_statistic(
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
)
|
||||
|
62
test/helpers/archive_helper.rb
Normal file
62
test/helpers/archive_helper.rb
Normal file
@ -0,0 +1,62 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Provides methods to help testing archives of accounting periods
|
||||
module ArchiveHelper
|
||||
# Force the generation of the archive now.
|
||||
# Then extract it, then check its contents, then delete the archive, finally delete the extracted content
|
||||
def assert_archive(accounting_period)
|
||||
assert_not_nil accounting_period, 'AccountingPeriod was not created'
|
||||
|
||||
archive_worker = ArchiveWorker.new
|
||||
archive_worker.perform(accounting_period.id)
|
||||
|
||||
assert FileTest.exist?(accounting_period.archive_file), 'ZIP archive was not generated'
|
||||
|
||||
dest = extract_archive(accounting_period)
|
||||
|
||||
# Check archive matches
|
||||
file = check_integrity(dest)
|
||||
|
||||
archive = File.read("#{dest}/#{file}")
|
||||
archive_json = JSON.parse(archive)
|
||||
invoices = Invoice.where(
|
||||
'created_at >= :start_date AND created_at <= :end_date',
|
||||
start_date: accounting_period.start_at.to_datetime, end_date: accounting_period.end_at.to_datetime
|
||||
)
|
||||
|
||||
assert_equal invoices.count, archive_json['invoices'].count
|
||||
assert_equal accounting_period.footprint, archive_json['period_footprint']
|
||||
|
||||
require 'version'
|
||||
assert_equal Version.current, archive_json['software']['version']
|
||||
|
||||
# we clean up the files before quitting
|
||||
FileUtils.rm_rf(dest)
|
||||
FileUtils.rm_rf(accounting_period.archive_folder)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Extract the archive to the temporary folder
|
||||
def extract_archive(accounting_period)
|
||||
require 'tmpdir'
|
||||
require 'fileutils'
|
||||
dest = "#{Dir.tmpdir}/accounting/#{accounting_period.id}"
|
||||
FileUtils.mkdir_p "#{dest}/accounting"
|
||||
Zip::File.open(accounting_period.archive_file) do |zip_file|
|
||||
# Handle entries one by one
|
||||
zip_file.each do |entry|
|
||||
# Extract to file/directory/symlink
|
||||
entry.extract("#{dest}/#{entry.name}")
|
||||
end
|
||||
end
|
||||
dest
|
||||
end
|
||||
|
||||
def check_integrity(extracted_path)
|
||||
require 'integrity/checksum'
|
||||
sumfile = File.read("#{dest}/checksum.sha256").split("\t")
|
||||
assert_equal sumfile[0], Integrity::Checksum.file("#{extracted_path}/#{sumfile[1]}"), 'archive checksum does not match'
|
||||
sumfile[1]
|
||||
end
|
||||
end
|
81
test/helpers/invoice_helper.rb
Normal file
81
test/helpers/invoice_helper.rb
Normal file
@ -0,0 +1,81 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Provides methods to help testing invoices
|
||||
module InvoiceHelper
|
||||
# Force the invoice generation worker to run NOW and check the resulting file generated.
|
||||
# Delete the file afterwards.
|
||||
# @param invoice {Invoice}
|
||||
def assert_invoice_pdf(invoice)
|
||||
assert_not_nil invoice, 'Invoice was not created'
|
||||
|
||||
generate_pdf(invoice)
|
||||
|
||||
assert File.exist?(invoice.file), 'Invoice PDF was not generated'
|
||||
|
||||
# now we check the file content
|
||||
reader = PDF::Reader.new(invoice.file)
|
||||
assert_equal 1, reader.page_count # single page invoice
|
||||
page = reader.pages.first
|
||||
lines = page.text.scan(/^.+/)
|
||||
|
||||
check_amounts(invoice, lines)
|
||||
check_user(invoice, lines)
|
||||
|
||||
File.delete(invoice.file)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_pdf(invoice)
|
||||
invoice_worker = InvoiceWorker.new
|
||||
invoice_worker.perform(invoice.id, invoice&.user&.subscription&.expired_at)
|
||||
end
|
||||
|
||||
# Parse a line of text read from a PDF file and return the price included inside
|
||||
# Line of text should be of form 'Label $10.00'
|
||||
# @returns {float}
|
||||
def parse_amount_from_invoice_line(line)
|
||||
line[line.rindex(' ') + 1..].tr(I18n.t('number.currency.format.unit'), '').to_f
|
||||
end
|
||||
|
||||
# check VAT and total excluding taxes
|
||||
def check_amounts(invoice, lines)
|
||||
ht_amount = invoice.total
|
||||
lines.each do |line|
|
||||
# check that the numbers printed into the PDF file match the total stored in DB
|
||||
if line.include? I18n.t('invoices.total_amount')
|
||||
assert_equal invoice.total / 100.0, parse_amount_from_invoice_line(line), 'Invoice total rendered in the PDF file does not match'
|
||||
end
|
||||
|
||||
# check that the VAT was correctly applied if it was configured
|
||||
ht_amount = parse_amount_from_invoice_line(line) if line.include? I18n.t('invoices.including_total_excluding_taxes')
|
||||
end
|
||||
|
||||
vat_service = VatHistoryService.new
|
||||
invoice.invoice_items.each do |item|
|
||||
vat_rate = vat_service.invoice_item_vat(item)
|
||||
if vat_rate.positive?
|
||||
computed_ht = sprintf('%.2f', (item.amount_after_coupon / ((vat_rate / 100.00) + 1)) / 100.00).to_f
|
||||
|
||||
assert_equal computed_ht, item.net_amount / 100.00, 'Total excluding taxes rendered in the PDF file is not computed correctly'
|
||||
else
|
||||
assert_equal item.amount_after_coupon, item.net_amount, 'VAT information was rendered in the PDF file despite that VAT was disabled'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# check the recipient & the address
|
||||
def check_user(invoice, lines)
|
||||
if invoice.invoicing_profile.organization
|
||||
assert lines.first.include?(invoice.invoicing_profile.organization.name), 'On the PDF invoice, organization name is invalid'
|
||||
assert invoice.invoicing_profile.organization.address.address.include?(lines[2].split(' ').last.strip),
|
||||
'On the PDF invoice, organization address is invalid'
|
||||
else
|
||||
assert lines.first.include?(invoice.invoicing_profile.full_name), 'On the PDF invoice, customer name is invalid'
|
||||
assert invoice.invoicing_profile.address.address.include?(lines[2].split(' ').last.strip),
|
||||
'On the PDF invoice, customer address is invalid'
|
||||
end
|
||||
# check the email
|
||||
assert lines[1].include?(invoice.invoicing_profile.email), 'On the PDF invoice, email is invalid'
|
||||
end
|
||||
end
|
@ -2,17 +2,14 @@
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class StatisticServiceTest < ActiveSupport::TestCase
|
||||
class StatisticServiceTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = User.members.without_subscription.first
|
||||
@admin = User.with_role(:admin).first
|
||||
login_as(@admin, scope: :user)
|
||||
end
|
||||
|
||||
def test
|
||||
machine_stats_count = Stats::Machine.all.count
|
||||
subscription_stats_count = Stats::Subscription.all.count
|
||||
|
||||
test 'build stats' do
|
||||
# Create a reservation to generate an invoice
|
||||
machine = Machine.find(1)
|
||||
slot = Availability.find(19).slots.first
|
||||
@ -48,12 +45,40 @@ class StatisticServiceTest < ActiveSupport::TestCase
|
||||
}.to_json, headers: default_headers
|
||||
|
||||
# Build the stats for today, we expect the above invoices (reservation+subscription) to appear in the resulting stats
|
||||
StatisticService.new.generate_statistic(
|
||||
start_date: DateTime.current.beginning_of_day,
|
||||
end_date: DateTime.current.end_of_day
|
||||
)
|
||||
::Statistics::BuilderService.generate_statistic({ start_date: DateTime.current.beginning_of_day,
|
||||
end_date: DateTime.current.end_of_day })
|
||||
|
||||
assert_equal machine_stats_count + 1, Stats::Machine.all.count
|
||||
assert_equal subscription_stats_count + 1, Stats::Subscription.all.count
|
||||
Stats::Machine.refresh_index!
|
||||
|
||||
stat_booking = Stats::Machine.search(query: { bool: { must: [{ term: { date: DateTime.current.to_date.iso8601 } },
|
||||
{ term: { type: 'booking' } }] } }).first
|
||||
assert_not_nil stat_booking
|
||||
assert_equal machine.friendly_id, stat_booking['subType']
|
||||
check_statistics_on_user(stat_booking)
|
||||
|
||||
stat_hour = Stats::Machine.search(query: { bool: { must: [{ term: { date: DateTime.current.to_date.iso8601 } },
|
||||
{ term: { type: 'hour' } }] } }).first
|
||||
|
||||
assert_not_nil stat_hour
|
||||
assert_equal machine.friendly_id, stat_hour['subType']
|
||||
check_statistics_on_user(stat_hour)
|
||||
|
||||
Stats::Subscription.refresh_index!
|
||||
|
||||
stat_subscription = Stats::Subscription.search(query: { bool: { must: [{ term: { date: DateTime.current.to_date.iso8601 } },
|
||||
{ term: { type: plan.find_statistic_type.key } }] } }).first
|
||||
|
||||
assert_not_nil stat_subscription
|
||||
assert_equal plan.find_statistic_type.key, stat_subscription['type']
|
||||
assert_equal plan.slug, stat_subscription['subType']
|
||||
assert_equal plan.id, stat_subscription['planId']
|
||||
assert_equal 1, stat_subscription['stat']
|
||||
check_statistics_on_user(stat_subscription)
|
||||
end
|
||||
|
||||
def check_statistics_on_user(stat)
|
||||
assert_equal @user.statistic_profile.str_gender, stat['gender']
|
||||
assert_equal @user.statistic_profile.age.to_i, stat['age']
|
||||
assert_equal @user.statistic_profile.group.slug, stat['group']
|
||||
end
|
||||
end
|
||||
|
@ -10,20 +10,25 @@ require 'rails/test_help'
|
||||
require 'vcr'
|
||||
require 'sidekiq/testing'
|
||||
require 'minitest/reporters'
|
||||
|
||||
include ActionDispatch::TestProcess
|
||||
require 'helpers/invoice_helper'
|
||||
require 'helpers/archive_helper'
|
||||
|
||||
VCR.configure do |config|
|
||||
config.cassette_library_dir = 'test/vcr_cassettes'
|
||||
config.hook_into :webmock
|
||||
config.filter_sensitive_data('sk_test_testfaketestfaketestfake') { Setting.get('stripe_secret_key') }
|
||||
config.filter_sensitive_data('pk_test_faketestfaketestfaketest') { Setting.get('stripe_public_key') }
|
||||
config.ignore_request { |req| URI(req.uri).port == 9200 }
|
||||
end
|
||||
|
||||
Sidekiq::Testing.fake!
|
||||
Minitest::Reporters.use! [Minitest::Reporters::DefaultReporter.new(color: true)] unless ENV['RM_INFO']
|
||||
|
||||
class ActiveSupport::TestCase
|
||||
include ActionDispatch::TestProcess
|
||||
include InvoiceHelper
|
||||
include ArchiveHelper
|
||||
|
||||
# Add more helper methods to be used by all tests here...
|
||||
ActiveRecord::Migration.check_pending!
|
||||
fixtures :all
|
||||
@ -75,60 +80,6 @@ class ActiveSupport::TestCase
|
||||
).id
|
||||
end
|
||||
|
||||
# Force the invoice generation worker to run NOW and check the resulting file generated.
|
||||
# Delete the file afterwards.
|
||||
# @param invoice {Invoice}
|
||||
def assert_invoice_pdf(invoice)
|
||||
assert_not_nil invoice, 'Invoice was not created'
|
||||
|
||||
invoice_worker = InvoiceWorker.new
|
||||
invoice_worker.perform(invoice.id, invoice&.user&.subscription&.expired_at)
|
||||
|
||||
assert File.exist?(invoice.file), 'Invoice PDF was not generated'
|
||||
|
||||
# now we check the file content
|
||||
reader = PDF::Reader.new(invoice.file)
|
||||
assert_equal 1, reader.page_count # single page invoice
|
||||
|
||||
ht_amount = invoice.total
|
||||
page = reader.pages.first
|
||||
lines = page.text.scan(/^.+/)
|
||||
lines.each do |line|
|
||||
# check that the numbers printed into the PDF file match the total stored in DB
|
||||
if line.include? I18n.t('invoices.total_amount')
|
||||
assert_equal invoice.total / 100.0, parse_amount_from_invoice_line(line), 'Invoice total rendered in the PDF file does not match'
|
||||
end
|
||||
|
||||
# check that the VAT was correctly applied if it was configured
|
||||
ht_amount = parse_amount_from_invoice_line(line) if line.include? I18n.t('invoices.including_total_excluding_taxes')
|
||||
end
|
||||
|
||||
vat_service = VatHistoryService.new
|
||||
invoice.invoice_items.each do |item|
|
||||
vat_rate = vat_service.invoice_item_vat(item)
|
||||
if vat_rate.positive?
|
||||
computed_ht = sprintf('%.2f', (item.amount_after_coupon / (vat_rate / 100.00 + 1)) / 100.00).to_f
|
||||
|
||||
assert_equal computed_ht, item.net_amount / 100.00, 'Total excluding taxes rendered in the PDF file is not computed correctly'
|
||||
else
|
||||
assert_equal item.amount_after_coupon, item.net_amount, 'VAT information was rendered in the PDF file despite that VAT was disabled'
|
||||
end
|
||||
end
|
||||
|
||||
# check the recipient & the address
|
||||
if invoice.invoicing_profile.organization
|
||||
assert lines.first.include?(invoice.invoicing_profile.organization.name), 'On the PDF invoice, organization name is invalid'
|
||||
assert invoice.invoicing_profile.organization.address.address.include?(lines[2].split(' ').last.strip), 'On the PDF invoice, organization address is invalid'
|
||||
else
|
||||
assert lines.first.include?(invoice.invoicing_profile.full_name), 'On the PDF invoice, customer name is invalid'
|
||||
assert invoice.invoicing_profile.address.address.include?(lines[2].split(' ').last.strip), 'On the PDF invoice, customer address is invalid'
|
||||
end
|
||||
# check the email
|
||||
assert lines[1].include?(invoice.invoicing_profile.email), 'On the PDF invoice, email is invalid'
|
||||
|
||||
File.delete(invoice.file)
|
||||
end
|
||||
|
||||
# Force the statistics export generation worker to run NOW and check the resulting file generated.
|
||||
# Delete the file afterwards.
|
||||
# @param export {Export}
|
||||
@ -147,63 +98,10 @@ class ActiveSupport::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
def assert_archive(accounting_period)
|
||||
assert_not_nil accounting_period, 'AccountingPeriod was not created'
|
||||
|
||||
archive_worker = ArchiveWorker.new
|
||||
archive_worker.perform(accounting_period.id)
|
||||
|
||||
assert FileTest.exist?(accounting_period.archive_file), 'ZIP archive was not generated'
|
||||
|
||||
# Extract archive
|
||||
require 'tmpdir'
|
||||
require 'fileutils'
|
||||
dest = "#{Dir.tmpdir}/accounting/#{accounting_period.id}"
|
||||
FileUtils.mkdir_p "#{dest}/accounting"
|
||||
Zip::File.open(accounting_period.archive_file) do |zip_file|
|
||||
# Handle entries one by one
|
||||
zip_file.each do |entry|
|
||||
# Extract to file/directory/symlink
|
||||
entry.extract("#{dest}/#{entry.name}")
|
||||
end
|
||||
end
|
||||
|
||||
# Check archive matches
|
||||
require 'integrity/checksum'
|
||||
sumfile = File.read("#{dest}/checksum.sha256").split("\t")
|
||||
assert_equal sumfile[0], Integrity::Checksum.file("#{dest}/#{sumfile[1]}"), 'archive checksum does not match'
|
||||
|
||||
archive = File.read("#{dest}/#{sumfile[1]}")
|
||||
archive_json = JSON.parse(archive)
|
||||
invoices = Invoice.where(
|
||||
'created_at >= :start_date AND created_at <= :end_date',
|
||||
start_date: accounting_period.start_at.to_datetime, end_date: accounting_period.end_at.to_datetime
|
||||
)
|
||||
|
||||
assert_equal invoices.count, archive_json['invoices'].count
|
||||
assert_equal accounting_period.footprint, archive_json['period_footprint']
|
||||
|
||||
require 'version'
|
||||
assert_equal Version.current, archive_json['software']['version']
|
||||
|
||||
# we clean up the files before quitting
|
||||
FileUtils.rm_rf(dest)
|
||||
FileUtils.rm_rf(accounting_period.archive_folder)
|
||||
end
|
||||
|
||||
def assert_dates_equal(expected, actual, msg = nil)
|
||||
assert_not_nil actual, msg
|
||||
assert_equal expected.to_date, actual.to_date, msg
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Parse a line of text read from a PDF file and return the price included inside
|
||||
# Line of text should be of form 'Label $10.00'
|
||||
# @returns {float}
|
||||
def parse_amount_from_invoice_line(line)
|
||||
line[line.rindex(' ') + 1..-1].tr(I18n.t('number.currency.format.unit'), '').to_f
|
||||
end
|
||||
end
|
||||
|
||||
class ActionDispatch::IntegrationTest
|
||||
|
Loading…
x
Reference in New Issue
Block a user