Spaces:
Sleeping
Sleeping
| # app.R - 警備マッチング可視化アプリ(改良版) | |
| # 要件: 充足率表示、日付期間選択、要営業強化エリア識別 | |
| 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 | |
| } | |
| 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("contract_list.csv") | |
| guard_raw <- read_csv_bom("guard_master.csv") | |
| avail_raw <- read_csv_bom("availability.csv") | |
| # ---- contract_list ---- | |
| contract <- contract_raw %>% | |
| mutate( | |
| `案件予定日(開始)` = to_date(`案件予定日(開始)`), | |
| `案件予定日(終了)` = to_date(`案件予定日(終了)`), | |
| 現場住所 = paste0( | |
| ifelse(is.na(`現場住所1`), "", `現場住所1`), | |
| ifelse(is.na(`現場住所2`) | trimws(`現場住所2`) == "", "", paste0(" ", `現場住所2`)) | |
| ), | |
| required_guards = `必要人数`, | |
| site_city = `市区町村` | |
| ) | |
| # ---- guard_master ---- | |
| guard <- guard_raw %>% | |
| mutate( | |
| 従業員番号 = suppressWarnings(as.integer(`従業員番号`)), | |
| guard_city = vapply(`住所`, extract_city, character(1)) | |
| ) | |
| # ---- availability ---- | |
| avail <- avail_raw %>% | |
| mutate( | |
| 日付 = to_date(`日付`), | |
| 従業員番号 = suppressWarnings(as.integer(`従業員番号`)), | |
| available_flag = as.integer(`対応可否`) | |
| ) | |
| 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("警備マッチング可視化(充足率・期間選択対応)"), | |
| sidebarLayout( | |
| sidebarPanel( | |
| if (!is.null(LOAD_ERROR)) { | |
| tags$div( | |
| style = "color:#b00020; font-weight:600;", | |
| paste0("データ読込エラー: ", LOAD_ERROR), | |
| tags$br(), | |
| "同じフォルダに contract_list.csv / guard_master.csv / availability.csv を置いているか確認する。" | |
| ) | |
| } else { | |
| tagList( | |
| h4("📅 期間選択"), | |
| sliderInput( | |
| "date_range", | |
| "対象期間", | |
| min = min(DATA$contract$`案件予定日(開始)`, na.rm = TRUE), | |
| max = max(DATA$contract$`案件予定日(終了)`, na.rm = TRUE), | |
| value = c( | |
| min(DATA$contract$`案件予定日(開始)`, na.rm = TRUE), | |
| min(DATA$contract$`案件予定日(開始)`, na.rm = TRUE) + 7 | |
| ), | |
| timeFormat = "%m/%d", | |
| step = 1 | |
| ), | |
| hr(), | |
| h4("🗺️ 表示設定"), | |
| checkboxInput("show_guards", "アベイラブル隊員を表示", 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; opacity: 0.7;"), | |
| tags$span("アベイラブル隊員") | |
| ) | |
| ) | |
| ) | |
| }, | |
| 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) | |
| ) | |
| ) | |
| ), | |
| leafletOutput("map", height = 480), | |
| br(), | |
| h4("現場別 需給状況(選択期間)"), | |
| 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] | |
| # 期間内に稼働中の現場 | |
| active_sites <- contract %>% | |
| filter(!is.na(`案件予定日(開始)`), !is.na(`案件予定日(終了)`)) %>% | |
| filter(`案件予定日(開始)` <= d_end, `案件予定日(終了)` >= d_start) | |
| # 期間内で「対応可」の日が1日でもある隊員 | |
| available_emp <- avail %>% | |
| filter(!is.na(日付), 日付 >= d_start, 日付 <= d_end, available_flag == 1L) %>% | |
| distinct(従業員番号) | |
| available_guards <- guard %>% | |
| inner_join(available_emp, by = "従業員番号") | |
| list( | |
| date_start = d_start, | |
| date_end = d_end, | |
| active_sites = active_sites, | |
| available_guards = available_guards | |
| ) | |
| }) | |
| # 需給サマリー計算 | |
| summary_tbl <- reactive({ | |
| dd <- daily() | |
| sites <- dd$active_sites | |
| gds <- dd$available_guards | |
| if (nrow(sites) == 0) { | |
| return(data.frame(メッセージ = "選択期間に稼働中の現場がない", stringsAsFactors = FALSE)) | |
| } | |
| if (input$match_mode == "city") { | |
| g_by_city <- gds %>% | |
| mutate(guard_city = ifelse(is.na(guard_city), "不明", guard_city)) %>% | |
| count(guard_city, name = "available_guards") | |
| result <- sites %>% | |
| mutate(site_city = ifelse(is.na(site_city), "不明", site_city)) %>% | |
| left_join(g_by_city, by = c("site_city" = "guard_city")) %>% | |
| mutate( | |
| available_guards = ifelse(is.na(available_guards), 0L, available_guards), | |
| shortage = pmax(required_guards - available_guards, 0L), | |
| fulfillment_rate = round(available_guards / required_guards * 100, 0) | |
| ) | |
| } else { | |
| radius <- input$radius_km | |
| if (is.null(radius) || is.na(radius) || radius <= 0) radius <- 20 | |
| if (nrow(gds) == 0) { | |
| result <- sites %>% | |
| mutate( | |
| available_guards = 0L, | |
| shortage = required_guards, | |
| fulfillment_rate = 0 | |
| ) | |
| } else { | |
| result <- sites %>% | |
| rowwise() %>% | |
| mutate( | |
| available_guards = { | |
| dist <- haversine_km(site_lat, site_lng, gds$home_lat, gds$home_lng) | |
| sum(dist <= radius, na.rm = TRUE) | |
| } | |
| ) %>% | |
| ungroup() %>% | |
| mutate( | |
| shortage = pmax(required_guards - available_guards, 0L), | |
| fulfillment_rate = round(available_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 | |
| }) | |
| # 表示用テーブル | |
| output$summary <- renderTable({ | |
| tbl <- summary_tbl() | |
| if ("メッセージ" %in% colnames(tbl)) return(tbl) | |
| tbl %>% | |
| transmute( | |
| 契約ID = contract_id, | |
| 契約No = `契約No`, | |
| 顧客 = `顧客`, | |
| 件名 = paste0(`件名1`, ifelse(is.na(`件名2`) | trimws(`件名2`) == "", "", paste0(" ", `件名2`))), | |
| 市区町村 = site_city, | |
| 必要人数 = required_guards, | |
| 対応可能 = as.integer(available_guards), | |
| 充足率 = paste0(fulfillment_rate, "%"), | |
| 状態 = status | |
| ) | |
| }) | |
| # サマリー統計 | |
| output$stat_sites <- renderText({ | |
| dd <- daily() | |
| paste0(nrow(dd$active_sites), " 件") | |
| }) | |
| output$stat_required <- renderText({ | |
| tbl <- summary_tbl() | |
| if ("メッセージ" %in% colnames(tbl)) return("-") | |
| paste0(sum(tbl$required_guards, na.rm = TRUE), " 人") | |
| }) | |
| output$stat_available <- renderText({ | |
| dd <- daily() | |
| paste0(nrow(dd$available_guards), " 人") | |
| }) | |
| 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$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() | |
| gds <- dd$available_guards | |
| proxy <- leafletProxy("map") | |
| proxy %>% | |
| clearGroup("現場(充足率)") %>% | |
| clearGroup("アベイラブル隊員") | |
| # 現場マーカー(充足率付き) | |
| if (!("メッセージ" %in% colnames(sites_data)) && nrow(sites_data) > 0) { | |
| # CircleMarkersを追加 | |
| proxy %>% | |
| addCircleMarkers( | |
| data = sites_data, | |
| lng = ~site_lng, lat = ~site_lat, | |
| radius = 14, | |
| color = ~fill_color, | |
| fillColor = ~fill_color, | |
| fillOpacity = 0.85, | |
| weight = 2, | |
| label = ~paste0( | |
| "【", `契約No`, "】", `件名1`, | |
| " | 充足率: ", fulfillment_rate, "%", | |
| " | 必要: ", required_guards, "人", | |
| " | 対応可: ", available_guards, "人" | |
| ), | |
| group = "現場(充足率)" | |
| ) | |
| # 充足率ラベルを個別に追加(色を動的に設定するため) | |
| for (i in seq_len(nrow(sites_data))) { | |
| row <- sites_data[i, ] | |
| proxy %>% | |
| addLabelOnlyMarkers( | |
| lng = row$site_lng, | |
| lat = row$site_lat, | |
| label = paste0(row$fulfillment_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_guards) && nrow(gds) > 0) { | |
| proxy %>% | |
| addCircleMarkers( | |
| data = gds, | |
| lng = ~home_lng, lat = ~home_lat, | |
| radius = 6, | |
| color = "#3498db", | |
| fillColor = "#3498db", | |
| fillOpacity = 0.6, | |
| weight = 1, | |
| label = ~paste0("従業員番号: ", 従業員番号, " | ", 苗字, " ", 名前, " | ", guard_city), | |
| group = "アベイラブル隊員" | |
| ) | |
| } | |
| }) | |
| } | |
| shinyApp(ui, server) | |