"""記事「単価改定はいつの受注から?基準日の決め方」(price-revision-effective-date)の計算と図。
python scripts/figs/price_revision_effective_date.py   (fields/sales-management-app/ で実行。外部データなし、Python 標準ライブラリだけ)

架空の卸で、単価の改定日(2026-11-01、全商品 +5%)をまたぐ受注300件を、乱数の種を固定して作る。
単価を決める基準日を「受注日」「出荷日」「請求締め日」の3通りにして、新単価になる明細行・受注の数と、売上の差を数える。
条件(すべて架空):
  得意先 80社。締め日は 15日 30% / 20日 30% / 末日 40%。掛け率 0.70〜0.90(5段階)
  商品 400点。標準価格は price_master_three_ways.py と同じ20通りの値から選ぶ
  受注日:改定日の直前の営業日 2026-10-15〜10-30(土日を除く12日)に300件。1件の明細は1〜4行、数量は1〜30
  出荷日:受注日から 0営業日 20% / 1営業日 40% / 2〜4営業日 30% / 5〜10営業日 10%(取り寄せ)
  分納:2行以上の受注の 10% は、後ろ半分の行を さらに 5〜10営業日 遅らせて出荷する
  休日:土日と 2026-11-03・11-23(祝日)
  旧単価 = 標準価格 × 掛け率(円未満四捨五入)。新単価 = 旧単価 × 1.05(円未満四捨五入。Python の round() は銀行丸めなので使わない)
  請求締め日 = 出荷日以後で最初に来る、その得意先の締め日
結果は JSON で出し、記事の図3枚(public/img/articles/price-revision-effective-date-*.svg)を書き出す。
"""
import calendar, datetime as dt, json, pathlib, platform, random, sys
from decimal import Decimal, ROUND_HALF_UP

ROOT = pathlib.Path(__file__).resolve().parents[2]
OUT = ROOT / 'public/img/articles'
SEED = 20261016
REV = dt.date(2026, 11, 1)
RAISE = Decimal('1.05')
HOLI = {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]
RATES = [Decimal('0.90'), Decimal('0.85'), Decimal('0.80'), Decimal('0.75'), Decimal('0.70')]


def yen(x: Decimal) -> int:
    return int(x.quantize(Decimal('1'), rounding=ROUND_HALF_UP))


def bizday(d):
    return d.weekday() < 5 and d not in HOLI


def add_biz(d, n):
    while n > 0:
        d += dt.timedelta(days=1)
        n -= bizday(d)
    return d


def close_date(d, cutoff):
    """出荷日 d 以後で最初に来る締め日。cutoff は 15・20・0(末日)"""
    y, m = d.year, d.month
    while True:
        last = calendar.monthrange(y, m)[1]
        c = dt.date(y, m, last if cutoff == 0 else cutoff)
        if c >= d:
            return c
        m += 1
        if m == 13:
            y, m = y + 1, 1


rng = random.Random(SEED)
cust = [{'id': i, 'cutoff': rng.choices([15, 20, 0], [30, 30, 40])[0], 'rate': rng.choice(RATES)} for i in range(80)]
std = [rng.choice(LIST) for _ in range(400)]
days = [d for d in (dt.date(2026, 10, 15) + dt.timedelta(days=k) for k in range(16)) if bizday(d)]
assert len(days) == 12, days


def lead():
    r = rng.random()
    return 0 if r < .2 else 1 if r < .6 else rng.randint(2, 4) if r < .9 else rng.randint(5, 10)


orders, lines = [], []
for o in range(300):
    c = rng.choice(cust)
    od = rng.choice(days)
    sd = add_biz(od, lead())
    n = rng.choices([1, 2, 3, 4], [35, 30, 20, 15])[0]
    split = n >= 2 and rng.random() < .10
    prods = rng.sample(range(400), n)
    orders.append({'id': o, 'cust': c['id'], 'split': split})
    for k, p in enumerate(prods):
        s = add_biz(sd, rng.randint(5, 10)) if split and k >= n // 2 else sd
        old = yen(Decimal(std[p]) * c['rate'])
        lines.append({'order': o, 'cust': c['id'], 'cutoff': c['cutoff'], 'qty': rng.randint(1, 30),
                      'od': od, 'sd': s, 'cd': close_date(s, c['cutoff']), 'old': old, 'new': yen(Decimal(old) * RAISE)})

