仕様ドキュメント

Design (EN)

03-design.md

Design Document — Anti-Social Forces Check Application

1. Architecture Overview

graph TB
  subgraph Client["Client (Browser)"]
    UI["Next.js App Router<br/>React Server Components + Client Islands"]
  end

  UI -->|HTTPS| Backend

  subgraph Backend["Next.js Backend (Bun)"]
    direction TB
    subgraph Handlers[" "]
      direction LR
      API["API Routes<br/>(REST)"]
      SA["Server<br/>Actions"]
      BG["Background Jobs<br/>(Bun worker threads)"]
    end

    subgraph Services["Service Layer"]
      direction LR
      CRS["CheckRequestService"]
      SS["ScreeningService"]
      NS["NotificationSvc"]
    end

    subgraph Infra[" "]
      direction LR
      Prisma["Prisma ORM"]
      subgraph Clients["External API Clients"]
        HB["HoujinBangouClient"]
        ED["EdinetClient"]
        KP["KanpouClient"]
        FSA["FsaClient"]
        MLIT["MlitClient"]
        GS["GoogleSearchClient"]
        NT["NikkeiTelecomHelper"]
      end
    end

    Handlers --> Services
    Services --> Infra
  end

  Prisma --> DB[("PostgreSQL")]
  NS --> SMTP["Email / SMTP"]

Design Principles

  • SOLID: Each external API client is a single-responsibility injectable service
  • DI: All external dependencies injected — mockable for testing
  • SSOT: Corporate number (法人番号) is the single source of truth for entity identity
  • KISS: No premature abstraction — build the 3 screening tiers incrementally

2. Project Structure

anti-social-check/
├── src/
│   ├── app/                          # Next.js App Router
│   │   ├── layout.tsx                # Root layout (Japanese locale)
│   │   ├── page.tsx                  # Dashboard
│   │   ├── checks/
│   │   │   ├── page.tsx              # Check request list
│   │   │   ├── new/page.tsx          # Create check request
│   │   │   ├── [id]/
│   │   │   │   ├── page.tsx          # Check request detail
│   │   │   │   ├── screening/page.tsx # Screening execution view
│   │   │   │   └── results/page.tsx  # Results & determination
│   │   │   └── import/page.tsx       # Bulk CSV import
│   │   ├── ledger/
│   │   │   └── page.tsx              # 検索結果一覧表 view
│   │   ├── monitoring/
│   │   │   └── page.tsx              # Annual re-check dashboard
│   │   ├── admin/
│   │   │   ├── keywords/page.tsx     # Negative keyword management
│   │   │   └── users/page.tsx        # User/role management
│   │   └── api/
│   │       ├── checks/route.ts       # Check CRUD
│   │       ├── screening/route.ts    # Trigger screening
│   │       ├── houjin/route.ts       # Corporate number lookup proxy
│   │       ├── edinet/route.ts       # EDINET proxy
│   │       ├── osint/route.ts        # OSINT checks proxy
│   │       └── export/route.ts       # Excel/PDF export
│   ├── services/
│   │   ├── check-request.service.ts
│   │   ├── screening.service.ts      # Orchestrates all screening tiers
│   │   ├── notification.service.ts
│   │   └── export.service.ts
│   ├── clients/                      # External API clients (injected)
│   │   ├── houjin-bangou.client.ts   # National Tax Agency API
│   │   ├── edinet.client.ts          # EDINET API
│   │   ├── kanpou.client.ts          # Official Gazette search
│   │   ├── fsa.client.ts             # Financial Services Agency
│   │   ├── mlit.client.ts            # Ministry of Land
│   │   ├── google-search.client.ts   # Google Custom Search API
│   │   └── nikkei-telecom.helper.ts  # Query builder (no direct API)
│   ├── db/
│   │   ├── schema.prisma
│   │   └── seed.ts                   # Default keywords, roles
│   ├── types/
│   │   ├── check.ts
│   │   ├── screening.ts
│   │   ├── entity.ts
│   │   └── api-responses.ts
│   ├── lib/
│   │   ├── auth.ts                   # Session/auth utilities
│   │   ├── keywords.ts               # Default negative keyword set
│   │   └── query-builder.ts          # Build search strings from keywords + entity
│   └── components/
│       ├── check-request-form.tsx
│       ├── screening-panel.tsx
│       ├── results-table.tsx
│       ├── status-badge.tsx
│       ├── entity-card.tsx
│       └── dashboard-widgets.tsx
├── prisma/
│   └── migrations/
├── public/
├── tests/
│   ├── services/
│   ├── clients/
│   └── e2e/
├── .env.example
├── bunfig.toml
├── next.config.ts
├── tailwind.config.ts
├── tsconfig.json
├── package.json
└── Dockerfile

