"""記事「販売管理の入れ替えは月のどこで切り替える?売掛の引き継ぎを数えた」(sales-system-switch-closing-day)の計算と図。
python scripts/figs/sales_system_switch_closing_day.py   (fields/sales-management-app/ で実行。外部データなし、Python 標準ライブラリだけ)

架空の卸の1か月(2026年11月)で、販売管理を入れ替える日を3通りに置き、旧新の二重入力と売掛の引き継ぎ行数を数える。
条件(すべて架空):
  得意先 80社。締め日は 15日 17社・20日 30社・末日 33社(price-revision-effective-date と同じ分け方)
  納品:2026-10-01〜12-31 の営業日に、得意先ごとに 1か月 3〜9回。1回の明細は 1〜4行、1行は 数量1〜30 × 単価 120〜18,500円
  休日:土日と 2026-10-12・11-03・11-23
  支払い:締めた月の翌月末(10月の締めは 11/30、11月の締めは 12/31)に全額入金。遅れ・分割・相殺は置かない
  切替日:① 11/02(月初。11/1 は日曜、末日締めの直後)② 11/10(月途中)③ 11/24(20日締めの直後。11/21〜23 は土日と祝日)
  未請求の明細 = 切替日より前に納品して、まだ締めていない明細。1行以上ある得意先を「締めをまたぐ得意先」とする
  方法A(旧で締め切る):またぐ得意先の、切替日から次の締め日までの納品を旧新の両方に入れる(二重入力。納品伝票の数)
  方法B(明細を移す):またぐ得意先の未請求の明細を新に移す(行)。どちらの方法でも、未入金の請求書は1枚1行で新に移す
  方法C(締めグループ別):15日締めは 11/16、20日締めは 11/21(営業日では 11/24)、末日締めは 12/01 に、それぞれ締めの直後に切り替える
結果は JSON で出し、記事の図(cms/figs/switch-*.svg)を書き出す。
"""
import calendar, datetime as dt, json, pathlib, platform, random

ROOT = pathlib.Path(__file__).resolve().parents[2]
FIG = ROOT / 'cms/figs'; FIG.mkdir(parents=True, exist_ok=True)
SEED = 20261017
HOLI = {dt.date(2026, 10, 12), dt.date(2026, 11, 3), dt.date(2026, 11, 23)}
LIST = [120, 180, 250, 380, 450, 580, 720, 860, 980, 1250, 1480, 1800, 2350, 2980, 3600, 4800, 6500, 8200, 12000, 18500]
D = dt.date


def biz(d): return d.weekday() < 5 and d not in HOLI


def close_of(d, cutoff):
    """納品日 d を含む締め期間の締め日(d 当日が締め日ならその日)"""
    y, m = d.year, d.month
    while True:
        c = D(y, m, calendar.monthrange(y, m)[1] if cutoff == 0 else cutoff)
        if c >= d: return c
        m += 1
        if m == 13: y, m = y + 1, 1


def due(c):
    """締めた月の翌月末"""
    y, m = (c.year, c.month + 1) if c.month < 12 else (c.year + 1, 1)
    return D(y, m, calendar.monthrange(y, m)[1])


rng = random.Random(SEED)
cust = []
for i in range(80):
    cust.append({'id': i, 'cutoff': ([15] * 17 + [20] * 30 + [0] * 33)[i]})
rng.shuffle(cust)
for i, c in enumerate(cust): c['id'] = i

lines = []   # (cust, 納品日, 納品番号, 金額)
dn = 0
for c in cust:
    for m, last in ((10, 31), (11, 30), (12, 31)):
        days = [D(2026, m, k) for k in range(1, last + 1) if biz(D(2026, m, k))]
        for d in rng.sample(days, rng.randint(3, 9)):
            dn += 1
            for _ in range(rng.randint(1, 4)):
                lines.append({'c': c['id'], 'd': d, 'dn': dn, 'amt': rng.randint(1, 30) * rng.choice(LIST)})