BASES = {'order': 'od', 'ship': 'sd', 'close': 'cd'}
res = {}
for b, key in BASES.items():
    newl = [l for l in lines if l[key] >= REV]
    ords = {l['order'] for l in newl}
    mixed = {o for o in ords if any(l['order'] == o and l[key] < REV for l in lines)}
    res[b] = {
        'newLines': len(newl), 'newOrders': len(ords), 'mixedOrders': len(mixed),
        'diffYen': sum((l['new'] - l['old']) * l['qty'] for l in newl),
    }
# 基準日ごとの「ずれ」
res['order']['shippedAfterAtOld'] = sum(1 for l in lines if l['sd'] >= REV)                       # 改定日以後に出荷するのに旧単価
res['ship']['orderedBeforeAtNew'] = sum(1 for l in lines if l['sd'] >= REV)                        # 受注のときは旧単価だった行
res['close']['shippedBeforeAtNew'] = sum(1 for l in lines if l['sd'] < REV <= l['cd'])             # 改定日より前に出荷したのに新単価
by_cut = {}
for cut, name in [(15, '15日'), (20, '20日'), (0, '末日')]:
    ls = [l for l in lines if l['cutoff'] == cut]
    by_cut[name] = {'lines': len(ls), 'ship': sum(l['sd'] >= REV for l in ls), 'close': sum(l['cd'] >= REV for l in ls)}
total_old = sum(l['old'] * l['qty'] for l in lines)
lead_dist = {'sameDay': sum(l['sd'] == l['od'] for l in lines)}

out = {
    'seed': SEED, 'run': str(dt.date.today()), 'python': sys.version.split()[0], 'os': platform.platform(),
    'cond': {'orders': len(orders), 'lines': len(lines), 'customers': len(cust), 'products': len(std),
             'cutoffs': {k: sum(c['cutoff'] == v for c in cust) for k, v in [('15日', 15), ('20日', 20), ('末日', 0)]},
             'splitOrders': sum(o['split'] for o in orders), 'revision': str(REV), 'raise': '5%',
             'orderDays': [str(days[0]), str(days[-1])], 'lastShip': str(max(l['sd'] for l in lines)),
             'lastClose': str(max(l['cd'] for l in lines))},
    'totalOldYen': total_old, 'result': res, 'byCutoff': by_cut,
}
print(json.dumps(out, ensure_ascii=False, indent=1))

# ---- 図(テーマ:地は白、面は #678b2c、文字は #557323、墨 #111、灰 #6b6b6b)
FONT = "'Trebuchet MS','Yu Gothic','Hiragino Sans','Meiryo',sans-serif"
G, GT, INK, MUTE, RULE, PALE = '#678b2c', '#557323', '#111111', '#6b6b6b', '#dcdcdc', '#c9d8b0'
NL = len(lines)


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 head(W, H):
    return [f"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 {W} {H}' width='{W}' height='{H}' font-family=\"{FONT}\">",
            f"<rect width='{W}' height='{H}' fill='#fff'/>"]