3. Data Model

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// ─── Users & Roles ─────────────────────────────────────────

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String
  role      Role
  createdAt DateTime @default(now())

  createdChecks  CheckRequest[] @relation("CreatedBy")
  assignedChecks CheckRequest[] @relation("AssignedTo")
  auditLogs      AuditLog[]
}

enum Role {
  SALES           // 営業担当 — create requests
  LEGAL           // グループ法務担当 — perform searches
  FF_LEGAL        // FF法務担当 — review escalated hits
  COMPLIANCE      // コンプライアンス責任者 — final approvals
  ADMIN           // System admin
}

// ─── Check Requests ────────────────────────────────────────

model CheckRequest {
  id              String       @id @default(cuid())
  trackingNumber  String       @unique // e.g. "ASC-2026-0001"
  status          CheckStatus  @default(DRAFT)

  // Target company info
  companyName     String
  companyAddress  String
  representativeName String
  companyType     CompanyType
  officerInfoDisclosed Boolean @default(false)

  // Resolved identity
  corporateNumber String?      // 13-digit 法人番号 (null for sole proprietors)

  // Determination
  determination   Determination?
  determinationComment String?
  determinedAt    DateTime?

  // Relations
  entities        CheckEntity[]
  attachments     Attachment[]
  auditLogs       AuditLog[]

  createdById     String
  createdBy       User         @relation("CreatedBy", fields: [createdById], references: [id])
  assignedToId    String?
  assignedTo      User?        @relation("AssignedTo", fields: [assignedToId], references: [id])

  createdAt       DateTime     @default(now())
  updatedAt       DateTime     @updatedAt
  nextCheckDue    DateTime?    // Annual re-check date
  archivedAt      DateTime?

  @@index([status])
  @@index([corporateNumber])
  @@index([nextCheckDue])
}

enum CheckStatus {
  DRAFT
  SUBMITTED
  IN_REVIEW          // Legal is performing searches
  AWAITING_FF_LEGAL  // Escalated — hits found
  COMPLETED
  ARCHIVED
}

enum CompanyType {
  LISTED             // 上場
  UNLISTED_DISCLOSED // 未上場・役員開示あり
  UNLISTED_UNDISCLOSED // 未上場・役員開示なし
  SOLE_PROPRIETOR    // 個人事業主
}

enum Determination {
  NO_CONCERNS        // 取引開始に懸念ない
  TRANSACTION_REFUSED // 取引謝絶
}

// ─── Check Entities (each person/org to screen) ────────────

model CheckEntity {
  id              String       @id @default(cuid())
  checkRequestId  String
  checkRequest    CheckRequest @relation(fields: [checkRequestId], references: [id], onDelete: Cascade)

  entityType      EntityType
  name            String       // Person or company name
  nameKana        String?      // Reading in kana for disambiguation

  // Screening results per source
  screeningResults ScreeningResult[]

  createdAt       DateTime     @default(now())

  @@index([checkRequestId])
}

enum EntityType {
  COMPANY          // 法人
  REPRESENTATIVE   // 代表者
  OFFICER          // 役員
  SHAREHOLDER      // 主要株主
}

// ─── Screening Results (one per entity per source) ─────────

