New project-bound submission drafts now default to a sortable, legal-
convention title instead of the bare "Entwurf N" counter:
<YYYY-MM-DD> <ClientName> ./. <ForumShort> ./. <OpponentName>
- Date leads (ISO, Europe/Berlin) so drafts list chronologically; " ./. "
is the German legal "gegen" separator.
- Client = root 'client' ancestor of the project tree.
- Forum = proceeding-type jurisdiction (UPC/EPA/DPMA); German proceedings
resolve to the deciding court (LG/OLG/BGH/BPatG) from the code tail.
- Opponent = primary opposing party, picked by our_side posture
(active → defendant bucket, reactive → claimant bucket).
- Any segment that resolves empty is omitted with its leading separator;
a project-less draft keeps the legacy "Entwurf N" scheme entirely.
- Create-time only: existing drafts are never renamed, and a lawyer's
later manual rename via Update is untouched. Same-slot collisions
de-duplicate with a " (N)" suffix.
Customization scope (per-user / firm / template, issue #155 Q4) is v1.1 —
the template is hardcoded in submission_autoname.go for now; the override
string is documented as the single extension point on AutoSubmissionTitle.
Example output:
full: 2026-05-31 Bayer AG ./. UPC ./. Novartis Pharma
no opponent: 2026-05-31 Bayer AG ./. BPatG
no forum: 2026-05-31 Bayer AG ./. Novartis Pharma
date only: 2026-05-31
AutoSubmissionTitle + segment resolvers are pure and table-tested
(submission_autoname_test.go); the Create flow is covered end-to-end
against real Postgres in submission_draft_autoname_live_test.go (gated
on TEST_DATABASE_URL).
179 lines
6.2 KiB
Go
179 lines
6.2 KiB
Go
package services
|
|
|
|
// Auto-naming for freshly-created submission drafts (t-paliad-352 /
|
|
// m/paliad#155). A new project-bound draft gets a sortable, legal-
|
|
// convention default title instead of the bare "Entwurf N" counter:
|
|
//
|
|
// <YYYY-MM-DD> <ClientName> ./. <ForumShort> ./. <OpponentName>
|
|
//
|
|
// The date leads so drafts sort chronologically; " ./. " is the German
|
|
// legal shorthand for "gegen". The three identity segments are the
|
|
// client we act for, the forum the proceeding runs in, and the opposing
|
|
// party — exactly the trio m named ("CLIENTNAME / UPC / OPPONENTNAME").
|
|
//
|
|
// Missing-segment rule: any segment that resolves empty is dropped
|
|
// together with its leading separator, so a project without an opponent
|
|
// yet renders "2026-05-31 Bayer AG ./. UPC" (no trailing separator) and
|
|
// a project-less draft never reaches this path at all (it keeps the
|
|
// "Entwurf N" counter — see SubmissionDraftService.Create).
|
|
//
|
|
// v1.1 customization hook: the template is hardcoded here in v1. When m
|
|
// promotes naming to a per-user / per-firm / per-base setting (issue
|
|
// #155 Q4), the override string lands as an extra parameter on
|
|
// AutoSubmissionTitle (or a small template struct) and the segment
|
|
// resolvers below stay as the value source. Nothing else needs to move.
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
)
|
|
|
|
// submissionTitleSep is the separator between identity segments —
|
|
// " ./. " is the German legal convention for "gegen" / "versus".
|
|
const submissionTitleSep = " ./. "
|
|
|
|
// AutoSubmissionTitle assembles the auto-generated draft title from the
|
|
// resolved identity pieces. Pure and table-testable — every DB hop
|
|
// happens in the caller (SubmissionDraftService.autoNameForProject).
|
|
//
|
|
// clientName is passed separately because the client we act for is the
|
|
// root ancestor of the project tree, not a field on the draft's own
|
|
// project node; the caller walks the path to resolve it. ourSide and
|
|
// the proceeding type both come off the draft's project node, the
|
|
// parties hang directly off it.
|
|
//
|
|
// The date is always present (formatted in Europe/Berlin to match the
|
|
// today.* render vars); the three identity segments are appended only
|
|
// when non-empty.
|
|
func AutoSubmissionTitle(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType) string {
|
|
loc, _ := time.LoadLocation("Europe/Berlin")
|
|
if loc != nil {
|
|
now = now.In(loc)
|
|
}
|
|
date := now.Format("2006-01-02")
|
|
|
|
segments := make([]string, 0, 3)
|
|
if c := strings.TrimSpace(clientName); c != "" {
|
|
segments = append(segments, c)
|
|
}
|
|
if f := submissionForumShort(pt); f != "" {
|
|
segments = append(segments, f)
|
|
}
|
|
ourSide := ""
|
|
if project != nil {
|
|
ourSide = derefString(project.OurSide)
|
|
}
|
|
if o := submissionOpponentName(parties, ourSide); o != "" {
|
|
segments = append(segments, o)
|
|
}
|
|
|
|
if len(segments) == 0 {
|
|
return date
|
|
}
|
|
return date + " " + strings.Join(segments, submissionTitleSep)
|
|
}
|
|
|
|
// submissionForumShort maps a proceeding type to the short forum label
|
|
// used in the auto-name. The jurisdiction is the forum for the
|
|
// supranational / office tracks (UPC, EPA, DPMA); German court
|
|
// proceedings disambiguate by the court that hears them (LG / OLG /
|
|
// BGH / BPatG), which is the tail segment of the proceeding code
|
|
// (de.inf.lg → LG, de.null.bpatg → BPatG). nil / unknown → "".
|
|
func submissionForumShort(pt *models.ProceedingType) string {
|
|
if pt == nil {
|
|
return ""
|
|
}
|
|
switch j := strings.ToUpper(strings.TrimSpace(derefString(pt.Jurisdiction))); j {
|
|
case "":
|
|
return ""
|
|
case "DE":
|
|
return germanCourtShort(pt.Code)
|
|
default:
|
|
// UPC / EPA / DPMA and any future jurisdiction are their own
|
|
// forum label.
|
|
return j
|
|
}
|
|
}
|
|
|
|
// germanCourtShort returns the court abbreviation from the tail segment
|
|
// of a German proceeding code (the part after the last "."). Known
|
|
// courts get their canonical casing; anything else falls back to the
|
|
// uppercased tail so a new German proceeding still yields a label.
|
|
func germanCourtShort(code string) string {
|
|
parts := strings.Split(code, ".")
|
|
tail := strings.ToLower(strings.TrimSpace(parts[len(parts)-1]))
|
|
switch tail {
|
|
case "":
|
|
return ""
|
|
case "lg":
|
|
return "LG"
|
|
case "olg":
|
|
return "OLG"
|
|
case "bgh":
|
|
return "BGH"
|
|
case "bpatg":
|
|
return "BPatG"
|
|
default:
|
|
return strings.ToUpper(tail)
|
|
}
|
|
}
|
|
|
|
// submissionOpponentName picks the name of the primary opposing party
|
|
// given the side we act for. We act actively (claimant / applicant /
|
|
// appellant) → the opponent is on the defendant bucket; we act
|
|
// reactively (defendant / respondent) → the opponent is the claimant.
|
|
// An unknown / unset side (third_party, other, NULL) can't fix a
|
|
// posture, so no opponent is derived (the segment is omitted). The
|
|
// first party of the opposing bucket wins — PartyService.ListForProject
|
|
// orders by name, so the pick is deterministic for a given project.
|
|
func submissionOpponentName(parties []models.Party, ourSide string) string {
|
|
var want string
|
|
switch sidePosture(ourSide) {
|
|
case "active":
|
|
want = "defendant"
|
|
case "reactive":
|
|
want = "claimant"
|
|
default:
|
|
return ""
|
|
}
|
|
for i := range parties {
|
|
if partyRoleBucket(parties[i].Role) == want {
|
|
if n := strings.TrimSpace(parties[i].Name); n != "" {
|
|
return n
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// sidePosture folds the our_side sub-role vocabulary (t-paliad-222)
|
|
// down to the active / reactive axis. Returns "" for sides that have no
|
|
// clear posture (third_party, other) or an unset value.
|
|
func sidePosture(ourSide string) string {
|
|
switch strings.ToLower(strings.TrimSpace(ourSide)) {
|
|
case "claimant", "applicant", "appellant":
|
|
return "active"
|
|
case "defendant", "respondent":
|
|
return "reactive"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// partyRoleBucket folds a party's free-text role into the
|
|
// claimant / defendant / other buckets. German and English spellings
|
|
// both fold in; everything else (Streithelfer, Patentinhaberin, …) is
|
|
// "other". Shared with addPartyVars so the two paths can't drift.
|
|
func partyRoleBucket(role *string) string {
|
|
switch strings.ToLower(strings.TrimSpace(derefString(role))) {
|
|
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
|
|
return "claimant"
|
|
case "defendant", "beklagter", "beklagte":
|
|
return "defendant"
|
|
default:
|
|
return "other"
|
|
}
|
|
}
|