def fig_timeline():
    """1行の明細(10/28 受注、11/2 出荷、11/20 締め)で、3つの基準日がどちらの単価を拾うか"""
    W, H = 760, 330
    x0, x1 = 60, 720
    d0, d1 = dt.date(2026, 10, 20), dt.date(2026, 11, 24)
    X = lambda d: x0 + (x1 - x0) * (d - d0).days / (d1 - d0).days
    s = head(W, H)
    s.append(t(24, 32, '同じ1行の明細でも、基準日によって拾う単価が変わる(改定日 11月1日、+5%)', 15, INK, w=700))
    xr = X(REV)
    s.append(f"<rect x='{x0}' y='62' width='{xr - x0:.1f}' height='26' fill='#f1f1f1'/>")
    s.append(f"<rect x='{xr:.1f}' y='62' width='{x1 - xr:.1f}' height='26' fill='{PALE}'/>")
    s.append(t((x0 + xr) / 2, 80, '旧単価 1,000円', 13, INK, 'middle', 700))
    s.append(t((xr + x1) / 2, 80, '新単価 1,050円(11月1日から)', 13, GT, 'middle', 700))
    s.append(f"<line x1='{x0}' y1='130' x2='{x1}' y2='130' stroke='{INK}' stroke-width='1.4'/>")
    d = d0
    while d <= d1:
        if d.day in (1, 10, 20) or d == d0:
            s.append(f"<line x1='{X(d):.1f}' y1='126' x2='{X(d):.1f}' y2='134' stroke='{INK}'/>" + t(round(X(d)), 150, f'{d.month}/{d.day}', 11, MUTE, 'middle'))
        d += dt.timedelta(days=1)
    s.append(f"<line x1='{xr:.1f}' y1='56' x2='{xr:.1f}' y2='290' stroke='{G}' stroke-width='2' stroke-dasharray='5 4'/>")
    s.append(t(round(xr) + 6, 104, '改定日', 12, GT, w=700))
    ev = [(dt.date(2026, 10, 28), '受注日 10/28', '旧単価 1,000円', False),
          (dt.date(2026, 11, 2), '出荷日 11/2', '新単価 1,050円', True),
          (dt.date(2026, 11, 20), '請求締め日 11/20', '新単価 1,050円', True)]
    for i, (dd, lab, res_, new) in enumerate(ev):
        x, y = X(dd), 184 + i * 40
        s.append(f"<circle cx='{x:.1f}' cy='130' r='6' fill='{G if new else '#fff'}' stroke='{G if new else INK}' stroke-width='2'/>")
        s.append(f"<line x1='{x:.1f}' y1='136' x2='{x:.1f}' y2='{y - 14}' stroke='{RULE}'/>")
        anchor = 'end' if i == 2 else 'start'
        tx = x - 8 if i == 2 else x + 8
        s.append(t(round(tx), y, f'{lab}を基準にすると → {res_}', 13, GT if new else INK, anchor, 700))
    s.append(t(24, 316, '商いの帳場の作図(架空の明細1行。20日締めの得意先が10月28日に注文し、11月2日に出荷した場合)', 11, MUTE))
    s.append('</svg>')
    return ''.join(s)


def fig_result():
    W, H = 760, 330
    names = [('order', '受注日基準'), ('ship', '出荷日基準'), ('close', '請求締め日基準')]
    x0, x1 = 170, 560
    s = head(W, H)
    s.append(t(24, 32, f'改定日をまたぐ受注300件({NL}行)で、新単価になる行数と売上の差', 15, INK, w=700))
    s.append(t(x0, 60, '新単価になる明細行', 12, MUTE) + t(x1 + 30, 60, '受注日基準との売上の差', 12, MUTE))
    for i in range(0, NL - 80, 200):
        x = x0 + (x1 - x0) * i / NL
        s.append(f"<line x1='{x:.0f}' y1='70' x2='{x:.0f}' y2='250' stroke='{RULE}' stroke-dasharray='2 3'/>" + t(round(x), 268, f'{i}', 11, MUTE, 'middle'))
    s.append(f"<line x1='{x1:.0f}' y1='70' x2='{x1:.0f}' y2='250' stroke='{RULE}'/>" + t(x1, 268, f'{NL}行', 11, MUTE, 'middle'))
    for k, (key, label) in enumerate(names):
        y = 86 + k * 58
        r = res[key]
        w = (x1 - x0) * r['newLines'] / NL
        s.append(t(x0 - 12, y + 20, label, 13.5, INK, 'end', 700))
        s.append(f"<rect x='{x0}' y='{y}' width='{x1 - x0}' height='28' fill='#f1f1f1'/>")
        if w:
            s.append(f"<rect x='{x0}' y='{y}' width='{w:.1f}' height='28' fill='{G}'/>")
        s.append(t(x0 + w + 8 if w < 300 else x0 + w - 8, y + 19, f"{r['newLines']}行", 13, GT if w < 300 else '#fff', 'start' if w < 300 else 'end', 700))
        s.append(t(x1 + 30, y + 19, f"+{r['diffYen']:,}円", 14, GT if r['diffYen'] else INK, 'start', 700))
    s.append(t(24, 294, f"改定前の単価での売上の合計 {total_old:,}円。差は新単価 − 旧単価(+5%)× 数量の合計", 12, INK))
    s.append(t(24, 318, f'商いの帳場の計算(架空データ:得意先80・商品400、受注日 10/15〜10/30、乱数の種 {SEED})', 11, MUTE))
    s.append('</svg>')
    return ''.join(s)