cut = {c['id']: c['cutoff'] for c in cust}
for l in lines: l['close'] = close_of(l['d'], cut[l['c']])
GROUP = {15: '15日締め', 20: '20日締め', 0: '末日締め'}


def at(S):
    """切替日 S より前に納品して、まだ締めていない明細(その締め日が S 以後)= 未請求"""
    res = {'groups': {}}
    tot = {'cust': 0, 'open_lines': 0, 'open_amt': 0, 'dbl_deliv': 0, 'dbl_lines': 0, 'unpaid_inv': 0, 'unpaid_amt': 0}
    for g in (15, 20, 0):
        gg = {'cust': 0, 'open_lines': 0, 'dbl_deliv': 0, 'dbl_lines': 0, 'unpaid_inv': 0, 'members': 0}
        for c in [x for x in cust if x['cutoff'] == g]:
            gg['members'] += 1
            op = [l for l in lines if l['c'] == c['id'] and l['d'] < S and l['close'] >= S]
            # 未入金の請求書:締めた(締め日 < S)請求のうち、支払日が S 以後のもの。納品があった期間だけ請求書になる
            invs = {}
            for l in lines:
                if l['c'] == c['id'] and l['close'] < S and due(l['close']) >= S:
                    invs[l['close']] = invs.get(l['close'], 0) + l['amt']
            gg['unpaid_inv'] += len(invs); tot['unpaid_amt'] += sum(invs.values())
            if op:
                gg['cust'] += 1
                nxt = max(l['close'] for l in op)
                dbl = [l for l in lines if l['c'] == c['id'] and S <= l['d'] <= nxt]
                gg['open_lines'] += len(op); tot['open_amt'] += sum(l['amt'] for l in op)
                gg['dbl_lines'] += len(dbl); gg['dbl_deliv'] += len({l['dn'] for l in dbl})
        res['groups'][GROUP[g]] = gg
        for k in ('cust', 'open_lines', 'dbl_deliv', 'dbl_lines', 'unpaid_inv'): tot[k] += gg[k]
    res['total'] = tot
    return res


SW = {'①月初': D(2026, 11, 2), '②月途中': D(2026, 11, 10), '③締め日直後': D(2026, 11, 24)}
result = {k: dict(S=str(S), **at(S)) for k, S in SW.items()}

# 方法C:締めグループ別の切り替え(締めの直後)
SC = {15: D(2026, 11, 16), 20: D(2026, 11, 24), 0: D(2026, 12, 1)}
cc = {'groups': {}, 'unpaid_inv': 0, 'open_lines': 0}
for g, S in SC.items():
    n_inv = 0; n_open = 0
    for c in [x for x in cust if x['cutoff'] == g]:
        invs = {l['close'] for l in lines if l['c'] == c['id'] and l['close'] < S and due(l['close']) >= S}
        n_inv += len(invs)
        n_open += len([l for l in lines if l['c'] == c['id'] and l['d'] < S and l['close'] >= S])
    cc['groups'][GROUP[g]] = {'S': str(S), 'unpaid_inv': n_inv, 'open_lines': n_open}
    cc['unpaid_inv'] += n_inv; cc['open_lines'] += n_open
result['C:締めグループ別'] = cc
result['meta'] = {'seed': SEED, 'python': platform.python_version(), 'customers': 80,
                  'groups': {GROUP[g]: sum(1 for c in cust if c['cutoff'] == g) for g in (15, 20, 0)},
                  'deliveries_total': dn, 'lines_total': len(lines)}
print(json.dumps(result, ensure_ascii=False, indent=1, default=str))
(ROOT / 'scripts/figs/out').mkdir(exist_ok=True)
(ROOT / 'scripts/figs/out/sales-system-switch.json').write_text(json.dumps(result, ensure_ascii=False, indent=1, default=str), encoding='utf-8')


# ---- 図 ----
INK, MUTE, AI, SOFT, GREY = '#111111', '#6b6b6b', '#557323', '#c9d8b0', '#e4e4e4'
FONT = "font-family=\"'Trebuchet MS','Yu Gothic','Hiragino Sans','Meiryo',sans-serif\""
MONTH_ENDS = lambda g: [D(2026, m, calendar.monthrange(2026, m)[1] if g == 0 else g) for m in (9, 10, 11, 12)]


