From 6315b7d3f8b2457b169cde864dc546f20d4fb60d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 02:37:22 +0000 Subject: [PATCH 1/2] feat: add comprehensive export system (CSV, XLSX, PDF, JSON) Implement a unified export framework supporting all major data views: - Order list, revenue/profit, production/factory floor, inventory, supply intake, and shipping chart exports - All 4 formats: CSV (BOM for Excel JP), XLSX (caxlsx), PDF (Prawn), and JSON with metadata - Async generation via Solid Queue (ExportJob) with Message tracking - Reusable export button partial with format dropdown + date range modal - Pundit authorization (ExportPolicy) matching order read access - Japanese-first i18n translations (exports.ja.yml + exports.en.yml) - Export buttons wired into all dashboard tabs (orders, revenue, shipping, production, supply usage, supply dates) - Comprehensive test suite: 9 test files covering base export, all data exporters, job execution, and controller authorization https://claude.ai/code/session_01VBKF79FuRwrf32F3WFCTJ6 --- Gemfile | 3 + app/controllers/oroshi/exports_controller.rb | 70 ++++++++++ app/helpers/oroshi/exports_helper.rb | 16 +++ app/jobs/oroshi/export_job.rb | 31 +++++ app/policies/oroshi/export_policy.rb | 11 ++ .../exports/_date_range_export.html.erb | 47 +++++++ .../oroshi/exports/_export_button.html.erb | 31 +++++ .../orders/dashboard/_production.html.erb | 7 +- .../orders/dashboard/_supply_usage.html.erb | 5 +- .../filter_buttons/_filter_card.html.erb | 13 ++ .../oroshi/supply_dates/show/_header.html.erb | 9 +- config/locales/exports.en.yml | 26 ++++ config/locales/exports.ja.yml | 26 ++++ config/routes.rb | 3 + lib/exports/base_export.rb | 105 ++++++++++++++ lib/exports/csv_export.rb | 20 +++ lib/exports/inventory_export.rb | 70 ++++++++++ lib/exports/json_export.rb | 26 ++++ lib/exports/orders_export.rb | 76 ++++++++++ lib/exports/pdf_export.rb | 41 ++++++ lib/exports/production_export.rb | 65 +++++++++ lib/exports/revenue_export.rb | 126 +++++++++++++++++ lib/exports/shipping_export.rb | 90 ++++++++++++ lib/exports/supply_export.rb | 61 ++++++++ lib/exports/xlsx_export.rb | 46 ++++++ .../oroshi/exports_controller_test.rb | 131 ++++++++++++++++++ test/jobs/oroshi/export_job_test.rb | 114 +++++++++++++++ test/lib/exports/base_export_test.rb | 114 +++++++++++++++ test/lib/exports/inventory_export_test.rb | 40 ++++++ test/lib/exports/orders_export_test.rb | 74 ++++++++++ test/lib/exports/production_export_test.rb | 34 +++++ test/lib/exports/revenue_export_test.rb | 51 +++++++ test/lib/exports/shipping_export_test.rb | 44 ++++++ test/lib/exports/supply_export_test.rb | 39 ++++++ 34 files changed, 1659 insertions(+), 6 deletions(-) create mode 100644 app/controllers/oroshi/exports_controller.rb create mode 100644 app/helpers/oroshi/exports_helper.rb create mode 100644 app/jobs/oroshi/export_job.rb create mode 100644 app/policies/oroshi/export_policy.rb create mode 100644 app/views/oroshi/exports/_date_range_export.html.erb create mode 100644 app/views/oroshi/exports/_export_button.html.erb create mode 100644 config/locales/exports.en.yml create mode 100644 config/locales/exports.ja.yml create mode 100644 lib/exports/base_export.rb create mode 100644 lib/exports/csv_export.rb create mode 100644 lib/exports/inventory_export.rb create mode 100644 lib/exports/json_export.rb create mode 100644 lib/exports/orders_export.rb create mode 100644 lib/exports/pdf_export.rb create mode 100644 lib/exports/production_export.rb create mode 100644 lib/exports/revenue_export.rb create mode 100644 lib/exports/shipping_export.rb create mode 100644 lib/exports/supply_export.rb create mode 100644 lib/exports/xlsx_export.rb create mode 100644 test/controllers/oroshi/exports_controller_test.rb create mode 100644 test/jobs/oroshi/export_job_test.rb create mode 100644 test/lib/exports/base_export_test.rb create mode 100644 test/lib/exports/inventory_export_test.rb create mode 100644 test/lib/exports/orders_export_test.rb create mode 100644 test/lib/exports/production_export_test.rb create mode 100644 test/lib/exports/revenue_export_test.rb create mode 100644 test/lib/exports/shipping_export_test.rb create mode 100644 test/lib/exports/supply_export_test.rb diff --git a/Gemfile b/Gemfile index 530084b..e22afcf 100644 --- a/Gemfile +++ b/Gemfile @@ -108,6 +108,9 @@ gem "prawn", "2.4.0" # PDF generation gem "prawn-table" # Table support for prawn gem "ttfunk", "1.7.0" # TrueType font parsing (prawn dependency) +# Export formats +gem "caxlsx" # Excel XLSX generation (community-maintained axlsx successor) + # API and integrations gem "httparty" # HTTP client for API calls diff --git a/app/controllers/oroshi/exports_controller.rb b/app/controllers/oroshi/exports_controller.rb new file mode 100644 index 0000000..cbea528 --- /dev/null +++ b/app/controllers/oroshi/exports_controller.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class Oroshi::ExportsController < Oroshi::ApplicationController + before_action :authorize_export + + EXPORT_TYPES = { + "orders" => "Exports::OrdersExport", + "revenue" => "Exports::RevenueExport", + "production" => "Exports::ProductionExport", + "inventory" => "Exports::InventoryExport", + "supply" => "Exports::SupplyExport", + "shipping" => "Exports::ShippingExport" + }.freeze + + VALID_FORMATS = %w[csv xlsx json pdf].freeze + + # POST /oroshi/exports + def create + unless EXPORT_TYPES.key?(params[:export_type]) + return head :unprocessable_entity + end + + unless VALID_FORMATS.include?(params[:format_type]) + return head :unprocessable_entity + end + + message = create_export_message + Oroshi::ExportJob.perform_later( + export_class_name, + params[:format_type], + message.id, + export_options + ) + + head :ok + end + + private + + def export_class_name + EXPORT_TYPES.fetch(params[:export_type]) + end + + def export_options + params.permit( + :date, :start_date, :end_date, + :shipping_organization_id, :print_empty_buyers, + buyer_ids: [], shipping_method_ids: [], + order_category_ids: [], buyer_category_ids: [] + ).to_h.compact_blank + end + + def create_export_message + Message.create!( + user: current_user.id, + model: "oroshi_export", + state: nil, + message: I18n.t("oroshi.exports.processing"), + data: { + export_type: params[:export_type], + format: params[:format_type], + expiration: (DateTime.now + 1.day) + } + ) + end + + def authorize_export + authorize :export, :create? + end +end diff --git a/app/helpers/oroshi/exports_helper.rb b/app/helpers/oroshi/exports_helper.rb new file mode 100644 index 0000000..8409413 --- /dev/null +++ b/app/helpers/oroshi/exports_helper.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Oroshi + module ExportsHelper + FORMAT_ICONS = { + "csv" => "filetype-csv", + "xlsx" => "file-earmark-spreadsheet", + "pdf" => "filetype-pdf", + "json" => "filetype-json" + }.freeze + + def export_format_icon(format) + icon(FORMAT_ICONS.fetch(format, "file-earmark")) + end + end +end diff --git a/app/jobs/oroshi/export_job.rb b/app/jobs/oroshi/export_job.rb new file mode 100644 index 0000000..606588a --- /dev/null +++ b/app/jobs/oroshi/export_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Oroshi::ExportJob < ApplicationJob + queue_as :default + + # @param export_class [String] fully qualified class name (e.g., "Exports::OrdersExport") + # @param format [String] export format ("csv", "xlsx", "json", "pdf") + # @param message_id [Integer] Message record ID for status tracking + # @param options [Hash] export options (date, filters, etc.) + def perform(export_class, format, message_id, options = {}) + message = Message.find(message_id) + + exporter = export_class.constantize.new(options) + content = exporter.generate(format) + + io = StringIO.new(content) + message.stored_file.attach( + io: io, + content_type: exporter.content_type(format), + filename: exporter.filename(format) + ) + + message.update(state: true, message: I18n.t("oroshi.exports.completed")) + + # Free memory after PDF generation (matches existing pattern) + GC.start if format.to_s == "pdf" + rescue StandardError => e + message&.update(state: false, message: I18n.t("oroshi.exports.failed", error: e.message)) + raise + end +end diff --git a/app/policies/oroshi/export_policy.rb b/app/policies/oroshi/export_policy.rb new file mode 100644 index 0000000..b4a7d1b --- /dev/null +++ b/app/policies/oroshi/export_policy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Oroshi + class ExportPolicy < ApplicationPolicy + # Export access mirrors order read access: + # Admin, VIP, and Employee can export data + def create? + user.admin? || user.vip? || user.employee? + end + end +end diff --git a/app/views/oroshi/exports/_date_range_export.html.erb b/app/views/oroshi/exports/_date_range_export.html.erb new file mode 100644 index 0000000..323d3d6 --- /dev/null +++ b/app/views/oroshi/exports/_date_range_export.html.erb @@ -0,0 +1,47 @@ +<%# Date range export modal %> +<%# locals: (export_type:) %> + diff --git a/app/views/oroshi/exports/_export_button.html.erb b/app/views/oroshi/exports/_export_button.html.erb new file mode 100644 index 0000000..546e91b --- /dev/null +++ b/app/views/oroshi/exports/_export_button.html.erb @@ -0,0 +1,31 @@ +<%# Reusable export dropdown button for dashboard views %> +<%# locals: (export_type:, date:, additional_params: {}) %> + + +<%= render "oroshi/exports/date_range_export", export_type: export_type %> diff --git a/app/views/oroshi/orders/dashboard/_production.html.erb b/app/views/oroshi/orders/dashboard/_production.html.erb index bc83e51..c307785 100644 --- a/app/views/oroshi/orders/dashboard/_production.html.erb +++ b/app/views/oroshi/orders/dashboard/_production.html.erb @@ -8,9 +8,12 @@ <%= production_tab_link('出荷日表', 'shipping_date_production') %> <%= production_tab_link('製造依頼', 'production_request') %> <%= production_tab_link('在庫表', 'product_inventory') %> - <%= link_to "依頼反映 #{icon('fast-forward-circle-fill')}".html_safe, + + <%= link_to "依頼反映 #{icon('fast-forward-circle-fill')}".html_safe, convert_oroshi_production_requests_path(@date), - class: "nav-link ms-auto", + class: "nav-link", data: { controller: 'tippy', tippy_content: '不足している在庫を生産リクエストに変換する', diff --git a/app/views/oroshi/orders/dashboard/_supply_usage.html.erb b/app/views/oroshi/orders/dashboard/_supply_usage.html.erb index 0640add..c82efb8 100644 --- a/app/views/oroshi/orders/dashboard/_supply_usage.html.erb +++ b/app/views/oroshi/orders/dashboard/_supply_usage.html.erb @@ -1,7 +1,10 @@ <%= turbo_frame_tag 'orders_dashboard' do %>
-
+
供給変種・在庫 表示/非表示 +
+ <%= render 'oroshi/exports/export_button', export_type: 'inventory', date: @date %> +
diff --git a/app/views/oroshi/orders/dashboard/shared/filter_buttons/_filter_card.html.erb b/app/views/oroshi/orders/dashboard/shared/filter_buttons/_filter_card.html.erb index 427249f..4c33794 100644 --- a/app/views/oroshi/orders/dashboard/shared/filter_buttons/_filter_card.html.erb +++ b/app/views/oroshi/orders/dashboard/shared/filter_buttons/_filter_card.html.erb @@ -34,5 +34,18 @@ <%= render 'oroshi/orders/dashboard/shared/filter_buttons/filter_buttons', view:, filter_category:, filter_params: %> <% end %> + + <% export_type = { 'orders' => 'orders', 'templates' => nil, 'shipping' => 'shipping', 'revenue' => 'revenue' }[view] %> + <% if export_type %> +
+ <%= render 'oroshi/exports/export_button', export_type: export_type, date: @date, + additional_params: { + buyer_ids: @buyer_ids, + shipping_method_ids: @shipping_method_ids, + order_category_ids: @order_category_ids, + buyer_category_ids: @buyer_category_ids + }.compact %> +
+ <% end %>
diff --git a/app/views/oroshi/supply_dates/show/_header.html.erb b/app/views/oroshi/supply_dates/show/_header.html.erb index bb69160..afa2d8c 100644 --- a/app/views/oroshi/supply_dates/show/_header.html.erb +++ b/app/views/oroshi/supply_dates/show/_header.html.erb @@ -1,6 +1,9 @@ -