def fig_master():
    W, H = 760, 270
    s = head(W, H)
    s.append(t(24, 32, '適用開始日つきの単価マスタと、受注明細に残す3つの列', 15, INK, w=700))
    # 単価マスタ
    x, y = 24, 52
    cols = ['得意先', '商品', '単価', '適用開始日']
    rows = [['T1023', 'K-201', '1,000', '2026-04-01'], ['T1023', 'K-201', '1,050', '2026-11-01'], ['T2087', 'K-201', '960', '2026-04-01']]
    cw = [96, 70, 64, 104]
    s.append(t(x, y + 14, '単価マスタ(行を上書きせず、改定のたびに1行足す)', 13, GT, w=700))
    yy = y + 24
    s.append(f"<rect x='{x}' y='{yy}' width='{sum(cw)}' height='24' fill='{PALE}'/>")
    cx = x
    for c, w_ in zip(cols, cw):
        s.append(t(cx + 8, yy + 17, c, 12, INK, w=700)); cx += w_
    for i, r in enumerate(rows):
        ry = yy + 24 + i * 26
        s.append(f"<rect x='{x}' y='{ry}' width='{sum(cw)}' height='26' fill='#fff' stroke='{RULE}'/>")
        cx = x
        for v, w_ in zip(r, cw):
            s.append(t(cx + 8, ry + 18, v, 12, INK)); cx += w_
    s.append(t(x, yy + 24 + 3 * 26 + 22, '引くとき:基準日 ≧ 適用開始日 の行のうち、いちばん新しい行', 12, INK))
    s.append(t(x, yy + 24 + 3 * 26 + 42, 'T2087 は改定の行がまだないので、11月以後も 960円', 12, MUTE))
    # 矢印
    s.append(f"<path d='M372 140 h40' stroke='{INK}' stroke-width='1.6'/><path d='M405 134 l8 6 l-8 6' fill='none' stroke='{INK}' stroke-width='1.6'/>")
    # 受注明細
    x2 = 428
    s.append(t(x2, y + 14, '受注明細(決めた単価を書き込んで残す)', 13, GT, w=700))
    items = [('単価', '1,050'), ('単価の基準日', '2026-11-02(出荷日)'), ('引いたマスタの行', '単価マスタの2行目')]
    for i, (k, v) in enumerate(items):
        ry = y + 24 + i * 40
        s.append(f"<rect x='{x2}' y='{ry}' width='300' height='34' fill='#fff' stroke='{G}' stroke-width='1.4' rx='3'/>")
        s.append(t(x2 + 10, ry + 22, k, 12, MUTE) + t(x2 + 290, ry + 22, v, 12.5, INK, 'end', 700))
    s.append(t(x2, y + 24 + 3 * 40 + 18, 'あとでマスタを直しても、出した伝票の単価は動かない', 12, INK))
    s.append(t(24, 256, '商いの帳場の設計例(得意先名・品番・金額は架空)。基準日をどれにするかは、会社で1つに決めて得意先に伝える', 11, MUTE))
    s.append('</svg>')
    return ''.join(s)


OUT.mkdir(parents=True, exist_ok=True)
(OUT / 'price-revision-effective-date-timeline.svg').write_text(fig_timeline(), encoding='utf-8')
(OUT / 'price-revision-effective-date-3bases.svg').write_text(fig_result(), encoding='utf-8')
(OUT / 'price-revision-effective-date-master.svg').write_text(fig_master(), encoding='utf-8')