def T(x, y, s, size=13, fill=INK, anchor='start', w=400):
    return f"<text x='{x}' y='{y}' font-size='{size}' fill='{fill}' text-anchor='{anchor}' font-weight='{w}'>{s}</text>"


def svg(w, h, title, body):
    return (f"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 {w} {h}' width='{w}' height='{h}' role='img' {FONT}>"
            f"<title>{title}</title><rect width='{w}' height='{h}' fill='#fff'/>{body}</svg>")


# 図1:切替日ごとのタイムライン(10/16〜12/16)
X0, X1 = 110, 580
A, B = D(2026, 10, 16), D(2026, 12, 16)


def X(d): return X0 + (d - A).days * (X1 - X0) / (B - A).days


body = T(24, 28, '切替日を3通りに置いたときの、締めグループごとの引き継ぎ(架空の卸・80社)', 15, INK, 'start', 700)
body += f"<rect x='24' y='42' width='14' height='10' fill='{SOFT}' stroke='{AI}'/>" + T(44, 51, '切替日より前の未請求(新へ移す行)', 12)
body += f"<rect x='300' y='42' width='14' height='10' fill='#fff' stroke='{MUTE}' stroke-dasharray='3 2'/>" + T(320, 51, '切替日から次の締めまで(旧新の両方に入れる納品)', 12)
y = 84
CUT = {'15日締め': 15, '20日締め': 20, '末日締め': 0}
for k, S in SW.items():
    r = result[k]
    body += T(24, y, f"{k} {S.month}/{S.day} に切り替える", 14, AI, 'start', 700)
    for tick in (D(2026, 11, 1), D(2026, 12, 1)):
        body += f"<line x1='{X(tick):.1f}' y1='{y+6}' x2='{X(tick):.1f}' y2='{y+82}' stroke='#dcdcdc'/>"
        body += T(f"{X(tick):.0f}", y + 96, f"{tick.month}/1", 11, MUTE, 'middle')
    for i, (gname, g) in enumerate(CUT.items()):
        gy = y + 12 + i * 22
        c0 = [c for c in MONTH_ENDS(g) if c < S][-1]
        c1 = [c for c in MONTH_ENDS(g) if c >= S][0]
        st = max(c0 + dt.timedelta(days=1), A)
        gr = r['groups'][gname]
        if st < S and gr['open_lines']:
            body += f"<rect x='{X(st):.1f}' y='{gy}' width='{X(S)-X(st):.1f}' height='16' fill='{SOFT}' stroke='{AI}'/>"
        if gr['cust']:
            body += f"<rect x='{X(S):.1f}' y='{gy}' width='{X(c1)-X(S):.1f}' height='16' fill='#fff' stroke='{MUTE}' stroke-dasharray='3 2'/>"
        body += T(24, gy + 13, gname, 12, INK)
        if gr['cust']:
            body += T(X1 + 16, gy + 13, f"未請求{gr['open_lines']}行・二重{gr['dbl_deliv']}件", 12)
        else:
            body += T(X1 + 16, gy + 13, 'またぐ得意先なし(0行・0件)', 12, AI, 'start', 700)
    body += f"<line x1='{X(S):.1f}' y1='{y+6}' x2='{X(S):.1f}' y2='{y+82}' stroke='{AI}' stroke-width='2' stroke-dasharray='5 4'/>"
    y += 122
body += T(24, y - 8, '横軸は 10/16〜12/16。未入金の請求書は①②が78枚、③が125枚で、別に新へ移す。', 11, MUTE)
fig1 = svg(800, y + 4, '切替日を月初・月途中・締め日直後に置いたときの、締めグループごとの未請求の明細と二重入力', body)
(FIG / 'switch-timeline.svg').write_text(fig1, encoding='utf-8')