model ScreeningResult {
  id              String          @id @default(cuid())
  entityId        String
  entity          CheckEntity     @relation(fields: [entityId], references: [id], onDelete: Cascade)

  source          ScreeningSource
  status          ScreeningStatus @default(PENDING)

  // Result data
  hitCount        Int?            // Number of matches found
  summary         String?         // Brief description of findings
  rawResponse     Json?           // API response metadata (no copyrighted content)
  searchQuery     String?         // The query that was used

  // For Nikkei Telecom escalation
  escalatedToFfLegal Boolean     @default(false)
  ffLegalReview      String?     // FF Legal's findings
  ffLegalReviewerId  String?
  ffLegalReviewedAt  DateTime?

  executedAt      DateTime?
  createdAt       DateTime     @default(now())

  @@unique([entityId, source])
  @@index([status])
}

enum ScreeningSource {
  HOUJIN_BANGOU     // 法人番号 API
  EDINET            // EDINET disclosure
  KANPOU            // 官報
  FSA               // 金融庁
  MLIT              // 国土交通省
  NIKKEI_TELECOM    // 日経テレコン
  INTERNET_SEARCH   // Google search
}

enum ScreeningStatus {
  PENDING
  IN_PROGRESS
  CLEAR             // No hits
  HIT               // Matches found — needs review
  ESCALATED         // Sent to FF Legal
  REVIEWED          // FF Legal reviewed
  SKIPPED           // Not applicable for this entity type
}

// ─── Negative Keywords ─────────────────────────────────────

model NegativeKeyword {
  id        String   @id @default(cuid())
  word      String   @unique
  category  String   // e.g. "organized_crime", "criminal", "financial", "drugs"
  isActive  Boolean  @default(true)
  createdAt DateTime @default(now())
}

// ─── Attachments ───────────────────────────────────────────

model Attachment {
  id              String       @id @default(cuid())
  checkRequestId  String
  checkRequest    CheckRequest @relation(fields: [checkRequestId], references: [id], onDelete: Cascade)

  fileName        String
  fileType        String       // MIME type
  filePath        String       // Storage path
  fileSize        Int

  createdAt       DateTime     @default(now())
}

// ─── Audit Log ─────────────────────────────────────────────

model AuditLog {
  id              String       @id @default(cuid())
  checkRequestId  String?
  checkRequest    CheckRequest? @relation(fields: [checkRequestId], references: [id])

  userId          String
  user            User         @relation(fields: [userId], references: [id])

  action          String       // e.g. "check.created", "screening.executed", "determination.set"
  details         Json?        // Structured change data

  createdAt       DateTime     @default(now())

  @@index([checkRequestId])
  @@index([userId])
  @@index([createdAt])
}

4. Screening Pipeline Design

The ScreeningService orchestrates the multi-tier check flow:

flowchart TD
  Start(["ScreeningService.execute()<br/>Input: CheckRequest with resolved entities"])

  Start --> T1

  subgraph T1["Tier 1: OSINT (automatic, parallel)"]
    direction LR
    HB["HoujinBangou<br/>(resolve)"]
    KP["Kanpou"]
    FSA["FSA"]
    MLIT["MLIT"]
  end

  T1 --> HitCheck{"Any HIT?"}
  HitCheck -->|Yes| AutoFlag["Auto-flag alert"]
  HitCheck -->|No| T15
  AutoFlag --> T15

  subgraph T15["Tier 1.5: EDINET (if listed company)"]
    EDINET["Fetch officer list → create additional CheckEntities"]
  end

  T15 --> T2

  subgraph T2["Tier 2: Nikkei Telecom + Internet (semi-manual)"]
    direction TB
    GenNikkei["1. Generate Nikkei Telecom query string"]
    GenGoogle["2. Generate Google search URL"]
    UserSearch["3. User executes searches, records hit counts"]
    Escalate{"Hits > 0<br/>on Nikkei?"}
    EscFF["Auto-escalate to FF Legal"]

    GenNikkei --> GenGoogle --> UserSearch --> Escalate
    Escalate -->|Yes| EscFF
    Escalate -->|No| Done2[Continue]
  end

  T2 --> Det

  subgraph Det["Determination"]
    Review["All entities screened → Legal records determination"]
    Refused{"REFUSED?"}
    Approve["Compliance Officer approval required"]

    Review --> Refused
    Refused -->|Yes| Approve
    Refused -->|No| Complete
  end

  Complete(["Check Complete"])
  Approve --> Complete