- <%= t('oroshi.supply_dates.show.header_title') %> -

+
+

+ <%= t('oroshi.supply_dates.show.header_title') %> +

+ <%= render 'oroshi/exports/export_button', export_type: 'supply', date: @supply_date.date %> +
<%= form_with model: @supply_date, class: "container-xl d-block d-lg-flex gap-3 mb-3 justify-content-center align-items-center" do |f| %> diff --git a/config/locales/exports.en.yml b/config/locales/exports.en.yml new file mode 100644 index 0000000..4a9bcb4 --- /dev/null +++ b/config/locales/exports.en.yml @@ -0,0 +1,26 @@ +en: + oroshi: + exports: + button: "Export" + processing: "Generating export…" + completed: "Export completed" + failed: "Export failed: %{error}" + download: "Download" + start_date: "Start date" + end_date: "End date" + format: "Format" + date_range: "Date range export" + current_view: "Current view" + close: "Close" + formats: + csv: "CSV" + xlsx: "Excel (XLSX)" + pdf: "PDF" + json: "JSON" + types: + orders: "Orders" + revenue: "Revenue & Profit" + production: "Production" + inventory: "Inventory" + supply: "Supply intake" + shipping: "Shipping chart" diff --git a/config/locales/exports.ja.yml b/config/locales/exports.ja.yml new file mode 100644 index 0000000..db2a601 --- /dev/null +++ b/config/locales/exports.ja.yml @@ -0,0 +1,26 @@ +ja: + oroshi: + exports: + button: "エクスポート" + processing: "エクスポート処理中…" + completed: "エクスポート完了" + failed: "エクスポートに失敗しました: %{error}" + download: "ダウンロード" + start_date: "開始日" + end_date: "終了日" + format: "形式" + date_range: "期間指定エクスポート" + current_view: "現在の表示" + close: "閉じる" + formats: + csv: "CSV" + xlsx: "Excel (XLSX)" + pdf: "PDF" + json: "JSON" + types: + orders: "注文一覧" + revenue: "売上・利益" + production: "製造・工場" + inventory: "在庫一覧" + supply: "入荷一覧" + shipping: "出荷表" diff --git a/config/routes.rb b/config/routes.rb index a2cec3b..94486ee 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -30,6 +30,9 @@ Oroshi::Engine.routes.draw do root to: "dashboard#index" + # Exports + resources :exports, only: [ :create ] + # Legal pages get "privacy_policy", to: "legal#privacy_policy", as: :privacy_policy get "terms_of_service", to: "legal#terms_of_service", as: :terms_of_service diff --git a/lib/exports/base_export.rb b/lib/exports/base_export.rb new file mode 100644 index 0000000..7925620 --- /dev/null +++ b/lib/exports/base_export.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Exports + class BaseExport + include Exports::CsvExport + include Exports::XlsxExport + include Exports::JsonExport + include Exports::PdfExport + + attr_reader :options, :records + + def initialize(options = {}) + @options = options.with_indifferent_access + @records = load_data + end + + def generate(format) + validate_format!(format) + send("generate_#{format}") + end + + def filename(format) + ext = FORMAT_EXTENSIONS.fetch(format.to_sym) + "#{export_name}_#{date_label}_#{timestamp}.#{ext}" + end + + def content_type(format) + CONTENT_TYPES.fetch(format.to_sym) + end + + FORMAT_EXTENSIONS = { csv: "csv", xlsx: "xlsx", json: "json", pdf: "pdf" }.freeze + CONTENT_TYPES = { + csv: "text/csv; charset=utf-8", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + json: "application/json; charset=utf-8", + pdf: "application/pdf" + }.freeze + SUPPORTED_FORMATS = FORMAT_EXTENSIONS.keys.freeze + + private + + # Subclasses must implement these + def load_data = raise NotImplementedError, "#{self.class} must implement #load_data" + def columns = raise NotImplementedError, "#{self.class} must implement #columns" + def export_name = raise NotImplementedError, "#{self.class} must implement #export_name" + + def validate_format!(format) + unless SUPPORTED_FORMATS.include?(format.to_sym) + raise ArgumentError, "Unsupported export format: #{format}. Supported: #{SUPPORTED_FORMATS.join(', ')}" + end + end + + def date_range + if options[:start_date].present? && options[:end_date].present? + Date.parse(options[:start_date].to_s)..Date.parse(options[:end_date].to_s) + elsif options[:date].present? + date = Date.parse(options[:date].to_s) + date..date + else + today = Time.zone.today + today..today + end + end + + def date_label + range = date_range + if range.first == range.last + range.first.to_s + else + "#{range.first}_#{range.last}" + end + end + + def timestamp + Time.zone.now.strftime("%Y%m%d%H%M%S") + end + + # Shared filter logic (mirrors OrdersDashboard::Shared#set_filters) + def apply_order_filters(scope) + scope = scope.where(buyer_id: options[:buyer_ids]) if options[:buyer_ids].present? + scope = scope.where(shipping_method_id: options[:shipping_method_ids]) if options[:shipping_method_ids].present? + if options[:order_category_ids].present? + scope = scope.joins(:order_categories) + .where(order_categories: { id: options[:order_category_ids] }) + end + if options[:buyer_category_ids].present? + scope = scope.joins(buyer: :buyer_categories) + .where(buyer_categories: { id: options[:buyer_category_ids] }) + end + scope + end + + def format_date(date) + return "" if date.nil? + + I18n.l(date, format: :short) + end + + def format_currency(amount) + return 0 if amount.nil? + + amount.to_i + end + end +end diff --git a/lib/exports/csv_export.rb b/lib/exports/csv_export.rb new file mode 100644 index 0000000..8ee7c92 --- /dev/null +++ b/lib/exports/csv_export.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "csv" + +module Exports + module CsvExport + # BOM prefix ensures Excel opens UTF-8 CSV with Japanese characters correctly + BOM = "\xEF\xBB\xBF" + + def generate_csv + BOM + CSV.generate do |csv| + csv << columns.map { |c| c[:header] } + records.each do |record| + csv << columns.map { |c| c[:value].call(record) } + end + append_summary_rows(csv) if respond_to?(:summary_rows, true) + end + end + end +end diff --git a/lib/exports/inventory_export.rb b/lib/exports/inventory_export.rb new file mode 100644 index 0000000..f6a5860 --- /dev/null +++ b/lib/exports/inventory_export.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Exports + class InventoryExport < BaseExport + private + + def export_name + I18n.t("oroshi.exports.types.inventory", default: "在庫一覧") + end + + def load_data + scope = Oroshi::ProductInventory + .where("quantity > 0") + .includes( + product_variation: :product, + orders: :buyer + ) + + # If date is provided, filter by manufacture_date range + if options[:date].present? || (options[:start_date].present? && options[:end_date].present?) + scope = scope.where(manufacture_date: date_range) + end + + scope.order("oroshi_product_inventories.manufacture_date DESC").to_a + end + + def columns + [ + { key: :product, header: "商品", type: :string, + value: ->(pi) { pi.product_variation.product.name } }, + { key: :product_variation, header: "バリエーション", type: :string, + value: ->(pi) { pi.product_variation.name } }, + { key: :manufacture_date, header: "製造日", type: :date, + value: ->(pi) { pi.manufacture_date } }, + { key: :expiration_date, header: "賞味期限", type: :date, + value: ->(pi) { pi.expiration_date } }, + { key: :quantity, header: "在庫数量", type: :integer, + value: ->(pi) { pi.quantity } }, + { key: :freight_quantity, header: "フレート数", type: :integer, + value: ->(pi) { safe_freight_quantity(pi) } }, + { key: :pending_orders, header: "未出荷注文数", type: :integer, + value: ->(pi) { pending_order_quantity(pi) } }, + { key: :difference, header: "差分", type: :integer, + value: ->(pi) { pi.quantity - pending_order_quantity(pi) } } + ] + end + + def summary_rows + return [] if records.empty? + + total_quantity = records.sum(&:quantity) + total_pending = records.sum { |pi| pending_order_quantity(pi) } + + [ + [ "合計", "", "", "", total_quantity, "", + total_pending, total_quantity - total_pending ] + ] + end + + def pending_order_quantity(product_inventory) + product_inventory.orders.reject(&:shipped?).sum(&:item_quantity) + end + + def safe_freight_quantity(product_inventory) + product_inventory.freight_quantity + rescue + 0 + end + end +end diff --git a/lib/exports/json_export.rb b/lib/exports/json_export.rb new file mode 100644 index 0000000..ec25db7 --- /dev/null +++ b/lib/exports/json_export.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Exports + module JsonExport + def generate_json + data = records.map do |record| + columns.each_with_object({}) do |col, hash| + hash[col[:key]] = col[:value].call(record) + end + end + + result = { + export_name: export_name, + exported_at: Time.zone.now.iso8601, + date_range: { start: date_range.first.to_s, end: date_range.last.to_s }, + filters: options.except(:date, :start_date, :end_date, :format), + record_count: data.size, + data: data + } + + result[:summary] = json_summary if respond_to?(:json_summary, true) + + result.to_json + end + end +end diff --git a/lib/exports/orders_export.rb b/lib/exports/orders_export.rb new file mode 100644 index 0000000..6b76b3e --- /dev/null +++ b/lib/exports/orders_export.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Exports + class OrdersExport < BaseExport + private + + def export_name + I18n.t("oroshi.exports.types.orders", default: "注文一覧") + end + + def load_data + scope = Oroshi::Order.non_template + .where(shipping_date: date_range) + .includes(:buyer, :product, :shipping_receptacle, + :shipping_organization, :order_categories, + product_variation: :product, + shipping_method: :shipping_organization) + .order(:shipping_date, :buyer_id) + apply_order_filters(scope).to_a + end + + def columns + [ + { key: :shipping_date, header: "出荷日", type: :date, + value: ->(o) { o.shipping_date } }, + { key: :arrival_date, header: "到着日", type: :date, + value: ->(o) { o.arrival_date } }, + { key: :buyer, header: "買い手", type: :string, + value: ->(o) { o.buyer.name } }, + { key: :product, header: "商品", type: :string, + value: ->(o) { o.product.name } }, + { key: :product_variation, header: "バリエーション", type: :string, + value: ->(o) { o.product_variation.name } }, + { key: :item_quantity, header: "数量", type: :integer, + value: ->(o) { o.item_quantity } }, + { key: :receptacle_quantity, header: "ケース数", type: :integer, + value: ->(o) { o.receptacle_quantity } }, + { key: :freight_quantity, header: "フレート数", type: :integer, + value: ->(o) { o.freight_quantity } }, + { key: :sale_price_per_item, header: "単価", type: :currency, + value: ->(o) { format_currency(o.sale_price_per_item) } }, + { key: :revenue, header: "売上", type: :currency, + value: ->(o) { format_currency(o.revenue) } }, + { key: :expenses, header: "経費", type: :currency, + value: ->(o) { format_currency(o.expenses) } }, + { key: :total, header: "利益", type: :currency, + value: ->(o) { format_currency(o.total) } }, + { key: :shipping_method, header: "配送方法", type: :string, + value: ->(o) { o.shipping_method.name } }, + { key: :shipping_organization, header: "配送組織", type: :string, + value: ->(o) { o.shipping_organization&.name || "" } }, + { key: :categories, header: "カテゴリ", type: :string, + value: ->(o) { o.order_categories.map(&:name).join(", ") } }, + { key: :status, header: "ステータス", type: :string, + value: ->(o) { I18n.t("activerecord.enums.oroshi/order.status.#{o.status}", default: o.status) } }, + { key: :note, header: "ノート", type: :string, + value: ->(o) { o.note || "" } } + ] + end + + def summary_rows + return [] if records.empty? + + total_revenue = records.sum(&:revenue) + total_expenses = records.sum(&:expenses) + total_profit = records.sum(&:total) + total_items = records.sum(&:item_quantity) + + [ + [ "合計", "", "", "", "", total_items, "", "", "", + format_currency(total_revenue), format_currency(total_expenses), + format_currency(total_profit), "", "", "", "", "" ] + ] + end + end +end diff --git a/lib/exports/pdf_export.rb b/lib/exports/pdf_export.rb new file mode 100644 index 0000000..a2f9caa --- /dev/null +++ b/lib/exports/pdf_export.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Exports + module PdfExport + def generate_pdf + pdf = Printable.new(page_size: pdf_page_size, page_layout: pdf_page_layout) + pdf.text pdf_title, size: 14, style: :bold + pdf.move_down 5 + pdf.text pdf_subtitle, size: 8 if respond_to?(:pdf_subtitle, true) + pdf.move_down 10 + + table_data = [ columns.map { |c| c[:header] } ] + records.each do |record| + table_data << columns.map { |c| c[:value].call(record).to_s } + end + + if respond_to?(:summary_rows, true) + summary_rows.each { |row| table_data << row.map(&:to_s) } + end + + pdf.font_size 7 + pdf.table(table_data, header: true, width: pdf.bounds.width) do + row(0).font_style = :bold + row(0).background_color = "f0f0f0" + cells.border_width = 0.5 + cells.padding = 3 + end + + pdf.render + end + + private + + def pdf_page_size = "A4" + def pdf_page_layout = :landscape + + def pdf_title + "#{export_name} #{date_range.first == date_range.last ? format_date(date_range.first) : "#{format_date(date_range.first)} ~ #{format_date(date_range.last)}"}" + end + end +end diff --git a/lib/exports/production_export.rb b/lib/exports/production_export.rb new file mode 100644 index 0000000..764d4b4 --- /dev/null +++ b/lib/exports/production_export.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Exports + class ProductionExport < BaseExport + private + + def export_name + I18n.t("oroshi.exports.types.production", default: "製造・工場") + end + + def load_data + # Match the production dashboard: query ±1 day buffer + expanded_range = (date_range.first - 1.day)..(date_range.last + 1.day) + + Oroshi::ProductionRequest + .joins(:product_inventory) + .where(product_inventories: { manufacture_date: expanded_range }) + .includes( + :production_zone, :shipping_receptacle, + product_variation: [ :product, :supply_type, :supply_type_variations ], + product_inventory: :orders + ) + .order("oroshi_product_inventories.manufacture_date ASC") + .to_a + end + + def columns + [ + { key: :manufacture_date, header: "製造日", type: :date, + value: ->(pr) { pr.product_inventory.manufacture_date } }, + { key: :expiration_date, header: "賞味期限", type: :date, + value: ->(pr) { pr.product_inventory.expiration_date } }, + { key: :product, header: "商品", type: :string, + value: ->(pr) { pr.product_variation.product.name } }, + { key: :product_variation, header: "バリエーション", type: :string, + value: ->(pr) { pr.product_variation.name } }, + { key: :production_zone, header: "製造ゾーン", type: :string, + value: ->(pr) { pr.production_zone&.name || "" } }, + { key: :request_quantity, header: "依頼数量", type: :integer, + value: ->(pr) { pr.request_quantity } }, + { key: :fulfilled_quantity, header: "完了数量", type: :integer, + value: ->(pr) { pr.fulfilled_quantity } }, + { key: :remaining, header: "残数量", type: :integer, + value: ->(pr) { pr.request_quantity - pr.fulfilled_quantity } }, + { key: :inventory_quantity, header: "在庫数量", type: :integer, + value: ->(pr) { pr.product_inventory.quantity } }, + { key: :status, header: "ステータス", type: :string, + value: ->(pr) { I18n.t("activerecord.enums.oroshi/production_request.status.#{pr.status}", default: pr.status) } } + ] + end + + def summary_rows + return [] if records.empty? + + total_requested = records.sum(&:request_quantity) + total_fulfilled = records.sum(&:fulfilled_quantity) + total_remaining = total_requested - total_fulfilled + + [ + [ "合計", "", "", "", "", total_requested, total_fulfilled, + total_remaining, "", "" ] + ] + end + end +end diff --git a/lib/exports/revenue_export.rb b/lib/exports/revenue_export.rb new file mode 100644 index 0000000..06ed1a3 --- /dev/null +++ b/lib/exports/revenue_export.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module Exports + class RevenueExport < BaseExport + private + + def export_name + I18n.t("oroshi.exports.types.revenue", default: "売上・利益") + end + + def load_data + scope = Oroshi::Order.non_template + .where(shipping_date: date_range) + .includes(:buyer, :order_categories, + :shipping_method, :shipping_receptacle, + product_variation: [ :product, :supply_type_variations ]) + .order(:shipping_date, :buyer_id) + apply_order_filters(scope).to_a + end + + def columns + [ + { key: :shipping_date, header: "出荷日", type: :date, + value: ->(o) { o.shipping_date } }, + { key: :product, header: "商品", type: :string, + value: ->(o) { o.product.name } }, + { key: :product_variation, header: "バリエーション", type: :string, + value: ->(o) { o.product_variation.name } }, + { key: :buyer, header: "買い手", type: :string, + value: ->(o) { o.buyer.name } }, + { key: :item_quantity, header: "数量", type: :integer, + value: ->(o) { o.item_quantity } }, + { key: :sale_price_per_item, header: "単価", type: :currency, + value: ->(o) { format_currency(o.sale_price_per_item) } }, + { key: :revenue, header: "売上", type: :currency, + value: ->(o) { format_currency(o.revenue) } }, + { key: :revenue_minus_handling, header: "手数料後売上", type: :currency, + value: ->(o) { format_currency(o.revenue_minus_handling) } }, + { key: :materials_cost, header: "材料費", type: :currency, + value: ->(o) { format_currency(o.materials_cost) } }, + { key: :shipping_cost, header: "配送費", type: :currency, + value: ->(o) { format_currency(o.shipping_cost) } }, + { key: :adjustment, header: "調整", type: :currency, + value: ->(o) { format_currency(o.adjustment) } }, + { key: :expenses, header: "経費合計", type: :currency, + value: ->(o) { format_currency(o.expenses) } }, + { key: :total, header: "利益", type: :currency, + value: ->(o) { format_currency(o.total) } } + ] + end + + def summary_rows + return [] if records.empty? + + revenue_subtotal = records.sum(&:revenue_minus_handling) + expenses_subtotal = records.sum(&:expenses) + buyer_daily_costs = unique_buyers.sum(&:daily_cost) + shipping_daily_costs = unique_shipping_methods.sum(&:daily_cost) + net_profit = revenue_subtotal - expenses_subtotal - buyer_daily_costs - shipping_daily_costs + + [ + [ "収入小計", "", "", "", "", "", "", format_currency(revenue_subtotal), + "", "", "", "", "" ], + [ "経費小計", "", "", "", "", "", "", "", + "", "", "", format_currency(expenses_subtotal), "" ], + [ "買い手日額経費", "", "", "", "", "", "", "", + "", "", "", format_currency(buyer_daily_costs), "" ], + [ "配送日額経費", "", "", "", "", "", "", "", + "", "", "", format_currency(shipping_daily_costs), "" ], + [ "純利益", "", "", "", "", "", "", "", + "", "", "", "", format_currency(net_profit) ] + ] + end + + def json_summary + return {} if records.empty? + + revenue_subtotal = records.sum(&:revenue_minus_handling) + expenses_subtotal = records.sum(&:expenses) + buyer_daily_costs = unique_buyers.sum(&:daily_cost) + shipping_daily_costs = unique_shipping_methods.sum(&:daily_cost) + + { + revenue_subtotal: format_currency(revenue_subtotal), + expenses_subtotal: format_currency(expenses_subtotal), + buyer_daily_costs: format_currency(buyer_daily_costs), + shipping_method_daily_costs: format_currency(shipping_daily_costs), + net_profit: format_currency(revenue_subtotal - expenses_subtotal - buyer_daily_costs - shipping_daily_costs) + } + end + + def add_summary_worksheet(workbook) + return if records.empty? + + # Group by date for daily summaries + by_date = records.group_by(&:shipping_date) + header_style = workbook.styles.add_style(b: true, bg_color: "F0F0F0") + currency_style = workbook.styles.add_style(format_code: "#,##0") + + workbook.add_worksheet(name: "日別集計") do |sheet| + sheet.add_row [ "日付", "収入", "経費", "買い手日額", "配送日額", "純利益" ], style: header_style + + by_date.sort.each do |date, orders| + revenue = orders.sum(&:revenue_minus_handling) + expenses = orders.sum(&:expenses) + buyers = orders.map(&:buyer).uniq + methods = orders.map(&:shipping_method).uniq + buyer_cost = buyers.sum(&:daily_cost) + method_cost = methods.sum(&:daily_cost) + net = revenue - expenses - buyer_cost - method_cost + + sheet.add_row [ date, revenue, expenses, buyer_cost, method_cost, net ], + style: [ nil, currency_style, currency_style, currency_style, currency_style, currency_style ] + end + end + end + + def unique_buyers + @unique_buyers ||= records.map(&:buyer).uniq + end + + def unique_shipping_methods + @unique_shipping_methods ||= records.map(&:shipping_method).uniq + end + end +end diff --git a/lib/exports/shipping_export.rb b/lib/exports/shipping_export.rb new file mode 100644 index 0000000..df12643 --- /dev/null +++ b/lib/exports/shipping_export.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Exports + class ShippingExport < BaseExport + # For PDF, delegate to the existing OroshiOrderDocument which has the + # established B4 landscape layout for shipping charts. + def generate_pdf + if options[:shipping_organization_id].present? + pdf = OroshiOrderDocument.new( + date_range.first.to_s, + "shipping_chart", + options[:shipping_organization_id], + options[:print_empty_buyers] || "0", + filter_options + ) + pdf.render + else + super + end + end + + private + + def export_name + I18n.t("oroshi.exports.types.shipping", default: "出荷表") + end + + def load_data + scope = Oroshi::Order.non_template + .where(shipping_date: date_range) + .includes(:buyer, :product, :shipping_receptacle, + :shipping_organization, + product_variation: :product, + shipping_method: :shipping_organization) + .order(:shipping_date, :buyer_id) + apply_order_filters(scope).to_a + end + + def columns + [ + { key: :shipping_date, header: "出荷日", type: :date, + value: ->(o) { o.shipping_date } }, + { key: :shipping_organization, header: "配送組織", type: :string, + value: ->(o) { o.shipping_organization&.name || "" } }, + { key: :shipping_method, header: "配送方法", type: :string, + value: ->(o) { o.shipping_method.name } }, + { key: :buyer, header: "買い手", type: :string, + value: ->(o) { o.buyer.name } }, + { key: :buyer_handle, header: "買い手コード", type: :string, + value: ->(o) { o.buyer.handle } }, + { key: :product, header: "商品", type: :string, + value: ->(o) { o.product.name } }, + { key: :product_variation, header: "バリエーション", type: :string, + value: ->(o) { o.product_variation.name } }, + { key: :item_quantity, header: "数量", type: :integer, + value: ->(o) { o.item_quantity } }, + { key: :receptacle_quantity, header: "ケース数", type: :integer, + value: ->(o) { o.receptacle_quantity } }, + { key: :freight_quantity, header: "フレート数", type: :integer, + value: ->(o) { o.freight_quantity } }, + { key: :receptacle, header: "容器", type: :string, + value: ->(o) { o.shipping_receptacle.name } }, + { key: :note, header: "ノート", type: :string, + value: ->(o) { o.note || "" } } + ] + end + + def summary_rows + return [] if records.empty? + + total_items = records.sum(&:item_quantity) + total_receptacles = records.sum(&:receptacle_quantity) + total_freight = records.sum(&:freight_quantity) + + [ + [ "合計", "", "", "", "", "", "", total_items, + total_receptacles, total_freight, "", "" ] + ] + end + + def filter_options + { + "buyer_ids" => options[:buyer_ids], + "shipping_method_ids" => options[:shipping_method_ids], + "order_category_ids" => options[:order_category_ids], + "buyer_category_ids" => options[:buyer_category_ids] + }.compact + end + end +end diff --git a/lib/exports/supply_export.rb b/lib/exports/supply_export.rb new file mode 100644 index 0000000..00a5238 --- /dev/null +++ b/lib/exports/supply_export.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Exports + class SupplyExport < BaseExport + private + + def export_name + I18n.t("oroshi.exports.types.supply", default: "入荷一覧") + end + + def load_data + scope = Oroshi::Supply.with_quantity + .joins(:supply_date) + .where(supply_dates: { date: date_range }) + .includes( + :supply_date, + :supply_reception_time, + supply_type_variation: :supply_type, + supplier: :supplier_organization + ) + .order("oroshi_supply_dates.date ASC, oroshi_supplies.entry_index ASC") + scope.to_a + end + + def columns + [ + { key: :supply_date, header: "入荷日", type: :date, + value: ->(s) { s.supply_date.date } }, + { key: :supplier_organization, header: "仕入先組織", type: :string, + value: ->(s) { s.supplier&.supplier_organization&.entity_name || "" } }, + { key: :supplier, header: "仕入先", type: :string, + value: ->(s) { s.supplier&.company_name || "" } }, + { key: :supply_type, header: "原料種類", type: :string, + value: ->(s) { s.supply_type_variation&.supply_type&.name || "" } }, + { key: :supply_type_variation, header: "バリエーション", type: :string, + value: ->(s) { s.supply_type_variation&.name || "" } }, + { key: :quantity, header: "数量", type: :decimal, + value: ->(s) { s.quantity } }, + { key: :units, header: "単位", type: :string, + value: ->(s) { s.supply_type_variation&.supply_type&.units || "" } }, + { key: :price, header: "単価", type: :currency, + value: ->(s) { format_currency(s.price) } }, + { key: :subtotal, header: "金額", type: :currency, + value: ->(s) { format_currency(s.quantity * s.price) } }, + { key: :reception_time, header: "受入時間", type: :string, + value: ->(s) { s.supply_reception_time&.time_qualifier || "" } } + ] + end + + def summary_rows + return [] if records.empty? + + total_amount = records.sum { |s| s.quantity * s.price } + + [ + [ "合計", "", "", "", "", "", "", "", + format_currency(total_amount), "" ] + ] + end + end +end diff --git a/lib/exports/xlsx_export.rb b/lib/exports/xlsx_export.rb new file mode 100644 index 0000000..7eac001 --- /dev/null +++ b/lib/exports/xlsx_export.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "caxlsx" + +module Exports + module XlsxExport + def generate_xlsx + package = Axlsx::Package.new + workbook = package.workbook + + header_style = workbook.styles.add_style(b: true, bg_color: "F0F0F0", border: Axlsx::STYLE_THIN_BORDER) + currency_style = workbook.styles.add_style(format_code: '#,##0', border: Axlsx::STYLE_THIN_BORDER) + date_style = workbook.styles.add_style(format_code: "yyyy/mm/dd", border: Axlsx::STYLE_THIN_BORDER) + default_style = workbook.styles.add_style(border: Axlsx::STYLE_THIN_BORDER) + + workbook.add_worksheet(name: export_name.truncate(31)) do |sheet| + sheet.add_row columns.map { |c| c[:header] }, style: header_style + + records.each do |record| + values = columns.map { |c| c[:value].call(record) } + styles = columns.map do |c| + case c[:type] + when :currency then currency_style + when :date then date_style + else default_style + end + end + sheet.add_row values, style: styles + end + + if respond_to?(:summary_rows, true) + summary_style = workbook.styles.add_style(b: true, bg_color: "FFFFCC", border: Axlsx::STYLE_THIN_BORDER) + summary_rows.each do |row| + sheet.add_row row, style: summary_style + end + end + end + + # Add summary worksheet if available + add_summary_worksheet(workbook) if respond_to?(:add_summary_worksheet, true) + + stream = package.to_stream + stream.read + end + end +end diff --git a/test/controllers/oroshi/exports_controller_test.rb b/test/controllers/oroshi/exports_controller_test.rb new file mode 100644 index 0000000..fe8df52 --- /dev/null +++ b/test/controllers/oroshi/exports_controller_test.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require "test_helper" + +class Oroshi::ExportsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = create(:user) + sign_in @user + end + + test "creates export for valid params" do + assert_enqueued_with(job: Oroshi::ExportJob) do + post oroshi_exports_path, params: { + export_type: "orders", + format_type: "csv", + date: Time.zone.today.to_s + } + end + + assert_response :ok + end + + test "rejects invalid export_type" do + post oroshi_exports_path, params: { + export_type: "invalid", + format_type: "csv" + } + + assert_response :unprocessable_entity + end + + test "rejects invalid format_type" do + post oroshi_exports_path, params: { + export_type: "orders", + format_type: "xml" + } + + assert_response :unprocessable_entity + end + + test "creates message record with correct attributes" do + assert_difference("Message.count", 1) do + post oroshi_exports_path, params: { + export_type: "revenue", + format_type: "xlsx", + date: Time.zone.today.to_s + } + end + + message = Message.last + assert_nil message.state + assert_equal I18n.t("oroshi.exports.processing"), message.message + assert_equal "revenue", message.data["export_type"] + assert_equal "xlsx", message.data["format"] + end + + test "passes filter params to export job" do + buyer = create(:oroshi_buyer) + + assert_enqueued_with(job: Oroshi::ExportJob) do + post oroshi_exports_path, params: { + export_type: "orders", + format_type: "csv", + date: Time.zone.today.to_s, + buyer_ids: [buyer.id.to_s] + } + end + + assert_response :ok + end + + test "supports date range params" do + assert_enqueued_with(job: Oroshi::ExportJob) do + post oroshi_exports_path, params: { + export_type: "orders", + format_type: "csv", + start_date: 1.month.ago.to_date.to_s, + end_date: Time.zone.today.to_s + } + end + + assert_response :ok + end + + test "all export types are accepted" do + %w[orders revenue production inventory supply shipping].each do |export_type| + post oroshi_exports_path, params: { + export_type: export_type, + format_type: "csv", + date: Time.zone.today.to_s + } + + assert_response :ok, "Export type '#{export_type}' should be accepted" + end + end + + test "all format types are accepted" do + %w[csv xlsx pdf json].each do |format_type| + post oroshi_exports_path, params: { + export_type: "orders", + format_type: format_type, + date: Time.zone.today.to_s + } + + assert_response :ok, "Format '#{format_type}' should be accepted" + end + end + + test "requires authentication" do + sign_out @user + post oroshi_exports_path, params: { + export_type: "orders", + format_type: "csv" + } + + assert_response :redirect + end + + test "supplier role is denied export access" do + supplier_user = create(:user, :supplier) + sign_in supplier_user + + assert_raises(Pundit::NotAuthorizedError) do + post oroshi_exports_path, params: { + export_type: "orders", + format_type: "csv", + date: Time.zone.today.to_s + } + end + end +end diff --git a/test/jobs/oroshi/export_job_test.rb b/test/jobs/oroshi/export_job_test.rb new file mode 100644 index 0000000..7b00956 --- /dev/null +++ b/test/jobs/oroshi/export_job_test.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "test_helper" + +class Oroshi::ExportJobTest < ActiveJob::TestCase + setup do + @message = create(:message) + @order = create(:oroshi_order, shipping_date: Time.zone.today) + end + + test "generates CSV export and attaches to message" do + Oroshi::ExportJob.perform_now( + "Exports::OrdersExport", + "csv", + @message.id, + { "date" => Time.zone.today.to_s } + ) + + @message.reload + assert_equal true, @message.state + assert @message.stored_file.attached? + assert @message.stored_file.filename.to_s.end_with?(".csv") + end + + test "generates XLSX export and attaches to message" do + Oroshi::ExportJob.perform_now( + "Exports::OrdersExport", + "xlsx", + @message.id, + { "date" => Time.zone.today.to_s } + ) + + @message.reload + assert_equal true, @message.state + assert @message.stored_file.attached? + assert @message.stored_file.filename.to_s.end_with?(".xlsx") + end + + test "generates JSON export and attaches to message" do + Oroshi::ExportJob.perform_now( + "Exports::OrdersExport", + "json", + @message.id, + { "date" => Time.zone.today.to_s } + ) + + @message.reload + assert_equal true, @message.state + assert @message.stored_file.attached? + assert @message.stored_file.filename.to_s.end_with?(".json") + end + + test "generates PDF export and attaches to message" do + Oroshi::ExportJob.perform_now( + "Exports::OrdersExport", + "pdf", + @message.id, + { "date" => Time.zone.today.to_s } + ) + + @message.reload + assert_equal true, @message.state + assert @message.stored_file.attached? + assert @message.stored_file.filename.to_s.end_with?(".pdf") + end + + test "sets completed message on success" do + Oroshi::ExportJob.perform_now( + "Exports::OrdersExport", + "csv", + @message.id, + { "date" => Time.zone.today.to_s } + ) + + @message.reload + assert_equal I18n.t("oroshi.exports.completed"), @message.message + end + + test "sets failure state on error" do + assert_raises(NameError) do + Oroshi::ExportJob.perform_now( + "Exports::NonexistentExport", + "csv", + @message.id, + {} + ) + end + + @message.reload + assert_equal false, @message.state + end + + test "works with all export types" do + export_types = %w[ + Exports::OrdersExport + Exports::RevenueExport + Exports::InventoryExport + Exports::ShippingExport + ] + + export_types.each do |export_class| + message = create(:message) + Oroshi::ExportJob.perform_now( + export_class, + "csv", + message.id, + { "date" => Time.zone.today.to_s } + ) + + message.reload + assert_equal true, message.state, "#{export_class} CSV export failed" + end + end +end diff --git a/test/lib/exports/base_export_test.rb b/test/lib/exports/base_export_test.rb new file mode 100644 index 0000000..cf3646b --- /dev/null +++ b/test/lib/exports/base_export_test.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "test_helper" + +class Exports::BaseExportTest < ActiveSupport::TestCase + # Concrete subclass for testing the abstract base + class TestExport < Exports::BaseExport + private + + def export_name = "テスト" + + def load_data + [ + OpenStruct.new(name: "Item 1", price: 100), + OpenStruct.new(name: "Item 2", price: 200) + ] + end + + def columns + [ + { key: :name, header: "名前", type: :string, value: ->(r) { r.name } }, + { key: :price, header: "価格", type: :currency, value: ->(r) { r.price } } + ] + end + end + + setup do + @export = TestExport.new(date: Time.zone.today.to_s) + end + + test "loads data on initialization" do + assert_equal 2, @export.records.size + end + + test "generates CSV with BOM and headers" do + csv = @export.generate("csv") + assert csv.start_with?(Exports::CsvExport::BOM) + lines = csv.delete_prefix(Exports::CsvExport::BOM).lines + assert_equal "名前,価格\n", lines.first + assert_equal 3, lines.size # header + 2 data rows + end + + test "generates JSON with metadata" do + json = JSON.parse(@export.generate("json")) + assert_equal "テスト", json["export_name"] + assert_equal 2, json["record_count"] + assert_equal 2, json["data"].size + assert_equal "Item 1", json["data"].first["name"] + assert json["exported_at"].present? + end + + test "generates XLSX" do + xlsx_data = @export.generate("xlsx") + assert xlsx_data.is_a?(String) + assert xlsx_data.length > 0 + # XLSX files start with PK (zip format) + assert_equal "PK", xlsx_data[0..1] + end + + test "generates PDF" do + pdf_data = @export.generate("pdf") + assert pdf_data.is_a?(String) + assert pdf_data.start_with?("%PDF") + end + + test "raises on unsupported format" do + assert_raises(ArgumentError) { @export.generate("xml") } + end + + test "filename includes export name and format extension" do + filename = @export.filename("csv") + assert filename.end_with?(".csv") + assert filename.include?("テスト") + end + + test "content_type returns correct MIME types" do + assert_equal "text/csv; charset=utf-8", @export.content_type("csv") + assert_equal "application/json; charset=utf-8", @export.content_type("json") + assert_equal "application/pdf", @export.content_type("pdf") + assert @export.content_type("xlsx").include?("spreadsheetml") + end + + test "handles date range options" do + export = TestExport.new(start_date: "2026-01-01", end_date: "2026-01-31") + filename = export.filename("csv") + assert filename.include?("2026-01-01") + assert filename.include?("2026-01-31") + end + + test "handles single date option" do + export = TestExport.new(date: "2026-02-15") + filename = export.filename("csv") + assert filename.include?("2026-02-15") + end + + test "handles empty records gracefully" do + empty_export = Class.new(Exports::BaseExport) do + private + def export_name = "空" + def load_data = [] + def columns + [{ key: :name, header: "名前", type: :string, value: ->(r) { r.name } }] + end + end.new + + csv = empty_export.generate("csv") + lines = csv.delete_prefix(Exports::CsvExport::BOM).lines + assert_equal 1, lines.size # headers only + + json = JSON.parse(empty_export.generate("json")) + assert_equal 0, json["record_count"] + assert_empty json["data"] + end +end diff --git a/test/lib/exports/inventory_export_test.rb b/test/lib/exports/inventory_export_test.rb new file mode 100644 index 0000000..b888ba4 --- /dev/null +++ b/test/lib/exports/inventory_export_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "test_helper" + +class Exports::InventoryExportTest < ActiveSupport::TestCase + setup do + @order = create(:oroshi_order, shipping_date: Time.zone.today) + @inventory = @order.product_inventory + end + + test "loads inventories with positive quantity" do + @inventory.update_column(:quantity, 50) + export = Exports::InventoryExport.new + assert export.records.any? { |pi| pi.id == @inventory.id } + end + + test "excludes zero-quantity inventories" do + @inventory.update_column(:quantity, 0) + export = Exports::InventoryExport.new + refute export.records.any? { |pi| pi.id == @inventory.id } + end + + test "columns include inventory fields" do + @inventory.update_column(:quantity, 50) + export = Exports::InventoryExport.new + csv = export.generate("csv") + assert csv.include?("在庫数量") + assert csv.include?("製造日") + assert csv.include?("賞味期限") + assert csv.include?("未出荷注文数") + assert csv.include?("差分") + end + + test "generates valid XLSX" do + @inventory.update_column(:quantity, 50) + export = Exports::InventoryExport.new + xlsx = export.generate("xlsx") + assert xlsx.start_with?("PK") + end +end diff --git a/test/lib/exports/orders_export_test.rb b/test/lib/exports/orders_export_test.rb new file mode 100644 index 0000000..65778fc --- /dev/null +++ b/test/lib/exports/orders_export_test.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "test_helper" + +class Exports::OrdersExportTest < ActiveSupport::TestCase + setup do + @order = create(:oroshi_order, shipping_date: Time.zone.today) + end + + test "loads orders for given date" do + export = Exports::OrdersExport.new(date: Time.zone.today.to_s) + assert_includes export.records, @order + end + + test "excludes template orders" do + template_order = create(:oroshi_order, shipping_date: Time.zone.today) + Oroshi::OrderTemplate.create!(order: template_order) + + export = Exports::OrdersExport.new(date: Time.zone.today.to_s) + refute_includes export.records, template_order + end + + test "filters by buyer_ids" do + other_buyer = create(:oroshi_buyer) + other_order = create(:oroshi_order, shipping_date: Time.zone.today, buyer: other_buyer) + + export = Exports::OrdersExport.new( + date: Time.zone.today.to_s, + buyer_ids: [@order.buyer_id.to_s] + ) + assert_includes export.records, @order + refute_includes export.records, other_order + end + + test "generates CSV with all columns" do + export = Exports::OrdersExport.new(date: Time.zone.today.to_s) + csv = export.generate("csv") + assert csv.include?("出荷日") + assert csv.include?("買い手") + assert csv.include?("利益") + assert csv.include?(@order.buyer.name) + end + + test "generates JSON with order data" do + export = Exports::OrdersExport.new(date: Time.zone.today.to_s) + json = JSON.parse(export.generate("json")) + assert json["data"].any? { |d| d["buyer"] == @order.buyer.name } + end + + test "generates XLSX" do + export = Exports::OrdersExport.new(date: Time.zone.today.to_s) + xlsx = export.generate("xlsx") + assert xlsx.start_with?("PK") + end + + test "supports date range" do + past_order = create(:oroshi_order, shipping_date: 5.days.ago.to_date) + + export = Exports::OrdersExport.new( + start_date: 7.days.ago.to_date.to_s, + end_date: Time.zone.today.to_s + ) + assert_includes export.records, @order + assert_includes export.records, past_order + end + + test "empty data returns headers only in CSV" do + export = Exports::OrdersExport.new(date: 1.year.from_now.to_date.to_s) + csv = export.generate("csv") + lines = csv.delete_prefix(Exports::CsvExport::BOM).lines + # Only header row (+ possible summary row, but summary_rows returns [] for empty) + assert_equal 1, lines.size + end +end diff --git a/test/lib/exports/production_export_test.rb b/test/lib/exports/production_export_test.rb new file mode 100644 index 0000000..410ec01 --- /dev/null +++ b/test/lib/exports/production_export_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "test_helper" + +class Exports::ProductionExportTest < ActiveSupport::TestCase + setup do + @production_request = create(:oroshi_production_request) + end + + test "loads production requests" do + # Use the manufacture_date from the production request's inventory + date = @production_request.product_inventory.manufacture_date + export = Exports::ProductionExport.new(date: date.to_s) + assert export.records.any? + end + + test "columns include production fields" do + date = @production_request.product_inventory.manufacture_date + export = Exports::ProductionExport.new(date: date.to_s) + csv = export.generate("csv") + assert csv.include?("製造日") + assert csv.include?("依頼数量") + assert csv.include?("完了数量") + assert csv.include?("残数量") + assert csv.include?("ステータス") + end + + test "generates valid JSON" do + date = @production_request.product_inventory.manufacture_date + export = Exports::ProductionExport.new(date: date.to_s) + json = JSON.parse(export.generate("json")) + assert json["data"].is_a?(Array) + end +end diff --git a/test/lib/exports/revenue_export_test.rb b/test/lib/exports/revenue_export_test.rb new file mode 100644 index 0000000..5ef1485 --- /dev/null +++ b/test/lib/exports/revenue_export_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "test_helper" + +class Exports::RevenueExportTest < ActiveSupport::TestCase + setup do + @order = create(:oroshi_order, + shipping_date: Time.zone.today, + sale_price_per_item: 500, + item_quantity: 10) + end + + test "loads orders for revenue calculation" do + export = Exports::RevenueExport.new(date: Time.zone.today.to_s) + assert_includes export.records, @order + end + + test "columns include revenue breakdown" do + export = Exports::RevenueExport.new(date: Time.zone.today.to_s) + csv = export.generate("csv") + assert csv.include?("売上") + assert csv.include?("手数料後売上") + assert csv.include?("材料費") + assert csv.include?("配送費") + assert csv.include?("経費合計") + assert csv.include?("利益") + end + + test "JSON includes summary with net profit" do + export = Exports::RevenueExport.new(date: Time.zone.today.to_s) + json = JSON.parse(export.generate("json")) + assert json.key?("summary") + assert json["summary"].key?("net_profit") + assert json["summary"].key?("revenue_subtotal") + assert json["summary"].key?("expenses_subtotal") + end + + test "XLSX includes daily summary worksheet" do + export = Exports::RevenueExport.new(date: Time.zone.today.to_s) + xlsx = export.generate("xlsx") + # Valid XLSX file (zip format) + assert xlsx.start_with?("PK") + end + + test "CSV includes summary rows" do + export = Exports::RevenueExport.new(date: Time.zone.today.to_s) + csv = export.generate("csv") + assert csv.include?("収入小計") + assert csv.include?("純利益") + end +end diff --git a/test/lib/exports/shipping_export_test.rb b/test/lib/exports/shipping_export_test.rb new file mode 100644 index 0000000..ad8e0b6 --- /dev/null +++ b/test/lib/exports/shipping_export_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "test_helper" + +class Exports::ShippingExportTest < ActiveSupport::TestCase + setup do + @order = create(:oroshi_order, shipping_date: Time.zone.today) + end + + test "loads orders for shipping export" do + export = Exports::ShippingExport.new(date: Time.zone.today.to_s) + assert_includes export.records, @order + end + + test "columns include shipping-specific fields" do + export = Exports::ShippingExport.new(date: Time.zone.today.to_s) + csv = export.generate("csv") + assert csv.include?("配送組織") + assert csv.include?("配送方法") + assert csv.include?("買い手コード") + assert csv.include?("ケース数") + assert csv.include?("フレート数") + assert csv.include?("容器") + end + + test "filters by buyer_ids" do + other_buyer = create(:oroshi_buyer) + other_order = create(:oroshi_order, shipping_date: Time.zone.today, buyer: other_buyer) + + export = Exports::ShippingExport.new( + date: Time.zone.today.to_s, + buyer_ids: [@order.buyer_id.to_s] + ) + assert_includes export.records, @order + refute_includes export.records, other_order + end + + test "generates valid CSV" do + export = Exports::ShippingExport.new(date: Time.zone.today.to_s) + csv = export.generate("csv") + assert csv.present? + assert csv.include?(@order.buyer.name) + end +end diff --git a/test/lib/exports/supply_export_test.rb b/test/lib/exports/supply_export_test.rb new file mode 100644 index 0000000..cc9d6b1 --- /dev/null +++ b/test/lib/exports/supply_export_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "test_helper" + +class Exports::SupplyExportTest < ActiveSupport::TestCase + setup do + @supply = create(:oroshi_supply, quantity: 10, price: 500) + @date = @supply.supply_date.date + end + + test "loads supplies with quantity for given date" do + export = Exports::SupplyExport.new(date: @date.to_s) + assert export.records.any? { |s| s.id == @supply.id } + end + + test "excludes zero-quantity supplies" do + zero_supply = create(:oroshi_supply, quantity: 0, price: 0, supply_date: @supply.supply_date) + export = Exports::SupplyExport.new(date: @date.to_s) + refute export.records.any? { |s| s.id == zero_supply.id } + end + + test "columns include supply fields" do + export = Exports::SupplyExport.new(date: @date.to_s) + csv = export.generate("csv") + assert csv.include?("入荷日") + assert csv.include?("仕入先組織") + assert csv.include?("数量") + assert csv.include?("単価") + assert csv.include?("金額") + end + + test "supports date range" do + export = Exports::SupplyExport.new( + start_date: (@date - 1.day).to_s, + end_date: (@date + 1.day).to_s + ) + assert export.records.any? { |s| s.id == @supply.id } + end +end From e4414ea331ee112396d6aac119bd756a3113c080 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 02:38:14 +0000 Subject: [PATCH 2/2] docs: add export system implementation plan https://claude.ai/code/session_01VBKF79FuRwrf32F3WFCTJ6 --- plan.md | 575 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 575 insertions(+) create mode 100644 plan.md diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..1635daf --- /dev/null +++ b/plan.md @@ -0,0 +1,575 @@ +# Plan: Comprehensive Export System for Oroshi + +## Overview + +Build a unified, extensible export system supporting **PDF, CSV, XLSX, and JSON** formats across all major data views: Order List, Daily Intake (Supply), Inventory, Factory Floor (Production), and Profit Calculation (Revenue). All exports are **async via Solid Queue** using the existing `Message` + `ActiveStorage` pattern. + +--- + +## Architecture + +### Current State + +- `Printable` (base class, `lib/Printable.rb`) → extends `Prawn::Document` for PDF +- `OroshiOrderDocument`, `OroshiInvoice`, `SupplyCheck` — specific PDF generators in `lib/printables/` +- `Oroshi::OrderDocumentJob`, `Oroshi::InvoiceJob`, `Oroshi::SupplyCheckJob` — Solid Queue jobs +- `Message` model (parent app) — tracks job status via `state`, `message`, `data` (JSONB), `has_one_attached :stored_file` +- Shipping controller creates `Message`, enqueues job, job attaches PDF to `Message.stored_file`, sets `state: true` +- No CSV, XLSX, or JSON export infrastructure exists yet + +### Target Architecture + +``` +lib/ + exports/ # NEW: Export generator library + base_export.rb # Abstract base with shared logic + csv_export.rb # CSV generation mixin + xlsx_export.rb # XLSX generation mixin + json_export.rb # JSON generation mixin + pdf_export.rb # PDF generation (wraps existing Printable) + orders_export.rb # Order list data export + revenue_export.rb # Profit/revenue data export + production_export.rb # Factory floor data export + inventory_export.rb # Inventory data export + supply_export.rb # Daily intake/supply data export + shipping_export.rb # Shipping chart data export (extends existing) + +app/ + jobs/oroshi/ + export_job.rb # NEW: Unified export job (replaces per-type jobs pattern) + + controllers/oroshi/ + exports_controller.rb # NEW: Handles export requests for all data types + + views/oroshi/exports/ + _export_button.html.erb # NEW: Reusable export button partial (dropdown with format options) + _date_range_export.html.erb # NEW: Date range export modal/form + +config/ + locales/ + exports.ja.yml # NEW: Japanese translations for export UI +``` + +### New Gem Dependency + +Add to `Gemfile`: +```ruby +gem "caxlsx" # Excel XLSX generation (community-maintained successor to axlsx) +``` + +--- + +## Step-by-Step Implementation + +### Step 1: Add `caxlsx` gem + +- Add `gem "caxlsx"` to `Gemfile` +- `bundle install` +- No initializer needed — `caxlsx` is a standalone library + +### Step 2: Create `lib/exports/base_export.rb` + +The abstract base class all exports inherit from. Handles: + +- **Format dispatch**: `generate(format)` → delegates to `generate_csv`, `generate_xlsx`, `generate_json`, `generate_pdf` +- **Data loading**: Each subclass defines `load_data` to query and prepare records +- **Filter support**: Accepts a standardized `options` hash (buyer_ids, shipping_method_ids, order_category_ids, buyer_category_ids, date or date_range) +- **Column definitions**: Each subclass defines `columns` returning an array of `{ key:, header:, value: ->(record) {} }` hashes — used by CSV, XLSX, and JSON generators +- **Content type & filename helpers**: `content_type_for(format)`, `filename(format)` + +```ruby +# lib/exports/base_export.rb +module Exports + class BaseExport + attr_reader :options, :records + + def initialize(options = {}) + @options = options.with_indifferent_access + @records = load_data + end + + def generate(format) + send("generate_#{format}") + end + + def filename(format) + ext = { csv: "csv", xlsx: "xlsx", json: "json", pdf: "pdf" }[format.to_sym] + "#{export_name}_#{date_label}_#{timestamp}.#{ext}" + end + + def content_type(format) + { csv: "text/csv", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + json: "application/json", pdf: "application/pdf" }[format.to_sym] + end + + private + + def load_data = raise NotImplementedError + def columns = raise NotImplementedError + def export_name = raise NotImplementedError + + def date_label + if options[:start_date] && options[:end_date] + "#{options[:start_date]}_#{options[:end_date]}" + else + options[:date] || Time.zone.today.to_s + end + end + + def timestamp = Time.zone.now.strftime("%Y%m%d%H%M%S") + + # Shared filter logic (mirrors OrdersDashboard::Shared#set_filters) + def apply_order_filters(scope) + scope = scope.where(buyer_id: options[:buyer_ids]) if options[:buyer_ids].present? + scope = scope.where(shipping_method_id: options[:shipping_method_ids]) if options[:shipping_method_ids].present? + if options[:order_category_ids].present? + scope = scope.joins(:order_categories).where(order_categories: { id: options[:order_category_ids] }) + end + if options[:buyer_category_ids].present? + scope = scope.joins(buyer: :buyer_categories).where(buyer_categories: { id: options[:buyer_category_ids] }) + end + scope + end + end +end +``` + +### Step 3: Create format mixin modules + +**`lib/exports/csv_export.rb`** — included in `BaseExport`: +```ruby +module Exports + module CsvExport + def generate_csv + require "csv" + CSV.generate do |csv| + csv << columns.map { |c| c[:header] } + records.each { |r| csv << columns.map { |c| c[:value].call(r) } } + end + end + end +end +``` + +**`lib/exports/xlsx_export.rb`**: +```ruby +module Exports + module XlsxExport + def generate_xlsx + package = Caxlsx::Package.new + workbook = package.workbook + workbook.add_worksheet(name: export_name) do |sheet| + # Header row with bold style + header_style = workbook.styles.add_style(b: true, bg_color: "F0F0F0") + sheet.add_row columns.map { |c| c[:header] }, style: header_style + + # Currency style for yen columns + yen_style = workbook.styles.add_style(format_code: '¥#,##0') + + records.each do |record| + values = columns.map { |c| c[:value].call(record) } + styles = columns.map { |c| c[:type] == :currency ? yen_style : nil } + sheet.add_row values, style: styles + end + end + stream = package.to_stream + stream.read + end + end +end +``` + +**`lib/exports/json_export.rb`**: +```ruby +module Exports + module JsonExport + def generate_json + data = records.map do |record| + columns.each_with_object({}) do |col, hash| + hash[col[:key]] = col[:value].call(record) + end + end + { export_name: export_name, exported_at: Time.zone.now.iso8601, + filters: options.except(:format), record_count: data.size, data: data }.to_json + end + end +end +``` + +**`lib/exports/pdf_export.rb`** — wraps existing `Printable` subclasses or generates simple table PDFs: +```ruby +module Exports + module PdfExport + def generate_pdf + # Default: simple table PDF using Prawn + # Subclasses can override for custom layouts (e.g., ShippingExport delegates to OroshiOrderDocument) + pdf = Printable.new + pdf.text export_name, size: 14, style: :bold + pdf.move_down 10 + table_data = [columns.map { |c| c[:header] }] + records.each { |r| table_data << columns.map { |c| c[:value].call(r).to_s } } + pdf.table(table_data, header: true, width: pdf.bounds.width) do + row(0).font_style = :bold + row(0).background_color = "f0f0f0" + cells.border_width = 0.5 + cells.size = 7 + end + pdf.render + end + end +end +``` + +### Step 4: Create data-specific export classes + +Each export class defines `load_data`, `columns`, and `export_name`. Columns use Japanese headers matching the UI. + +#### **`lib/exports/orders_export.rb`** — Order List + +Columns: 出荷日, 到着日, 買い手, 商品, バリエーション, 数量, ケース数, フレート数, 単価, 売上, 経費, 利益, 配送方法, カテゴリ, ノート + +- `load_data`: `Oroshi::Order.non_template.where(shipping_date: date_range).includes(:buyer, :product_variation, :product, :shipping_method, :shipping_organization, :shipping_receptacle, :order_categories)` +- Applies all filters via `apply_order_filters` +- Date support: single date (current view) or date range + +#### **`lib/exports/revenue_export.rb`** — Profit Calculation + +Columns: 日付, 商品, バリエーション, 買い手, 数量, 単価, 売上, 手数料後売上, 材料費, 配送費, 調整, 経費合計, 利益 + +- `load_data`: Same as orders but groups by product → product_variation with totals +- Summary rows: revenue subtotal, expenses subtotal, buyer daily costs, shipping method daily costs, net profit +- For XLSX: adds a summary sheet with daily totals and formulas +- For CSV/JSON: appends summary rows at the end + +#### **`lib/exports/production_export.rb`** — Factory Floor + +Columns: 製造日, 出荷日, 商品, バリエーション, 製造ゾーン, 依頼数量, 完了数量, 残数量, 在庫数量, ステータス + +- `load_data`: Production requests + product inventories for the date range (±1 day buffer like the production dashboard) +- Includes inventory quantities and production zone assignments + +#### **`lib/exports/inventory_export.rb`** — Inventory List + +Columns: 商品, バリエーション, 製造日, 賞味期限, 在庫数量, フレート数, 未出荷注文数, 差分 + +- `load_data`: `Oroshi::ProductInventory` with associated product variations and pending orders +- Shows current stock vs outstanding orders + +#### **`lib/exports/supply_export.rb`** — Daily Intake + +Columns: 供給日, 仕入先組織, 仕入先, 原料種類, バリエーション, 数量, 単位, 単価, 金額, 受入時間 + +- `load_data`: `Oroshi::Supply.with_quantity` for the given date/range +- Includes supplier organization and supply type information + +#### **`lib/exports/shipping_export.rb`** — Shipping Chart + +- For **PDF**: delegates to existing `OroshiOrderDocument` (preserves current B4 landscape layout) +- For **CSV/XLSX/JSON**: flattened order data grouped by shipping organization with freight quantities + +### Step 5: Create unified `Oroshi::ExportJob` + +```ruby +# app/jobs/oroshi/export_job.rb +class Oroshi::ExportJob < ApplicationJob + queue_as :default + + def perform(export_class, format, message_id, options = {}) + message = Message.find(message_id) + exporter = export_class.constantize.new(options) + content = exporter.generate(format) + io = StringIO.new(content) + message.stored_file.attach( + io: io, + content_type: exporter.content_type(format), + filename: exporter.filename(format) + ) + message.update(state: true, message: I18n.t("oroshi.exports.completed")) + GC.start if format == "pdf" + rescue => e + message&.update(state: false, message: I18n.t("oroshi.exports.failed", error: e.message)) + raise + end +end +``` + +This replaces the pattern of having one job per document type. The existing `OrderDocumentJob` and `SupplyCheckJob` remain untouched for backward compatibility — only new exports use `ExportJob`. + +### Step 6: Create `Oroshi::ExportsController` + +```ruby +# app/controllers/oroshi/exports_controller.rb +class Oroshi::ExportsController < Oroshi::ApplicationController + before_action :authorize_export + + # POST /oroshi/exports + def create + message = create_export_message + Oroshi::ExportJob.perform_later( + export_class_name, + params[:format_type], + message.id, + export_options + ) + head :ok + end + + private + + def export_class_name + # Maps params[:export_type] to class name + { + "orders" => "Exports::OrdersExport", + "revenue" => "Exports::RevenueExport", + "production" => "Exports::ProductionExport", + "inventory" => "Exports::InventoryExport", + "supply" => "Exports::SupplyExport", + "shipping" => "Exports::ShippingExport" + }.fetch(params[:export_type]) + end + + def export_options + params.permit(:date, :start_date, :end_date, + buyer_ids: [], shipping_method_ids: [], + order_category_ids: [], buyer_category_ids: []) + .to_h + .compact_blank + end + + def create_export_message + Message.create!( + user: current_user.id, + model: "oroshi_export", + state: nil, + message: I18n.t("oroshi.exports.processing"), + data: { + export_type: params[:export_type], + format: params[:format_type], + expiration: 1.day.from_now + } + ) + end + + def authorize_export + authorize :export, :create? + end +end +``` + +### Step 7: Add routes + +```ruby +# In config/routes.rb, add inside Oroshi::Engine.routes.draw: +resources :exports, only: [:create] +``` + +### Step 8: Create Pundit policy + +```ruby +# app/policies/oroshi/export_policy.rb +class Oroshi::ExportPolicy < Oroshi::ApplicationPolicy + def create? + # Same authorization as viewing the data being exported + user.present? + end +end +``` + +### Step 9: Create export button partial + +A reusable dropdown button that can be placed on any dashboard tab: + +```erb +<%# app/views/oroshi/exports/_export_button.html.erb %> +<%# locals: export_type, date, additional_params: {} %> + +``` + +### Step 10: Create date range export form + +A modal form for exporting a custom date range (in addition to "current view" exports): + +```erb +<%# app/views/oroshi/exports/_date_range_export.html.erb %> +<%# locals: export_type %> + + <%= form_with url: exports_path, method: :post do |f| %> + <%= f.hidden_field :export_type, value: export_type %> +
+ <%= f.label :start_date, t('oroshi.exports.start_date') %> + <%= f.date_field :start_date, value: 1.month.ago.to_date, class: "form-control" %> +
+
+ <%= f.label :end_date, t('oroshi.exports.end_date') %> + <%= f.date_field :end_date, value: Time.zone.today, class: "form-control" %> +
+
+ <%= f.label :format_type, t('oroshi.exports.format') %> + <%= f.select :format_type, export_format_options, {}, class: "form-select" %> +
+ <%= f.submit t('oroshi.exports.download'), class: "btn btn-primary" %> + <% end %> +
+``` + +### Step 11: Add i18n translations + +```yaml +# config/locales/exports.ja.yml +ja: + oroshi: + exports: + button: "エクスポート" + processing: "エクスポート処理中…" + completed: "エクスポート完了" + failed: "エクスポートに失敗しました: %{error}" + download: "ダウンロード" + start_date: "開始日" + end_date: "終了日" + format: "形式" + date_range: "期間指定エクスポート" + formats: + csv: "CSV" + xlsx: "Excel (XLSX)" + pdf: "PDF" + json: "JSON" + types: + orders: "注文一覧" + revenue: "売上・利益" + production: "製造・工場" + inventory: "在庫一覧" + supply: "入荷一覧" + shipping: "出荷表" +``` + +### Step 12: Integrate export buttons into existing views + +Add the `_export_button` partial to each dashboard tab view: + +1. **Order List** (`_orders.html.erb`) — `export_type: "orders"` +2. **Revenue** (`_revenue.html.erb`) — `export_type: "revenue"` +3. **Production** (`_production.html.erb`) — `export_type: "production"` +4. **Supply Usage** (`_supply_usage.html.erb`) — `export_type: "inventory"` +5. **Shipping** (`_shipping.html.erb`) — `export_type: "shipping"` +6. **Supply Dates** (supply_dates show) — `export_type: "supply"` + +Each button passes the current `@date` and any active filter params as `additional_params`. + +### Step 13: Write tests + +Create test files following existing patterns: + +- `test/lib/exports/base_export_test.rb` — column definition, format dispatch +- `test/lib/exports/orders_export_test.rb` — data loading, filtering, CSV/XLSX/JSON output +- `test/lib/exports/revenue_export_test.rb` — profit calculation accuracy +- `test/lib/exports/production_export_test.rb` — production request data +- `test/lib/exports/inventory_export_test.rb` — inventory quantities +- `test/lib/exports/supply_export_test.rb` — supply data +- `test/jobs/oroshi/export_job_test.rb` — job execution, message updates, error handling +- `test/controllers/oroshi/exports_controller_test.rb` — authorization, parameter handling + +--- + +## Edge Cases & Considerations + +### Data Integrity +- **Empty data**: Export gracefully with headers only (no crash on zero records) +- **Large datasets**: Date range exports could be large — XLSX has ~1M row limit, CSV streams fine, JSON may need pagination consideration (but for typical wholesale volumes this is unlikely to be hit) +- **Concurrent exports**: Multiple users can export simultaneously; each gets their own `Message` record + +### Encoding +- **CSV**: Use `"\xEF\xBB\xBF"` BOM prefix for proper Excel UTF-8 handling of Japanese characters +- **XLSX**: Native Unicode support via caxlsx +- **JSON**: UTF-8 by default + +### Filters +- "Current view" exports respect all active dashboard filters (buyer, shipping method, order category, buyer category) +- "Date range" exports only accept date range + optional filters +- Filters are serialized into `Message.data` for audit trail + +### Backward Compatibility +- Existing `OrderDocumentJob`, `InvoiceJob`, `SupplyCheckJob` remain untouched +- Existing shipping chart PDF generation continues to work exactly as before +- `ShippingExport` for PDF format delegates to `OroshiOrderDocument` to preserve the established B4 landscape layout + +### Security +- All exports go through Pundit authorization (`ExportPolicy`) +- Export options are permitted via strong parameters +- Files are served through ActiveStorage's signed URLs (existing pattern) +- No user-supplied strings used in filenames without sanitization + +### Performance +- All formats run async via Solid Queue — no request blocking +- `GC.start` after PDF generation (matching existing pattern) +- Eager loading (`.includes()`) on all queries to prevent N+1 +- Date range exports with many records: XLSX uses streaming where possible + +### Japanese-First +- All column headers, UI labels, and status messages in Japanese +- Currency formatting with `¥` symbol and comma separators +- Date formatting using Japanese locale (`%Y年%m月%d日`) +- Translations in `config/locales/exports.ja.yml` + +--- + +## File Summary + +| File | Action | Description | +|------|--------|-------------| +| `Gemfile` | EDIT | Add `gem "caxlsx"` | +| `lib/exports/base_export.rb` | CREATE | Abstract base class | +| `lib/exports/csv_export.rb` | CREATE | CSV generation mixin | +| `lib/exports/xlsx_export.rb` | CREATE | XLSX generation mixin | +| `lib/exports/json_export.rb` | CREATE | JSON generation mixin | +| `lib/exports/pdf_export.rb` | CREATE | PDF generation mixin | +| `lib/exports/orders_export.rb` | CREATE | Order list export | +| `lib/exports/revenue_export.rb` | CREATE | Revenue/profit export | +| `lib/exports/production_export.rb` | CREATE | Production/factory export | +| `lib/exports/inventory_export.rb` | CREATE | Inventory export | +| `lib/exports/supply_export.rb` | CREATE | Daily supply intake export | +| `lib/exports/shipping_export.rb` | CREATE | Shipping data export | +| `app/jobs/oroshi/export_job.rb` | CREATE | Unified export job | +| `app/controllers/oroshi/exports_controller.rb` | CREATE | Export request handler | +| `app/policies/oroshi/export_policy.rb` | CREATE | Pundit policy | +| `app/views/oroshi/exports/_export_button.html.erb` | CREATE | Reusable dropdown button | +| `app/views/oroshi/exports/_date_range_export.html.erb` | CREATE | Date range modal | +| `config/locales/exports.ja.yml` | CREATE | Japanese translations | +| `config/routes.rb` | EDIT | Add export route | +| `app/views/oroshi/orders/dashboard/_orders.html.erb` | EDIT | Add export button | +| `app/views/oroshi/orders/dashboard/_revenue.html.erb` | EDIT | Add export button | +| `app/views/oroshi/orders/dashboard/_production.html.erb` | EDIT | Add export button | +| `app/views/oroshi/orders/dashboard/_supply_usage.html.erb` | EDIT | Add export button | +| `app/views/oroshi/orders/dashboard/_shipping.html.erb` | EDIT | Add export button | +| `test/lib/exports/*_test.rb` | CREATE | Export unit tests | +| `test/jobs/oroshi/export_job_test.rb` | CREATE | Job test | +| `test/controllers/oroshi/exports_controller_test.rb` | CREATE | Controller test | + +**Total: ~12 new files, ~8 edits to existing files, plus ~7 test files** + +--- + +## Implementation Order + +1. Gem dependency + base export classes (Steps 1-3) +2. Data-specific export classes (Step 4) +3. Job + Controller + Routes + Policy (Steps 5-8) +4. UI integration — button partial + date range form (Steps 9-10) +5. i18n translations (Step 11) +6. Wire buttons into existing views (Step 12) +7. Tests (Step 13)