# app.R - 警備マッチング可視化アプリ(日付×contract_idベースマッチング対応版) # 要件: 充足率表示、日付期間選択、要営業強化エリア識別 # 修正: ac_date_xxx.csv対応、日付とcontract_idの組み合わせでマッチング # 追加: 割当済隊員/未割当隊員の表示切替 library(shiny) library(dplyr) library(leaflet) # ========================================================= # 0) 設定 # ========================================================= BBOX <- list( lat_min = 36.0, lat_max = 36.9, lng_min = 139.3, lng_max = 140.3 ) # 充足率の閾値 THRESHOLD_LOW <- 80 # 80%未満 = 要営業強化(赤) THRESHOLD_HIGH <- 100 # 100%以上 = 充足(緑) # ========================================================= # 1) ユーティリティ # ========================================================= read_csv_bom <- function(path) { if (!file.exists(path)) stop(paste("ファイルが見つからない:", path)) df <- tryCatch( read.csv(path, fileEncoding = "UTF-8-BOM", stringsAsFactors = FALSE, check.names = FALSE), error = function(e) read.csv(path, stringsAsFactors = FALSE, check.names = FALSE) ) df } # 日付変換関数(yyyy/m/d または yyyy-m-d 形式対応) to_date <- function(x) { if (inherits(x, "Date")) return(x) x <- as.character(x) x <- trimws(x) x[x == ""] <- NA # / を - に統一 x2 <- gsub("/", "-", x, fixed = TRUE) as.Date(x2) } haversine_km <- function(lat1, lon1, lat2, lon2) { r <- 6371.0 to_rad <- function(x) x * pi / 180 dlat <- to_rad(lat2 - lat1) dlon <- to_rad(lon2 - lon1) a <- sin(dlat/2)^2 + cos(to_rad(lat1)) * cos(to_rad(lat2)) * sin(dlon/2)^2 2 * r * asin(pmin(1, sqrt(a))) } extract_city <- function(addr) { if (is.na(addr) || trimws(addr) == "") return(NA_character_) a <- gsub(" ", " ", addr, fixed = TRUE) a <- gsub("\\(.*?\\)", "", a) a <- gsub("\\(.*?\\)", "", a) a2 <- sub(".*県", "", a) m <- regexpr("(市|町|村|区)", a2) if (m[1] == -1) return(NA_character_) endpos <- m[1] + attr(m, "match.length") - 1 substr(a2, 1, endpos) } # 充足率に応じた色を返す get_fill_color <- function(rate) { case_when( is.na(rate) ~ "gray", rate < THRESHOLD_LOW ~ "#e74c3c", # 赤 - 要営業強化 rate < THRESHOLD_HIGH ~ "#f39c12", # オレンジ - 注意 TRUE ~ "#27ae60" # 緑 - 充足 ) } # ========================================================= # 2) データ読み込み&前処理 # ========================================================= load_all_data <- function() { # 新しいファイル名に変更 contract_raw <- read_csv_bom("ac_date_contract_list.csv") guard_raw <- read_csv_bom("ac_date_guard_master.csv") avail_raw <- read_csv_bom("ac_date_availability.csv") # ---- contract_list (日付展開済み) ---- # 対応日は yyyy/m/d 形式の文字列 → Dateに変換 contract <- contract_raw %>% mutate( 対応日 = to_date(`対応日`), 現場住所 = paste0( ifelse(is.na(`現場住所1`), "", `現場住所1`), ifelse(is.na(`現場住所2`) | trimws(`現場住所2`) == "", "", paste0(" ", `現場住所2`)) ), required_guards = `必要人数`, site_city = `市区町村` ) %>% # 2025/1/1〜2025/12/31のデータに限定 filter(対応日 >= as.Date("2025-01-01"), 対応日 <= as.Date("2025-12-31")) # ---- guard_master ---- guard <- guard_raw %>% mutate( ユーザー番号 = suppressWarnings(as.integer(`ユーザー番号`)), guard_city = vapply(`住所`, extract_city, character(1)) ) # ---- availability ---- # 日付はyyyy/m/d形式 avail <- avail_raw %>% mutate( 日付 = to_date(`日付`), ユーザー番号 = suppressWarnings(as.integer(`ユーザー番号`)), available_flag = as.integer(`対応可否`), 担当予定contract_id = trimws(as.character(`担当予定contract_id`)) ) %>% # 担当予定contract_idが空文字やNAの場合はNA扱い mutate(担当予定contract_id = ifelse(担当予定contract_id == "" | 担当予定contract_id == "NA", NA_character_, 担当予定contract_id)) %>% # 2025/1/1〜2025/12/31のデータに限定 filter(日付 >= as.Date("2025-01-01"), 日付 <= as.Date("2025-12-31")) list(contract = contract, guard = guard, avail = avail) } DATA <- NULL LOAD_ERROR <- NULL tryCatch({ DATA <- load_all_data() }, error = function(e) { LOAD_ERROR <<- e$message }) # ========================================================= # 3) UI # ========================================================= ui <- fluidPage( tags$head( tags$link(rel = "icon", href = "data:,"), tags$style(HTML(" .legend-box { padding: 10px; background: white; border-radius: 5px; } .legend-item { display: flex; align-items: center; margin: 5px 0; } .legend-color { width: 20px; height: 20px; border-radius: 50%; margin-right: 8px; } .summary-header { background-color: #f8f9fa; padding: 10px; border-radius: 5px; margin-bottom: 10px; } ")) ), titlePanel("警備マッチング可視化(日付×契約ID別対応版)"), sidebarLayout( sidebarPanel( if (!is.null(LOAD_ERROR)) { tags$div( style = "color:#b00020; font-weight:600;", paste0("データ読込エラー: ", LOAD_ERROR), tags$br(), "同じフォルダに ac_date_contract_list.csv / ac_date_guard_master.csv / ac_date_availability.csv を置いているか確認する。" ) } else { tagList( h4("📅 期間選択"), dateRangeInput( "date_range", "対象期間", start = as.Date("2025-01-01"), end = as.Date("2025-03-31"), min = as.Date("2025-01-01"), max = as.Date("2025-12-31"), format = "yyyy/mm/dd", language = "ja", separator = " ~ " ), hr(), h4("🗺️ 表示設定"), checkboxInput("show_available", "割当済隊員を表示(対応可否=1)", TRUE), checkboxInput("show_non_available", "未割当隊員を表示(対応可否=0)", TRUE), radioButtons( "match_mode", "追加マッチング方法(未割当隊員の参考)", choices = c("市区町村一致" = "city", "距離(半径km)" = "dist"), selected = "city" ), conditionalPanel( condition = "input.match_mode == 'dist'", numericInput("radius_km", "半径(km)", value = 20, min = 1, max = 200, step = 1) ), hr(), h4("📊 凡例"), tags$div( class = "legend-box", tags$div(class = "legend-item", tags$div(class = "legend-color", style = "background-color: #27ae60;"), tags$span("充足(100%以上)") ), tags$div(class = "legend-item", tags$div(class = "legend-color", style = "background-color: #f39c12;"), tags$span("注意(80-99%)") ), tags$div(class = "legend-item", tags$div(class = "legend-color", style = "background-color: #e74c3c;"), tags$span("要営業強化(80%未満)") ), tags$div(class = "legend-item", tags$div(class = "legend-color", style = "background-color: #3498db;"), tags$span("割当済隊員(対応可否=1)") ), tags$div(class = "legend-item", tags$div(class = "legend-color", style = "background-color: #95a5a6;"), tags$span("未割当隊員(対応可否=0)") ) ), hr(), helpText("※ 対応可能人数 = 日付×契約IDが一致する担当予定の隊員数をカウント") ) }, width = 3 ), mainPanel( # サマリー統計 fluidRow( column(3, tags$div(class = "summary-header", h5("稼働現場件数"), textOutput("stat_sites", inline = TRUE) ) ), column(3, tags$div(class = "summary-header", h5("必要人数合計"), textOutput("stat_required", inline = TRUE) ) ), column(3, tags$div(class = "summary-header", h5("対応可能人数"), textOutput("stat_available", inline = TRUE) ) ), column(3, tags$div(class = "summary-header", h5("平均充足率"), textOutput("stat_avg_rate", inline = TRUE) ) ) ), # 隊員統計 fluidRow( column(6, tags$div(class = "summary-header", h5("割当済隊員数(期間内)"), textOutput("stat_available_guards", inline = TRUE) ) ), column(6, tags$div(class = "summary-header", h5("未割当隊員数(期間内)"), textOutput("stat_non_available_guards", inline = TRUE) ) ) ), leafletOutput("map", height = 480), br(), h4("現場別 需給状況(選択期間・日付×契約ID別集計)"), tableOutput("summary"), width = 9 ) ) ) # ========================================================= # 4) Server # ========================================================= server <- function(input, output, session) { if (!is.null(LOAD_ERROR)) { output$map <- renderLeaflet({ leaflet() %>% addTiles() }) output$summary <- renderTable({ data.frame(エラー = LOAD_ERROR, stringsAsFactors = FALSE) }) return() } contract <- DATA$contract guard <- DATA$guard avail <- DATA$avail # 選択期間のデータを取得 daily <- reactive({ date_range <- input$date_range d_start <- date_range[1] d_end <- date_range[2] # 期間内の現場(日付×contract_idの組み合わせ) active_sites <- contract %>% filter(!is.na(対応日)) %>% filter(対応日 >= d_start, 対応日 <= d_end) # 期間内で担当予定がある隊員(日付×contract_id単位)- 充足率計算用 assigned_avail <- avail %>% filter(!is.na(日付), 日付 >= d_start, 日付 <= d_end) %>% filter(available_flag == 1L, !is.na(担当予定contract_id)) # 割当済隊員(対応可否=1) available_guards_avail <- avail %>% filter(!is.na(日付), 日付 >= d_start, 日付 <= d_end) %>% filter(available_flag == 1L) %>% distinct(ユーザー番号) available_guards <- available_guards_avail %>% inner_join(guard, by = "ユーザー番号") # 未割当隊員(対応可否=0) non_available_guards_avail <- avail %>% filter(!is.na(日付), 日付 >= d_start, 日付 <= d_end) %>% filter(available_flag == 0L) %>% distinct(ユーザー番号) non_available_guards <- non_available_guards_avail %>% inner_join(guard, by = "ユーザー番号") list( date_start = d_start, date_end = d_end, active_sites = active_sites, assigned_avail = assigned_avail, available_guards = available_guards, non_available_guards = non_available_guards ) }) # 需給サマリー計算(日付×contract_id別) summary_tbl <- reactive({ dd <- daily() sites <- dd$active_sites assigned <- dd$assigned_avail if (nrow(sites) == 0) { return(data.frame(メッセージ = "選択期間に稼働中の現場がない", stringsAsFactors = FALSE)) } # 日付×contract_id別の割当済み隊員数をカウント assigned_count <- assigned %>% count(日付, 担当予定contract_id, name = "assigned_guards") # 現場データと結合 result <- sites %>% left_join( assigned_count, by = c("対応日" = "日付", "contract_id" = "担当予定contract_id") ) %>% mutate( assigned_guards = ifelse(is.na(assigned_guards), 0L, assigned_guards), shortage = pmax(required_guards - assigned_guards, 0L), fulfillment_rate = round(assigned_guards / required_guards * 100, 0) ) # 色と状態を追加 result <- result %>% mutate( fill_color = get_fill_color(fulfillment_rate), status = case_when( fulfillment_rate < THRESHOLD_LOW ~ "⚠️ 要営業強化", fulfillment_rate < THRESHOLD_HIGH ~ "△ 注意", TRUE ~ "○ 充足" ) ) result }) # 表示用テーブル(日付×契約ID別) output$summary <- renderTable({ tbl <- summary_tbl() if ("メッセージ" %in% colnames(tbl)) return(tbl) tbl %>% transmute( 対応日 = format(対応日, "%Y/%m/%d"), 契約ID = contract_id, 契約No = `契約No`, 顧客 = `顧客`, 件名 = paste0(`件名1`, ifelse(is.na(`件名2`) | trimws(`件名2`) == "" | `件名2` == "NA", "", paste0(" ", `件名2`))), 市区町村 = site_city, 必要人数 = required_guards, 対応可能 = as.integer(assigned_guards), 充足率 = paste0(fulfillment_rate, "%"), 状態 = status ) %>% arrange(対応日, 契約ID) }) # サマリー統計 output$stat_sites <- renderText({ tbl <- summary_tbl() if ("メッセージ" %in% colnames(tbl)) return("-") # ユニークな日付×契約IDの組み合わせ数 paste0(nrow(tbl), " 件(日付×契約ID)") }) output$stat_required <- renderText({ tbl <- summary_tbl() if ("メッセージ" %in% colnames(tbl)) return("-") paste0(sum(tbl$required_guards, na.rm = TRUE), " 人") }) output$stat_available <- renderText({ tbl <- summary_tbl() if ("メッセージ" %in% colnames(tbl)) return("-") paste0(sum(tbl$assigned_guards, na.rm = TRUE), " 人") }) output$stat_avg_rate <- renderText({ tbl <- summary_tbl() if ("メッセージ" %in% colnames(tbl)) return("-") avg_rate <- mean(tbl$fulfillment_rate, na.rm = TRUE) paste0(round(avg_rate, 0), "%") }) # 隊員統計 output$stat_available_guards <- renderText({ dd <- daily() paste0(nrow(dd$available_guards), " 人") }) output$stat_non_available_guards <- renderText({ dd <- daily() paste0(nrow(dd$non_available_guards), " 人") }) # 地図(初回のみ) output$map <- renderLeaflet({ center_lat <- mean(c(BBOX$lat_min, BBOX$lat_max)) center_lng <- mean(c(BBOX$lng_min, BBOX$lng_max)) leaflet() %>% addTiles() %>% setView(lng = center_lng, lat = center_lat, zoom = 9) %>% addLayersControl( overlayGroups = c("現場(充足率)", "割当済隊員", "未割当隊員"), options = layersControlOptions(collapsed = FALSE) ) }) # 地図更新(マーカー) observe({ dd <- daily() sites_data <- summary_tbl() available_gds <- dd$available_guards non_available_gds <- dd$non_available_guards proxy <- leafletProxy("map") proxy %>% clearGroup("現場(充足率)") %>% clearGroup("割当済隊員") %>% clearGroup("未割当隊員") # 現場マーカー(充足率付き)- 地理的にユニークな場所ごとに集約 if (!("メッセージ" %in% colnames(sites_data)) && nrow(sites_data) > 0) { # 同じ座標の現場を集約(代表値として平均充足率を表示) sites_geo <- sites_data %>% filter(!is.na(site_lat), !is.na(site_lng)) %>% group_by(site_lat, site_lng, site_city, `契約No`, `件名1`) %>% summarise( avg_rate = round(mean(fulfillment_rate, na.rm = TRUE), 0), total_required = sum(required_guards, na.rm = TRUE), total_assigned = sum(assigned_guards, na.rm = TRUE), n_days = n(), .groups = "drop" ) %>% mutate( fill_color = get_fill_color(avg_rate) ) if (nrow(sites_geo) > 0) { proxy %>% addCircleMarkers( data = sites_geo, lng = ~site_lng, lat = ~site_lat, radius = 14, color = ~fill_color, fillColor = ~fill_color, fillOpacity = 0.85, weight = 2, label = ~paste0( "【", `契約No`, "】", `件名1`, " | 平均充足率: ", avg_rate, "%", " | 延べ必要: ", total_required, "人", " | 延べ対応: ", total_assigned, "人", " | 対応日数: ", n_days, "日" ), group = "現場(充足率)" ) # 充足率ラベルを個別に追加 for (i in seq_len(nrow(sites_geo))) { row <- sites_geo[i, ] proxy %>% addLabelOnlyMarkers( lng = row$site_lng, lat = row$site_lat, label = paste0(row$avg_rate, "%"), labelOptions = labelOptions( noHide = TRUE, direction = "top", textOnly = TRUE, style = list( "font-weight" = "bold", "font-size" = "11px", "color" = "white", "background-color" = row$fill_color, "padding" = "2px 5px", "border-radius" = "4px" ) ), group = "現場(充足率)" ) } } } # 割当済隊員マーカー(青) if (isTRUE(input$show_available) && nrow(available_gds) > 0) { gds_unique <- available_gds %>% filter(!is.na(home_lat), !is.na(home_lng)) %>% distinct(ユーザー番号, .keep_all = TRUE) if (nrow(gds_unique) > 0) { proxy %>% addCircleMarkers( data = gds_unique, lng = ~home_lng, lat = ~home_lat, radius = 6, color = "#3498db", fillColor = "#3498db", fillOpacity = 0.7, weight = 1, label = ~paste0("【割当済】ユーザー番号: ", ユーザー番号, " | ", 苗字, " ", 名前, " | ", guard_city), group = "割当済隊員" ) } } # 未割当隊員マーカー(グレー) if (isTRUE(input$show_non_available) && nrow(non_available_gds) > 0) { gds_unique <- non_available_gds %>% filter(!is.na(home_lat), !is.na(home_lng)) %>% distinct(ユーザー番号, .keep_all = TRUE) if (nrow(gds_unique) > 0) { proxy %>% addCircleMarkers( data = gds_unique, lng = ~home_lng, lat = ~home_lat, radius = 6, color = "#95a5a6", fillColor = "#95a5a6", fillOpacity = 0.7, weight = 1, label = ~paste0("【未割当】ユーザー番号: ", ユーザー番号, " | ", 苗字, " ", 名前, " | ", guard_city), group = "未割当隊員" ) } } }) } shinyApp(ui, server)