1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-20 14:54:15 +01:00

(feat) rebuild accounting lines on updates

This commit is contained in:
Sylvain 2022-11-22 17:43:19 +01:00
parent b3072ec444
commit 749f848034
13 changed files with 185 additions and 100 deletions

View File

@ -12,10 +12,10 @@ class API::SettingsController < API::ApiController
authorize Setting
@setting = Setting.find_or_initialize_by(name: params[:name])
render status: :not_modified and return if setting_params[:value] == @setting.value
render status: :locked, json: { error: I18n.t('settings.locked_setting') } and return unless SettingService.before_update(@setting)
render status: :locked, json: { error: I18n.t('settings.locked_setting') } and return unless SettingService.update_allowed?(@setting)
if @setting.save && @setting.history_values.create(value: setting_params[:value], invoicing_profile: current_user.invoicing_profile)
SettingService.after_update(@setting)
SettingService.run_after_update([@setting])
render status: :ok
else
render json: @setting.errors.full_messages, status: :unprocessable_entity
@ -31,17 +31,17 @@ class API::SettingsController < API::ApiController
next if !setting[:name] || !setting[:value]
db_setting = Setting.find_or_initialize_by(name: setting[:name])
if !SettingService.before_update(db_setting)
if !SettingService.update_allowed?(db_setting)
db_setting.errors.add(:-, "#{I18n.t("settings.#{setting[:name]}")}: #{I18n.t('settings.locked_setting')}")
elsif db_setting.save
db_setting.history_values.create(value: setting[:value], invoicing_profile: current_user.invoicing_profile)
SettingService.after_update(db_setting)
end
@settings.push db_setting
may_rollback(params[:transactional]) if db_setting.errors.keys.count.positive?
end
end
SettingService.run_after_update(@settings)
end
def show
@ -61,7 +61,7 @@ class API::SettingsController < API::ApiController
authorize Setting
setting = Setting.find_or_create_by(name: params[:name])
render status: :locked, json: { error: 'locked setting' } and return unless SettingService.before_update(setting)
render status: :locked, json: { error: 'locked setting' } and return unless SettingService.update_allowed?(setting)
first_val = setting.history_values.order(created_at: :asc).limit(1).first
new_val = HistoryValue.create!(
@ -69,7 +69,7 @@ class API::SettingsController < API::ApiController
value: first_val&.value,
invoicing_profile_id: current_user.invoicing_profile.id
)
SettingService.after_update(setting)
SettingService.run_after_update([setting])
render json: new_val, status: :ok
end

View File

@ -1,5 +1,7 @@
import { ReactNode, useEffect, useState } from 'react';
import * as React from 'react';
import _ from 'lodash';
import { usePrevious } from '../../lib/use-previous';
type tabId = string|number;
@ -21,9 +23,12 @@ interface FabTabsProps {
*/
export const FabTabs: React.FC<FabTabsProps> = ({ tabs, defaultTab, className }) => {
const [active, setActive] = useState<Tab>(tabs.filter(Boolean).find(t => t.id === defaultTab) || tabs.filter(Boolean)[0]);
const previousTabs = usePrevious<Tab[]>(tabs);
useEffect(() => {
setActive(tabs.filter(Boolean).find(t => t.id === defaultTab) || tabs.filter(Boolean)[0]);
if (!_.isEqual(previousTabs?.filter(Boolean).map(t => t.id), tabs?.filter(Boolean).map(t => t?.id))) {
setActive(tabs.filter(Boolean).find(t => t.id === defaultTab) || tabs.filter(Boolean)[0]);
}
}, [tabs]);
/**

View File

@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
// provides the previous value of a Prop, in a useEffect hook
// Credits to: https://stackoverflow.com/a/57706747/1039377
export const usePrevious = <T>(value: T): T | undefined => {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current;
};

View File

@ -8,4 +8,25 @@ class AdvancedAccounting < ApplicationRecord
belongs_to :space, foreign_type: 'Space', foreign_key: 'accountable_id', inverse_of: :advanced_accounting
belongs_to :event, foreign_type: 'Event', foreign_key: 'accountable_id', inverse_of: :advanced_accounting
belongs_to :product, foreign_type: 'Product', foreign_key: 'accountable_id', inverse_of: :advanced_accounting
belongs_to :plan, foreign_type: 'Plan', foreign_key: 'accountable_id', inverse_of: :advanced_accounting
after_save :rebuild_accounting_lines
private
def rebuild_accounting_lines
invoices = case accountable_type
when 'Machine', 'Training', 'Space', 'Event'
accountable.reservations.map(&:invoice_items).flatten.map(&:invoice).uniq
when 'Product'
accountable.order_items.map(&:order).flatten.map(&:invoice).uniq
when 'Plan'
accountable.subscriptions.map(&:invoice_items).flatten.map(&:invoice).uniq
else
raise TypeError "Unknown accountable_type #{accountable_type}"
end
ids = invoices.map(&:id)
AccountingLine.where(invoice_id: ids).destroy_all
AccountingWorker.perform_async(:invoices, ids)
end
end

View File

@ -0,0 +1,44 @@
# frozen_string_literal: false
# module definition
module OpenlabSync
extend ActiveSupport::Concern
included do
after_create :openlab_create, if: :openlab_sync_active?
run_after_update :openlab_update, if: :openlab_sync_active?
after_destroy :openlab_destroy, if: :openlab_sync_active?
def openlab_create
OpenlabWorker.perform_in(2.seconds, :create, id) if published?
end
def openlab_update
return unless published?
if state_was == 'draft'
OpenlabWorker.perform_async(:create, id)
else
OpenlabWorker.perform_async(:update, id)
end
end
def openlab_destroy
OpenlabWorker.perform_async(:destroy, id)
end
def openlab_attributes
OpenLabService.to_hash(self)
end
def openlab_sync_active?
self.class.openlab_sync_active?
end
end
class_methods do
def openlab_sync_active?
Setting.get('openlab_app_secret').present?
end
end
end

View File

@ -4,6 +4,7 @@
class OrderItem < ApplicationRecord
belongs_to :order
belongs_to :orderable, polymorphic: true
belongs_to :product, foreign_type: 'Product', foreign_key: 'orderable_id', inverse_of: :order_items
validates :orderable, :order_id, :amount, presence: true
end

View File

@ -20,6 +20,8 @@ class Product < ApplicationRecord
has_many :product_stock_movements, dependent: :destroy
accepts_nested_attributes_for :product_stock_movements, allow_destroy: true, reject_if: :all_blank
has_many :order_items, as: :orderable, dependent: :nullify
has_one :advanced_accounting, as: :accountable, dependent: :destroy
accepts_nested_attributes_for :advanced_accounting, allow_destroy: true

View File

@ -1,65 +0,0 @@
module Project::OpenlabSync
extend ActiveSupport::Concern
included do
include ActionView::Helpers::SanitizeHelper
after_create :openlab_create, if: :openlab_sync_active?
after_update :openlab_update, if: :openlab_sync_active?
after_destroy :openlab_destroy, if: :openlab_sync_active?
def openlab_create
OpenlabWorker.perform_in(2.seconds, :create, self.id) if self.published?
end
def openlab_update
if self.published?
if self.state_was == 'draft'
OpenlabWorker.perform_async(:create, self.id)
else
OpenlabWorker.perform_async(:update, self.id)
end
end
end
def openlab_destroy
OpenlabWorker.perform_async(:destroy, self.id)
end
def openlab_attributes
{
id: id, slug: slug, name: name, description: description, tags: tags,
machines: machines.map(&:name),
components: components.map(&:name),
themes: themes.map(&:name),
author: author&.user&.profile&.full_name,
collaborators: users.map { |u| u&.profile&.full_name },
steps_body: steps_body,
image_path: project_image&.attachment&.medium&.url,
project_path: "/#!/projects/#{slug}",
updated_at: updated_at.to_s(:iso8601),
created_at: created_at.to_s(:iso8601),
published_at: published_at.to_s(:iso8601)
}
end
def steps_body
concatenated_steps = project_steps.map { |s| "#{s.title} #{s.description}" }
.join(' ').gsub('</p>', ' </p>')
.gsub("\r\n", ' ').gsub("\n\r", ' ')
.gsub("\n", ' ').gsub("\r", ' ').gsub("\t", ' ')
strip_tags(concatenated_steps).strip
end
def openlab_sync_active?
self.class.openlab_sync_active?
end
end
class_methods do
def openlab_sync_active?
Setting.get('openlab_app_secret').present?
end
end
end

View File

@ -43,7 +43,7 @@ class Accounting::AccountingCodeService
raise ArgumentError('invalid section') unless %i[code analytical_section].include?(section)
if type == :code
item_code = Setting.get('advanced_accounting') ? invoice_item.object.reservable.advanced_accounting.send(section) : nil
item_code = Setting.get('advanced_accounting') ? invoice_item.object.reservable.advanced_accounting&.send(section) : nil
return Setting.get("accounting_#{invoice_item.object.reservable_type}_code") if item_code.nil? && section == :code
item_code

View File

@ -12,11 +12,16 @@ class Accounting::AccountingService
@journal_code = Setting.get('accounting_journal_code') || ''
end
# build accounting lines for invoices between the provided dates
def build(start_date, end_date)
# build accounting lines
lines = []
invoices = Invoice.where('created_at >= ? AND created_at <= ?', start_date, end_date).order('created_at ASC')
invoices.each do |i|
build_from_invoices(invoices)
end
# build accounting lines for the provided invoices
def build_from_invoices(invoices)
lines = []
invoices.find_each do |i|
Rails.logger.debug { "processing invoice #{i.id}..." } unless Rails.env.test?
lines << generate_lines(i)
end

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
# Provides methods to sync projects on OpenLab
class OpenLabService
class << self
include ActionView::Helpers::SanitizeHelper
def to_hash(project)
{
id: project.id,
slug: project.slug,
name: project.name,
description: project.description,
tags: project.tags,
machines: project.machines.map(&:name),
components: project.components.map(&:name),
themes: project.themes.map(&:name),
author: project.author&.user&.profile&.full_name,
collaborators: project.users.map { |u| u&.profile&.full_name },
steps_body: steps_body(project),
image_path: project.project_image&.attachment&.medium&.url,
project_path: "/#!/projects/#{project.slug}",
updated_at: project.updated_at.to_s(:iso8601),
created_at: project.created_at.to_s(:iso8601),
published_at: project.published_at.to_s(:iso8601)
}
end
def steps_body(project)
concatenated_steps = project.project_steps.map { |s| "#{s.title} #{s.description}" }
.join(' ').gsub('</p>', ' </p>')
.gsub("\r\n", ' ').gsub("\n\r", ' ')
.gsub("\n", ' ').gsub("\r", ' ').gsub("\t", ' ')
strip_tags(concatenated_steps).strip
end
end
end

View File

@ -5,72 +5,80 @@
# so this service provides a wrapper around these operations.
class SettingService
class << self
def before_update(setting)
def update_allowed?(setting)
return false if Rails.application.secrets.locked_settings.include? setting.name
true
end
def after_update(setting)
update_theme_stylesheet(setting)
update_home_stylesheet(setting)
notify_privacy_update(setting)
sync_stripe_objects(setting)
build_stats(setting)
export_projects_to_openlab(setting)
validate_admins(setting)
def run_after_update(settings)
update_theme_stylesheet(settings)
update_home_stylesheet(settings)
notify_privacy_update(settings)
sync_stripe_objects(settings)
build_stats(settings)
export_projects_to_openlab(settings)
validate_admins(settings)
update_accounting_line(settings)
end
private
# rebuild the theme stylesheet
def update_theme_stylesheet(setting)
return unless %w[main_color secondary_color].include? setting.name
def update_theme_stylesheet(settings)
return unless (%w[main_color secondary_color] & settings.map(&:name)).count.positive?
Stylesheet.theme&.rebuild!
end
# rebuild the home page stylesheet
def update_home_stylesheet(setting)
return unless setting.name == 'home_css'
def update_home_stylesheet(settings)
return unless settings.any? { |s| s.name == 'home_css' }
Stylesheet.home_page&.rebuild!
end
# notify about a change in privacy policy
def notify_privacy_update(setting)
return unless setting.name == 'privacy_body'
def notify_privacy_update(settings)
return unless settings.any? { |s| s.name == 'privacy_body' }
NotifyPrivacyUpdateWorker.perform_async(setting.id)
end
# sync all objects on stripe
def sync_stripe_objects(setting)
return unless %w[stripe_secret_key online_payment_module].include?(setting.name)
def sync_stripe_objects(settings)
return unless (%w[stripe_secret_key online_payment_module] & settings.map(&:name)).count.positive?
SyncObjectsOnStripeWorker.perform_async(setting.history_values.last&.invoicing_profile&.user&.id)
end
# generate the statistics since the last update
def build_stats(setting)
return unless setting.name == 'statistics_module' && setting.value == 'true'
def build_stats(settings)
return unless settings.any? { |s| s.name == 'statistics_module' && s.value == 'true' }
PeriodStatisticsWorker.perform_async(setting.previous_update)
end
# export projects to openlab
def export_projects_to_openlab(setting)
return unless %w[openlab_app_id openlab_app_secret].include?(setting.name) &&
def export_projects_to_openlab(settings)
return unless (%w[openlab_app_id openlab_app_secret] & settings.map(&:name)).count.positive? &&
Setting.get('openlab_app_id').present? && Setting.get('openlab_app_secret').present?
Project.all.each(&:openlab_create)
end
# automatically validate the admins
def validate_admins(setting)
return unless setting.name == 'user_validation_required' && setting.value == 'true'
def validate_admins(settings)
return unless settings.any? { |s| s.name == 'user_validation_required' && s.value == 'true' }
User.admins.each { |admin| admin.update(validated_at: DateTime.current) if admin.validated_at.nil? }
end
def update_accounting_line(settings)
return unless settings.any? { |s| s.name.match(/^accounting_/) || s.name == 'advanced_accounting' }
AccountingLine.destroy_all
AccountingWorker.perform_async(:all)
end
end
end

View File

@ -4,8 +4,23 @@
class AccountingWorker
include Sidekiq::Worker
def perform
def perform(action = :today, *params)
send(action, *params)
end
def today
service = Accounting::AccountingService.new
service.build(DateTime.current.beginning_of_day, DateTime.current.end_of_day)
end
def invoices(invoices_ids)
service = Accounting::AccountingService.new
invoices = Invoice.where(id: invoices_ids)
service.build_from_invoices(invoices)
end
def all
service = Accounting::AccountingService.new
service.build_from_invoices(Invoice.all)
end
end