Tier 1: Automated OSINT Checks

Each client implements a common interface:

interface ScreeningClient {
  source: ScreeningSource;
  search(entity: { name: string; address?: string; corporateNumber?: string }): Promise<ScreeningOutcome>;
}

type ScreeningOutcome = {
  status: "clear" | "hit";
  hitCount: number;
  summary: string | null;
  rawMetadata: Record<string, unknown>;
};

All Tier 1 checks run in parallel via Promise.all() for each entity.

Tier 2: Nikkei Telecom (Query Generation)

Nikkei Telecom does not have a public API. The application generates search queries but does not automate the web UI. The NikkeiTelecomHelper builds the query string:

class NikkeiTelecomHelper {
  buildSearchQuery(entityName: string, keywords: NegativeKeyword[]): string {
    // Returns: "entityName and (構成員 or 準構成 or 組員 or ...)"
    const keywordClause = keywords
      .filter(k => k.isActive)
      .map(k => k.word)
      .join(" or ");
    return `${entityName} and (${keywordClause})`;
  }

  buildNarrowDownQuery(entityName: string): string {
    // For the "絞込み" step — just the entity name
    return entityName;
  }

  getLoginUrl(): string {
    return "https://t21.nikkei.co.jp/";
  }
}

User workflow in the screening UI:

  1. System displays the generated query and a "Open Nikkei Telecom" button
  2. User logs in, navigates to article search, executes the pre-saved search condition
  3. User uses "絞込み" with the entity name shown by the system
  4. User enters the hit count back into the application
  5. System routes based on result (0 = clear, >0 = escalate)

Tier 2: Internet Search (URL Generation)

class GoogleSearchClient {
  buildSearchUrl(entityName: string, keywords: NegativeKeyword[]): string {
    const keywordClause = keywords
      .filter(k => k.isActive)
      .map(k => k.word)
      .join(" OR ");
    const query = `"${entityName}" (${keywordClause})`;
    return `https://www.google.com/search?q=${encodeURIComponent(query)}`;
  }
}

5. External API Integration Details

5.1 National Tax Agency — 法人番号 Web-API

  • Endpoint: https://api.houjin-bangou.nta.go.jp/4/name
  • Auth: Application ID (free registration)
  • Usage: Resolve company name + address → 13-digit corporate number
  • Rate limit: Respectful crawl delay (1 req/sec)

5.2 EDINET API

  • Endpoint: https://api.edinet-fsa.go.jp/api/v2/documents
  • Auth: API key (free registration)
  • Usage: Fetch disclosure documents for listed companies; parse officer names from 有価証券報告書
  • Format: Returns XBRL/JSON metadata; document content as ZIP