# 図2:二重入力の件数と引き継ぎ行数
rows = []
for k, S in SW.items():
    t_ = result[k]['total']
    rows.append((f"{k} {S.month}/{S.day}", t_['dbl_deliv'], t_['open_lines'], t_['unpaid_inv']))
rows.append(('締めグループ別 3回', 0, 0, cc['unpaid_inv']))
mx = 800
sc = lambda v: v * 480 / mx
body = T(24, 28, '方法Aの二重入力(納品伝票の件)と、方法Bの引き継ぎ(行と枚)', 15, INK, 'start', 700)
body += f"<rect x='24' y='42' width='14' height='10' fill='{GREY}'/>" + T(44, 51, '方法A 二重入力(件)', 12)
body += f"<rect x='190' y='42' width='14' height='10' fill='{SOFT}' stroke='{AI}'/>" + T(210, 51, '方法B 未請求の明細(行)', 12)
body += f"<rect x='370' y='42' width='14' height='10' fill='{AI}'/>" + T(390, 51, '未入金の請求書(枚。どちらでも移す)', 12)
yy = 76
for lab, dbl, op, inv in rows:
    body += T(24, yy + 14, lab, 13, INK, 'start', 700)
    body += f"<rect x='190' y='{yy}' width='{max(sc(dbl),1):.1f}' height='16' fill='{GREY}'/>" + T(196 + sc(dbl), yy + 13, f"A {dbl}件", 12)
    body += f"<rect x='190' y='{yy+20}' width='{max(sc(op),0):.1f}' height='16' fill='{SOFT}' stroke='{AI}'/><rect x='{190+sc(op):.1f}' y='{yy+20}' width='{sc(inv):.1f}' height='16' fill='{AI}'/>"
    body += T(196 + sc(op + inv), yy + 33, f"B {op}行 + {inv}枚", 12)
    yy += 58
body += T(24, yy + 2, '架空の卸・80社、乱数の種 20261017。支払いは翌月末の全額入金と仮定。', 11, MUTE)
fig2 = svg(760, yy + 12, '切替日ごとの二重入力の件数と、引き継ぎの行数を比べた横棒グラフ', body)
(FIG / 'switch-bars.svg').write_text(fig2, encoding='utf-8')


# 図3:新に入れる行の種類と突き合わせ
def box(x, y_, w, h, head, ls, hl):
    s = f"<rect x='{x}' y='{y_}' width='{w}' height='{h}' fill='{'#f6f9ef' if hl else '#fff'}' stroke='{INK}'/>" + T(x + 12, y_ + 24, head, 14, AI if hl else INK, 'start', 700)
    for i, l_ in enumerate(ls): s += T(x + 12, y_ + 48 + i * 20, l_, 12.5)
    return s


body = T(24, 28, '新しい販売管理に入れる行は2種類。入れたら旧と合計を突き合わせる', 15, INK, 'start', 700)
body += box(24, 46, 220, 112, '① 未入金の請求書', ['1枚を1行で入れる', '得意先・締め日・請求額・支払日', '入金されたら消し込む'], True)
body += box(270, 46, 220, 112, '② 未請求の納品明細', ['切替日より前に納品して', 'まだ締めていない明細を1行ずつ', '次の締め日に新で請求する'], True)
body += box(516, 46, 220, 112, '入れないもの', ['入金済みの請求書', '締め済みの過去の明細', '(旧は閲覧用に残す)'], False)
body += f"<path d='M134 158V184H380V158M257 184V200' fill='none' stroke='{INK}'/>"
body += f"<rect x='24' y='200' width='712' height='64' fill='#fff' stroke='{AI}' stroke-width='2'/>" + T(40, 226, '突き合わせ:旧の売掛金残高の一覧の合計 = ①の請求額の合計 + ②の金額の合計', 14, AI, 'start', 700)
body += T(40, 250, '差が出た得意先は、伝票番号まで下りて原因を探します。', 12.5)
fig3 = svg(760, 280, '新しい販売管理に入れる行の種類と、旧との突き合わせの図', body)
(FIG / 'switch-rows.svg').write_text(fig3, encoding='utf-8')
print('figs written')
