export default { async fetch(request, env, ctx) { const url = new URL(request.url); if (request.method === "OPTIONS") { return new Response(null, { headers: corsHeaders(request) }); } try { // ===== 署名発行(FileMaker → Worker)===== if (url.pathname === "/auth/sign") { const issuerSecret = request.headers.get("X-Issuer-Secret") || ""; if (!env.SIGN_ISSUER_SECRET || issuerSecret !== env.SIGN_ISSUER_SECRET) { return json({ error: "unauthorized" }, 401, request); } const uid = (url.searchParams.get("uid") || "").trim(); if (!uid) return json({ error: "uid required" }, 400, request); const ttlSec = Number(env.SIGN_TTL_SEC || 1800); const now = Math.floor(Date.now() / 1000); const cacheKey = `sign:${uid}`; const cacheReq = new Request("https://cache.local/" + encodeURIComponent(cacheKey)); const cached = await caches.default.match(cacheReq); if (cached) return withCors(cached, request); const exp = now + ttlSec; const nonce = randomNonce(24); const payload = `uid=${uid}&exp=${exp}&nonce=${nonce}`; const sig = await hmacHex(env.AUTH_HMAC_SECRET, payload); const out = { uid, exp, nonce, sig, token: `${payload}&sig=${sig}`, pagesUrl: env.PAGES_URL ? `${env.PAGES_URL}?uid=${encodeURIComponent(uid)}&exp=${exp}&nonce=${encodeURIComponent(nonce)}&sig=${sig}` : null }; const body = JSON.stringify(out); const headers = new Headers({ "Content-Type": "application/json; charset=utf-8", "Cache-Control": `public, max-age=${ttlSec}` }); const ch = corsHeaders(request); for (const [k, v] of Object.entries(ch)) headers.set(k, v); const res = new Response(body, { status: 200, headers }); ctx.waitUntil(caches.default.put(cacheReq, new Response(body, { status: 200, headers }))); return res; } // ===== ここから下は署名トークン必須(Pages → Worker)===== const auth = await requireSignedAuth(request, env, url); if (url.pathname === "/health") return json({ ok: true }, 200, request); if (url.pathname === "/config") return json({ mapboxToken: env.MAPBOX_TOKEN || "" }, 200, request); // ========================================================= // staff master (共通:案件/訪販) // GET /staff_list // ========================================================= if (url.pathname === "/staff_list") { const ttl = Number(env.STAFF_LIST_CACHE_SEC || 3600); const cacheReq = new Request("https://cache.local/" + encodeURIComponent(`staff_list:v1`)); const cached = await caches.default.match(cacheReq); if (cached) return withCors(cached, request); const token = await getFmsToken(env); const findBody = { query: [{ [ACCOUNT_FIELDS.attr]: "正社員", [ACCOUNT_FIELDS.exit_date]: "=" }], limit: Number(env.STAFF_LIST_LIMIT || 2000) }; const records = await fmsFindAccounts(env, token, findBody); const names = []; for (const r of records) { const f = r.fieldData || {}; const name = (f[ACCOUNT_FIELDS.name] || "").toString().trim(); if (!name) continue; names.push(name); } const uniq = Array.from(new Set(names)).sort((a, b) => a.localeCompare(b, "ja")); const items = uniq.map(name => ({ name })); return cachedJson({ items }, 200, request, ctx, cacheReq, ttl); } // ========================================================= // 訪販:報告(更新→script endpointで通知) // POST /prospect_report // body: { record_id, status, memo } // status: excluded|ordered|approach|posting // ========================================================= if (url.pathname === "/prospect_report" && request.method === "POST") { const token = await getFmsToken(env); const raw = await request.text(); let body; try { body = JSON.parse(raw || "{}"); } catch { return json({ error: "invalid json" }, 400, request); } const record_id = (body.record_id || "").toString().trim(); const status = (body.status || "").toString().trim(); const memo = (body.memo ?? "").toString(); if (!record_id) return json({ error: "record_id required" }, 400, request); if (!["excluded","ordered","approach","posting"].includes(status)) { return json({ error: "invalid status" }, 400, request); } // 1) record_idで対象レコード特定(FM内部 recordId を得る) const findBody = { query: [{ [PROSPECT_FIELDS.record_id]: record_id }], limit: 1 }; const records = await fmsFindProspects(env, token, findBody); const rec = records[0]; if (!rec) return json({ error: "not found" }, 404, request); const f = rec.fieldData || {}; const fmRecordId = String(rec.recordId || ""); if (!fmRecordId) throw new Error("Missing FileMaker recordId"); // 2) uid → アカウント管理(t_ID) → tc_氏名(見つからなければスキップ) let staffName = ""; try { const uid = (auth.uid || "").toString().trim(); if (uid) { const accFind = { query: [{ [ACCOUNT_FIELDS.id]: uid }], limit: 1 }; const accRecs = await fmsFindAccounts(env, token, accFind); const accRec = accRecs[0]; if (accRec?.fieldData) { staffName = (accRec.fieldData[ACCOUNT_FIELDS.name] || "").toString().trim(); } } } catch (_e) { staffName = ""; } const todayMdy = todayMDY_JST(); // 3) 更新 fieldData const fieldData = {}; if (status === "excluded") fieldData[PROSPECT_FIELDS.excluded_date] = todayMdy; if (status === "ordered") fieldData[PROSPECT_FIELDS.ordered_date] = todayMdy; if (status === "approach") fieldData[PROSPECT_FIELDS.approach_date] = todayMdy; if (status === "posting") fieldData[PROSPECT_FIELDS.posted_date] = todayMdy; if (staffName) fieldData[PROSPECT_FIELDS.staff] = staffName; const prevMemo = (f[PROSPECT_FIELDS.visit_memo] ?? "").toString(); fieldData[PROSPECT_FIELDS.visit_memo] = appendProspectMemo({ prev: prevMemo, todayMdy, staffName: staffName || (f[PROSPECT_FIELDS.staff] ?? "").toString().trim(), uid: auth.uid, status, memo }); // 4) まず更新(scriptはここでは実行しない) const updateResult = await fmsUpdateProspectRecord(env, token, fmRecordId, { fieldData }); // 5) script endpoint で通知(paramは record_id のみ) const scriptName = (env.FMS_SCRIPT_PROSPECT_NOTIFY || "").toString().trim(); if (!scriptName) throw new Error("Missing FMS_SCRIPT_PROSPECT_NOTIFY"); const scriptResult = await fmsRunProspectScript(env, token, scriptName, record_id); return json({ ok: true, record_id, status, staff_updated: !!staffName, updateResult, scriptResult }, 200, request); } // ===== bboxで点取得(従来)===== if (url.pathname === "/projects") { const bboxStr = url.searchParams.get("bbox") || ""; const parts = bboxStr.split(",").map(Number); if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) { return json({ error: "bbox required: minLng,minLat,maxLng,maxLat" }, 400, request); } const [minLng, minLat, maxLng, maxLat] = parts; const key = `projects:${auth.uid}:${round(minLng, 3)}:${round(minLat, 3)}:${round(maxLng, 3)}:${round(maxLat, 3)}`; const cacheReq = new Request("https://cache.local/" + encodeURIComponent(key)); const cached = await caches.default.match(cacheReq); if (cached) return withCors(cached, request); const token = await getFmsToken(env); const findBody = { query: [{ [FIELDS.has_coord]: "1" }], limit: Number(env.PROJECTS_LIMIT || 3000) }; const records = await fmsFind(env, token, findBody); const items = []; for (const r of records) { const f = r.fieldData || {}; const lat = Number(f[FIELDS.lat]); const lng = Number(f[FIELDS.lng]); if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue; if (lng < minLng || lng > maxLng || lat < minLat || lat > maxLat) continue; items.push(projectToItemMDY(f)); } return cachedJson({ items }, 200, request, ctx, cacheReq, 15); } // ===== 全域:日付(MIX)で絞り込み(従来)===== if (url.pathname === "/projects_by_date") { const raw = (url.searchParams.get("date") || "").trim(); if (!raw) return json({ error: "date required" }, 400, request); const mdy = normalizeMDY(raw); if (!mdy) return json({ error: "invalid date format" }, 400, request); const ttl = Number(env.PROJECTS_BY_DATE_CACHE_SEC || 60); const cacheReq = new Request("https://cache.local/" + encodeURIComponent(`projects_by_date:${auth.uid}:${mdy}`)); const cached = await caches.default.match(cacheReq); if (cached) return withCors(cached, request); const token = await getFmsToken(env); const findBody = { query: [ { [FIELDS.has_coord]: "1", [FIELDS.visit_date]: mdy }, { [FIELDS.has_coord]: "1", [FIELDS.cvisit_date]: mdy } ], limit: Number(env.PROJECTS_LIMIT || 3000) }; const records = await fmsFind(env, token, findBody); const items = []; for (const r of records) { const f = r.fieldData || {}; const lat = Number(f[FIELDS.lat]); const lng = Number(f[FIELDS.lng]); if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue; items.push(projectToItemMDY(f)); } return cachedJson({ items }, 200, request, ctx, cacheReq, ttl); } // ===== ★軽量化の本命:bbox + filters + search(案件)===== if (url.pathname === "/projects_search") { const bboxStr = url.searchParams.get("bbox") || ""; const parts = bboxStr.split(",").map(Number); if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) { return json({ error: "bbox required: minLng,minLat,maxLng,maxLat" }, 400, request); } const [minLng0, minLat0, maxLng0, maxLat0] = parts; const r6 = (n) => Math.round(n * 1e6) / 1e6; const minLng = r6(minLng0); const minLat = r6(minLat0); const maxLng = r6(maxLng0); const maxLat = r6(maxLat0); const status = (url.searchParams.get("status") || "").trim(); const channel = (url.searchParams.get("channel") || "").trim(); const staff = (url.searchParams.get("staff") || "").trim(); const rawDate = (url.searchParams.get("date") || "").trim(); const qRaw = (url.searchParams.get("q") || "").trim(); const year = (url.searchParams.get("year") || "").trim(); const date = rawDate ? normalizeMDY(rawDate) : ""; const ttl = Number(env.PROJECTS_SEARCH_CACHE_SEC || 10); const key = `projects_search:${auth.uid}:` + `${minLng}:${minLat}:${maxLng}:${maxLat}:` + `${status}:${channel}:${staff}:${date}:${qRaw}:${year}`; const cacheReq = new Request("https://cache.local/" + encodeURIComponent(key)); const cached = await caches.default.match(cacheReq); if (cached) return withCors(cached, request); const token = await getFmsToken(env); const base = { [FIELDS.has_coord]: "1", [FIELDS.lat]: `${minLat}...${maxLat}`, [FIELDS.lng]: `${minLng}...${maxLng}`, }; if (status) base[FIELDS.status] = status; if (channel && FIELDS.channel) base[FIELDS.channel] = channel; if (staff) base[FIELDS.staff] = staff; const baseClauses = []; if (date) { baseClauses.push({ ...base, [FIELDS.visit_date]: date }); baseClauses.push({ ...base, [FIELDS.cvisit_date]: date }); } else { baseClauses.push({ ...base }); } const q = qRaw; const qDigits = q.replace(/[^\d]/g, ""); const isNumericOnly = q && qDigits && qDigits.length === q.length; const query = []; if (!q) { query.push(...baseClauses); } else if (isNumericOnly) { for (const bc of baseClauses) { query.push({ ...bc, [FIELDS.id]: q }); query.push({ ...bc, [FIELDS.phone]: `*${q}*` }); query.push({ ...bc, [FIELDS.mobile]: `*${q}*` }); } } else { for (const bc of baseClauses) { query.push({ ...bc, [FIELDS.customer_name]: `*${q}*` }); query.push({ ...bc, [FIELDS.address]: `*${q}*` }); } } const LIMIT = Number(env.PROJECTS_LIMIT || 3000); const MAX_PAGES = Number(env.PROJECTS_MAX_PAGES || 20); const items = []; let offset = 1; for (let page = 0; page < MAX_PAGES; page++) { const findBody = { query, limit: LIMIT, offset }; const records = await fmsFind(env, token, findBody); if (!records.length) break; for (const r of records) { const f = r.fieldData || {}; const lat = Number(f[FIELDS.lat]); const lng = Number(f[FIELDS.lng]); if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue; if (lng < minLng || lng > maxLng || lat < minLat || lat > maxLat) continue; const it = projectToItemMDY(f); if (year) { const ed = normalizeMDY(it.end_date); const m = ed.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); if (!m || m[3] !== year) continue; } items.push(it); } offset += records.length; if (records.length < LIMIT) break; } return cachedJson({ items }, 200, request, ctx, cacheReq, ttl); } // ===== 1件詳細(案件)===== if (url.pathname === "/project") { const id = url.searchParams.get("id"); if (!id) return json({ error: "id required" }, 400, request); const token = await getFmsToken(env); const findBody = { query: [{ [FIELDS.id]: String(id) }], limit: 1 }; const records = await fmsFind(env, token, findBody); const rec = records[0]; if (!rec) return json({ error: "not found" }, 404, request); const f = rec.fieldData || {}; return json({ item: { id: String(f[FIELDS.id] ?? id), customer_name: f[FIELDS.customer_name] ?? "", lat: Number(f[FIELDS.lat]), lng: Number(f[FIELDS.lng]), status: f[FIELDS.status] ?? "", channel: f[FIELDS.channel] ?? "", address: f[FIELDS.address] ?? "", phone: f[FIELDS.phone] ?? "", mobile: f[FIELDS.mobile] ?? "", staff: f[FIELDS.staff] ?? "", cvisit_date: normalizeMDY(f[FIELDS.cvisit_date]), cvisit_time1: normalizeTime(f[FIELDS.cvisit_time1]), cvisit_time2: normalizeTime(f[FIELDS.cvisit_time2]), visit_date: normalizeMDY(f[FIELDS.visit_date]), visit_time1: normalizeTime(f[FIELDS.visit_time1]), visit_time2: normalizeTime(f[FIELDS.visit_time2]), start_date: normalizeMDY(f[FIELDS.start_date]), s_end_date: normalizeMDY(f[FIELDS.s_end_date]), end_date: normalizeMDY(f[FIELDS.end_date]), amount: f[FIELDS.amount] ?? "", accuracy: f[FIELDS.accuracy] ?? "", apo_memo: f[FIELDS.apo_memo] ?? "", closing_memo: f[FIELDS.closing_memo] ?? "", construction_memo: f[FIELDS.construction_memo] ?? "" } }, 200, request); } // ========================================================= // 訪販:bbox + filters + search(最小返却) // ★ is_recent_1w を返す(今回追加) // ========================================================= if (url.pathname === "/prospects_search") { const bboxStr = url.searchParams.get("bbox") || ""; const parts = bboxStr.split(",").map(Number); if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) { return json({ error: "bbox required: minLng,minLat,maxLng,maxLat" }, 400, request); } const [minLng0, minLat0, maxLng0, maxLat0] = parts; const r6 = (n) => Math.round(n * 1e6) / 1e6; const minLng = r6(minLng0); const minLat = r6(minLat0); const maxLng = r6(maxLng0); const maxLat = r6(maxLat0); const qRaw = (url.searchParams.get("q") || "").trim(); const posting = (url.searchParams.get("posting") || "").trim() === "1"; const approach = (url.searchParams.get("approach") || "").trim() === "1"; const ordered = (url.searchParams.get("ordered") || "").trim() === "1"; const excluded = (url.searchParams.get("excluded") || "").trim() === "1"; const dist = (url.searchParams.get("dist") || "").trim(); const staff = (url.searchParams.get("staff") || "").trim(); const ttl = Number(env.PROJECTS_SEARCH_CACHE_SEC || 10); const key = `prospects_search:${auth.uid}:` + `${minLng}:${minLat}:${maxLng}:${maxLat}:` + `${qRaw}:${posting?1:0}:${approach?1:0}:${ordered?1:0}:${excluded?1:0}:${dist}:${staff}`; const cacheReq = new Request("https://cache.local/" + encodeURIComponent(key)); const cached = await caches.default.match(cacheReq); if (cached) return withCors(cached, request); const token = await getFmsToken(env); const base = { [PROSPECT_FIELDS.has_coord]: "1", [PROSPECT_FIELDS.lat]: `${minLat}...${maxLat}`, [PROSPECT_FIELDS.lng]: `${minLng}...${maxLng}`, }; if (staff) base[PROSPECT_FIELDS.staff] = staff; const baseClauses = qRaw ? [{ ...base, [PROSPECT_FIELDS.address_join]: `*${qRaw}*` }] : [{ ...base }]; const statusClauses = []; if (excluded) statusClauses.push({ [PROSPECT_FIELDS.excluded_date]: "*" }); if (ordered) statusClauses.push({ [PROSPECT_FIELDS.ordered_date]: "*" }); if (approach) statusClauses.push({ [PROSPECT_FIELDS.approach_date]: "*" }); if (posting) statusClauses.push({ [PROSPECT_FIELDS.posted_date]: "*" }); const distClause = buildProspectDistClause(dist); const query = []; if (!statusClauses.length && !distClause) { query.push(...baseClauses); } else { for (const bc of baseClauses) { if (statusClauses.length) { for (const sc of statusClauses) { const merged = { ...bc, ...sc }; if (distClause) Object.assign(merged, distClause); query.push(merged); } } else { const merged = { ...bc }; if (distClause) Object.assign(merged, distClause); query.push(merged); } } } const LIMIT = Number(env.PROJECTS_LIMIT || 3000); const MAX_PAGES = Number(env.PROJECTS_MAX_PAGES || 20); const items = []; let offset = 1; for (let page = 0; page < MAX_PAGES; page++) { const findBody = { query, limit: LIMIT, offset }; const records = await fmsFindProspects(env, token, findBody); if (!records.length) break; for (const r of records) { const f = r.fieldData || {}; const lat = Number(f[PROSPECT_FIELDS.lat]); const lng = Number(f[PROSPECT_FIELDS.lng]); if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue; if (lng < minLng || lng > maxLng || lat < minLat || lat > maxLat) continue; items.push(prospectToMapItem(f)); // ★ここで is_recent_1w も返る } offset += records.length; if (records.length < LIMIT) break; } return cachedJson({ items }, 200, request, ctx, cacheReq, ttl); } // ===== 1件詳細(訪販)===== if (url.pathname === "/prospect") { const id = (url.searchParams.get("id") || "").trim(); if (!id) return json({ error: "id required" }, 400, request); const token = await getFmsToken(env); const findBody = { query: [{ [PROSPECT_FIELDS.record_id]: String(id) }], limit: 1 }; const records = await fmsFindProspects(env, token, findBody); const rec = records[0]; if (!rec) return json({ error: "not found" }, 404, request); const f = rec.fieldData || {}; return json({ item: prospectToDetailMDY(f) }, 200, request); } return json({ error: "not found" }, 404, request); } catch (e) { return json({ error: e?.message || String(e) }, 500, request); } } }; // --- FileMaker field names (Projects) --- const FIELDS = { has_coord: "map_has_coord", id: "n_管理番号", customer_name: "t_契約者名", lat: "開発_緯度", lng: "開発_経度", status: "t_エントリーステータス", channel: "ct_チャネル", address: "t_施工先住所", phone: "t_固定電話番号", mobile: "t_携帯電話番号", staff: "t_担当クローザー", cvisit_date: "d_現地調査予定日", cvisit_time1: "ti_現地調査時間1", cvisit_time2: "ti_現地調査時間2", visit_date: "d_訪問予定日", visit_time1: "ti_訪問時間1", visit_time2: "ti_訪問時間2", start_date: "d_工務店着工日", s_end_date: "d_工務店完工予定日", end_date: "d_工務店完工日", amount: "n_火災保険適用金額", accuracy: "開発_ジオコード精度", apo_memo: "t_アポイントmemo", closing_memo: "t_クロージング備考", construction_memo: "t_工事備考" }; // --- FileMaker field names (Prospects: エコxエネリスト) --- const PROSPECT_FIELDS = { has_coord: "map_has_coord", record_id: "record_id", lat: "n_緯度", lng: "n_経度", geo_precision: "t_GEO", address_join: "tc_連結住所", delivery_dt: "d_配信日時", posted_date: "d_チラシ投函日", approach_date: "d_対面アプローチ日", ordered_date: "d_受注日", excluded_date: "d_対象外確定日", staff: "t_担当者", // ★追加(FM計算フィールド) is_recent_1w: "is_recent_1w", name: "お名前", kana: "フリガナ", age: "年齢", phone: "電話番号", email: "メールアドレス", applicant: "お申込者", place: "設置場所", quote_method: "お見積もり方法", quote_status: "お見積もり状況", has_solar: "太陽光パネルは設置されていますか?", timing: "導入検討時期", purpose: "導入目的", choose_point: "設置会社を選ぶポイント", also_products: "あわせて見積もりを取りたい商品", ownership: "建物の所有区分", inquiry: "お問合せ内容", visit_memo: "t_訪問備考" }; // --- FileMaker field names (Accounts: アカウント管理) --- const ACCOUNT_FIELDS = { id: "t_ID", name: "tc_氏名", attr: "t_属性", exit_date: "d_退社日" }; function projectToItemMDY(f) { return { id: String(f[FIELDS.id] ?? ""), lat: Number(f[FIELDS.lat]), lng: Number(f[FIELDS.lng]), status: f[FIELDS.status] ?? "", customer_name: f[FIELDS.customer_name] ?? "", channel: f[FIELDS.channel] ?? "", staff: f[FIELDS.staff] ?? "", visit_date: normalizeMDY(f[FIELDS.visit_date]), visit_time1: normalizeTime(f[FIELDS.visit_time1]), visit_time2: normalizeTime(f[FIELDS.visit_time2]), cvisit_date: normalizeMDY(f[FIELDS.cvisit_date]), cvisit_time1: normalizeTime(f[FIELDS.cvisit_time1]), cvisit_time2: normalizeTime(f[FIELDS.cvisit_time2]), end_date: normalizeMDY(f[FIELDS.end_date]), address: f[FIELDS.address] ?? "", phone: f[FIELDS.phone] ?? "", mobile: f[FIELDS.mobile] ?? "" }; } function prospectStatusCodeFromFieldData(f){ if (normalizeMDY(f[PROSPECT_FIELDS.excluded_date])) return "excluded"; if (normalizeMDY(f[PROSPECT_FIELDS.ordered_date])) return "ordered"; if (normalizeMDY(f[PROSPECT_FIELDS.approach_date])) return "approach"; if (normalizeMDY(f[PROSPECT_FIELDS.posted_date])) return "posting"; return "todo"; } function prospectToMapItem(f){ const code = prospectStatusCodeFromFieldData(f); return { record_id: String(f[PROSPECT_FIELDS.record_id] ?? ""), lat: Number(f[PROSPECT_FIELDS.lat]), lng: Number(f[PROSPECT_FIELDS.lng]), status_code: code, // ★追加:FM計算フィールドをそのまま返す(0/1) is_recent_1w: Number(f[PROSPECT_FIELDS.is_recent_1w] || 0), is_todo: code === "todo" ? 1 : 0, is_posted: code === "posting" ? 1 : 0, is_approached: code === "approach" ? 1 : 0, is_ordered: code === "ordered" ? 1 : 0, is_excluded: code === "excluded" ? 1 : 0 }; } function prospectToDetailMDY(f){ return { record_id: String(f[PROSPECT_FIELDS.record_id] ?? ""), lat: Number(f[PROSPECT_FIELDS.lat]), lng: Number(f[PROSPECT_FIELDS.lng]), t_GEO: f[PROSPECT_FIELDS.geo_precision] ?? "", "お名前": f[PROSPECT_FIELDS.name] ?? "", "フリガナ": f[PROSPECT_FIELDS.kana] ?? "", "年齢": f[PROSPECT_FIELDS.age] ?? "", "電話番号": f[PROSPECT_FIELDS.phone] ?? "", "メールアドレス": f[PROSPECT_FIELDS.email] ?? "", "tc_連結住所": f[PROSPECT_FIELDS.address_join] ?? "", "t_担当者": f[PROSPECT_FIELDS.staff] ?? "", "お申込者": f[PROSPECT_FIELDS.applicant] ?? "", "設置場所": f[PROSPECT_FIELDS.place] ?? "", "お見積もり方法": f[PROSPECT_FIELDS.quote_method] ?? "", "お見積もり状況": f[PROSPECT_FIELDS.quote_status] ?? "", "太陽光パネルは設置されていますか?": f[PROSPECT_FIELDS.has_solar] ?? "", "導入検討時期": f[PROSPECT_FIELDS.timing] ?? "", "導入目的": f[PROSPECT_FIELDS.purpose] ?? "", "設置会社を選ぶポイント": f[PROSPECT_FIELDS.choose_point] ?? "", "あわせて見積もりを取りたい商品": f[PROSPECT_FIELDS.also_products] ?? "", "建物の所有区分": f[PROSPECT_FIELDS.ownership] ?? "", "お問合せ内容": f[PROSPECT_FIELDS.inquiry] ?? "", "t_訪問備考": f[PROSPECT_FIELDS.visit_memo] ?? "", "d_配信日時": normalizeMDY(f[PROSPECT_FIELDS.delivery_dt]), "d_チラシ投函日": normalizeMDY(f[PROSPECT_FIELDS.posted_date]), "d_対面アプローチ日": normalizeMDY(f[PROSPECT_FIELDS.approach_date]), "d_受注日": normalizeMDY(f[PROSPECT_FIELDS.ordered_date]), "d_対象外確定日": normalizeMDY(f[PROSPECT_FIELDS.excluded_date]) }; } function appendProspectMemo({ prev, todayMdy, staffName, uid, status, memo }){ const cleanMemo = (memo ?? "").toString().replace(/\r\n/g,"\n").replace(/\r/g,"\n").trim(); const who = (staffName || "").trim() || `uid:${uid}`; const head = `[${todayMdy}] (${who}) status=${status}`; const block = cleanMemo ? `${head}\n${cleanMemo}` : head; const p = (prev ?? "").toString().replace(/\r\n/g,"\n").replace(/\r/g,"\n").trim(); if (!p) return block; return `${block}\n\n${p}`; } function todayMDY_JST(){ const now = new Date(); const jst = new Date(now.getTime() + 9*60*60*1000); const y = jst.getUTCFullYear(); const m = String(jst.getUTCMonth()+1).padStart(2,"0"); const d = String(jst.getUTCDate()).padStart(2,"0"); return `${m}/${d}/${y}`; } // ---- dist clause builder ---- function buildProspectDistClause(dist){ if (!dist) return null; const now = new Date(); const jst = new Date(now.getTime() + 9*60*60*1000); const y = jst.getUTCFullYear(); const m = jst.getUTCMonth(); const d = jst.getUTCDate(); const startOfToday = new Date(Date.UTC(y, m, d)); const cutoff = (months, years) => { const dt = new Date(startOfToday); if (years) dt.setUTCFullYear(dt.getUTCFullYear() - years); if (months) dt.setUTCMonth(dt.getUTCMonth() - months); const yy = dt.getUTCFullYear(); const mm = String(dt.getUTCMonth()+1).padStart(2,"0"); const dd = String(dt.getUTCDate()).padStart(2,"0"); return `${mm}/${dd}/${yy}`; }; if (dist === "3m") return { [PROSPECT_FIELDS.delivery_dt]: `>=${cutoff(3,0)}` }; if (dist === "6m") return { [PROSPECT_FIELDS.delivery_dt]: `>=${cutoff(6,0)}` }; if (dist === "1y") return { [PROSPECT_FIELDS.delivery_dt]: `>=${cutoff(0,1)}` }; if (dist === "past") return { [PROSPECT_FIELDS.delivery_dt]: `<${cutoff(0,1)}` }; return null; } // --- normalize date/time --- function normalizeMDY(s) { s = (s ?? "").toString().trim(); if (!s) return ""; let m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); if (m) { const mm = String(m[1]).padStart(2, "0"); const dd = String(m[2]).padStart(2, "0"); const yy = m[3]; return `${mm}/${dd}/${yy}`; } m = s.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/); if (m) { const yy = m[1]; const mm = String(m[2]).padStart(2, "0"); const dd = String(m[3]).padStart(2, "0"); return `${mm}/${dd}/${yy}`; } m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); if (m) { const yy = m[1]; const mm = String(m[2]).padStart(2, "0"); const dd = String(m[3]).padStart(2, "0"); return `${mm}/${dd}/${yy}`; } return ""; } function normalizeTime(s) { s = (s ?? "").toString().trim(); if (!s) return ""; const m = s.match(/^(\d{1,2}):(\d{2})/); if (m) return `${String(m[1]).padStart(2, "0")}:${m[2]}`; const m2 = s.match(/^(\d{1,2})時(\d{1,2})?/); if (m2) return `${String(m2[1]).padStart(2, "0")}:${String(m2[2] ?? "00").padStart(2, "0")}`; return s; } // --- CORS/helpers --- function corsHeaders(request) { const origin = request.headers.get("Origin") || "*"; return { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Methods": "GET,POST,OPTIONS,PATCH", "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Issuer-Secret", "Access-Control-Max-Age": "86400" }; } function withCors(response, request) { const headers = new Headers(response.headers); const ch = corsHeaders(request); for (const [k, v] of Object.entries(ch)) headers.set(k, v); return new Response(response.body, { status: response.status, headers }); } function json(obj, status, request) { const body = JSON.stringify(obj); const headers = new Headers({ "Content-Type": "application/json; charset=utf-8" }); const ch = corsHeaders(request); for (const [k, v] of Object.entries(ch)) headers.set(k, v); return new Response(body, { status, headers }); } function cachedJson(obj, status, request, ctx, cacheReq, ttlSec) { const body = JSON.stringify(obj); const headers = new Headers({ "Content-Type": "application/json; charset=utf-8", "Cache-Control": `public, max-age=${ttlSec}` }); const ch = corsHeaders(request); for (const [k, v] of Object.entries(ch)) headers.set(k, v); const res = new Response(body, { status, headers }); ctx.waitUntil(caches.default.put(cacheReq, new Response(body, { status, headers }))); return res; } function round(n, digits) { const p = 10 ** digits; return Math.round(n * p) / p; } // ===== Signed auth ===== async function requireSignedAuth(request, env, url) { const secret = env.AUTH_HMAC_SECRET; if (!secret) throw new Error("Missing AUTH_HMAC_SECRET"); const authz = request.headers.get("Authorization") || ""; let tokenStr = ""; if (authz.toLowerCase().startsWith("bearer ")) tokenStr = authz.slice(7).trim(); if (!tokenStr) { const uid = url.searchParams.get("uid") || ""; const exp = url.searchParams.get("exp") || ""; const nonce = url.searchParams.get("nonce") || ""; const sig = url.searchParams.get("sig") || ""; if (uid && exp && nonce && sig) tokenStr = `uid=${uid}&exp=${exp}&nonce=${nonce}&sig=${sig}`; } if (!tokenStr) throw new Error("Missing auth token"); const p = new URLSearchParams(tokenStr); const uid = (p.get("uid") || "").trim(); const expStr = (p.get("exp") || "").trim(); const nonce = (p.get("nonce") || "").trim(); const sigHex = (p.get("sig") || "").trim(); if (!uid || !expStr || !nonce || !sigHex) throw new Error("Invalid token: missing fields"); const exp = Number(expStr); if (!Number.isFinite(exp)) throw new Error("Invalid token: exp"); const now = Math.floor(Date.now() / 1000); if (exp < now) throw new Error("Token expired"); const payload = `uid=${uid}&exp=${expStr}&nonce=${nonce}`; const expected = await hmacHex(secret, payload); if (!timingSafeEqualHex(expected, sigHex)) throw new Error("Invalid signature"); return { uid, exp, nonce }; } async function hmacHex(secret, payload) { const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"] ); const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload)); return bufToHex(new Uint8Array(sig)); } function bufToHex(buf) { let s = ""; for (const b of buf) s += b.toString(16).padStart(2, "0"); return s; } function timingSafeEqualHex(a, b) { const aa = (a || "").toLowerCase(); const bb = (b || "").toLowerCase(); if (aa.length !== bb.length) return false; let r = 0; for (let i = 0; i < aa.length; i++) r |= aa.charCodeAt(i) ^ bb.charCodeAt(i); return r === 0; } function randomNonce(len) { const bytes = new Uint8Array(len); crypto.getRandomValues(bytes); return Array.from(bytes, b => (b % 36).toString(36)).join(""); } // ===== FMS Data API ===== async function getFmsToken(env) { if (globalThis.__FMS_TOKEN && globalThis.__FMS_TOKEN_EXPIRES > Date.now()) { return globalThis.__FMS_TOKEN; } const host = env.FMS_HOST; const db = env.FMS_DB; const user = env.FMS_USER; const pass = env.FMS_PASS; if (!host || !db || !user || !pass) { throw new Error("Missing FMS_* secrets"); } const loginUrl = `${host}/fmi/data/vLatest/databases/${encodeURIComponent(db)}/sessions`; const auth = btoa(`${user}:${pass}`); const resp = await fetch(loginUrl, { method: "POST", headers: { "Authorization": `Basic ${auth}`, "Content-Type": "application/json" }, body: JSON.stringify({}) }); if (!resp.ok) throw new Error(`FMS login failed: ${resp.status} ${await resp.text()}`); const data = await resp.json(); const token = data?.response?.token; if (!token) throw new Error("FMS login response missing token"); globalThis.__FMS_TOKEN = token; globalThis.__FMS_TOKEN_EXPIRES = Date.now() + 13 * 60 * 1000; return token; } async function fmsFind(env, token, bodyObj) { const host = env.FMS_HOST; const db = env.FMS_DB; const layout = env.FMS_LAYOUT; if (!layout) throw new Error("Missing FMS_LAYOUT secret"); const findUrl = `${host}/fmi/data/vLatest/databases/${encodeURIComponent(db)}` + `/layouts/${encodeURIComponent(layout)}/_find`; let resp = await fetch(findUrl, { method: "POST", headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify(bodyObj) }); if (resp.status === 401) { globalThis.__FMS_TOKEN = null; globalThis.__FMS_TOKEN_EXPIRES = 0; const newToken = await getFmsToken(env); resp = await fetch(findUrl, { method: "POST", headers: { "Authorization": `Bearer ${newToken}`, "Content-Type": "application/json" }, body: JSON.stringify(bodyObj) }); } if (!resp.ok) throw new Error(`FMS find failed: ${resp.status} ${await resp.text()}`); const data = await resp.json(); return data?.response?.data ?? []; } async function fmsFindProspects(env, token, bodyObj) { const host = env.FMS_HOST; const db = env.FMS_DB; const layout = env.FMS_LAYOUT_PROSPECTS; if (!layout) throw new Error("Missing FMS_LAYOUT_PROSPECTS secret"); const findUrl = `${host}/fmi/data/vLatest/databases/${encodeURIComponent(db)}` + `/layouts/${encodeURIComponent(layout)}/_find`; let resp = await fetch(findUrl, { method: "POST", headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify(bodyObj) }); if (resp.status === 401) { globalThis.__FMS_TOKEN = null; globalThis.__FMS_TOKEN_EXPIRES = 0; const newToken = await getFmsToken(env); resp = await fetch(findUrl, { method: "POST", headers: { "Authorization": `Bearer ${newToken}`, "Content-Type": "application/json" }, body: JSON.stringify(bodyObj) }); } if (!resp.ok) throw new Error(`FMS find (prospects) failed: ${resp.status} ${await resp.text()}`); const data = await resp.json(); return data?.response?.data ?? []; } async function fmsFindAccounts(env, token, bodyObj) { const host = env.FMS_HOST; const db = env.FMS_DB; const layout = env.FMS_LAYOUT_ACCOUNTS; if (!layout) throw new Error("Missing FMS_LAYOUT_ACCOUNTS secret"); const findUrl = `${host}/fmi/data/vLatest/databases/${encodeURIComponent(db)}` + `/layouts/${encodeURIComponent(layout)}/_find`; let resp = await fetch(findUrl, { method: "POST", headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify(bodyObj) }); if (resp.status === 401) { globalThis.__FMS_TOKEN = null; globalThis.__FMS_TOKEN_EXPIRES = 0; const newToken = await getFmsToken(env); resp = await fetch(findUrl, { method: "POST", headers: { "Authorization": `Bearer ${newToken}`, "Content-Type": "application/json" }, body: JSON.stringify(bodyObj) }); } if (!resp.ok) throw new Error(`FMS find (accounts) failed: ${resp.status} ${await resp.text()}`); const data = await resp.json(); return data?.response?.data ?? []; } async function fmsUpdateProspectRecord(env, token, fmRecordId, { fieldData }) { const host = env.FMS_HOST; const db = env.FMS_DB; const layout = env.FMS_LAYOUT_PROSPECTS; if (!layout) throw new Error("Missing FMS_LAYOUT_PROSPECTS secret"); const updateUrl = `${host}/fmi/data/vLatest/databases/${encodeURIComponent(db)}` + `/layouts/${encodeURIComponent(layout)}/records/${encodeURIComponent(String(fmRecordId))}`; const bodyObj = { fieldData: fieldData || {} }; let resp = await fetch(updateUrl, { method: "PATCH", headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify(bodyObj) }); if (resp.status === 401) { globalThis.__FMS_TOKEN = null; globalThis.__FMS_TOKEN_EXPIRES = 0; const newToken = await getFmsToken(env); resp = await fetch(updateUrl, { method: "PATCH", headers: { "Authorization": `Bearer ${newToken}`, "Content-Type": "application/json" }, body: JSON.stringify(bodyObj) }); } if (!resp.ok) throw new Error(`FMS update (prospect) failed: ${resp.status} ${await resp.text()}`); return await resp.json(); } // ★公式の script endpoint で実行(layoutコンテキスト:エコxエネリスト) async function fmsRunProspectScript(env, token, scriptName, scriptParam) { const host = env.FMS_HOST; const db = env.FMS_DB; const layout = env.FMS_LAYOUT_PROSPECTS; if (!layout) throw new Error("Missing FMS_LAYOUT_PROSPECTS secret"); const qs = new URLSearchParams(); if (scriptParam != null) qs.set("script.param", String(scriptParam)); const runUrl = `${host}/fmi/data/vLatest/databases/${encodeURIComponent(db)}` + `/layouts/${encodeURIComponent(layout)}/script/${encodeURIComponent(scriptName)}` + (qs.toString() ? `?${qs.toString()}` : ""); let resp = await fetch(runUrl, { method: "GET", headers: { "Authorization": `Bearer ${token}` } }); if (resp.status === 401) { globalThis.__FMS_TOKEN = null; globalThis.__FMS_TOKEN_EXPIRES = 0; const newToken = await getFmsToken(env); resp = await fetch(runUrl, { method: "GET", headers: { "Authorization": `Bearer ${newToken}` } }); } if (!resp.ok) throw new Error(`FMS run script (prospect) failed: ${resp.status} ${await resp.text()}`); return await resp.json(); } デプロイ後の確認 /prospects_search の items に is_recent_1w が出るか確認してください: DevTools → Network → prospects_search → Response items[0].is_recent_1w が 0/1 になっていればOK 次はHTML側で 直近1週間 フィルター追加(フロントで is_recent_1w==1 で絞る) どのdistでも is_recent_1w==1 を リング/グローで爆目立ちさせる の差分を出せます。どれくらい派手にしたい?(白リング+黄色グロー、みたいな方向でOK?) Notionに保存 とりあえず爆目立ちすればおkw 完全版HTMLでちょうだい Copy 案件地図
-
案件地図
※検索は案件/訪販どちらにも効きます(訪販は住所部分一致)
案件フィルター
※担当者候補はマスタ(在籍正社員)優先
訪販フィルター
※訪販は8000件想定のため常にクラスタ表示
※「直近1週間」は is_recent_1w=1 で絞り込みます
※担当者候補はマスタ(在籍正社員)優先
-
表示
ID/契約者名+担当+直近予定
OFF
追従なし(矢印+点滅表示)