5.3 官報 (Official Gazette)

  • Source: Internet Official Gazette (https://kanpou.npb.go.jp/)
  • Method: HTTP search with entity name
  • Extracts: Bankruptcy (破産), dissolution (解散), civil rehabilitation (民事再生)

5.4 金融庁 (FSA) Administrative Actions

  • Source: https://www.fsa.go.jp/ administrative action lists
  • Method: Cached lookup — periodically fetch and index published CSV/HTML data
  • Extracts: Administrative actions against financial institutions, unlicensed operators

5.5 国土交通省 (MLIT) Negative Information

  • Source: https://www.mlit.go.jp/nega-inf/ (ネガティブ情報等検索システム)
  • Method: HTTP query with entity name
  • Extracts: Construction/real estate violations, license revocations

6. Key UI Screens

画面レイアウトは実際のHTMLファイルで管理しています(SSOT)。以下のリンクからブラウザで確認できます。

6.1 Dashboard (/)

画面レイアウトを見る →

ステータスカード(未着手/審査中/要確認/完了)、期限アラート、最近のチェック一覧を表示するダッシュボード画面。

6.2 Check Request Detail — Screening View (/checks/[id]/screening)

画面レイアウトを見る →

Tier 1(公的DB自動チェック)、EDINET、Tier 2(日経テレコン+Internet手動検索)のスクリーニング結果と操作パネル、最終判定ボタンを含む画面。


7. API Route Design

Method Path Description Auth
GET /api/checks List check requests (filterable by status, date) SALES+
POST /api/checks Create new check request SALES+
GET /api/checks/:id Get check request detail with entities and results LEGAL+
PATCH /api/checks/:id Update check request (status, determination) LEGAL+
POST /api/checks/:id/entities Add entities to check request LEGAL+
POST /api/checks/import Bulk import from CSV SALES+
POST /api/screening/:checkId/osint Run Tier 1 OSINT checks for all entities LEGAL+
POST /api/screening/:checkId/edinet Run EDINET check (listed companies) LEGAL+
POST /api/screening/:entityId/nikkei-result Record Nikkei Telecom search result LEGAL+
POST /api/screening/:entityId/internet-result Record internet search result LEGAL+
POST /api/screening/:entityId/ff-legal-review Record FF Legal review findings FF_LEGAL+
GET /api/houjin?name=...&address=... Corporate number lookup LEGAL+
GET /api/edinet?code=... EDINET document search LEGAL+
GET /api/export/checks/:id/pdf Export check result as PDF LEGAL+
GET /api/export/ledger?from=...&to=... Export results ledger as Excel LEGAL+
GET /api/keywords List negative keywords LEGAL+
POST /api/keywords Add/update negative keyword ADMIN
GET /api/monitoring/due List checks due for annual re-check LEGAL+

8. Authentication & Authorization

Authentication

  • Session-based auth using next-auth (or lucia-auth) with email/password
  • Sessions stored in PostgreSQL
  • 30-minute inactivity timeout

Authorization Matrix

Action SALES LEGAL FF_LEGAL COMPLIANCE ADMIN
Create check request
View own check requests
View all check requests
Execute screening
Record Nikkei Telecom results
Review escalated hits
Set determination
Approve transaction refusal
Manage keywords
Manage users
Export data

9. Negative Keyword Seed Data

Default keyword set derived from the operational procedures, organized by category:

export const DEFAULT_KEYWORDS = [
  // Organized crime (暴力団関連)
  { word: "構成員", category: "organized_crime" },
  { word: "準構成", category: "organized_crime" },
  { word: "組員", category: "organized_crime" },
  { word: "組長", category: "organized_crime" },
  { word: "暴力団", category: "organized_crime" },
  { word: "総会屋", category: "organized_crime" },
  { word: "活動家", category: "organized_crime" },
  { word: "やくざ", category: "organized_crime" },
  { word: "ヤクザ", category: "organized_crime" },
  { word: "マフィア", category: "organized_crime" },
  { word: "えせ", category: "organized_crime" },
  { word: "エセ", category: "organized_crime" },
  { word: "テロリスト", category: "organized_crime" },
  { word: "フロント企業", category: "organized_crime" },
  { word: "密接交際者", category: "organized_crime" },
  { word: "仕手筋", category: "organized_crime" },
  { word: "共生者", category: "organized_crime" },

  // Criminal investigation (犯罪捜査関連)
  { word: "捜査", category: "criminal" },
  { word: "捜索", category: "criminal" },
  { word: "指名手配", category: "criminal" },
  { word: "逮捕", category: "criminal" },
  { word: "検挙", category: "criminal" },
  { word: "摘発", category: "criminal" },
  { word: "送検", category: "criminal" },
  { word: "起訴", category: "criminal" },
  { word: "訴訟", category: "criminal" },
  { word: "違反", category: "criminal" },
  { word: "告発", category: "criminal" },
  { word: "容疑", category: "criminal" },
  { word: "容疑者", category: "criminal" },
  { word: "被告", category: "criminal" },
  { word: "釈放", category: "criminal" },
  { word: "警察", category: "criminal" },
  { word: "犯罪", category: "criminal" },

  // Financial crimes (金融犯罪)
  { word: "罰金", category: "financial" },
  { word: "追徴金", category: "financial" },
  { word: "行政処分", category: "financial" },
  { word: "行政指導", category: "financial" },
  { word: "押収", category: "financial" },
  { word: "申告漏れ", category: "financial" },
  { word: "脱税", category: "financial" },
  { word: "背任", category: "financial" },
  { word: "横領", category: "financial" },
  { word: "悪徳商法", category: "financial" },
  { word: "恐喝", category: "financial" },
  { word: "インサイダー", category: "financial" },
  { word: "相場操縦", category: "financial" },

  // Drugs & weapons (薬物・武器)
  { word: "殺人", category: "drugs_weapons" },
  { word: "麻薬", category: "drugs_weapons" },
  { word: "大麻", category: "drugs_weapons" },
  { word: "コカイン", category: "drugs_weapons" },
  { word: "覚醒剤", category: "drugs_weapons" },
  { word: "銃", category: "drugs_weapons" },
  { word: "日本刀", category: "drugs_weapons" },
  { word: "ピストル", category: "drugs_weapons" },
  { word: "とばく", category: "drugs_weapons" },
  { word: "賭博", category: "drugs_weapons" },
] as const;

10. Background Jobs

Using Bun's worker threads for non-blocking operations:

Job Trigger Description
osint-screening On check request submission Runs all Tier 1 OSINT checks in parallel
annual-recheck-alert Daily cron (via Bun.CronJob) Finds checks due within 30 days, sends reminders
overdue-check-alert Daily cron Finds in-progress checks older than threshold
fsa-data-sync Weekly cron Refreshes local cache of FSA administrative action data

11. Implementation Phases

Phase 1 — Core Foundation (MVP)

  • Project scaffolding (Bun + Next.js + Prisma + PostgreSQL)
  • Data model and migrations
  • User auth with role-based access
  • Check request CRUD and workflow state machine
  • Entity management (add company, representative, officers)
  • Negative keyword management with seed data

Phase 2 — OSINT Screening (Tier 1)

  • 法人番号 API client and entity resolution
  • EDINET API client and officer extraction for listed companies
  • 官報 search client
  • 金融庁 administrative action lookup
  • 国土交通省 negative information lookup
  • Automated screening orchestration with parallel execution

Phase 3 — Nikkei Telecom & Internet Search (Tier 2)

  • Nikkei Telecom query generator and UI workflow
  • Google search URL generator
  • Manual result recording UI
  • FF Legal escalation workflow
  • Determination recording with mandatory comments on refusal

Phase 4 — Reporting & Monitoring

  • 検索結果一覧表 digital ledger with Excel export
  • Individual check PDF export for audit evidence
  • Dashboard with summary widgets and alerts
  • Annual re-check monitoring and notifications
  • Overdue check alerts

Phase 5 — Polish & Hardening

  • Bulk CSV import
  • Email notifications
  • Audit log viewer
  • Data retention policy enforcement (auto-archive after 5 years)
  • E2E tests and load testing
  • Docker packaging for deployment

12. Technology Decisions

Decision Choice Rationale
Runtime Bun User requirement; fast startup, native TypeScript
Framework Next.js (App Router) Server components reduce client JS; built-in API routes
ORM Prisma Type-safe queries, migration management, PostgreSQL support
UI styling Tailwind CSS Utility-first, fast iteration, no CSS-in-JS overhead
Component library shadcn/ui Accessible, unstyled primitives; Japanese text friendly
Auth next-auth v5 Built-in session management, credential provider
PDF export @react-pdf/renderer Generate audit PDFs from React components
Excel export exceljs Match existing spreadsheet format
HTTP client Built-in fetch (Bun) No extra dependency needed for API calls
Testing Bun test + Playwright Unit/integration via bun:test, E2E via Playwright